Files
meijiaka-zy/docs/unified-async-scheduler.md
T

351 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 统一异步任务调度方案
> **状态:已完成(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 EngineCelery 整体移除
---
## 6. 迁移路径
| 阶段 | 时间 | 动作 | 风险 |
|------|------|------|------|
| **Phase 1** | 本周 | Async Engine 只接管 `video`18 slots);`avatar_clone` 仍由 Celery 运行 | 改动面最小,只验证 video 链路 |
| **Phase 2** | 1-2 周后 | Async Engine 新增 `avatar_clone` Handler2 slots);彻底下线 Celery 的 `worker-video``worker-avatar` | 验证 avatar 链路,解决资源饿死 |
| **Phase 3** | 未来 | `subtitle``image` 等陆续迁入 Async EngineCelery 完全移除 | 统一所有第三方异步任务调度 |
---
## 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 EngineSlot-Based Scheduler)替代 Celery 管理所有第三方异步任务**
2. **Video 分配 18 槽,Avatar Clone 分配 2 槽,由唯一调度器全局管理**
3. **API 层和前端轮询逻辑完全不变,实现渐进式迁移**
4. **本地开发环境保持原样,无需引入 webhook 或云端部署**
这是根治"任务队列生成视频总会出问题"的唯一长期方案。