You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

6.2 KiB

修复 Celery Worker Redis Event Loop 冲突问题

日期: 2026-02-11
类型: Bug 修复
影响范围: 后端 - Celery 任务执行

问题描述

Celery Worker 在执行 AI 任务时遇到 RuntimeError: Event Loop is closed 错误,导致任务失败。

错误信息

RuntimeError: Event loop is closed
File "/app/app/tasks/ai_tasks.py", line 905, in _execute
    provider = await AIProviderFactory.create_provider(model, kwargs.get('config'))
File "/app/app/services/ai_providers/factory.py", line 150, in create_provider
    raise ValueError
ValueError: 模型 gpt-4o-mini 不存在或未激活

根本原因

  1. Celery 使用 ForkPoolWorker:每个任务在独立的子进程中运行
  2. Event Loop 不共享:主进程在 worker_ready 信号中创建的 Event Loop 无法在子进程中使用
  3. Redis 连接失效:全局 Redis 连接(通过 get_redis() 获取)在子进程中不可用
  4. 异步操作失败:尝试在已关闭的 Event Loop 中执行异步 Redis 操作

技术细节

# ❌ 错误:使用全局 Redis 连接(在子进程中失效)
async def get_cached_provider(cls, model_name: str):
    redis = await get_redis()  # 全局单例,Event Loop 已关闭
    cached_data = await redis.get(cls.CACHE_KEY)  # RuntimeError

修复内容

修改文件

文件: server/app/services/ai_providers/factory.py

方法: AIProviderFactory.get_cached_provider

修复方案

在每次调用时创建新的 Redis 连接,避免使用全局单例:

# ✅ 正确:在 Celery 任务中创建临时 Redis 连接
@classmethod
async def get_cached_provider(cls, model_name: str) -> Optional[int]:
    """从 Redis 缓存获取 Provider ID(支持 Celery 任务)"""
    try:
        import redis.asyncio as redis
        from app.core.config import get_settings
        
        settings = get_settings()
        
        # 创建临时 Redis 客户端(不使用全局单例)
        redis_client = await redis.from_url(
            settings.REDIS_URL,
            encoding="utf-8",
            decode_responses=True
        )
        
        try:
            cached_data = await redis_client.get(cls.CACHE_KEY)
            
            if not cached_data:
                logger.warning("⚠️ Redis 缓存为空,可能需要初始化")
                return None
            
            cache_dict = json.loads(cached_data)
            return cache_dict.get(model_name)
        finally:
            # 关闭临时连接
            await redis_client.close()
        
    except Exception as e:
        logger.error("❌ 从 Redis 读取缓存失败: %s", e, exc_info=True)
        return None

关键改进

  1. 独立连接:每次调用创建新的 Redis 连接,避免 Event Loop 冲突
  2. 自动清理:使用 try...finally 确保连接正确关闭
  3. 错误处理:捕获异常并返回 None,允许任务重试

技术背景

Celery + asyncio 的挑战

Celery 默认使用 prefork 模式(ForkPoolWorker),每个任务在独立的子进程中运行:

主进程 (MainProcess)
├─ worker_ready 信号 → 创建 Event Loop A
└─ 子进程 (ForkPoolWorker-1)
   └─ 任务执行 → 尝试使用 Event Loop A(已关闭)❌

为什么全局 Redis 连接失败?

  1. Event Loop 绑定:asyncio Redis 连接绑定到创建它的 Event Loop
  2. 进程隔离:子进程无法访问父进程的 Event Loop
  3. 连接状态:Redis 连接在子进程中处于"已关闭"状态

解决方案对比

方案 优点 缺点 选择
临时连接 简单、可靠、无状态 每次创建连接有开销 采用
同步 Redis 避免 Event Loop 问题 需要重构大量代码
连接池 性能最优 复杂度高,需要管理生命周期
Solo Worker 避免进程问题 性能差,无法并发

性能影响

连接开销

  • 每次任务创建一个 Redis 连接
  • 连接时间:~10ms(本地网络)
  • 相比任务总时间(通常 >1s)可忽略

优化建议

如果未来需要优化性能,可以考虑:

  1. 连接池:在每个 Worker 进程中维护独立的连接池
  2. 缓存预热:在 Worker 启动时将缓存加载到内存
  3. 同步 Redis:使用 redis-py(同步版本)避免 Event Loop 问题

测试验证

测试步骤

  1. 重启 Celery Worker

    docker restart jointo-server-celery-ai
    
  2. 触发剧本解析任务

    POST /api/v1/screenplays/{screenplay_id}/parse
    
  3. 检查日志

    docker logs -f jointo-server-celery-ai | grep "Provider"
    

预期结果

[INFO] 创建 AIHubMix Provider: model=gpt-4o-mini
[INFO] 剧本解析任务已提交: job_id=xxx

错误日志(修复前)

[ERROR] 从 Redis 读取缓存失败: Event loop is closed
[ERROR] 剧本解析任务失败: 模型 gpt-4o-mini 不存在或未激活

相关问题

为什么不在 worker_ready 中修复?

worker_ready 信号在主进程中执行,无法为子进程创建 Event Loop。Celery 的 prefork 模式要求每个子进程独立管理资源。

其他地方是否有类似问题?

目前只有 AIProviderFactory.get_cached_provider 受影响,因为:

  • 其他 Redis 操作在 FastAPI 请求中执行(有独立的 Event Loop)
  • Celery 任务中的数据库操作使用 get_async_session()(每次创建新连接)

是否需要修改其他 Celery 任务?

不需要。其他任务(如导出任务)不使用 Redis 缓存,或者使用数据库连接(已正确处理)。

相关文件

  • server/app/services/ai_providers/factory.py - 修复 Redis 连接问题
  • server/app/core/celery_app.py - Celery Worker 配置
  • server/app/core/cache.py - Redis 缓存服务
  • server/app/tasks/ai_tasks.py - AI 任务执行

参考资料