16 KiB
统一异步任务调度方案
状态:已完成(2026-04-17) — 本文档所述方案已全面实施,Celery 已完全移除,所有第三方异步任务现由 Async Engine Scheduler 统一调度。
本文档用于替代原 Celery 在"提交→轮询→收尾"类第三方异步任务中的角色,解决视频生成、形象克隆等任务在队列中频繁出现的拥堵、死锁和状态不一致问题。
1. 背景与问题
1.1 当前架构的缺陷
目前项目使用 Celery Worker 处理所有第三方异步任务,包括:
- 视频生成 (
video):提交 Kling 分镜 → 轮询状态 → 下载上传 - 形象克隆 (
avatar_clone):提交音色 → 轮询 → 提交主体 → 轮询 - 字幕对齐 (
subtitle) - 图片生成 (
image)
这些任务都被放进 Celery 队列,由 Worker 并发消费。但 Kling 视频生成本质上是**"占用并发槽位并长时间等待"**的过程,当前设计存在三个结构性问题:
- 轮询任务风暴:
poll_video_task用 Celeryretry(countdown=5)模拟轮询,一个 8 分钟的 Kling 任务会产生近百个 Celery Task,淹没队列调度器和 Redis Result Backend。 - 快慢任务混排:下载上传(IO 密集型)和提交/轮询(轻量 HTTP)共用
video队列,Worker 被长任务占满,新任务饿死。 - 状态死锁:
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 脚本 实现严格的原子槽位管理:
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 进程,主循环如下:
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:
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 逻辑
- 查:遍历所有
submitted的 shots,批量并行查询 Kling 状态 - 收:
succeed→release_slot+asyncio.create_task(download_and_upload(shot))+ 状态改为completedfailed→release_slot+ 状态改为failed
- 补:计算空闲槽位数,从
pending队列 FIFO 取出新 shot 提交给 Kling,直到槽满 - 写:更新 task 的聚合状态(completed/total/message)到 Redis
Avatar Handler 的 tick 逻辑
- 检查当前 stage(如
voice_submitted),查询 Kling 状态 - 若
voice_succeed→ 释放 slot,推进到element_pending,并在同一 tick 内尝试申请 slot 提交主体创建 - 若
element_succeed→ 释放 slot,状态改为completed
4. API 层与数据流
4.1 创建任务(不变)
@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 查询状态(不变)
@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
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 在"提交→轮询→收尾"类任务中的角色是结构性错位的。它带来的任务风暴、队列拥堵和状态死锁不是可以通过调参修复的,而是其模型与问题本质不匹配的结果。
统一异步调度方案的核心决策:
- 用 Async Engine(Slot-Based Scheduler)替代 Celery 管理所有第三方异步任务
- Video 分配 18 槽,Avatar Clone 分配 2 槽,由唯一调度器全局管理
- API 层和前端轮询逻辑完全不变,实现渐进式迁移
- 本地开发环境保持原样,无需引入 webhook 或云端部署
这是根治"任务队列生成视频总会出问题"的唯一长期方案。