351 lines
16 KiB
Markdown
351 lines
16 KiB
Markdown
# 统一异步任务调度方案
|
||
|
||
> **状态:已完成(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 或云端部署**
|
||
|
||
这是根治"任务队列生成视频总会出问题"的唯一长期方案。
|