AiVedio:整体系统架构
为 AI 生成视频与在线视频剪辑平台建立控制面、执行面、媒体数据面、供应商适配、异步事件、对象存储和多租户调度的整体架构。
第 03 章:整体系统架构
本章定位:为“AI 生成视频与在线视频剪辑平台”建立一套可落地、可扩展、可面试表达的总体架构。重点不是罗列服务名称,而是解释每个边界为什么存在、哪些数据必须强一致、哪些流程只能最终一致,以及第三方模型和大媒体文件如何被安全地纳入统一工作流。
3.1 本章要解决的业务问题
AI 视频平台表面上是“输入提示词,返回视频”,工程上却同时具备以下特征:
- 请求短、任务长:创建任务的 HTTP 请求应在秒级返回,但模型生成可能持续数分钟。
- 单次执行有真实成本:一次重复提交可能造成重复生成、重复供应商扣费和用户重复计费。
- 第三方结果弱确定:会出现限流、超时、回调丢失、回调乱序、任务状态未知、临时结果地址过期等情况。
- 媒体体积远大于元数据:参考视频、生成结果、代理视频、HLS 切片和最终成品不应穿过普通 Go API 服务。
- 资源类型差异显著:API 服务主要消耗连接和数据库;媒体处理消耗 CPU、GPU、显存和临时磁盘;供应商调用受外部并发配额约束。
- 业务事实跨多个阶段形成:任务状态、额度预占、供应商作业、媒体资产、项目版本和最终渲染不能由单一缓存或单条消息决定。
- 系统需要支持多租户与多供应商:普通用户、付费用户和企业租户需要不同的额度、优先级、隔离级别和合规路由。
- 生成只是前半程:结果还要回源、探测、转码、审核、分发、在线编辑和最终渲染。
因此,本章要形成的架构结论是:
平台不是一个“同步调用模型 API”的应用,而是一个由数据库事实、异步事件、供应商执行和媒体流水线共同组成的长任务工作流系统。
本章的交付目标包括:
- 明确控制面、执行面和媒体数据面的边界。
- 明确每个逻辑组件的单一职责和禁止承担的职责。
- 明确同步调用、异步消息和媒体直传分别用于什么场景。
- 明确 PostgreSQL、Redis、RocketMQ、对象存储和 CDN 的权威边界。
- 给出任务创建、供应商执行、结果回源、媒体处理和通知的完整调用关系。
- 为后续状态机、数据库、消息队列、调度、媒体处理和高可用章节建立统一上下文。
3.2 核心设计原则
3.2.1 控制面与媒体数据面分离
控制面处理小而关键的数据:
- 身份、租户和权限。
- 任务创建、取消、查询和状态转换。
- 资产元数据、项目版本和时间轴 JSON。
- 调度、供应商路由、额度、计费和审核结论。
- 消息事件、审计记录和通知元数据。
媒体数据面处理大字节流:
- 图片、音频、视频上传。
- 第三方生成结果下载。
- 转码、切片、缩略图、波形和代理视频。
- 最终渲染、中间文件和成品分发。
核心规则是:
Go 业务服务传递对象标识、元数据和短期授权,不代理传输整段视频。
客户端应通过预签名地址直接上传对象存储;大文件可使用分片上传,失败时只重传失败分片,而不是从头上传。5
3.2.2 PostgreSQL 管事实,Redis 管速度,RocketMQ 管异步,对象存储管字节
| 基础组件 | 应承担的职责 | 不应承担的职责 |
|---|---|---|
| PostgreSQL | 任务、资产、供应商作业、项目版本、计费流水、Outbox、审核记录等事实数据 | 高频百分比进度、大文件内容、无界日志流 |
| Redis | API 限流、短期缓存、并发槽位、租约、热点状态、SSE/WebSocket 通知加速 | 余额、核心任务状态、唯一任务记录的权威来源 |
| RocketMQ | 长任务解耦、削峰、消费重试、延迟调度、死信和跨组件事件传递 | 天然 exactly-once、业务状态事实、媒体文件传输 |
| 对象存储 | 原始素材、供应商回源结果、代理文件、切片、缩略图、中间文件和成品 | 复杂事务、任务状态机、租户业务授权判断 |
| CDN | 边缘缓存、范围请求、播放分发、签名访问 | 永久业务授权事实、源文件唯一副本 |
Redis Pub/Sub 是至多一次投递,订阅者断线期间的消息可能永久丢失,因此它适合实时提示,不适合作为任务完成事件的唯一事实来源。2
RocketMQ 在消费失败或消费确认超时时会触发重新投递,并可在超过重试次数后进入死信队列;这意味着消费者必须按“可能重复收到”设计。3
3.2.3 同步只处理有界短操作,异步承载长操作
适合同步处理:
- 鉴权、租户解析和参数校验。
- 查询资产元数据和任务快照。
- 费用估算、余额检查和额度预占。
- 输入侧快速审核。
- 获取预签名上传或播放地址。
适合异步处理:
- 模型排队与提交。
- 供应商状态轮询。
- 结果回源。
- FFmpeg/GPU 媒体处理。
- 最终渲染。
- 通知扇出、对账、补偿和清理。
判断标准不是“代码是否容易写”,而是:
- 操作是否可能超过 API 超时时间。
- 是否需要独立重试。
- 是否受外部配额或资源槽位限制。
- 是否应在系统高压时被排队或降级。
- 是否会因失败而阻塞数据库事务。
3.2.4 用状态机驱动工作流,而不是依赖消息到达顺序
消息可能重复、延迟或乱序,回调和轮询也可能同时到达。系统不能假设:
- “先发送的消息一定先消费”。
- “回调一定只到一次”。
- “取消请求一定早于成功回调生效”。
- “Redis 中的进度一定比 PostgreSQL 新”。
每次状态变更都必须经过允许的状态转换表,并使用数据库条件更新或版本号进行并发控制。消息只表达“发生了什么”或“请尝试做什么”,最终是否允许落库由当前任务事实决定。
3.2.5 不追求基础设施层 exactly-once,追求业务上的一次效果
业务一次效果由多层机制共同实现:
- API
Idempotency-Key。 - PostgreSQL 唯一约束。
- Outbox 与 Inbox 去重。
- 状态版本号和条件更新。
- 供应商幂等请求键。
- 账本唯一业务键。
- 确定性的对象键和媒体作业键。
- 对账与补偿任务。
PostgreSQL 唯一约束可保证一列或一组列在表内唯一,适合承载任务幂等键、供应商任务标识和账本业务键等最终防线。1
3.2.6 第三方供应商必须通过适配层接入
业务层不直接散落 if provider == ...。统一适配接口至少包括:
ValidateCapability
PrepareInput
Submit
Query
Cancel
ParseCallback
ClassifyError
FetchOutput
EstimateCost
适配层输出统一结构:
provider_job_id
normalized_status
progress
retry_class
provider_error_code
output_assets
estimated_cost
actual_cost
raw_response_ref
适配层负责“协议差异”,调度器负责“选谁执行”,任务服务负责“业务状态”,计费服务负责“钱”,不能混为一个巨型 Provider Service。
3.2.7 逻辑组件不等于必须独立部署
本章列出的服务首先是逻辑边界。是否拆成独立进程,应由以下条件决定:
- 是否需要独立扩缩容。
- 是否需要故障隔离。
- 是否有明显不同的安全边界。
- 是否有不同的发布频率或团队归属。
- 是否会破坏关键本地事务。
早期阶段可以将 Generation、Asset Metadata、Billing Reservation 和 Moderation Orchestration 作为同一控制面应用中的模块;Scheduler、Provider Worker、Callback/Polling、Media Worker、Render Worker 和 Notification Gateway 则更适合独立部署。这样既保留清晰边界,又避免过早引入大量跨服务事务。
3.2.8 架构必须内置对账和恢复能力
长任务系统不能只设计正常路径。必须能回答:
- 数据库有任务但 MQ 没消息怎么办?
- MQ 重投后会不会再次调用供应商?
- 供应商已受理但本地超时怎么办?
- 回调丢失后如何恢复?
- Redis 并发槽位泄漏如何修复?
- 输出 URL 过期前没有回源怎么办?
- 供应商扣费与用户账本不一致怎么办?
因此,Outbox Relay、轮询、过期任务扫描、账单对账、资产完整性扫描和人工重放工具都属于主架构,而不是上线后的补丁。
3.2.9 必须坚持的架构不变量
- 任何核心业务对象都能仅依靠 PostgreSQL 重建当前事实。
- 任何消息都允许重复消费,消费者不能依赖“只来一次”。
- 任何第三方
Submit超时都不能被默认解释为失败。 - 任何媒体对象在业务表中保存的是内部
asset_id/object_key,不是临时公开 URL。 - 任何计费动作都有唯一业务键,并以追加账本记录表达。
- 终态不能被旧事件回退到非终态。
- 通知丢失不影响任务事实;客户端重连后重新读取快照。
- API、供应商调用、媒体处理和渲染使用不同的并发池和资源配额。
3.3 详细架构与组件职责
3.3.1 完整文字架构图
┌────────────────────────────── 客户端层 ──────────────────────────────┐
│ Browser / Mobile / Open API Client │
│ ├─ HTTPS:创建任务、查询状态、编辑项目 │
│ ├─ SSE/WebSocket:接收状态提示 │
│ ├─ Presigned PUT:直接上传对象存储 │
│ └─ CDN GET/Range:播放代理视频、HLS 和最终成品 │
└────────────────────────────────┬────────────────────────────────────┘
│
▼
┌────────────────────────────── 边缘接入层 ────────────────────────────┐
│ API Gateway / WAF / Auth / Tenant Context / Rate Limit │
│ Notification Gateway(SSE/WebSocket) │
└───────────────┬───────────────────────────────────┬──────────────────┘
│短同步请求 │实时通知
▼ ▲
┌────────────────────────────── 控制面 ────────────────────────────────┐
│ Generation Service │
│ ├─ create/query/cancel │
│ ├─ 参数归一化、状态机命令 │
│ └─ task + reservation + outbox 事务 │
│ │
│ Asset Service Billing Service Moderation Service │
│ ├─ 资产元数据 ├─ quote/reserve ├─ 输入审核 │
│ ├─ 预签名地址 ├─ settle/release ├─ 输出审核 │
│ └─ 租户 ACL └─ refund/reconcile └─ 策略版本 │
│ │
│ PostgreSQL:task / asset / ledger / provider_job / outbox / audit │
│ Redis:限流 / 槽位 / 租约 / 热点进度 / 通知加速 │
└──────────────────────────┬───────────────────────────────────────────┘
│ Outbox Relay
▼
┌────────────────────────── RocketMQ 异步总线 ─────────────────────────┐
│ generation.dispatch │ provider.poll │ output.fetch │ media.process │
│ render.execute │ notify │ billing │ retry / DLQ │
└───────────────┬───────────────────┬───────────────────┬───────────────┘
│ │ │
▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────┐
│ Scheduler │ │ Callback/Polling │ │ Render Service │
│ ├─ 租户公平 │ │ ├─ 签名验证 │ │ ├─ 时间轴校验 │
│ ├─ 优先级与 aging │ │ ├─ 回调归档/去重 │ │ ├─ 编译 Render DAG │
│ ├─ 平台/租户/模型槽位 │ │ ├─ 状态查询与退避 │ │ └─ 创建渲染任务 │
│ └─ 供应商路由 │ │ └─ 归一化状态事件 │ └────────┬─────────┘
└──────────┬───────────┘ └──────────┬───────────┘ │
▼ │ ▼
┌──────────────────────┐ │ ┌──────────────────┐
│ Provider Adapter │◄────────────┘ │ Render Worker │
│ ├─ Submit/Query │ │ FFmpeg/GPU │
│ ├─ Cancel/Callback │ └────────┬─────────┘
│ └─ Error Normalize │ │
└──────────┬───────────┘ │
▼ │
┌──────────────────────── 第三方/自建模型执行面 ───────────────┐ │
│ Runway / Veo / 其他供应商 / 自建推理集群 │ │
└──────────┬───────────────────────────────────────────────────┘ │
│ 临时输出 URL / 对象位置 │
▼ │
┌──────────────────────────── 媒体数据面 ────────────────────────────────┐
│ Output Fetch Worker │
│ ├─ 安全下载、限速、校验、checksum │
│ └─ 写入自有对象存储 │
│ │ │
│ ▼ │
│ Media Worker │
│ ├─ ffprobe / 转码 / HLS / 缩略图 / 波形 / 代理视频 │
│ └─ 输出复审 │
│ │ │
│ ▼ │
│ Object Storage ───────────────► CDN ───────────────► Browser │
│ 原始素材/回源结果/变体/中间文件/最终成品 │
└────────────────────────────────────────────────────────────────────────┘
横切能力:Trace、Metrics、Logs、Audit、Secrets、Feature Flags、Reconcile
这张图强调三点:
- 浏览器上传和播放走对象存储/CDN,不走 Generation Service。
- 供应商提交、轮询、回调、回源和媒体处理都不占用用户请求线程。
- 所有异步组件最终都通过受控状态转换回到 PostgreSQL 事实,而不是互相覆盖状态。
3.3.2 控制面、执行面与数据面
| 平面 | 典型组件 | 主要数据 | 设计重点 |
|---|---|---|---|
| 边缘接入层 | API Gateway、Notification Gateway | Token、租户上下文、请求元数据、连接状态 | 认证、限流、防滥用、连接管理 |
| 控制面 | Generation、Asset、Billing、Moderation、Scheduler | 任务、资产元数据、额度、策略、路由决策 | 强事务边界、状态机、幂等、公平调度 |
| 供应商执行面 | Provider Adapter、Callback Gateway、Polling Service | provider_job_id、供应商状态、原始错误 | 弱确定性处理、协议归一化、熔断降级 |
| 媒体数据面 | Output Fetch、Media Worker、Render Worker、对象存储、CDN | 视频字节、切片、缩略图、中间文件 | 流式 IO、CPU/GPU 隔离、临时盘、生命周期 |
| 横切治理面 | 观测、审计、密钥、对账、人工重放 | trace、事件、账单、操作记录 | 可排障、可恢复、可证明 |
3.3.3 组件职责清单
1. API Gateway
负责:
- TLS 终止、WAF、身份认证和租户上下文注入。
- API 级速率限制、请求体上限和基础 schema 校验。
- 透传并规范化
Idempotency-Key、trace_id、客户端版本。 - 路由到 Generation、Asset、Render 等控制面服务。
不负责:
- 不创建业务任务记录。
- 不持有用户余额事实。
- 不代理大文件上传、下载或供应商输出回源。
- 不通过网关重试非幂等的生成请求。
2. Generation Service
负责:
- 创建、查询、取消生成任务。
- 参数归一化、能力初筛和引用资产检查。
- 编排输入审核、费用估算和额度预占。
- 在一个本地事务中写入任务、额度预占流水和 Outbox。
- 作为任务状态机命令的唯一业务入口,执行条件状态转换。
- 向客户端返回稳定的内部
task_id。
不负责:
- 不在 HTTP 请求中同步等待供应商完成。
- 不直接执行 FFmpeg。
- 不把供应商原始错误和临时输出 URL 直接返回用户。
- 不把高频进度每 1% 写入 PostgreSQL。
3. Asset Service
负责:
- 创建资产占位记录和对象键。
- 生成带租户范围、过期时间、大小约束的预签名上传地址。
- 上传完成确认、对象存在性检查、checksum 和媒体元数据登记。
- 管理原始资产与代理、缩略图、波形、HLS 等变体的关系。
- 执行租户 ACL、生命周期和删除策略。
不负责:
- 不让素材先进入 Go 内存再转发对象存储。
- 不因客户端声明
Content-Type就信任文件类型。 - 不把对象存储 bucket/key 直接作为跨租户授权依据。
预签名 URL 能在不向客户端暴露存储凭据的情况下授权特定对象操作;该授权继承签发者的权限,因此对象键、有效期和条件必须收紧。5
4. Scheduler
负责:
- 从待调度事件中选择可执行任务。
- 实施平台、租户、供应商和模型四级并发准入。
- 按租户公平、付费等级、优先级和等待时长进行排序。
- 根据模型能力、健康度、延迟、成本、地区和合规要求选择供应商。
- 获取短期调度租约,并创建新的
task_attempt。 - 在容量不足时延迟重投或保持排队,而不是无限拉取。
不负责:
- 不把 Redis 槽位数当作最终任务状态。
- 不直接结算用户费用。
- 不在一次消费中无限等待供应商。
- 不保证全局严格 FIFO。
5. Provider Adapter
负责:
- 将内部标准请求转换为供应商参数。
- 实现提交、查询、取消、回调解析和错误分类。
- 保存供应商原始请求标识、响应摘要和版本信息。
- 将供应商状态映射为统一内部状态。
- 在供应商支持时传递稳定幂等键。
不负责:
- 不决定租户优先级。
- 不直接修改余额。
- 不自行将未知超时判断为失败并盲目重提。
- 不执行跨供应商业务补偿。
6. Callback Gateway
负责:
- 提供公网回调入口。
- 验证签名、时间戳、nonce、来源配置和防重放条件。
- 原样归档必要的回调摘要或对象引用。
- 通过供应商事件 ID 或内容哈希去重。
- 快速返回成功响应,再异步解析和更新状态。
不负责:
- 不在回调线程中下载视频或转码。
- 不因为回调携带
SUCCEEDED就无条件覆盖本地终态。 - 不把回调作为唯一完成检测机制。
7. Polling Service
负责:
- 对没有回调、回调不稳定或状态未知的任务执行查询。
- 使用指数退避、抖动和供应商级 QPS 限制。
- 根据任务阶段动态调整轮询频率。
- 终态后停止轮询,并回收计划记录。
- 扫描长时间无变化的任务,触发对账或人工检查。
不负责:
- 不通过轮询再次提交生成。
- 不以高频轮询伪造实时进度。
- 不在多个实例之间无租约地重复轮询同一任务。
8. Output Fetch Worker
负责:
- 在供应商任务成功后尽快回源临时结果。
- 使用流式 IO 下载,限制响应大小、超时、速率和重定向次数。
- 验证协议、主机、解析 IP、MIME、magic number 和 checksum。
- 将结果写入自有对象存储并创建内部 Asset。
- 记录来源、下载时间、供应商作业和完整性信息。
不负责:
- 不把供应商临时 URL 直接发给前端。
- 不允许任意 URL 下载,避免 SSRF。
- 不一次性
ReadAll大视频到内存。
部分供应商明确说明生成结果 URL 是临时的,应下载到自有存储而不是直接暴露给产品用户。8
9. Media Worker
负责:
- 使用 ffprobe 获取编码、时长、帧率、分辨率和音轨信息。
- 生成标准播放文件、代理视频、缩略图、波形和 HLS/CMAF 切片。
- 执行输出侧内容审核、水印或格式规范化。
- 将每个变体登记为独立 Asset,并关联源资产。
- 在隔离容器中限制 CPU、内存、GPU、临时盘和执行时长。
不负责:
- 不与 API 服务共享无上限的 CPU/内存池。
- 不拼接未经转义的 shell 命令。
- 不把本地临时盘当作永久存储。
10. Render Service
负责:
- 接收项目版本和时间轴快照。
- 校验引用资产和编辑参数。
- 将时间轴编译为可执行 Render DAG。
- 生成确定性的渲染作业键和缓存键。
- 调度 FFmpeg/GPU Render Worker。
- 跟踪最终成品、失败节点和可复用中间结果。
不负责:
- 不读取“正在变化的项目草稿”直接渲染。
- 不让浏览器预览结果成为最终成品事实。
- 不在 API Pod 内启动长时间 FFmpeg 进程。
11. Notification Gateway
负责:
- 管理 SSE/WebSocket 长连接。
- 按用户、租户和项目进行消息扇出。
- 将状态变化、排队提示和资产就绪提示发送给在线客户端。
- 支持连接心跳、限速、背压和多实例路由。
- 客户端重连时引导其重新读取任务快照。
不负责:
- 不作为任务事实源。
- 不保证断线期间每条 UI 提示都可重放。
- 不因通知失败回滚任务成功状态。
12. Billing Service
负责:
- 估价、额度预占、实际结算、释放、退款和补偿。
- 使用追加式账本记录每个资金动作。
- 区分供应商成本、平台内部成本和用户收费。
- 按供应商账单、任务记录和用户账本执行每日对账。
- 对每个业务动作使用唯一业务键。
不负责:
- 不用 Redis 余额作为最终余额。
- 不覆盖或删除历史账本来“修正余额”。
- 不因消息重投重复结算。
13. Moderation Service
负责:
- 对提示词、参考素材和生成结果执行审核。
- 输出标准化决策:允许、拒绝、人工复核、降级模型或加水印。
- 记录策略版本、模型版本、命中规则和证据引用。
- 区分不可重试的内容拒绝与可重试的审核基础设施故障。
不负责:
- 不把供应商的单一审核结果当作平台完整政策。
- 不对明确内容违规无限自动重试。
- 不在普通业务日志中写入完整敏感提示词或媒体内容。
14. Outbox Relay 与 Inbox 去重中间件
负责:
- Relay 扫描已提交但未发布的 Outbox 事件并发送 RocketMQ。
- 发送成功后更新发布状态;发送结果未知时允许再次发送。
- 消费者在本地事务中记录
(consumer_name, event_id),防止同一业务事件重复生效。 - 提供死信重放、事件追踪和 schema 版本管理。
不负责:
- 不把“MQ 返回成功”解释为下游业务已完成。
- 不删除尚未被审计或超过保留期的事件证据。
3.3.4 推荐的物理部署方式
逻辑组件可以按资源与故障域分为以下部署单元:
| 部署单元 | 可包含的逻辑模块 | 拆分原因 |
|---|---|---|
control-api | Generation、Asset Metadata、Billing Reservation、Moderation Orchestration | 共享低延迟事务边界,避免任务与预占跨服务不一致 |
outbox-relay | Outbox 扫描与 RocketMQ Producer | 独立扩缩、可暂停、便于监控积压 |
scheduler-worker | Scheduler、Provider Adapter Submit | 受供应商配额约束,与 API 资源隔离 |
provider-state-worker | Callback Processor、Polling Service、状态归一化 | 独立处理外部回调和查询压力 |
output-fetch-worker | 安全下载与对象存储写入 | 网络带宽、连接和 egress 策略独立 |
media-worker | ffprobe、转码、切片、缩略图、输出审核 | CPU/GPU/临时盘重负载 |
render-api | 时间轴校验、Render DAG、作业创建 | 与编辑器协议演进一致 |
render-worker | FFmpeg/GPU 渲染执行 | 高资源、长时任务、独立队列 |
notification-gateway | SSE/WebSocket | 大量长连接、网络型负载 |
这里最关键的取舍是:
任务创建、额度预占和 Outbox 写入最好处在同一个 PostgreSQL 本地事务中。
若一开始就把 Billing 拆成独立数据库微服务,则只能通过 Saga 实现:先预占,再创建任务;创建失败后释放预占;任何中间故障都需要补偿和对账。对于团队规模和业务复杂度尚未要求独立账务域的阶段,保留一个明确的本地事务边界通常更稳妥。后续若因组织、安全或合规必须拆分,再接受 Saga 带来的中间状态和运维成本。
3.3.5 服务调用关系
| 调用方 | 被调用方 | 方式 | 典型超时/失败策略 |
|---|---|---|---|
| Client | API Gateway | HTTPS | 客户端可按相同幂等键重试创建请求 |
| Client | Object Storage | Presigned PUT/Multipart | 分片独立重试;完成后调用 Asset finalize |
| Client | CDN | HTTPS Range/HLS | CDN 失败可回源或切备用域名 |
| Gateway | Generation/Asset/Render | 同步 HTTP/gRPC | 只做短操作;设置严格 deadline |
| Generation | PostgreSQL | 本地事务 | 失败整体回滚,不调用供应商 |
| Generation | Moderation | 同步短调用或内部模块 | 超时按政策 fail-closed 或转待审核 |
| Outbox Relay | RocketMQ | 异步发送 | 发送结果未知时允许重复发送 |
| Scheduler | Redis | 槽位/租约 | Redis 故障时采取保守准入或暂停新调度 |
| Scheduler | Provider Adapter | 内部调用 | 创建 attempt 后提交;记录提交未知状态 |
| Provider Adapter | Provider API | HTTPS | Submit 与 Query 使用不同重试策略 |
| Provider | Callback Gateway | HTTPS Webhook | 快速验签、落事件、返回 2xx |
| Polling Service | Provider API | HTTPS GET | 指数退避、抖动、供应商级限流 |
| State Handler | PostgreSQL | 条件更新事务 | 终态保护、版本号、事件记录 |
| State Handler | RocketMQ | Outbox 事件 | 驱动 output fetch、media、notify |
| Output Fetch | Provider URL/Object | 流式 HTTPS | SSRF 防护、大小限制、可恢复重试 |
| Media/Render Worker | Object Storage | 流式读写 | 临时盘配额、分阶段 checkpoint |
| Notification Gateway | Client | SSE/WebSocket | 丢失可接受;重连后读取任务快照 |
3.4 文字版时序图
下面展示从参考素材上传到视频可播放的主流程。为突出架构关系,计费结算和渲染导出在主链路后补充说明。
Browser
│
│ 1. POST /assets/uploads
▼
API Gateway ──► Asset Service
│
│ 2. 创建 asset 占位,生成 object_key
│ 3. 返回 presigned multipart 信息
▼
Browser ─────────────────────────► Object Storage
│ │
│ 4. 直接分片上传 │
│ 5. POST /assets/{id}/complete │
▼ │
Asset Service ── HEAD/checksum ──────┘
│
│ 6. 标记素材 READY
▼
Browser
│
│ 7. POST /generation-tasks + Idempotency-Key
▼
API Gateway ──► Generation Service
│
├─ 8. 权限、参数、资产状态检查
├─ 9. Moderation 输入审核
├─ 10. Billing 估价与额度预占
│
├─ 11. PostgreSQL 单事务:
│ generation_task
│ credit_ledger(RESERVE)
│ outbox_event(TaskQueued)
│
└─ 12. 返回 task_id 与 QUEUED
Outbox Relay
│ 13. 发布 TaskQueued
▼
RocketMQ
│
▼
Scheduler
├─ 14. 租户公平与优先级选择
├─ 15. 获取 provider/model 槽位
├─ 16. 创建 task_attempt,状态改为 DISPATCHING
▼
Provider Adapter
│ 17. Submit(provider_request_key)
▼
Third-party Provider
│
├─ 18a. 正常返回 provider_job_id
│ Adapter 落 provider_job,任务转 SUBMITTED/RUNNING
│
└─ 18b. 或 HTTP 超时、结果未知
任务转 SUBMIT_UNKNOWN,进入查询/对账,不盲目重提
Third-party Provider
│
├─ 19a. Callback ─────────► Callback Gateway
│ ├─ 验签、防重放、归档
│ └─ 发布 ProviderStatusObserved
│
└─ 19b. 无可靠回调时 ◄──── Polling Service 定时 Query
State Handler
├─ 20. 按状态机和 version 条件更新 PostgreSQL
├─ 21. 若成功,写 Outbox(OutputFetchRequested)
└─ 22. 若失败,分类为可重试、最终失败或待人工确认
RocketMQ
│ 23. OutputFetchRequested
▼
Output Fetch Worker
├─ 24. 安全流式下载供应商临时结果
├─ 25. checksum、媒体初检
├─ 26. 写入自有对象存储并登记源资产
└─ 27. 发布 MediaProcessRequested
Media Worker
├─ 28. ffprobe、转码、代理视频、HLS、缩略图、波形
├─ 29. 输出侧审核
├─ 30. 登记 asset variants
└─ 31. 状态转 SUCCEEDED/PLAYABLE
Billing Service
├─ 32. 按业务政策结算、释放差额或退款
└─ 33. 写唯一业务键账本
Notification Gateway
│ 34. 推送“视频可播放”提示
▼
Browser
│ 35. 重新 GET task 快照,获取内部 asset_id
│ 36. 获取 CDN 签名播放地址
▼
CDN ──► 播放代理视频/HLS
最终导出是另一条异步链路:
Browser 提交 project_revision
→ Render Service 校验并编译 Render DAG
→ PostgreSQL 写 render_job + outbox
→ RocketMQ render.execute
→ Render Worker 读取对象存储素材并执行 FFmpeg/GPU
→ 成品写对象存储
→ CDN 分发
→ Notification Gateway 提示导出完成
3.5 关键数据结构、数据库表与消息字段
3.5.1 核心数据库表
generation_tasks
id UUID PK
tenant_id UUID NOT NULL
user_id UUID NOT NULL
project_id UUID NULL
idempotency_key VARCHAR NOT NULL
mode VARCHAR NOT NULL
requested_model VARCHAR NULL
normalized_params JSONB NOT NULL
status VARCHAR NOT NULL
status_version BIGINT NOT NULL DEFAULT 0
priority INT NOT NULL
current_attempt_id UUID NULL
estimated_cost NUMERIC
reserved_amount NUMERIC
result_asset_id UUID NULL
failure_class VARCHAR NULL
failure_code VARCHAR NULL
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ
terminal_at TIMESTAMPTZ NULL
UNIQUE (tenant_id, idempotency_key)
status_version 用于乐观并发控制。状态转换应类似:
UPDATE generation_tasks
SET status = $new_status,
status_version = status_version + 1,
updated_at = now()
WHERE id = $task_id
AND status = ANY($allowed_from_states)
AND status_version = $expected_version;
受影响行数为 0 时,说明事件已过期、状态不允许或存在并发竞争,必须重新读取事实,而不是直接覆盖。
task_attempts
id UUID PK
task_id UUID NOT NULL
attempt_no INT NOT NULL
provider VARCHAR NOT NULL
model VARCHAR NOT NULL
dispatch_token VARCHAR NOT NULL
status VARCHAR NOT NULL
submit_started_at TIMESTAMPTZ
submit_finished_at TIMESTAMPTZ NULL
unknown_since TIMESTAMPTZ NULL
error_class VARCHAR NULL
error_code VARCHAR NULL
created_at TIMESTAMPTZ
UNIQUE (task_id, attempt_no)
UNIQUE (dispatch_token)
一次业务任务可以有多个 attempt,但同一 attempt 的 Provider Submit 只能产生一次业务效果。
provider_jobs
id UUID PK
task_id UUID NOT NULL
attempt_id UUID NOT NULL
provider VARCHAR NOT NULL
provider_job_id VARCHAR NOT NULL
provider_request_key VARCHAR NULL
normalized_status VARCHAR NOT NULL
raw_status VARCHAR NULL
last_observed_at TIMESTAMPTZ
next_poll_at TIMESTAMPTZ NULL
output_ref JSONB NULL
raw_response_ref VARCHAR NULL
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ
UNIQUE (provider, provider_job_id)
assets
id UUID PK
tenant_id UUID NOT NULL
project_id UUID NULL
kind VARCHAR NOT NULL
source_asset_id UUID NULL
object_bucket VARCHAR NOT NULL
object_key VARCHAR NOT NULL
content_type VARCHAR
size_bytes BIGINT
checksum_algo VARCHAR
checksum_value VARCHAR
status VARCHAR NOT NULL
width INT NULL
height INT NULL
duration_ms BIGINT NULL
frame_rate_num INT NULL
frame_rate_den INT NULL
codec VARCHAR NULL
variant_profile VARCHAR NULL
created_at TIMESTAMPTZ
UNIQUE (object_bucket, object_key)
业务接口返回 asset_id,播放时再根据权限生成短期 CDN 签名 URL。CDN 签名 URL 可以附带过期时间和访问策略,但它是传输授权,不是业务所有权事实。7
credit_ledger
id UUID PK
tenant_id UUID NOT NULL
user_id UUID NOT NULL
task_id UUID NULL
business_type VARCHAR NOT NULL
business_id VARCHAR NOT NULL
type VARCHAR NOT NULL
amount NUMERIC NOT NULL
currency_or_credit VARCHAR NOT NULL
status VARCHAR NOT NULL
created_at TIMESTAMPTZ
UNIQUE (business_type, business_id)
type 可包括:
RESERVE
SETTLE
RELEASE
REFUND
COMPENSATE
outbox_events
id UUID PK
aggregate_type VARCHAR NOT NULL
aggregate_id UUID NOT NULL
event_type VARCHAR NOT NULL
payload JSONB NOT NULL
schema_version INT NOT NULL
trace_id VARCHAR
causation_id UUID NULL
status VARCHAR NOT NULL
available_at TIMESTAMPTZ NOT NULL
published_at TIMESTAMPTZ NULL
attempt_count INT NOT NULL DEFAULT 0
created_at TIMESTAMPTZ
inbox_dedup
consumer_name VARCHAR NOT NULL
event_id UUID NOT NULL
processed_at TIMESTAMPTZ NOT NULL
result_code VARCHAR NULL
PRIMARY KEY (consumer_name, event_id)
其他关键表
tenants
users
projects
project_revisions
moderation_records
callback_events
task_events
media_jobs
render_jobs
notification_events
provider_capabilities
provider_health_snapshots
reconciliation_cases
3.5.2 统一消息信封
{
"event_id": "uuid",
"event_type": "GenerationTaskQueued.v1",
"aggregate_type": "generation_task",
"aggregate_id": "task_uuid",
"tenant_id": "tenant_uuid",
"occurred_at": "2026-06-23T03:00:00Z",
"schema_version": 1,
"trace_id": "trace-id",
"causation_id": "previous-event-id",
"correlation_id": "task-id",
"partition_key": "task-id",
"retry_count": 0,
"payload": {
"task_id": "task_uuid",
"attempt_id": null,
"priority": 50
}
}
设计要求:
- 消息只携带定位业务对象所需的小字段,不携带视频二进制。
event_id全局唯一,供 Inbox 去重。correlation_id通常使用task_id,方便全链路检索。causation_id连接因果链,便于追踪“哪个事件触发了哪个事件”。- schema 采用显式版本,不在不兼容修改后复用旧事件名。
- 只有确实需要单任务顺序的 Topic 才使用
task_id作为顺序键;不要求全局顺序。
3.5.3 Topic 建议
generation.dispatch
provider.status.observe
provider.poll
output.fetch
media.process
render.execute
billing.command
notification.event
reconcile.command
应按以下维度拆分 Topic 或消费者组:
- 资源类型不同:网络下载、CPU 转码、GPU 渲染。
- SLA 不同:用户状态通知与离线清理。
- 重试语义不同:Provider Submit 与 Query。
- 安全权限不同:公网回调处理与内部媒体处理。
- 积压隔离不同:渲染积压不应阻塞生成状态更新。
3.5.4 Redis 键示例
rate:api:{tenant_id}:{route}
concurrency:tenant:{tenant_id}
slot:provider:{provider}:{model}
lease:schedule:{task_id}
lease:poll:{provider_job_id}
task:progress:{task_id}
notify:user:{user_id}
provider:health:{provider}:{model}
所有槽位和租约必须具备:
- TTL。
- owner token。
- 续租规则。
- 释放时校验 owner。
- 定期与 PostgreSQL 活跃任务对账。
即使 Redis 计数泄漏,也只能影响短期准入,不能改变 PostgreSQL 中任务是否已成功、是否已计费的事实。
3.6 正常流程
3.6.1 素材上传
- 客户端请求 Asset Service 创建上传会话。
- Asset Service 校验租户配额、文件声明和项目权限。
- PostgreSQL 创建
UPLOADING资产记录和唯一对象键。 - 返回预签名地址或分片上传参数。
- 客户端直接上传对象存储。
- 客户端提交完成确认,Asset Service 校验对象存在、大小和 checksum。
- 媒体探测任务异步执行,完成后资产变为
READY。
3.6.2 创建生成任务
- Gateway 完成认证、限流和租户上下文注入。
- Generation Service 以
(tenant_id, idempotency_key)查询或创建任务。 - 校验模式、模型、时长、宽高比、引用资产状态和租户权限。
- Moderation Service 对提示词和输入素材执行前置审核。
- Billing Service 计算估价并确认可预占额度。
- 在一个 PostgreSQL 事务中写入:
generation_tasks。credit_ledger(RESERVE)。outbox_events(TaskQueued)。
- 事务提交后立即向客户端返回
task_id和QUEUED。
3.6.3 异步调度与供应商提交
- Outbox Relay 将
TaskQueued发布到 RocketMQ。 - Scheduler 消费后先执行 Inbox 去重。
- 按租户公平和优先级选择任务。
- 检查平台、租户、供应商和模型槽位。
- 从能力矩阵中过滤不支持当前请求的供应商。
- 根据成本、可用性、延迟、地区和用户锁定策略选择路由。
- 创建
task_attempt,将任务从QUEUED条件更新为DISPATCHING。 - Provider Adapter 以稳定请求键调用供应商 Submit。
- 成功获得
provider_job_id后创建provider_jobs,任务进入SUBMITTED/RUNNING。
3.6.4 状态观测
- Callback Gateway 接收供应商回调,完成验签、防重放和事件归档。
- 对没有可靠回调的供应商,Polling Service 按计划查询。
- 两类观测统一转换为
ProviderStatusObserved。 - 状态处理器读取 PostgreSQL 当前任务和 attempt。
- 根据状态机、事件新旧和版本号执行条件更新。
- 非终态变化可更新 Redis 热点进度;关键阶段写 PostgreSQL 事件。
3.6.5 输出回源和媒体处理
- 供应商成功后写入
OutputFetchRequestedOutbox。 - Output Fetch Worker 下载临时输出,校验后存入自有对象存储。
- 创建源结果 Asset,并将任务转入
POST_PROCESSING。 - Media Worker 生成代理、缩略图、HLS、波形等变体。
- Moderation Service 执行输出侧审核。
- 所有必须变体就绪后,将任务转为
SUCCEEDED或PLAYABLE。 - Billing Service 根据计费政策结算并释放预占差额。
- Notification Gateway 向客户端发送状态提示。
- 客户端重新读取任务快照,再获取 CDN 签名播放地址。
3.6.6 在线编辑与最终渲染
- 浏览器以非破坏方式保存时间轴和项目修订版本。
- 用户提交导出时固定一个不可变
project_revision。 - Render Service 将时间轴编译成 Render DAG。
- 创建
render_job + outbox,异步进入渲染队列。 - Render Worker 读取对象存储素材,执行 FFmpeg/GPU 渲染。
- 成品写入对象存储并通过 CDN 分发。
- 渲染失败只影响导出作业,不回滚已经成功的生成资产。
3.7 异常流程和竞态条件
| 场景 | 主要风险 | 处理方式 |
|---|---|---|
| 客户端因超时重复创建任务 | 重复任务、重复预占 | 同一租户使用相同 Idempotency-Key;数据库唯一约束返回原任务 |
| Outbox 发送结果未知后再次发送 | MQ 重复消息 | 消费者 Inbox 去重;状态更新使用条件语句 |
| Scheduler 消费超时被 MQ 重投 | 重复 Provider Submit | attempt/dispatch token 唯一;提交前检查 attempt 状态;未知提交不盲重试 |
| 供应商已受理但本地 HTTP 超时 | 重复生成和重复供应商成本 | 标记 SUBMIT_UNKNOWN;按供应商幂等键查询或对账;确认未受理前不新建 attempt |
| 回调早于本地保存 provider_job | 无法关联回调 | 先归档 callback_events;按 request key/provider job id 延迟重放匹配 |
| 回调与轮询同时报告状态 | 重复转换、终态被覆盖 | 统一进入状态处理器;版本号、允许转换表和事件时间共同判断 |
| 旧 RUNNING 回调晚于 SUCCEEDED | 状态回退 | 终态保护;非终态事件不得覆盖终态 |
| 取消与成功同时发生 | 用户看到取消但供应商已完成,计费争议 | PostgreSQL 条件更新决定谁先赢;取消仅是请求,需区分 CANCEL_REQUESTED 与 CANCELED |
| 输出 URL 过期 | 已生成但无法取得媒体 | 成功后立即高优先级回源;监控 URL 剩余寿命;必要时向供应商刷新链接或人工补偿 |
| Redis 槽位泄漏 | 任务长期无法调度或超额调度 | TTL、owner token、续租、定期按数据库活跃任务重建 |
| Media Worker 重复消费 | 重复转码、重复对象和资源浪费 | (source_asset_id, profile, pipeline_version) 唯一作业键;存在有效成品即返回成功 |
| 结算与退款并发 | 余额错误 | 账本业务键唯一;状态机规定可执行的账务动作;冲突后重读 |
| 通知服务断线 | UI 没收到完成提示 | 通知非事实;客户端重连后 GET 任务快照 |
3.7.1 “供应商已成功但本地超时”的决策树
这是本系统最重要的异常之一。
Submit 超时
│
├─ 供应商支持 idempotency key?
│ ├─ 是:用同一 key 查询或重试同一请求
│ └─ 否
│
├─ 是否拿到 request-id / provider-job-id / 可查询句柄?
│ ├─ 是:进入查询流程
│ └─ 否
│
├─ 是否能按外部请求标签、时间窗口或账单记录对账?
│ ├─ 是:标记 SUBMIT_UNKNOWN,延迟确认
│ └─ 否:进入人工或保守等待窗口
│
└─ 只有确认第一次未受理,才允许新建下一 attempt
不能简单写成:
if err != nil {
retrySubmit()
}
因为网络超时只说明“本地没有拿到响应”,不说明“对方没有执行”。第二次 Submit 可能产生第二个收费任务。
3.7.2 取消与完成竞态
建议语义:
CANCEL_REQUESTED:平台已收到取消意图,但供应商是否真正停止尚未确认。CANCELED:确认未提交、供应商取消成功,或按业务规则可视为取消完成。SUCCEEDED:供应商已完成且结果有效,即使取消请求稍晚到达,也不能回退。
处理方式:
- 用户取消时执行条件更新,例如仅允许从
QUEUED/DISPATCHING/SUBMITTED/RUNNING进入CANCEL_REQUESTED。 - 若任务仍在
QUEUED,可阻止提交并释放预占。 - 若已提交,则异步调用供应商 Cancel。
- 若成功回调先落库,取消请求受影响行数为 0,向用户解释任务已完成。
- 若取消先落库,后续成功回调仍需按供应商真实成本和产品政策决定:保留结果、隐藏结果、部分收费或退款,不能仅凭 UI 状态决定账务。
3.8 幂等、一致性、重试与补偿设计
3.8.1 分层幂等
| 层次 | 幂等键/机制 | 防止的问题 |
|---|---|---|
| API 创建 | (tenant_id, idempotency_key) | 客户端超时重试导致重复任务 |
| 任务调度 | dispatch_token、attempt_no | MQ 重投导致重复 attempt |
| 供应商提交 | provider_request_key | Submit 重试导致供应商重复作业 |
| 回调处理 | provider_event_id 或事件哈希 | 供应商重复回调 |
| MQ 消费 | (consumer_name, event_id) | 同一事件重复生效 |
| 状态转换 | status_version + allowed states | 并发事件覆盖和状态回退 |
| 计费 | (business_type, business_id) | 重复预占、结算、退款 |
| 媒体处理 | (source_asset_id, profile, pipeline_version) | 重复转码和重复变体 |
| 渲染 | (project_revision_id, render_profile, idempotency_key) | 用户重复点击导出 |
| 对象写入 | 确定性 object key + checksum | 重试生成多个等价对象 |
3.8.2 一致性边界
强一致范围:
- 创建任务、额度预占、Outbox 事件。
- 单次任务状态转换与对应任务事件。
- 账本动作与其唯一业务键。
- 资产元数据与“内部对象已确认存在”的登记。
这些应尽量在单个 PostgreSQL 本地事务中完成。
最终一致范围:
- PostgreSQL 任务与 MQ 消费进度。
- 本地任务与供应商真实状态。
- 供应商成功与结果回源。
- 源资产与媒体变体。
- PostgreSQL 状态与 Redis 进度缓存。
- 任务事实与在线通知。
- 平台账本与供应商账单。
最终一致不等于“随便异步”,必须有:
- 明确的中间状态。
- 可观察的超时阈值。
- 可重复执行的恢复命令。
- 定期对账任务。
- 人工处理入口。
3.8.3 Outbox 与 RocketMQ 事务消息的取舍
RocketMQ 支持事务消息,用于协调本地事务与消息提交,但消费端仍会面临重试和重复处理,不能因此省略业务幂等。4
本项目优先选择 Outbox 的理由:
- 任务、账本和待发布事件能在同一 PostgreSQL 事务中审计。
- MQ 暂时不可用时,事件仍保留在数据库。
- 可按业务条件查询、重放和修复。
- 对 Go 服务和多类业务事件的理解成本较低。
代价是:
- 需要 Relay、归档和积压监控。
- Relay 可能重复发送。
- Outbox 表需要索引、清理和分区策略。
因此正确表述是:
Outbox 解决“数据库已提交但消息未可靠留下”的原子性缺口;Inbox、唯一约束和状态机解决“消息可能重复”的业务效果问题。
3.8.4 重试分类
| 操作 | 默认是否可重试 | 条件 |
|---|---|---|
| PostgreSQL 只读查询 | 可 | 短暂连接错误,设置次数和 deadline |
| RocketMQ 发布 Outbox | 可 | 同一 event_id,允许重复消息 |
| MQ 消费业务处理 | 可 | 处理逻辑幂等;失败分类明确 |
| Provider Query | 通常可 | 限流退避、抖动、供应商 QPS 保护 |
| Provider Submit | 不可盲重试 | 仅在幂等键可靠或确认首次未受理时重试 |
| Provider Cancel | 条件可重试 | 取消本身应有请求键;终态后停止 |
| 对象存储上传分片 | 可 | 同一 upload/part,完成时校验 |
| Output Fetch | 条件可重试 | 临时 URL 未过期、支持 Range 或重新下载;对象键幂等 |
| Media Process | 可 | 作业键唯一,输出可验证 |
| 内容违规 | 不可 | 属于业务拒绝,重试会重复触发审核风险 |
| 余额不足 | 不可自动 | 用户充值或调整请求后再建新任务 |
RocketMQ 的消费重试会在失败后重新投递,达到上限后可进入死信队列;生产上应按错误类别决定“立即重试、延迟重试、最终失败或人工处理”,而不是对所有异常使用同一重试次数。3
3.8.5 补偿策略
补偿不是把历史数据改回去,而是追加新的业务动作:
- 任务创建失败:追加
RELEASE释放预占。 - 供应商失败且未产生费用:释放全部预占。
- 供应商已收费但平台处理失败:记录供应商成本,按用户政策退款或补偿。
- 用户重复扣费:追加
REFUND/COMPENSATE,保留原流水。 - 媒体回源失败:保持任务在可恢复状态,优先重取,不立即把供应商成功改为完全失败。
- Redis 槽位错误:按 PostgreSQL 活跃 attempt 重建,不修改任务事实。
- 回调遗漏:由轮询和超时扫描补齐。
3.9 性能瓶颈与容量估算方法
3.9.1 先估算长任务在途量
AI 视频系统的核心不是普通 API QPS,而是任务到达率、平均处理时间和外部并发配额。
使用 Little’s Law:
平均在途任务数 N = 任务到达率 λ × 平均处理时间 W
例如峰值创建速率为 3 tasks/s,平均供应商执行时间为 180 s:
N_avg = 3 × 180 = 540 个在途任务
若 P95 执行时间为 420 s,在持续峰值下需要观察:
N_p95_reference = 3 × 420 = 1260
这不代表系统一定同时向供应商提交 1260 个任务。真实可运行量还受供应商并发槽位限制,其余任务会进入平台队列。
3.9.2 关键容量公式
供应商在途量 ≈ 到达率 × 供应商平均耗时
输出回源带宽 ≈ 到达率 × 成功率 × 平均输出大小
媒体 CPU 核需求 ≈ 到达率 × 成功率 × 单任务 CPU 秒
每日原始存储 ≈ 到达率 × 成功率 × 平均输出大小 × 86400
每日总存储 ≈ 每日原始存储 × 变体放大系数
MQ 吞吐 ≈ 到达率 × 每任务消息数
DB 核心写入 ≈ 到达率 × 每任务关键状态写次数
在线连接数 ≈ 日活用户 × 同时在线比例 × 平均页面连接数
假设:
峰值生成到达率:3 tasks/s
成功率:85%
平均输出大小:24 MB
每任务媒体 CPU:18 core-seconds
媒体变体放大系数:2.5
每任务 MQ 消息:12 条
每任务关键 DB 写:10 次
得到:
输出回源平均带宽:3 × 0.85 × 24 ≈ 61.2 MB/s
媒体 CPU:3 × 0.85 × 18 ≈ 45.9 cores
MQ:3 × 12 = 36 messages/s
核心 DB 写:3 × 10 = 30 writes/s
每日原始输出:约 5.29 TB/day
含变体:约 13.2 TB/day
该示例说明:即使控制面数据库写入并不高,媒体带宽、CPU 和存储也可能先成为成本瓶颈。真实规划必须替换为业务实际的时长、码率、保留期和模型成功率。
3.9.3 主要瓶颈与应对方式
1. 供应商配额
通常是最先出现的硬边界:
- 每模型并发。
- 组织级并发。
- RPM/QPS。
- 账户余额和分钟成本上限。
应使用平台侧排队、租户公平、槽位控制和路由,而不是把所有任务瞬间打到供应商。
2. PostgreSQL 热点与连接池
风险:
- 所有 Pod 总连接数超过数据库承载。
- 高频进度更新造成 WAL、死元组和热行竞争。
- Outbox 扫描不当导致表扫描和锁竞争。
措施:
- 关键阶段才写任务表,进度放 Redis 并周期快照。
- Outbox 使用
(status, available_at)索引和小批量锁定。 - 限制每实例连接池,统一计算全局连接预算。
- 活跃任务使用部分索引;历史事件分区归档。
3. RocketMQ 积压
不能只看消息条数,要看:
oldest_message_age。- 各 Topic 的消费速率差。
- 重试队列增长。
- 死信增长。
- 单任务停留时间。
自动扩容应结合队列年龄和资源槽位,而不是只看 Worker CPU。供应商已满时,盲目增加 Scheduler 实例没有意义。
4. Output Fetch 网络带宽
措施:
- 流式下载和上传。
- 限制单任务带宽和最大文件大小。
- 独立 egress 网络与连接池。
- 尽量选择与供应商和对象存储网络距离合理的区域。
- 对临时 URL 设置高优先级队列。
5. Media/Render CPU、GPU 与临时盘
措施:
- 按 CPU、GPU、临时盘类型分队列。
- 有界 worker pool,禁止无限制启动 FFmpeg。
- 任务级 cgroup/容器配额和最大执行时长。
- 监控临时盘高水位,达到阈值停止拉取。
- 中间结果 checkpoint 和可复用缓存。
6. Notification 长连接
措施:
- Gateway 无状态化,连接路由放短期缓存或一致性哈希。
- 心跳、连接上限、慢消费者断开。
- 事件合并,例如 500 ms 内的进度变化只发送最后一次。
- 重连后重新 GET 快照,不维护无限离线消息。
3.9.4 背压策略
背压从下游向上游传播:
供应商槽位满
→ Scheduler 不再提交
→ generation.dispatch 积压
→ 达到租户/平台排队阈值
→ 新请求返回排队等级、429 或 503 + Retry-After
Media Worker 临时盘或 CPU 满
→ 暂停 media.process 消费
→ 生成任务保持 POST_PROCESSING
→ 可选择先提供原始结果或延迟播放
→ 不拖垮 API 和供应商状态处理
高级表达:
高并发不是无限接受工作,而是在成本、外部配额和资源边界内进行准入、排队、公平和有序退让。
3.10 高可用与降级方式
| 组件故障 | 业务表现 | 降级/恢复策略 |
|---|---|---|
| API Gateway/Go 服务实例故障 | 短请求失败或连接中断 | 无状态多副本、跨可用区、readiness、优雅退出、客户端幂等重试 |
| PostgreSQL 主库故障 | 无法创建或推进任务 | 自动/人工故障转移、PITR、明确 RPO;故障期间暂停写操作,不把 Redis 升级为事实源 |
| Redis 故障 | 限流、进度、槽位和通知受影响 | 查询回源 PostgreSQL;采用保守并发或暂停新调度;恢复后按 DB 重建派生状态 |
| RocketMQ 故障 | 新事件暂时无法传递 | 任务与 Outbox 仍可原子提交;Relay 积压;超过安全阈值后停止接收昂贵新任务 |
| Scheduler 故障 | 任务排队不执行 | 多实例竞争租约;MQ 保留事件;恢复后继续消费 |
| 单一供应商故障 | 429、5xx、延迟上升 | 熔断、降权、切换兼容供应商;用户锁定模型时保持排队或明确失败 |
| Callback Gateway 故障 | 回调暂时丢失或供应商重试 | 多副本、快速落事件;Polling 作为恢复通道 |
| Polling Service 故障 | 无回调任务状态停滞 | 回调继续工作;恢复后扫描 next_poll_at 和超时任务 |
| Output Fetch 故障 | 供应商成功但暂不可播放 | 高优先级重试、URL 过期监控、刷新链接或人工补偿 |
| Object Storage 故障 | 上传、回源、播放和渲染受阻 | 重试、区域冗余/复制、延迟任务;不丢失 PostgreSQL 任务事实 |
| Media Worker 过载 | 任务停在后处理 | 独立队列和资源池;延迟代理/HLS;必要时提供安全原始文件 |
| Render Worker 过载 | 导出排队 | 与生成和媒体处理隔离;按付费等级排队;不影响已有资产播放 |
| Notification 故障 | UI 无实时提示 | 客户端轮询和重连读取快照;任务继续推进 |
| Billing/Moderation 不可用 | 无法安全接收新付费任务 | 查询类接口保持可用;新任务 fail-closed 或进入待审核,不先调用供应商 |
3.10.1 降级优先级
发生系统性故障时,建议按以下顺序保护:
- 保证 PostgreSQL 事实不被破坏。
- 保证已被供应商受理的任务能继续观测和回源。
- 保证账务不重复、不丢失。
- 保证查询任务状态可用。
- 限制或暂停新任务创建。
- 延迟非关键变体、通知和离线清理。
不应优先牺牲状态处理和回源去维持“还能继续创建新任务”的表面可用性。
3.10.2 可恢复性设计
每个阶段都应存在扫描器:
QUEUED 超时扫描
DISPATCHING 超时扫描
SUBMIT_UNKNOWN 对账扫描
RUNNING 长时间无观测扫描
OUTPUT_FETCHING 超时扫描
POST_PROCESSING 卡住扫描
CANCEL_REQUESTED 超时扫描
Outbox 未发布扫描
账本与供应商账单对账
对象存储孤儿和数据库悬空资产扫描
扫描器不能直接“把状态改成成功”,而应产生受审计的恢复命令或对账案例。
3.11 安全风险
| 风险 | 攻击面 | 设计措施 |
|---|---|---|
| 越权访问资产 | 猜测 asset_id、项目 ID、对象键 | 每次控制面请求校验 tenant/user ACL;对象存储私有;短期 CDN 签名授权 |
| 预签名 URL 滥用 | 上传任意对象、覆盖他人对象、长期传播 | 唯一对象键、短有效期、限制方法/大小/checksum、上传完成后二次校验 |
| 伪造供应商回调 | 公网 Webhook | HMAC/非对称签名、时间戳、nonce、防重放、事件去重;IP allowlist 仅作辅助 |
| SSRF | Output Fetch 下载供应商或用户提供的 URL | 仅允许 HTTPS、供应商域名白名单、DNS 与重定向复检、阻断私网/元数据地址、限制大小和超时 |
| 恶意媒体文件 | ffprobe/FFmpeg 漏洞、压缩炸弹、异常容器 | magic number、大小/时长限制、沙箱容器、只读根文件系统、最小网络权限、及时升级 |
| Shell 注入 | 拼接 FFmpeg 命令 | 直接传参数数组;禁止执行未经转义的 shell 字符串 |
| 供应商密钥泄露 | 前端、日志、错误响应 | 密钥管理系统、短期凭据、按供应商最小权限、日志脱敏、定期轮换 |
| 跨租户消息污染 | MQ payload 中 tenant 不可信 | 消费者从数据库重新确认 tenant 与对象归属;内部服务身份认证 |
| 提示词和媒体隐私 | 日志、追踪、供应商数据使用 | 敏感字段脱敏、按政策最小化保存、供应商数据处理条款、可删除和审计 |
| 内容与版权风险 | 输入/输出生成内容 | 输入和输出双重审核、肖像/版权政策、人工复核、申诉与下架流程 |
| 账务篡改 | 重复消息、管理操作 | 追加账本、唯一业务键、审计日志、双人审批高风险补偿 |
| CDN 链接传播 | 签名 URL 被转发 | 短有效期、签名 Cookie/URL、必要时绑定路径/IP、下载审计 |
特别注意:预签名 URL 是 bearer capability,拿到地址的人通常可在有效期内执行被授权的操作,因此不能把它当作永久公开链接。5
3.12 常见错误设计及其后果
| 错误设计 | 后果 |
|---|---|
| HTTP 请求同步等待模型完成 | 连接占用、超时、网关重试、用户重复提交、难以扩容 |
| 在数据库事务中调用供应商 | 长事务、锁占用、连接池耗尽;外部超时使事务结果难判断 |
| 浏览器先把视频上传 Go,再由 Go 转存 | 双倍带宽、内存/临时盘压力、API Pod 被大文件拖垮 |
| 将 Redis 当任务或余额唯一事实源 | 故障切换、淘汰或数据丢失后无法审计和恢复 |
| 认为 RocketMQ 保证 exactly-once | 重投后重复生成、重复转码、重复计费 |
| Provider Submit 遇到超时立即重试 | 对方已受理时产生第二次生成和第二笔成本 |
| 回调处理器直接覆盖任务状态 | 乱序回调导致终态回退;取消与完成竞态失控 |
| 只依赖回调,不做轮询和扫描 | 回调丢失后任务永久卡住 |
| 把临时供应商 URL 直接交给用户 | URL 过期、权限失控、供应商切换困难、无法统一审核与 CDN |
| API 与 FFmpeg/GPU Worker 混部 | 媒体任务抢占 CPU/内存/磁盘导致控制面雪崩 |
| 所有异步任务共用一个 Topic 和消费池 | 渲染积压阻塞状态更新和结果回源,无法按资源独立扩容 |
| 为每个逻辑方框立即建独立数据库微服务 | 任务、额度和 Outbox 跨服务一致性复杂化,补偿成本远大于收益 |
| 依赖通知事件恢复页面状态 | SSE/WebSocket 断线后 UI 与真实任务不一致 |
| 每 1% 进度更新 PostgreSQL | WAL 和表膨胀、热行竞争、Autovacuum 压力 |
| 没有对账和人工重放工具 | 生产故障只能手工改库,无法证明数据正确性 |
3.13 面试官可能追问的 10 个问题
- 为什么必须区分控制面和媒体数据面?
- 为什么不让 Generation Service 直接调用供应商并等待结果?
- PostgreSQL、Redis 和 RocketMQ 的边界如何划分?
- 为什么选择 Outbox,而不是只依赖 RocketMQ 事务消息?
- MQ 重复投递时,如何避免重复生成和重复计费?
- 供应商已经受理,但本地 Submit 超时,系统怎样处理?
- 回调和轮询同时到达、顺序相反时,如何保证状态正确?
- 这些组件是否都要拆成独立微服务?
- Redis、RocketMQ或某个供应商不可用时,系统如何降级?
- 这个系统怎样做容量规划,最先达到瓶颈的通常是什么?
3.14 每个追问的资深回答
问题 1:为什么必须区分控制面和媒体数据面?
回答:
两者的负载模型完全不同。控制面是小请求、高正确性,主要受数据库连接、事务和外部 API 延迟影响;媒体数据面是大字节流和重计算,主要受网络带宽、CPU、GPU、临时磁盘和对象存储吞吐影响。若让 Go API 代理上传、下载或转码,媒体高峰会直接耗尽 API 连接和资源,导致连任务查询都不可用。因此客户端直传对象存储、CDN 直接分发,Output Fetch、Media Worker 和 Render Worker独立部署。这样不仅性能更好,也让故障域和扩缩容策略与资源类型匹配。
问题 2:为什么不让 Generation Service 直接调用供应商并等待结果?
回答:
供应商生成是分钟级任务,远超普通 HTTP 超时。同步等待会长期占用连接,网关或客户端超时后还可能自动重试,引发重复生成。正确做法是请求内只完成校验、审核、预占、任务与 Outbox 的事务,然后返回 task_id。供应商调用由 Scheduler 和 Adapter 异步执行,状态通过回调或轮询收敛。这样可以独立做配额调度、重试、背压和故障恢复。
问题 3:PostgreSQL、Redis 和 RocketMQ 的边界如何划分?
回答:
PostgreSQL 是事实源,保存任务、资产、账本、供应商作业和 Outbox;Redis 是派生加速层,保存限流、租约、槽位、热点进度和通知路由;RocketMQ 负责至少一次风格的异步传递、削峰、重试和死信。Redis 丢失后可以从数据库重建;MQ 重投后消费者可幂等处理;任何任务是否成功、是否扣费最终都以 PostgreSQL 为准。媒体字节则进入对象存储和 CDN,而不是这三者。
问题 4:为什么选择 Outbox,而不是只依赖 RocketMQ 事务消息?
回答:
两者都可以缩小本地事务与发消息之间的缺口。Outbox 的优势是任务、账本和待发布事件都在同一个 PostgreSQL 事务内,可查询、审计、补发和人工重放;缺点是需要 Relay、清理和积压治理。RocketMQ 事务消息减少了自建 Relay 的一部分工作,但消费端仍会重试,业务幂等仍不可少,而且事务回查与业务数据库的耦合需要仔细实现。对于需要强审计和多类补偿的任务平台,我会优先 Outbox,并明确它只能保证事件最终留下,不能保证下游只执行一次。
问题 5:MQ 重复投递时,如何避免重复生成和重复计费?
回答:
不能只靠 MQ。调度消费先用 (consumer_name, event_id) 做 Inbox 去重;创建 attempt 使用 (task_id, attempt_no) 和 dispatch_token 唯一约束;状态更新带允许来源状态和 status_version;供应商支持时传 provider_request_key;计费每个预占、结算、退款都有唯一业务键。即使两个消费者并发,最终也只有一个数据库条件更新和唯一键插入成功。重复消费可以返回成功,但不再次产生外部副作用。
问题 6:供应商已经受理,但本地 Submit 超时,系统怎样处理?
回答:
我不会把网络超时直接标成失败,也不会立即重提。我会把 attempt 标成 SUBMIT_UNKNOWN。若供应商支持幂等键,就用同一键查询或重试;若拿到了 request-id,就按句柄查询;否则通过时间窗口、外部标签、回调或账单对账确认。只有证明第一次没有受理,才创建下一 attempt。因为盲目重提的代价不是普通重复消息,而是第二次模型执行和第二笔真实成本。
问题 7:回调和轮询同时到达、顺序相反时,如何保证状态正确?
回答:
Callback Gateway 和 Polling Service都只产生标准化的“状态观测事件”,不直接无条件更新任务。统一状态处理器读取当前状态,用允许转换表、终态保护、供应商事件时间和 status_version 做条件更新。旧的 RUNNING 事件不能覆盖 SUCCEEDED,重复 SUCCEEDED 只会被幂等忽略。取消与成功竞争也由数据库条件更新决定,而不是靠消息顺序。
问题 8:这些组件是否都要拆成独立微服务?
回答:
不需要。服务名称首先表达职责。物理拆分由独立扩容、资源隔离、安全边界和团队所有权决定。任务创建、额度预占和 Outbox 需要强本地事务,早期更适合作为同一控制面应用内的模块;Scheduler、Provider Worker、Output Fetch、Media、Render 和 Notification 因资源模型差异明显,适合独立部署。过早将 Billing 拆成独立数据库会把简单本地事务变成 Saga,只有组织或合规收益足够大时才值得。
问题 9:Redis、RocketMQ或某个供应商不可用时,系统如何降级?
回答:
Redis 不可用时,任务事实仍在 PostgreSQL;查询回源数据库,限流和并发采用保守策略或暂停新调度。RocketMQ 不可用时,任务与 Outbox 仍能原子提交,Relay 等待恢复;若 Outbox 积压超过安全阈值,则停止接收昂贵新任务。供应商不可用时,调度器熔断并选择兼容供应商;用户锁定模型时保持排队或明确失败。降级原则是优先保护事实、账务、已受理任务的观测和回源,而不是维持无限创建新任务。
问题 10:这个系统怎样做容量规划,最先达到瓶颈的通常是什么?
回答:
先用 Little’s Law 估算在途任务:到达率乘平均耗时。再分别计算供应商并发、输出回源带宽、媒体 CPU/GPU、临时盘、每日存储、MQ 消息率、数据库关键写入和在线连接数。多数情况下最先受限的不是 Go API CPU,而是供应商模型配额、媒体处理资源或对象存储/CDN成本。扩容必须按瓶颈做:供应商满时增加 Scheduler Pod没有意义;媒体积压要扩 Media Worker 或降低变体质量;数据库则要控制进度写放大和连接池。
3.15 三分钟口述稿
我们这个系统不是简单调用第三方 AI 接口,而是一个面向分钟级、高成本、强异步任务的媒体工作流平台。整体上我会把它分成控制面、供应商执行面和媒体数据面。
控制面由 API Gateway、Generation、Asset、Billing、Moderation 和 Scheduler 组成。用户创建任务时,HTTP 请求只做权限、参数、素材、审核和费用检查,然后在同一个 PostgreSQL 事务里写任务、额度预占和 Outbox,立即返回 task_id,不会同步等待模型。PostgreSQL 是事实源;Redis 只做限流、槽位、租约和热点进度;RocketMQ 负责异步解耦、重试和削峰,但我们不假设它 exactly-once。
Outbox Relay 把任务事件投递到 RocketMQ,Scheduler 再按租户公平、用户等级、供应商健康度、模型能力、成本和并发配额选择路由。所有供应商通过统一 Adapter 接入,提供 Submit、Query、Cancel、回调解析和错误分类。最关键的异常是供应商已经受理但本地 Submit 超时,这时不能盲目重试,而要把 attempt 标成 SUBMIT_UNKNOWN,使用供应商幂等键、request-id、轮询或账单对账确认,否则可能重复生成和重复成本。
状态通过 Callback Gateway 和 Polling Service共同观测,但它们都不直接覆盖数据库,而是进入统一状态机,用允许转换表和版本号处理重复、乱序以及取消与完成竞态。供应商成功后,Output Fetch Worker立即把临时结果回源到自己的对象存储,再由独立 Media Worker做探测、转码、HLS、缩略图、波形和输出审核。第三方临时 URL 不直接交给用户,播放统一通过内部 Asset、对象存储和 CDN。
前端编辑采用非破坏式时间轴,最终导出由 Render Service把固定项目版本编译成 Render DAG,再交给独立 FFmpeg 或 GPU Worker。这样 API、供应商调用、网络回源、转码和渲染分别使用独立资源池,某条重流水线积压不会拖垮任务查询。
整个系统的一次效果不是靠 MQ,而是靠 API 幂等键、数据库唯一约束、Inbox 去重、状态版本、供应商请求键和账本业务键共同实现。高可用也不只是多副本,还包括 Outbox 重放、轮询补偿、超时扫描、供应商切换、Redis 重建和账务对账。
3.16 十分钟深入讲解提纲
0:00—1:00:先定义系统本质
- 不是模型 API 包装器,而是长耗时、高成本、弱确定性的媒体工作流平台。
- 核心指标是任务到达率、在途量、供应商槽位、媒体吞吐和成功成本。
- 先给出一句边界:PostgreSQL 管事实,Redis 管速度,RocketMQ 管异步,对象存储管媒体。
1:00—2:20:解释三平面
- 控制面:任务、资产元数据、额度、审核、调度。
- 供应商执行面:Submit、Query、Cancel、Callback、错误归一化。
- 媒体数据面:上传、回源、转码、HLS、渲染、CDN。
- 说明为什么大文件不能穿过 Go API。
2:20—4:20:走一遍核心组件
- Gateway:认证、租户和限流,不做业务事务。
- Generation:创建/取消/查询,任务+预占+Outbox 本地事务。
- Scheduler:公平、优先级、四级槽位和供应商路由。
- Adapter:屏蔽供应商差异,不负责计费和调度。
- Callback/Polling:两个观测通道,统一进入状态机。
- Output Fetch/Media:临时 URL 回源、自有资产化和媒体变体。
- Notification:体验通道,不是事实源。
- Render:固定项目版本、编译 DAG、独立资源池。
4:20—5:40:讲一致性与幂等
- 为什么创建任务、额度预占和 Outbox 必须同事务。
- MQ 可能重复,消费者要 Inbox 去重。
- 状态使用允许转换表和
status_version。 - 账本使用追加记录和唯一业务键。
- 对象和媒体作业使用确定性键。
5:40—6:50:重点讲第三方结果未知
- Submit 超时不等于失败。
SUBMIT_UNKNOWN中间状态。- 供应商幂等键、request-id、查询、回调和账单对账。
- 只有确认未受理后才允许下一 attempt。
- 解释重复生成与重复成本风险。
6:50—7:50:讲竞态
- 回调与轮询并发。
- 旧 RUNNING 晚于 SUCCEEDED。
- 取消与完成竞争。
- 条件更新和终态保护如何收敛。
7:50—8:40:讲性能与容量
- Little’s Law:
N = λW。 - 供应商配额通常先于 API CPU 成为瓶颈。
- 再计算回源带宽、媒体 CPU/GPU、临时盘和每日存储。
- 背压按供应商、租户和资源池逐层传播。
8:40—9:30:讲高可用和降级
- Redis 故障可重建,采用保守准入。
- MQ 故障依赖 Outbox 保留事件,积压过高后停止昂贵新任务。
- Callback 故障由 Polling 补偿。
- 供应商故障通过熔断和兼容路由降级。
- Media/Render 积压不影响控制面查询。
9:30—10:00:用架构不变量收尾
- 不在数据库事务中调用第三方。
- 不把临时供应商 URL 交给用户。
- 不声称 MQ 或 Redis 天然 exactly-once。
- 不让通知决定任务事实。
- 不盲目重试未知 Submit。
- 任何阶段都能对账、重放和恢复。
本章结论
一个可信的 AI 视频平台架构,应同时满足以下条件:
- 用户请求快速返回,长任务完全异步化。
- 核心事实落在 PostgreSQL,本地关键事务边界清晰。
- Redis、RocketMQ、对象存储和 CDN 各自承担适合的职责。
- 供应商调用经过统一适配、调度、状态观测和未知结果处理。
- 媒体上传、回源、转码和渲染与控制面资源隔离。
- 重复、乱序、超时、取消竞态和临时 URL 过期都存在明确恢复路径。
- 业务一次效果由幂等键、唯一约束、状态机、账本和对账共同实现。
- 逻辑边界清楚,但物理拆分遵循实际扩容和事务需求,而不是为了微服务而微服务。