# 统一异步任务调度方案 > **状态:已完成(2026-04-17)** — 本文档所述方案已全面实施,Celery 已完全移除,所有第三方异步任务现由 Async Engine Scheduler 统一调度。 > 本文档用于替代原 Celery 在"提交→轮询→收尾"类第三方异步任务中的角色,解决视频生成、形象克隆等任务在队列中频繁出现的拥堵、死锁和状态不一致问题。 --- ## 1. 背景与问题 ### 1.1 当前架构的缺陷 目前项目使用 Celery Worker 处理所有第三方异步任务,包括: - **视频生成** (`video`):提交 Kling 分镜 → 轮询状态 → 下载上传 - **形象克隆** (`avatar_clone`):提交音色 → 轮询 → 提交主体 → 轮询 - **字幕对齐** (`subtitle`) - **图片生成** (`image`) 这些任务都被放进 Celery 队列,由 Worker 并发消费。但 Kling 视频生成本质上是**"占用并发槽位并长时间等待"**的过程,当前设计存在三个结构性问题: 1. **轮询任务风暴**:`poll_video_task` 用 Celery `retry(countdown=5)` 模拟轮询,一个 8 分钟的 Kling 任务会产生近百个 Celery Task,淹没队列调度器和 Redis Result Backend。 2. **快慢任务混排**:下载上传(IO 密集型)和提交/轮询(轻量 HTTP)共用 `video` 队列,Worker 被长任务占满,新任务饿死。 3. **状态死锁**:`download_upload_shot` 作为独立 Celery Task,一旦被 Worker 强制 Kill(如超时),shot 状态永远卡在 `downloading`,而轮询任务又不再处理它,导致整个任务假死。 ### 1.2 核心认知 Kling 是一个有**严格并发上限**(20 槽位)的第三方异步执行池。我们需要的是一个 **Slot-Based Scheduler**(槽位调度器),而不是一个任务队列(Celery)。 > **任务队列**擅长"把独立任务尽快分发出去"; > **槽位调度器**擅长"在有限资源下,周期性补货、轮询和收尾"。 Kling 视频生成和形象克隆属于后者。 --- ## 2. 架构总览 ``` ┌─────────────┐ HTTP ┌──────────────────┐ │ Tauri App │ ◄────────────► │ FastAPI API │ │ (React) │ │ (Gateway) │ └─────────────┘ └────────┬─────────┘ │ ┌───────────────────┼───────────────────┐ │ │ │ ▼ ▼ ▼ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ PostgreSQL │ │ Redis │ │ Object Store │ │ (持久化/审计) │ │ (运行时状态) │ │ (七牛/本地) │ └──────────────┘ └──────┬───────┘ └──────────────┘ │ ┌────────┴────────┐ │ Async Engine │ │ (Slot Scheduler)│ │ python main.py │ └────────┬────────┘ │ ┌──────────────────┼──────────────────┐ ▼ ▼ ▼ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Video Handler│ │Avatar Handler│ │Future Handler│ │ max_slots=18│ │ max_slots=2 │ │ (subtitle…) │ └─────────────┘ └─────────────┘ └─────────────┘ ``` ### 2.1 核心组件 | 组件 | 职责 | 技术选型 | |------|------|----------| | **FastAPI API** | 接收前端请求、创建任务、写入状态、供前端轮询 | 现有 FastAPI | | **Redis** | 存储任务的**运行时状态**(running shots、当前 stage、slot 占用集合) | 现有 Redis | | **PostgreSQL** | 存储任务的**持久化记录**(创建时间、最终结果、成本统计、失败原因) | 现有 PostgreSQL | | **Async Engine** | 独立的调度进程,每 10 秒一次 **Tick**,驱动所有任务状态推进 | Python `asyncio` | | **Handler** | 插件化模块,每个第三方平台一个实现 | 面向接口的 Python 类 | --- ## 3. 核心机制 ### 3.1 统一状态机 无论 video 还是 avatar_clone,所有第三方异步任务单元收敛到 **5 个统一状态**: ``` pending → submitted → succeed → completed │ │ └──────────────────────────────┘ ↓ failed ``` - **`pending`**:在队列里等待全局 slot 空闲 - **`submitted`**:已占用 slot,已提交给 Kling,等待轮询结果 - **`succeed`**:Kling 返回成功,Async Engine 立即触发下载/收尾(后台异步执行) - **`failed`**:Kling 返回失败或提交异常 - **`completed`**:下载、上传、DB 写入全部完成 对于 avatar_clone 这种**多阶段**任务,内部用 Sub-State 嵌套,但每个阶段仍遵循同一模式: ``` voice_pending → voice_submitted → voice_succeed ↓ element_pending → element_submitted → element_succeed → completed ``` ### 3.2 Slot Manager(全局并发控制器) 基于 **Redis SET + Lua 脚本** 实现严格的原子槽位管理: ```python class SlotManager: async def acquire(self, slot_key: str, slot_id: str, max_slots: int) -> bool: """Lua 脚本原子执行:SADD -> SCARD -> 超限则 SREM""" lua = """ local key = KEYS[1] local slot_id = ARGV[1] local max_slots = tonumber(ARGV[2]) redis.call('sadd', key, slot_id) local count = redis.call('scard', key) if count > max_slots then redis.call('srem', key, slot_id) return 0 end redis.call('expire', key, 1800) return 1 """ return await self.redis.eval(lua, 1, slot_key, slot_id, str(max_slots)) == 1 async def release(self, slot_key: str, slot_id: str) -> None: await self.redis.srem(slot_key, slot_id) ``` 当前配置: - **Video 槽位池**:`kling:video_slots`,上限 **18** - **Avatar 槽位池**:`kling:avatar_slots`,上限 **2** > **为什么 Lua 脚本?** 确保 `SADD + SCARD + 条件 SREM` 原子执行。即使未来启动第二个 Scheduler 实例做 HA,也不会出现并发超发。 ### 3.3 Async Engine Tick 循环 Scheduler 是一个独立的 `asyncio` 进程,主循环如下: ```python async def main(): engine = AsyncEngine() while True: tick_start = time.monotonic() # 1. 加载所有 running 的任务 tasks = await engine.registry.get_running_tasks() # 2. 按 Handler 分组,并行执行各自的 tick changes = await asyncio.gather(*[ handler.tick(tasks_for_handler, engine.slots) for handler in engine.handlers.values() ]) # 3. 批量应用状态变更(Pipeline 写入 Redis) await engine.registry.apply_changes(flatten(changes)) # 4. 对 completed/failed 的任务,持久化到 PostgreSQL await engine.persist_finished_tasks() # 5. 控制 Tick 间隔(固定 10 秒,执行过久时至少休息 2 秒) elapsed = time.monotonic() - tick_start await asyncio.sleep(max(10 - elapsed, 2)) ``` ### 3.4 Handler 插件化接口 每个第三方平台实现一个 Handler: ```python class AsyncHandler(ABC): name: str # e.g. "video" slot_key: str # e.g. "kling:video_slots" max_slots: int # e.g. 18 @abstractmethod async def tick(self, tasks: list[Task], slots: SlotManager) -> list[StateChange]: """每个 Tick 执行一次,返回需要更新的状态变更列表""" pass ``` #### Video Handler 的 tick 逻辑 1. **查**:遍历所有 `submitted` 的 shots,批量并行查询 Kling 状态 2. **收**: - `succeed` → `release_slot` + `asyncio.create_task(download_and_upload(shot))` + 状态改为 `completed` - `failed` → `release_slot` + 状态改为 `failed` 3. **补**:计算空闲槽位数,从 `pending` 队列 FIFO 取出新 shot 提交给 Kling,直到槽满 4. **写**:更新 task 的聚合状态(completed/total/message)到 Redis #### Avatar Handler 的 tick 逻辑 1. 检查当前 stage(如 `voice_submitted`),查询 Kling 状态 2. 若 `voice_succeed` → 释放 slot,推进到 `element_pending`,并在同一 tick 内尝试申请 slot 提交主体创建 3. 若 `element_succeed` → 释放 slot,状态改为 `completed` --- ## 4. API 层与数据流 ### 4.1 创建任务(不变) ```python @router.post("/{task_type}", response_model=TaskCreateResponse) async def create_task(task_type: str, request: TaskCreateRequest): task_id = generate_task_id() # 1. 写入 PostgreSQL(持久化底单) await db_task.create(task_id=task_id, type=task_type, user_id=...) # 2. 写入 Redis(标记为 pending,供 Async Engine 消费) await redis_task.create(task_id=task_id, type=task_type, status="pending", ...) return TaskCreateResponse(task_id=task_id, status="pending") ``` ### 4.2 查询状态(不变) ```python @router.get("/{task_id}", response_model=TaskStatusResponse) async def get_task_status(task_id: str): # 先读 Redis(热数据) task = await redis_task.get(task_id) if not task: # fallback 到 PostgreSQL(已完成的归档数据) task = await db_task.get(task_id) return task ``` ### 4.3 前端兼容性 **前端 `useTask.ts` 的轮询逻辑完全不需要修改。** 这是渐进式迁移的关键——调度层的重构对上层透明。 --- ## 5. 部署方案 ### 5.1 Docker Compose ```yaml services: api: build: context: ../python-api dockerfile: Dockerfile command: uvicorn app.main:app --host 0.0.0.0 --port 8000 # ... scheduler: build: context: ../python-api dockerfile: Dockerfile container_name: meijiaka-scheduler command: python -m app.scheduler.main environment: - REDIS_HOST=redis - DATABASE_URL=postgresql+asyncpg://... depends_on: - redis - db restart: unless-stopped deploy: resources: limits: memory: 512M ``` ### 5.2 Celery 的处置 - 立即下线 `worker-video`(不再消费 `video` 队列) - Phase 2 下线 `worker-avatar`(Avatar Handler 迁入 Async Engine 后) - 可选:暂时保留 Celery 跑 `subtitle`,待后续迁移 - 最终目标:所有"提交→轮询"类任务都迁入 Async Engine,Celery 整体移除 --- ## 6. 迁移路径 | 阶段 | 时间 | 动作 | 风险 | |------|------|------|------| | **Phase 1** | 本周 | Async Engine 只接管 `video`(18 slots);`avatar_clone` 仍由 Celery 运行 | 改动面最小,只验证 video 链路 | | **Phase 2** | 1-2 周后 | Async Engine 新增 `avatar_clone` Handler(2 slots);彻底下线 Celery 的 `worker-video` 和 `worker-avatar` | 验证 avatar 链路,解决资源饿死 | | **Phase 3** | 未来 | `subtitle`、`image` 等陆续迁入 Async Engine;Celery 完全移除 | 统一所有第三方异步任务调度 | --- ## 7. 设计原则论证 ### 7.1 主流(Mainstream) - **Redis + PostgreSQL 双存储**:运行态在 Redis,持久态在 PostgreSQL。这是现代异步系统的事实标准,从 AWS Lambda 到 Vercel 再到国内云厂商均采用类似模式。 - **Python asyncio 轻量调度器**:不引入 Kafka、RabbitMQ 或 Airflow 等重型框架,利用原生异步能力构建。Prefect、Dagster 的底层 Scheduler 也采用类似思想。 - **Gateway + 独立 Scheduler 进程**:API 负责接入,Scheduler 负责推进,职责清晰。这是当前中小型 SaaS 的主流演进方向。 ### 7.2 合理(Reasonable) - **完全匹配项目定位**:项目定位是"轻量云账号 + 全本地业务数据",不需要 Kubernetes 或复杂工作流引擎。Async Engine 只是一个额外的 Python 进程,资源占用 < 512MB。 - **渐进式迁移,契约不变**:前端轮询逻辑、API URL、响应 Schema 均不变。改动仅集中在后端任务分发层,业务代码零侵入。 - **资源隔离精确可控**:Video 18 slots + Avatar 2 slots = 20 slots,与 Kling 实际并发限制完全对齐。不会出现"形象克隆占满 Worker 导致视频饿死"的结构性问题。 - **开发体验优先**:本地开发时,scheduler 可以和 api 一起 `docker-compose up`,也可以单独 `python -m app.scheduler.main` 调试。不需要 ngrok,不需要把开发环境搬到云端。 - **幂等和可恢复**:每个 shot 的提交操作都是幂等的。Redis 记录了 `kling_task_id`,Scheduler 重启后从 Redis 恢复 running 任务,继续轮询,不会丢失状态。 ### 7.3 长期稳定(Long-term Stable) - **HA 预留,无单点故障**:`SlotManager` 基于 Redis Lua 脚本实现原子操作,天然支持多实例竞争。未来如需高可用,可启动第二个 Scheduler 实例,通过 Redis 分布式锁选举 Leader,实现秒级主备切换,无需重构。 - **Handler 插件化扩展**:未来接入即梦、Runway、Pika 或新的 AI 服务,只需实现新的 `AsyncHandler` 子类,配置 `slot_key` 和 `max_slots`。核心调度逻辑永远不需要改动。 - **数据一致性保障**:运行态在 Redis(崩溃恢复快),完成态在 PostgreSQL(数据不丢)。即使 Scheduler 挂掉 30 分钟,Kling 端的任务仍在运行,恢复后继续轮询即可。 - **第三方接口变更的防御性**:Handler 内部对 Kling API 的调用有统一的超时控制、重试策略和异常兜底。如果 Kling 某个接口升级,只改对应 Handler,不影响其他模块。 - **可观测性支撑长期运维**:通过 Prometheus 指标,可长期监控"视频生成成功率"、"平均生成耗时"、"槽位利用率"、"Kling API 延迟分布",为后续扩容和成本优化提供数据支撑。 --- ## 8. 关键文件位置(建议) ``` python-api/ ├── app/ │ └── scheduler/ │ ├── __init__.py │ ├── main.py # Async Engine 入口(Tick 循环) │ ├── engine.py # AsyncEngine 核心调度器 │ ├── slot_manager.py # 槽位管理器(Redis Lua) │ ├── registry.py # 任务注册表(Redis 读写) │ ├── handlers/ │ │ ├── __init__.py │ │ ├── base.py # AsyncHandler 抽象基类 │ │ ├── video_handler.py # Video 任务处理器 │ │ └── avatar_handler.py # Avatar Clone 处理器 │ └── models.py # Scheduler 内部数据模型 ``` --- ## 9. 结论 当前 Celery 在"提交→轮询→收尾"类任务中的角色是**结构性错位**的。它带来的任务风暴、队列拥堵和状态死锁不是可以通过调参修复的,而是其模型与问题本质不匹配的结果。 **统一异步调度方案**的核心决策: 1. **用 Async Engine(Slot-Based Scheduler)替代 Celery 管理所有第三方异步任务** 2. **Video 分配 18 槽,Avatar Clone 分配 2 槽,由唯一调度器全局管理** 3. **API 层和前端轮询逻辑完全不变,实现渐进式迁移** 4. **本地开发环境保持原样,无需引入 webhook 或云端部署** 这是根治"任务队列生成视频总会出问题"的唯一长期方案。