AiVedio:端到端生成时序与任务状态机
从浏览器提交、素材直传、任务创建、Outbox、调度、供应商调用、回调轮询、结果回源、媒体处理到通知播放,拆解 AI 视频生成的端到端时序与状态机。
第 04 章:端到端生成时序与任务状态机
本章主题:从浏览器提交提示词、参考图或参考视频开始,一直到生成结果被可靠回源、转码并通过 CDN 播放,完整拆解控制面时序、媒体数据面时序、任务状态机,以及重复、乱序、超时、取消和计费竞态。
核心结论:AI 视频生成不是一次普通 HTTP 调用,而是一条跨浏览器、对象存储、数据库、消息队列、调度器、第三方供应商和媒体处理集群的长事务工作流。平台无法依赖分布式事务把所有参与方一次性提交,只能通过本地事务、持久化状态机、幂等键、事件去重、重试分类和补偿对账实现“最终可解释、最终可恢复”。
4.1 本章要解决的业务问题
用户看到的操作通常只有两个:上传参考素材、点击“生成”。但平台内部至少需要解决以下问题:
- 大文件不能经过业务服务中转:参考视频可能达到数百 MB,生成结果也可能很大。若全部经过 Go API,会同时消耗入口带宽、出口带宽、连接、内存和临时磁盘。
- 生成任务是分钟级异步任务:HTTP 请求不能一直占用连接等待模型完成,浏览器断开也不能导致后台任务丢失。
- 任务创建与消息投递必须一致:不能出现数据库已经扣额度并创建任务,但 RocketMQ 没有消息;也不能出现消息已投递,但数据库中没有任务事实。
- 供应商调用存在“结果未知”窗口:请求超时不等于供应商失败。供应商可能已经创建了付费任务,只是响应在返回途中丢失。
- 回调和轮询可能重复、乱序、并发到达:平台必须把它们统一为状态事件,而不是让两个入口直接覆盖任务状态。
- 供应商成功不等于用户可播放:供应商临时 URL 仍可能失效、下载失败、文件损坏、编码不兼容或内容复审失败。
- 取消不是一次简单状态更新:用户点击取消时,任务可能尚未调度、正在提交、供应商运行中、已经完成但回调尚未到达,或者正在回源。
- 每次重试都可能产生真实成本:重试策略必须回答“是否会重复生成、是否会重复扣费、如何发现和冲正”。
- 系统必须可恢复和可解释:任何任务都应能回答“当前在哪一步、最后一次尝试发生了什么、下一步由谁处理、为什么失败、钱如何结算”。
本章不追求在所有组件之间实现理论上的全局 exactly-once。更实际的目标是:
消息可以重复,回调可以乱序,进程可以宕机,但每个业务副作用只能按定义生效一次,异常必须可以审计、补偿和重放。
4.2 核心设计原则
4.2.1 PostgreSQL 保存事实,其他组件负责加速或传递
| 组件 | 负责什么 | 不负责什么 |
|---|---|---|
| PostgreSQL | 任务事实、状态版本、调用尝试、资产元数据、额度流水、Outbox、去重记录 | 高频进度刷新、大文件内容、仅凭 TTL 存活的核心状态 |
| Redis | 限流、并发槽位、短期租约、热点状态缓存、通知加速 | 余额、终态、供应商任务映射的唯一事实源 |
| RocketMQ | 异步解耦、削峰、重试、延迟调度、死信转移、事件扇出 | 天然 exactly-once、跨系统事务提交、业务状态最终裁决 |
| 对象存储/CDN | 原始素材、供应商输出、代理视频、HLS/CMAF、缩略图、最终成品 | 任务状态、额度状态、调度决策 |
4.2.2 控制面与媒体数据面分离
控制面只传递小对象:任务 ID、资产 ID、状态、模型参数、对象键、校验和和错误码。
媒体数据面直接在浏览器、对象存储、供应商和媒体 Worker 之间流转,不把视频二进制塞进数据库、Redis 或 RocketMQ。
控制面:Browser → API → PostgreSQL / RocketMQ → Scheduler → Provider Adapter
媒体面:Browser → Object Storage → Provider → Output Fetch Worker → Object Storage → CDN
这样做的原因不是单纯“微服务拆分”,而是两类流量的特征完全不同:控制面要求低延迟、强审计和小消息;媒体面要求高吞吐、流式传输和可断点重试。
4.2.3 接受请求、供应商受理、供应商完成、用户可播放是四个不同事实
平台必须区分:
平台已接受请求
≠ 已投递到调度器
≠ 供应商已创建任务
≠ 供应商已生成输出
≠ 输出已进入平台对象存储
≠ 用户已经可以稳定播放
因此,不应在供应商返回 SUCCEEDED 时立即把平台任务标记为 SUCCEEDED。平台自己的成功条件至少应包括:
- 输出已从供应商临时地址回源到自有对象存储;
- 校验和、文件大小和媒体探测通过;
- 至少一个可播放版本已生成;
- 输出侧审核通过或进入明确的人工审核状态;
- 资产记录和任务输出引用已在数据库中提交。
4.2.4 每个外部边界都必须有幂等语义
需要独立设计以下幂等键:
- 浏览器重复提交:
Idempotency-Key; - Outbox 事件:
event_id; - RocketMQ 消费:
consumer_group + event_id; - 供应商提交:
provider_request_key或供应商支持的幂等键; - 供应商回调:
provider_event_id或事件指纹; - 输出回源:确定性的对象键和 checksum;
- 媒体处理:
asset_id + profile + pipeline_version; - 计费:唯一业务流水号。
幂等不是“接口返回相同响应”这么简单,而是相同业务请求不能再次产生生成成本、资产副本或余额变更。
4.2.5 状态机只描述事实,不用进度值推断终态
progress=100 不能替代 SUCCEEDED,progress=0 也不代表失败。进度通常来自供应商估算,可能回退、停滞或缺失。
所有状态转换必须经过显式转换表,并携带任务版本。终态具有粘性,不能被迟到的旧事件覆盖。
4.2.6 任务状态与供应商调用尝试分层建模
一个业务任务可能经历多个供应商调用尝试,例如:
- 第一次调用在连接前失败,可以安全重试;
- 第二次调用超时,进入结果未知;
- 对账后发现第二次其实已经成功创建供应商任务;
- 供应商运行失败,策略允许切换备用供应商;
- 最终由第三次尝试完成。
若只在 generation_tasks 表覆盖 provider_job_id 和最后一个错误,就无法解释历史,也无法判断重试是否产生重复费用。因此应至少分为:
- Task:用户视角的一次生成请求;
- Attempt:平台对某个供应商的一次提交尝试;
- State Event:回调、轮询、调度、取消等产生的事实事件。
4.2.7 所有外部调用都在数据库事务之外执行
正确结构是:
短事务 A:记录“准备调用”事实并提交
↓
事务外:调用供应商
↓
短事务 B:记录调用结果、更新状态、写 Outbox
不能在持有数据库锁时调用供应商。外部请求可能持续数秒、超时甚至卡死,长事务会扩大锁竞争、占满连接池,并使数据库故障恢复更困难。
4.3 详细架构和组件职责
4.3.1 组件总览
| 组件 | 核心职责 | 关键设计点 |
|---|---|---|
| Browser / Mobile | 获取上传凭证、直传素材、提交任务、接收状态通知、播放结果 | 客户端重试必须复用同一个幂等键;断线后可重新查询任务 |
| API Gateway | 认证、租户限流、请求大小限制、追踪 ID、WAF | 不持有长任务;不代理大文件 |
| Asset Service | 上传会话、对象键、素材状态、checksum、租户授权 | 服务端生成对象键;上传完成不等于素材可用 |
| Generation Service | 参数归一化、幂等检查、任务创建、额度预占、Outbox | 创建任务和额度流水必须在同一 PostgreSQL 事务中完成 |
| Moderation Service | 输入提示词、参考素材和输出内容审核 | 审核拒绝通常是不可重试业务失败 |
| Outbox Relay | 从 PostgreSQL 读取待发事件并投递 RocketMQ | 允许重复发送;消费者必须幂等 |
| RocketMQ | 传递生成、调度、回源、转码和通知事件 | 按至少一次投递设计;DLQ 需要人工治理 |
| Scheduler | 公平排队、优先级、租户配额、供应商容量、成本和路由 | Redis 槽位只是准入加速,任务事实仍在 PostgreSQL |
| Provider Adapter | 统一供应商能力、提交、查询、取消、错误分类 | 原始响应保留审计摘要;供应商密钥不进入业务日志 |
| Callback Gateway | 验签、防重放、快速持久化原始回调 | 不在回调请求线程下载视频或执行转码 |
| Polling Service | 为无回调、回调缺失或异常任务补充查询 | 自适应间隔、抖动、全局预算,避免轮询风暴 |
| State Reducer | 将内部事件归一化并执行合法状态转换 | 去重、版本检查、终态粘性、短事务 |
| Output Fetch Worker | 下载供应商输出、流式写入对象存储、校验 | 防 SSRF;不能直接信任 Content-Type 和文件扩展名 |
| Media Worker | ffprobe、转码、代理视频、缩略图、波形、输出审核 | 任务可重入;产物键确定;资源隔离 |
| Notification Gateway | SSE / WebSocket / 推送通知 | 通知不是事实源;丢通知后客户端通过查询恢复 |
| Billing Service | 预占、结算、释放、退款、供应商账单对账 | 追加式账本;每笔业务流水有唯一键 |
4.3.2 控制面架构
API Gateway
│
▼
Generation Service ─────► PostgreSQL
│ ├─ generation_tasks
│ ├─ task_attempts
│ ├─ credit_ledger
│ └─ outbox_events
│
▼
Outbox Relay ─────► RocketMQ ─────► Scheduler
│
▼
Provider Adapter
│
Submit / Query / Cancel
│
▼
Provider
│
Callback ─────────┴──── Polling
│ │
▼ ▼
Callback Gateway Polling Service
└──────────┬──────────┘
▼
State Reducer
│
PostgreSQL + Outbox
4.3.3 媒体数据面架构
Browser ──预签名上传──► Object Storage
│
Asset Validation
│
▼
Provider 可读取输入
Provider 临时输出 URL
│
▼
Output Fetch Worker ──流式回源──► Object Storage / Raw Zone
│
▼
Media Worker
┌──────────┼──────────┐
▼ ▼ ▼
Proxy MP4 HLS Thumbnail
└──────────┬──────────┘
▼
CDN / Editor
平台应尽可能避免“先下载到本地磁盘,再整体上传”的双重落盘。更常见的方式是限制并发后进行流式读取和流式上传,同时计算 checksum;但如果转码工具必须随机访问输入,则可使用受配额管理的本地临时盘,并在任务结束后清理。
4.4 文字版端到端时序图
参与者:Browser、API Gateway、Asset Service、Object Storage、Generation Service、
PostgreSQL、Outbox Relay、RocketMQ、Scheduler、Provider Adapter、Provider、
Callback/Poller、State Reducer、Output Fetch Worker、Media Worker、CDN、Notification
一、参考素材上传
1. Browser → Asset Service:申请上传会话,提交文件名、大小、类型、checksum 摘要。
2. Asset Service → PostgreSQL:创建 asset,状态为 UPLOADING,生成服务端对象键。
3. Asset Service → Browser:返回短期预签名 URL 或分片上传信息。
4. Browser → Object Storage:直接上传文件或并行上传各分片。
5. Browser → Asset Service:提交 complete 请求,携带 upload_id、part ETag、checksum。
6. Asset Service → Object Storage:确认对象存在、大小符合预期。
7. Asset Service → RocketMQ:通过 Outbox 发布 asset.validation.requested。
8. Asset Validator → Object Storage:流式读取头部或受控下载,执行 magic number、ffprobe、恶意文件和内容审核。
9. Asset Validator → PostgreSQL:将 asset 更新为 READY;失败则为 REJECTED。
二、创建生成任务
10. Browser → API Gateway:POST /generations,携带 Idempotency-Key、prompt、asset_ids、模型参数。
11. API Gateway:认证、租户限流、请求体限制,注入 trace_id。
12. Generation Service → PostgreSQL:按 tenant_id + Idempotency-Key 查询历史请求。
13. Generation Service:归一化参数,验证 asset 均为 READY,执行输入审核和费用估算。
14. Generation Service → PostgreSQL:在同一事务中写入额度预占流水、generation_task、task_state_event 和 outbox_event。
15. Generation Service → Browser:返回 202 Accepted、task_id、当前状态和查询地址。
三、异步调度与供应商提交
16. Outbox Relay → PostgreSQL:批量领取待发送 Outbox,使用 SKIP LOCKED 避免多个 Relay 重复领取同一行。
17. Outbox Relay → RocketMQ:发送 generation.queued;若发送成功后进程宕机,事件可能再次发送。
18. Scheduler → RocketMQ:消费消息并按 event_id 去重。
19. Scheduler:检查任务版本、取消标记、租户并发、模型能力、供应商健康度和成本预算。
20. Scheduler → Redis:申请带 TTL 的供应商/模型并发槽位;失败则延迟重新排队。
21. Scheduler → PostgreSQL:短事务内创建 attempt,任务转为 DISPATCHING,写状态事件。
22. Provider Adapter → Provider:事务外提交任务,携带 provider_request_key;设置连接、响应头和总超时。
23a. Provider → Adapter:明确返回 provider_job_id。
24a. Adapter → PostgreSQL:attempt=ACKED,任务=SUBMITTED,保存 provider_job_id,写 Outbox。
23b. 若请求超时且无法判断供应商是否受理:attempt=UNKNOWN,任务=SUBMIT_UNKNOWN,不立即盲目重试。
四、运行状态同步
25. Provider → Callback Gateway:发送状态回调;或者 Polling Service 定期查询 provider_job_id。
26. Callback Gateway:验签、检查时间戳/nonce、防重放,快速保存原始事件并返回 2xx。
27. Callback/Poller → State Reducer:发布统一 provider.status.observed 事件。
28. State Reducer → PostgreSQL:去重、锁定或 CAS 更新任务、校验允许的状态转换、追加状态历史。
29. State Reducer → Outbox:需要回源时发布 output.fetch.requested;需要通知时发布 task.status.changed。
五、输出回源和媒体处理
30. Output Fetch Worker → Provider URL:受控下载,限制协议、域名、跳转、大小、时长和超时。
31. Output Fetch Worker → Object Storage:流式写入 raw 对象,同时计算 checksum。
32. Output Fetch Worker → PostgreSQL:创建 output asset,任务转为 POST_PROCESSING,发布 media.process.requested。
33. Media Worker → Object Storage:读取 raw 资产,执行 ffprobe、转码、代理视频、HLS、缩略图、波形和输出审核。
34. Media Worker → Object Storage:写入确定性产物键。
35. Media Worker → PostgreSQL:在短事务内将资产置为 READY、任务置为 SUCCEEDED,并写结算与通知 Outbox。
36. Notification Gateway → Browser:通过 SSE/WebSocket 推送最终状态;若通知丢失,Browser 通过 GET /generations/{id} 恢复。
37. Browser → CDN:使用短期播放凭证获取代理视频或 HLS。
4.5 任务状态机设计
4.5.1 主任务状态
推荐的主链路如下:
CREATED
→ VALIDATING
→ QUOTA_RESERVED
→ QUEUED
→ DISPATCHING
→ SUBMITTED
→ RUNNING
→ OUTPUT_FETCHING
→ POST_PROCESSING
→ SUCCEEDED
异常、重试和取消分支:
SUBMIT_UNKNOWN
RETRY_WAIT
CANCEL_REQUESTED
CANCELED
FAILED_FINAL
EXPIRED
其中:
SUBMIT_UNKNOWN:提交请求没有得到可确认结果,供应商可能已受理,也可能未受理;RETRY_WAIT:已确认当前步骤可安全重试,等待next_retry_at;CANCEL_REQUESTED:平台已接受取消意图,但尚未确认供应商已经停止;EXPIRED:超过排队截止时间、结果保留期或业务允许的最长恢复窗口;FAILED_FINAL:已确定不可重试,或者重试预算耗尽;SUCCEEDED、CANCELED、FAILED_FINAL、EXPIRED为业务终态。
4.5.2 Attempt 状态
每次供应商调用尝试独立维护:
NEW → SENDING → ACKED → RUNNING → SUCCEEDED
│ ├────→ FAILED
│ └────→ CANCELED
└────→ UNKNOWN
NEW / SENDING ──────────────→ FAILED_BEFORE_ACCEPT
FAILED_BEFORE_ACCEPT 只应在平台能够较高置信度确认“请求未被供应商受理”时使用,例如本地参数序列化失败、连接建立前被拒绝、供应商明确返回可安全重试且未创建任务的错误。普通读超时不能直接归入该状态。
4.5.3 状态转换表
| 当前状态 | 事件 | 目标状态 | 必要条件与副作用 |
|---|---|---|---|
| CREATED | validation_started | VALIDATING | 追加状态事件 |
| VALIDATING | validation_passed | QUOTA_RESERVED | 额度预占流水成功 |
| QUOTA_RESERVED | queued | QUEUED | Outbox 已写入同一事务 |
| QUEUED | dispatch_claimed | DISPATCHING | 任务未取消、版本匹配、创建 attempt |
| DISPATCHING | provider_accepted | SUBMITTED | 保存唯一 provider_job_id |
| DISPATCHING | submit_ambiguous | SUBMIT_UNKNOWN | 进入对账,不立即创建新 attempt |
| SUBMIT_UNKNOWN | provider_job_found | SUBMITTED | 绑定对账发现的 provider_job_id |
| SUBMIT_UNKNOWN | confirmed_not_created | RETRY_WAIT | 只有确认未创建后才允许重试 |
| SUBMITTED | provider_running | RUNNING | 允许跳过该状态直接接收成功事件 |
| SUBMITTED/RUNNING | provider_succeeded | OUTPUT_FETCHING | 发布回源事件 |
| OUTPUT_FETCHING | output_persisted | POST_PROCESSING | 自有对象存储已有原始输出 |
| POST_PROCESSING | playable_asset_ready | SUCCEEDED | 写结算、通知事件 |
| 非终态 | retryable_failure | RETRY_WAIT | 写 next_retry_at、错误分类、重试预算 |
| RETRY_WAIT | retry_due | QUEUED | 创建下一 attempt 前再次检查取消和截止时间 |
| QUEUED 前状态 | cancel_requested | CANCELED | 无供应商成本时直接终止并释放预占 |
| DISPATCHING/SUBMITTED/RUNNING | cancel_requested | CANCEL_REQUESTED | 异步调用供应商取消 |
| CANCEL_REQUESTED | cancel_confirmed | CANCELED | 按供应商计费语义结算或退款 |
| CANCEL_REQUESTED | completion_won | OUTPUT_FETCHING | cancel_outcome=TOO_LATE,完成事实优先 |
| 任意非终态 | final_failure | FAILED_FINAL | 结算或释放额度,通知用户 |
4.5.4 状态转换规则
- 只允许显式转换:不能用
status_rank简单比较大小,因为取消和重试不是一条线性链。 - 短事务串行化单任务更新:可以使用
SELECT ... FOR UPDATE,也可以使用WHERE id=? AND version=?的 CAS。前者实现简单,后者更适合高并发状态入口。 - 每次转换递增
version:事件携带其观察到的任务版本,版本冲突时重新读取并重新判断,而不是直接覆盖。 - 终态粘性:迟到的
RUNNING不能覆盖SUCCEEDED;迟到的旧成功事件也不能自动推翻已经确认的CANCELED,应转入异常审计。 - 允许跳跃但不允许伪造:有些供应商第一次回调就是
SUCCEEDED,可从SUBMITTED直接进入OUTPUT_FETCHING;但不能从CREATED直接进入SUCCEEDED。 - 进度与状态解耦:
progress作为展示字段,可采用max(current, observed)或按供应商规则归一化,但不驱动终态。 - 副作用由 Outbox 驱动:状态更新和“下一步事件”写入同一事务,避免状态已变更但后续任务未触发。
4.6 关键数据结构、数据库表和消息字段
以下结构是面试级示意,不是完整迁移文件。
4.6.1 generation_tasks
CREATE TABLE generation_tasks (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
project_id uuid,
client_idempotency_key varchar(128) NOT NULL,
request_hash bytea NOT NULL,
status varchar(32) NOT NULL,
version bigint NOT NULL DEFAULT 0,
progress smallint NOT NULL DEFAULT 0,
requested_model varchar(64) NOT NULL,
selected_provider varchar(64),
current_attempt_no integer NOT NULL DEFAULT 0,
priority integer NOT NULL DEFAULT 0,
estimated_cost numeric(20, 8) NOT NULL,
reserved_amount numeric(20, 8) NOT NULL,
actual_cost numeric(20, 8),
output_asset_id uuid,
next_retry_at timestamptz,
deadline_at timestamptz,
cancel_requested_at timestamptz,
cancel_outcome varchar(32),
last_error_class varchar(32),
last_error_code varchar(128),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
started_at timestamptz,
finished_at timestamptz,
UNIQUE (tenant_id, client_idempotency_key)
);
关键点:
- 同一个幂等键再次请求时必须比较
request_hash;若请求内容不同,应返回409 Conflict,不能静默返回旧任务。 request_hash基于归一化后的业务参数计算,不应包含随机 trace ID 或字段顺序。status + next_retry_at用于恢复扫描;为活跃状态建立部分索引,而不是给所有低选择性字段建立普通索引。
4.6.2 task_attempts
CREATE TABLE task_attempts (
id uuid PRIMARY KEY,
task_id uuid NOT NULL REFERENCES generation_tasks(id),
attempt_no integer NOT NULL,
provider varchar(64) NOT NULL,
model varchar(64) NOT NULL,
provider_request_key varchar(128) NOT NULL,
provider_job_id varchar(256),
submit_state varchar(32) NOT NULL,
request_fingerprint bytea NOT NULL,
provider_error_code varchar(128),
provider_updated_at timestamptz,
submitted_at timestamptz,
completed_at timestamptz,
created_at timestamptz NOT NULL,
UNIQUE (task_id, attempt_no),
UNIQUE (provider, provider_request_key),
UNIQUE (provider, provider_job_id)
);
provider_job_id 为空时,PostgreSQL 普通唯一约束允许多行 NULL;落地时可使用部分唯一索引:
CREATE UNIQUE INDEX uq_attempt_provider_job
ON task_attempts(provider, provider_job_id)
WHERE provider_job_id IS NOT NULL;
4.6.3 task_state_events
event_id
task_id
attempt_id
source # api / scheduler / callback / polling / worker / admin
source_event_id # 供应商事件 ID;没有时使用稳定事件指纹
event_type
from_status
to_status
task_version
provider_updated_at
observed_at
payload_jsonb # 限量、脱敏、可审计
created_at
建议唯一约束:
UNIQUE(source, source_event_id)
若供应商没有事件 ID,可使用规范化字段计算指纹,例如:
SHA256(provider + provider_job_id + normalized_status
+ provider_updated_at + canonical_payload_hash)
但不能只用 provider_job_id + status,因为供应商可能多次发送相同状态但携带不同输出或错误信息。
4.6.4 outbox_events
id # 全局 event_id
aggregate_type
aggregate_id
aggregate_version
event_type
schema_version
payload_jsonb
status # PENDING / PUBLISHED / DEAD
available_at
publish_attempts
last_error
created_at
published_at
Relay 领取示意:
SELECT id
FROM outbox_events
WHERE status = 'PENDING'
AND available_at <= now()
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 200;
发送 RocketMQ 成功后再更新为 PUBLISHED。若“发送成功但更新数据库前宕机”,事件会被再次发送,所以消费者仍必须去重。
4.6.5 inbox_dedup
consumer_group
event_id
aggregate_id
processed_at
result_digest
PRIMARY KEY (consumer_group, event_id)
消费时常用模式:
开始数据库事务
→ 插入 inbox_dedup
→ 若唯一键冲突,说明已处理,直接成功 ACK
→ 执行业务状态转换
→ 写下一步 Outbox
提交事务
→ 向 RocketMQ ACK
如果事务提交成功但 ACK 丢失,消息会再次投递,唯一约束会阻止副作用重复发生。
4.6.6 assets
id
tenant_id
kind # INPUT_IMAGE / INPUT_VIDEO / RAW_OUTPUT / PROXY / HLS / THUMBNAIL
parent_asset_id
status # UPLOADING / VALIDATING / READY / REJECTED / DELETED
bucket
object_key
size_bytes
checksum_algorithm
checksum_value
mime_detected
duration_ms
width
height
frame_rate_num
frame_rate_den
pipeline_version
created_at
ready_at
对象键由服务端生成,例如:
tenants/{tenant_id}/tasks/{task_id}/raw/{attempt_id}.mp4
tenants/{tenant_id}/assets/{asset_id}/proxy/v3/main.mp4
客户端不能自由指定完整对象键,否则容易越权覆盖其他租户对象。
4.6.7 计费流水
采用追加式账本:
ledger_id
tenant_id
task_id
type # RESERVE / SETTLE / RELEASE / REFUND / ADJUSTMENT
amount
currency_or_credit_unit
business_key
provider_attempt_id
created_at
UNIQUE(business_key)
业务键示例:
reserve:{task_id}
settle:{task_id}
release:{task_id}:{reason}
adjustment:{attempt_id}:{provider_invoice_item_id}
4.6.8 RocketMQ 事件信封
{
"event_id": "uuid",
"event_type": "generation.queued",
"schema_version": 1,
"aggregate_id": "task_uuid",
"aggregate_version": 6,
"tenant_id": "tenant_uuid",
"trace_id": "trace_id",
"causation_id": "previous_event_id",
"occurred_at": "2026-06-23T03:00:00Z",
"payload": {
"task_id": "task_uuid",
"requested_model": "model_alias",
"priority": 10,
"deadline_at": "2026-06-23T03:30:00Z"
}
}
消息中只放 ID 和调度所需摘要,不放完整视频、超长 prompt、供应商密钥或未脱敏响应。
4.7 正常流程
4.7.1 预签名上传
- 客户端提交文件大小、扩展名、声明类型和可选 checksum。
- Asset Service 验证租户权限和上传配额,生成不可预测的
asset_id与服务端对象键。 - 小文件返回单次预签名上传地址;大文件创建 multipart upload,返回分片凭证。
- 客户端直接上传对象存储,失败分片独立重试。
- 客户端调用 complete;服务端核对对象大小、分片 ETag 和 checksum。
- 资产进入
VALIDATING,异步执行 magic number、媒体探测、时长/分辨率限制、恶意文件检查和内容审核。 - 验证通过后置为
READY。生成请求只能引用READY资产。
预签名 URL 本质上是短期 bearer token,必须限制对象键、HTTP 方法、有效期、大小和必要的 checksum。分片上传结束后要完成或中止,否则残留分片会持续占用存储空间。
4.7.2 创建生成任务
- API Gateway 完成认证、租户限流和基础请求体限制。
- Generation Service 对参数归一化,例如宽高比、时长、分辨率、模型别名和 seed。
- 按
tenant_id + Idempotency-Key查重:- 不存在:继续;
- 存在且
request_hash相同:返回原任务; - 存在但请求内容不同:返回 409。
- 校验输入资产状态和归属,执行输入审核。
- 根据模型、时长、分辨率和用户等级估算费用。
- 在同一个 PostgreSQL 事务中:
- 写入
RESERVE额度流水; - 创建任务;
- 追加初始状态事件;
- 写
generation.queuedOutbox。
- 写入
- 事务提交后立即返回
202 Accepted,不等待 MQ,不等待供应商。
4.7.3 Outbox 投递和调度
- Relay 批量领取 Outbox 并发送 RocketMQ。
- Scheduler 消费并先做 Inbox 去重。
- Scheduler 重新读取任务事实,确认任务仍为
QUEUED、未取消、未过期。 - 执行四层准入:
平台总并发
└─ 租户并发
└─ 供应商并发
└─ 模型并发
- 根据能力、健康度、延迟、成本、地区、用户是否锁定模型进行路由。
- 获取 Redis 并发槽位;槽位带 TTL,并由心跳或状态转换续约/释放。
- 在 PostgreSQL 中创建 attempt,将任务变更为
DISPATCHING。
Redis 槽位丢失或过期不能直接让任务成功或失败。后台 Reconciler 应比较 PostgreSQL 活跃 attempt 和 Redis 槽位,修正泄漏或过量发放。
4.7.4 提交供应商
- Adapter 从对象存储生成供应商可访问的短期输入 URL,或使用供应商上传 API。
- 使用
attempt_id派生provider_request_key。 - 事务外调用供应商;HTTP Client 配置独立的连接超时、TLS 超时、响应头超时和总预算。
- 供应商明确返回
provider_job_id后,短事务保存映射并将任务置为SUBMITTED。 - 若调用结果不明确,写入
SUBMIT_UNKNOWN,交给对账器处理。
4.7.5 回调和轮询
- 优先使用供应商回调降低轮询压力;轮询作为无回调供应商和回调缺失的恢复机制。
- Callback Gateway 验签后先持久化原始事件,再快速返回 2xx。
- Polling Service 按任务年龄自适应间隔,例如运行初期较快、长任务逐步放慢,并加入随机抖动。
- 两类入口都转换为统一
provider.status.observed事件。 - State Reducer 完成去重、版本校验和合法状态转换。
4.7.6 输出回源、转码和播放
- 供应商成功后,任务进入
OUTPUT_FETCHING。 - Output Fetch Worker 获取输出 URL,并在受控网络环境中流式下载。
- 下载过程中限制最大字节数、最低/最高速率、总时长、重定向次数;同时计算 checksum。
- 写入自有对象存储 raw 区,执行 ffprobe 和基本完整性检查。
- 创建 output asset,发布媒体处理事件。
- Media Worker 生成代理 MP4、HLS/CMAF、缩略图、波形和编辑器所需元数据。
- 输出审核通过且至少一个播放产物 READY 后,任务进入
SUCCEEDED。 - Billing Service 根据供应商计费语义进行结算或释放差额。
- Notification Gateway 推送状态;客户端最终以查询接口为准。
部分供应商输出地址是短期临时 URL。例如 Runway 官方文档提示输出 URL 会在访问 API 后的 24~48 小时内过期,因此平台不应将其直接暴露为长期播放地址,而应尽快回源到自己的存储。[1]
4.8 异常流程和竞态条件
4.8.1 客户端重复提交
场景:用户双击按钮、浏览器重试、移动网络切换,导致相同请求到达多次。
处理:
- 客户端在第一次点击时生成幂等键,重试必须复用;
- 数据库唯一约束是最终防线;
- 相同键、相同请求返回原任务;
- 相同键、不同请求返回 409;
- 不能仅在 Redis 保存幂等结果,因为缓存丢失后仍可能重复生成。
4.8.2 Outbox 已发送但未标记发布
场景:Relay 向 RocketMQ 发送成功,更新 published_at 前宕机。
结果:恢复后再次发送同一事件。
处理:消费者使用 event_id 去重;Outbox 不需要伪装成 exactly-once。
4.8.3 调度消息重复消费
场景:消费者事务已提交,但 ACK 丢失,Broker 重投。
危险:重复调用供应商,产生两次付费任务。
处理:
- Inbox 唯一约束;
- 重新读取任务状态;
(task_id, attempt_no)和provider_request_key唯一;- 已处于
DISPATCHING/SUBMITTED/RUNNING时,重复消息不能再次创建 attempt。
4.8.4 供应商已经受理,但本地请求超时
这是最重要的故障窗口:
Adapter → Provider:创建任务
Provider:创建成功并开始计费
Provider → Adapter:响应返回途中网络超时
Adapter:不知道任务是否已创建
错误做法是立即重试。正确处理:
- attempt 进入
UNKNOWN,任务进入SUBMIT_UNKNOWN; - 若供应商支持幂等键,使用相同 key 查询或重放;
- 若支持按 client request ID 查询,先对账;
- 若只能列举近期任务,则按时间窗口、请求指纹、输入资产和模型匹配;
- 若仍无法确认,延迟重试并触发成本风险告警;
- 重试前必须创建新的 attempt,并保留旧 attempt;
- 日后发现重复任务时,通过供应商账单对账和平台调整流水处理。
4.8.5 回调先于提交响应落库
某些供应商创建任务很快,回调可能在 Adapter 保存 provider_job_id 之前到达。
处理方式:
- 优先要求供应商回调携带平台传入的
provider_request_key; - Callback Gateway 无法映射时,将事件保存为
orphan_provider_events; - Adapter 保存
provider_job_id后触发一次 orphan 事件重匹配; - orphan 事件不能直接丢弃,也不能无限占用主表,应有 TTL、索引和人工排查指标。
4.8.6 回调与轮询并发到达
回调:SUCCEEDED
轮询:RUNNING(数据稍旧)
处理方式:
- 两者都写统一状态事件;
- 使用
source_event_id去重; - 比较供应商更新时间、事件版本和允许转换表;
OUTPUT_FETCHING不能被旧RUNNING回退;- 终态具有粘性;
- 原始事件保留审计,不因被忽略就删除。
4.8.7 重复成功回调触发多次回源
危险:重复下载、重复占用带宽、重复创建资产。
处理:
output.fetch.requested事件有唯一event_id;- 输出对象键由
task_id + attempt_id + output_index确定; assets表对业务键建立唯一约束;- 若对象已存在且 checksum 相同,直接复用;
- 若对象存在但 checksum 不同,记录冲突并人工审计,不能静默覆盖。
4.8.8 用户取消与供应商完成竞态
可能出现:
T1 用户点击取消
T2 平台写 CANCEL_REQUESTED
T3 供应商实际已经完成
T4 取消 API 返回“太晚,无法取消”
T5 成功回调到达
推荐语义:
CANCEL_REQUESTED只是意图,不是终态;- 若供应商明确确认取消,进入
CANCELED; - 若完成事实先得到确认,进入
OUTPUT_FETCHING,并记录cancel_outcome=TOO_LATE; - 用户界面应展示“取消未能生效,任务已完成”,而不是伪造取消成功;
- 计费按供应商实际收费阶段结算。
另一种竞态是平台先确认 CANCELED,随后收到迟到成功回调。此时不应自动反转用户终态;应将输出标记为孤儿产物,触发供应商账单异常对账。
4.8.9 输出 URL 即将或已经过期
处理策略:
- 成功事件到达后立即发布高优先级回源任务;
- 记录
output_url_expires_at; - 根据剩余有效期调整队列优先级;
- 403/404 时先重新 Query 供应商,尝试获取新 URL;
- 无法恢复时进入
FAILED_FINAL或人工恢复,而不是继续无限下载重试。
4.8.10 回源完成,但数据库提交失败
场景:对象已经写入存储,创建资产记录的事务失败。
处理方式:
- 对象键确定且可重复检查;
- Worker 重试时先 HEAD 对象并核对 checksum;
- 定期扫描无数据库引用的临时对象,超过安全窗口后清理;
- 不应因为数据库失败就立即删除刚写入对象,否则可能与重试并发产生竞态。
4.8.11 媒体处理部分成功
例如代理 MP4 已完成,HLS 失败。
需要定义产品语义:
- 若代理 MP4 足以满足编辑器播放,可以将任务标为
SUCCEEDED,HLS 作为可重试派生资产; - 若业务必须依赖 HLS,则任务保持
POST_PROCESSING; - 各派生资产拥有独立状态和
pipeline_version,不能用一个布尔值代表所有产物。
4.8.12 Redis 槽位租约过期
场景:任务仍在供应商运行,但 Scheduler 心跳中断,Redis 槽位过期,另一个任务取得槽位,导致超过供应商并发限制。
处理方式:
- Redis 仅做软准入;
- 以 PostgreSQL 活跃 attempt 定期重建槽位;
- 槽位使用 fencing token 或 lease version,旧持有者不能释放新租约;
- 保留供应商侧 429/THROTTLED 反馈,动态收紧容量估计。
4.8.13 额度预占成功但任务未创建
通过同一 PostgreSQL 事务避免。若 Billing Service 使用独立数据库,则需要 Saga:
预占成功 → 创建任务失败 → 发布释放预占补偿
但这比同库本地事务复杂得多。若业务规模允许,额度账本和任务元数据放在同一 PostgreSQL 集群通常更容易保证一致性。
4.9 幂等、一致性、重试和补偿设计
4.9.1 各边界的幂等策略
| 边界 | 幂等键 | 重复时的行为 |
|---|---|---|
| 创建任务 API | tenant_id + Idempotency-Key | 同请求返回原 task;不同请求返回 409 |
| Outbox → RocketMQ | event_id | 可重复发送 |
| MQ 消费 | consumer_group + event_id | 唯一冲突后直接 ACK |
| Scheduler 创建 attempt | task_id + attempt_no | 已存在则读取,不重复创建 |
| 供应商提交 | provider_request_key | 支持时由供应商去重;不支持时进入 UNKNOWN 对账 |
| 回调 | provider + source_event_id | 重复事件不重复转换 |
| 输出回源 | task_id + attempt_id + output_index | 对象和资产记录复用 |
| 媒体处理 | asset_id + profile + pipeline_version | 已成功产物不重复转码 |
| 计费 | business_key | 同一预占、结算或退款只入账一次 |
4.9.2 一致性边界
事务 1:任务创建
credit_ledger.RESERVE
+ generation_tasks
+ task_state_events
+ outbox_events(generation.queued)
必须同事务提交。
事务 2:状态转换
inbox_dedup
+ generation_tasks(version+1)
+ task_state_events
+ outbox_events(next_action)
必须同事务提交。
供应商调用不属于数据库事务,只能通过 attempt 状态和对账恢复。
对象存储写入也不属于数据库事务,通过确定性对象键、checksum、临时区和孤儿清理实现补偿。
4.9.3 错误分类与重试矩阵
| 错误类型 | 示例 | 是否自动重试 | 是否可能重复生成/计费 | 策略 |
|---|---|---|---|---|
| 本地参数错误 | JSON 序列化失败、必填字段缺失 | 否 | 否 | 修复代码或返回最终失败 |
| 供应商明确 4xx | 不支持分辨率、内容审核拒绝 | 通常否 | 通常否 | 归一化为业务失败;审核拒绝不反复提交 |
| 供应商 429 | 并发或额度限制 | 是,延迟 | 若未创建任务通常低;需按文档确认 | 尊重 Retry-After,减少槽位,加入抖动 |
| 供应商 5xx 且明确未受理 | 明确错误响应 | 可重试 | 低,但仍需供应商语义 | 指数退避、重试预算 |
| 连接建立前失败 | DNS、连接拒绝 | 可重试 | 较低但非绝对为零 | 记录阶段,短退避 |
| 写请求后读超时 | 响应丢失 | 不立即重试 | 高 | SUBMIT_UNKNOWN + 对账 |
| 查询超时 | Polling 失败 | 可重试 | 不会新建任务 | 退避,不改变业务状态 |
| 回调处理失败 | 数据库短暂不可用 | 由供应商或 MQ 重试 | 不应重复副作用 | 原始事件持久化和去重 |
| 输出下载 5xx/断流 | 临时网络问题 | 可重试 | 不重复生成,但增加流量 | Range/重新下载、checksum |
| 输出 URL 403/404 | URL 过期 | 有条件 | 不重复生成 | 先重新 Query 获取 URL |
| 转码 Worker 宕机 | 进程退出、节点故障 | 可重试 | 不重复生成;可能重复计算 | 确定性产物键和资源租约 |
4.9.4 重试预算
每类步骤应有独立预算:
submit_retry_budget
query_retry_budget
output_fetch_retry_budget
media_retry_budget
notification_retry_budget
不能只用一个 retry_count,因为查询失败 20 次并不等于应该重新提交供应商。建议记录:
- 最大尝试次数;
- 最大累计等待时长;
- 业务截止时间
deadline_at; - 指数退避上限;
- 随机抖动;
- 是否允许切换供应商;
- 是否需要人工确认。
4.9.5 补偿机制
| 异常 | 补偿 |
|---|---|
| 输入审核失败 | 释放全部预占额度 |
| 排队超时未提交供应商 | 释放全部预占额度 |
| 供应商明确未收费失败 | 释放预占 |
| 供应商已收费但生成失败 | 按合同结算或退款,不做主观假设 |
| 重复生成 | 保留异常 attempt,供应商账单对账,写 ADJUSTMENT 流水 |
| 输出无法回源 | 先尝试刷新 URL;最终失败时按实际供应商成本结算 |
| 媒体处理失败 | 通常不重新生成;保留 raw 输出并重试转码 |
| 对象孤儿 | 安全窗口后由垃圾回收任务清理 |
| 通知丢失 | 无需回滚任务;客户端查询恢复 |
4.9.6 为什么这不是 exactly-once
RocketMQ 可能重复投递,Outbox Relay 可能重复发送,回调可能重复,Worker 也可能在提交结果前宕机。系统真正实现的是:
至少一次传递
+ 持久化去重
+ 唯一约束
+ 状态版本
+ 确定性资源键
+ 可补偿账本
= 业务副作用按定义至多生效一次
这比声称“MQ 保证 exactly-once”更准确,也更容易在生产环境验证。
4.10 性能瓶颈和容量估算方法
4.10.1 使用 Little’s Law 估算在途任务
设:
- 峰值任务到达率
λ = 3 个任务/秒; - 平均供应商运行时间
W = 180 秒; - 目标资源利用率不超过
ρ = 75%。
平均运行中任务:
L = λ × W = 3 × 180 = 540 个
为维持 75% 利用率,所需可用并发槽位约为:
C_required = λ × W / ρ
= 3 × 180 / 0.75
= 720 个并发槽位
若所有供应商总并发只有 400,则最大稳定吞吐约为:
μ = C / W = 400 / 180 ≈ 2.22 个任务/秒
峰值期间队列增长速度:
λ - μ = 3 - 2.22 = 0.78 个/秒
≈ 46.8 个/分钟
这说明系统瓶颈通常不是 API QPS,而是供应商并发和生成时长。入口即使能接收 1 万 QPS,也不能消除后端排队。
4.10.2 轮询容量
若有 540 个运行任务,每 10 秒轮询一次:
Polling QPS = 540 / 10 = 54 QPS
若积压扩大到 5,000 个任务,同样策略将产生 500 QPS。应使用:
- 回调优先;
- 自适应轮询间隔;
- 按供应商设置全局查询预算;
- 随机抖动,避免整点同时查询;
- 任务越老,轮询间隔可逐步变长;
- 临近输出 URL 过期或取消请求时临时提高查询优先级。
Runway 官方 SDK 文档建议异步任务轮询间隔至少约 5 秒,并使用抖动和指数退避处理非 2xx 响应,说明固定高频轮询并不是稳健方案。[2]
4.10.3 输出带宽
假设:
- 峰值提交 3 个/秒;
- 成功率 90%;
- 平均输出大小 60 MB。
平均回源吞吐:
3 × 90% × 60 MB = 162 MB/s
≈ 1.30 Gbit/s
若按 2 倍突发和协议开销规划,回源链路应准备约 2.6 Gbit/s 以上,并考虑供应商跨区域流量费用和对象存储写入限额。
4.10.4 媒体处理并发
若成功任务到达率为 2.7 个/秒,单个媒体处理平均占用 45 秒:
Media concurrency = 2.7 × 45 = 121.5
若每个 Worker 平均需要 2 vCPU,则仅该阶段约需 243 vCPU,再加上峰值冗余、失败重试和不同编码配置。
4.10.5 数据库写入量
假设每个任务平均产生 14 次状态转换:
状态更新写 QPS ≈ 3 × 14 = 42 次/秒
这个量通常不是 PostgreSQL 的主要瓶颈。更常见的问题是:
- 所有消费者争用同一热点任务行;
- 状态历史 JSONB 无限制膨胀;
- Outbox 缺少
(status, available_at)索引; - Relay 一次事务领取太多行;
- 轮询服务反复扫描全表;
- 频繁更新同一索引列造成写放大;
- 长事务和外部调用占满连接池。
4.10.6 SSE / WebSocket 容量
若有 100,000 个在线连接,每连接在网关层平均占用 20 KB 内存:
100,000 × 20 KB ≈ 2 GB
实际还需考虑 TLS、缓冲区、goroutine、订阅映射和内核 socket。应通过分片网关、连接上限、心跳、慢消费者淘汰和消息合并控制资源。
4.10.7 关键容量指标
至少监控:
submission_rate
queue_depth / queue_age_p95
provider_active_jobs / provider_limit
submit_unknown_count
callback_lag
polling_qps / polling_error_rate
output_fetch_backlog / url_expiry_risk
media_worker_utilization
outbox_oldest_age
mq_consumer_lag
state_transition_conflict_rate
orphan_object_count
reserved_credit_age
其中 queue_age_p95、submit_unknown_count 和 output_url_expiry_risk 往往比单纯 CPU 使用率更能反映业务风险。
4.11 高可用和降级方式
4.11.1 组件级高可用
| 组件 | 高可用策略 | 故障时行为 |
|---|---|---|
| API / Generation Service | 多实例、跨可用区、无状态、优雅停机 | 数据库不可用时拒绝新任务,不能先返回成功后补写 |
| PostgreSQL | 多可用区主备、WAL 备份、PITR、连接池隔离 | 作为事实源,故障期间新任务应失败关闭;已运行任务由恢复扫描接续 |
| Redis | 主从/集群、短 TTL、可重建 | 限流和调度降级;不丢任务事实 |
| RocketMQ | 多 Broker、副本、消费组、DLQ | Outbox 暂存待发事件;积压超过阈值后限制新任务 |
| Scheduler | 多实例竞争领取、fencing token | 单实例宕机后租约过期,其他实例接管 |
| Callback Gateway | 多实例、快速落库 | 回调入口异常时由供应商重试和 Polling 补偿 |
| Polling Service | 分片、全局预算、任务租约 | 可短暂暂停,不影响供应商任务本身运行 |
| Output Fetch / Media Worker | 有界 Worker Pool、任务租约、可重入 | 节点宕机后任务重新投递,产物按确定性键复用 |
| Notification Gateway | 多实例、连接分片 | 推送不可用时客户端退化为轮询 |
4.11.2 关键降级策略
RocketMQ 故障
- 任务创建事务仍可写 Outbox;
- Relay 暂停,任务保持
QUEUED; - 监控 Outbox 最老事件年龄;
- 若积压超过可接受窗口,入口返回 429/503 或只允许高等级租户,避免无限接受付费任务。
Redis 故障
- 不将任务置为失败;
- Scheduler 可切换到保守的 PostgreSQL 计数或直接暂停新 dispatch;
- 已提交供应商的任务继续由回调/轮询推进;
- 不应因为 Redis 缓存丢失重新扣额度或重新提交。
单一供应商故障
- 熔断新提交,保留查询和取消通道;
- 若用户锁定模型,不应静默切换模型;
- 若允许自动降级,必须确认能力、输出质量、价格、合规地区和用户协议;
- 已处于
SUBMIT_UNKNOWN的任务不能因为熔断就直接切换,否则更容易重复生成。
回调故障
- 启用 Polling 补偿;
- 回调恢复后重复事件由去重处理;
- 不需要暂停供应商任务。
对象存储或回源链路故障
- 任务保持
OUTPUT_FETCHING,不标记成功; - 根据 URL 剩余有效期提高优先级;
- 必要时临时降低新生成并发,将带宽留给即将过期的输出。
媒体处理集群故障
- 保留 raw 输出;
- 用户可看到“生成完成,正在处理”;
- 若已有基础 MP4 且产品允许,可提供有限预览,复杂 HLS/缩略图稍后补齐。
Billing Service 故障
- 对新付费任务宜失败关闭,避免无预占生成;
- 已经预占的运行任务继续完成;
- 结算事件留在 Outbox,恢复后补账;
- 不因结算延迟撤销已成功生成的用户资产。
4.12 安全风险
4.12.1 预签名上传风险
- 预签名 URL 是 bearer token,应短期有效并通过 HTTPS 传输;
- 对象键由服务端生成,禁止客户端提交任意 bucket/key;
- 限制
Content-Length、允许的 Content-Type 和 checksum; - 声明的 MIME 不能替代 magic number 检查;
- multipart upload 必须设置过期清理规则;
- 上传成功后先进入隔离区,验证通过再进入可消费区。
AWS 官方文档说明,预签名 URL 可在不向客户端下发存储凭据的情况下授予限时上传权限;multipart upload 可独立重传失败分片。[3][4]
4.12.2 供应商输出 URL 的 SSRF 风险
Output Fetch Worker 不应盲目请求回调中的任意 URL。至少需要:
- 仅允许 HTTPS;
- 供应商域名白名单或签名 URL 规则;
- DNS 解析后拒绝私网、环回、链路本地和云元数据地址;
- 每次重定向重新校验目标;
- 限制重定向次数;
- 设置连接、读取和总超时;
- 限制最大响应体;
- 不把 URL 中的签名参数写入普通日志。
4.12.3 Webhook 风险
- 验证签名和签名算法;
- 校验时间戳,限制允许的时钟偏差;
- nonce 或 event ID 防重放;
- 原始 body 验签,不能先 JSON 重序列化;
- 密钥轮换支持双 key 过渡;
- 回调接口只做快速持久化,不暴露内部错误堆栈;
- 对来源 IP 白名单只能作为补充,不能替代签名。
4.12.4 多租户越权
- 每次任务、资产和项目查询都携带
tenant_id条件; - CDN 使用短期签名 URL 或签名 Cookie;
- 不能仅凭不可猜测 UUID 视为授权;
- 后台人工操作写审计日志;
- 企业租户需要独立密钥、配额和数据保留策略。
4.12.5 FFmpeg 和媒体解析风险
- 在隔离容器中运行,非 root;
- 限制 CPU、内存、临时盘、进程数和执行时间;
- 禁止不需要的网络访问;
- 对异常分辨率、帧率、时长和解码膨胀设置上限;
- 及时更新 FFmpeg;
- 不直接拼接用户输入为 shell 命令,使用参数数组调用。
4.12.6 敏感数据和密钥
- 供应商 API Key 存入密钥管理系统;
- 不下发前端,不硬编码,不写日志;
- prompt、参考图、回调 payload 和错误响应需要脱敏;
- 对象存储启用服务端加密和最小权限;
- 明确数据保留、删除和供应商训练使用政策。
4.13 常见错误设计及其后果
| 错误设计 | 直接后果 | 更深层问题 |
|---|---|---|
| HTTP 请求同步等待视频生成 | 连接超时、goroutine 堆积、网关断开 | 浏览器断线会与后台任务生命周期耦合 |
| 浏览器把大视频先传 Go API | 双倍带宽、内存和临时盘压力 | 业务服务成为媒体数据面瓶颈 |
| 在数据库事务中调用供应商 | 长事务、锁等待、连接池耗尽 | 外部故障会放大为数据库故障 |
| 创建任务后直接发 MQ,没有 Outbox | DB 成功但消息失败,任务永久卡住 | 无可靠恢复点 |
| 认为 RocketMQ 不会重复消费 | 重复提交供应商、重复扣费 | 将传递语义误当业务语义 |
| HTTP 超时后立即重试提交 | 产生两个付费任务 | 忽略“对方成功、本地未知”窗口 |
| 只保存最后一个 provider_job_id | 历史尝试丢失、无法对账 | 不能解释重复费用和切换供应商 |
| 回调直接覆盖 status | 乱序回调导致状态回退 | 缺少事件去重和转换表 |
| 用进度 100 代表成功 | 文件尚未回源就展示成功 | 供应商成功与平台可用混淆 |
| 将供应商临时 URL 直接给前端 | URL 过期、权限泄露、播放不可控 | 平台没有自己的资产事实源 |
| 用户点击取消就写 CANCELED | 实际任务仍在运行并收费 | 取消意图与取消确认混淆 |
| 回调线程中下载并转码 | 回调超时,供应商重复发送 | 入口职责过重、故障耦合 |
| 将任务状态只放 Redis | 缓存故障后无法恢复和审计 | Redis 被错误当作事实数据库 |
| MQ 消息携带完整视频或大 prompt | Broker 压力、重试成本巨大 | 控制面与数据面没有分离 |
| 所有任务固定 1 秒轮询 | 供应商限流、查询风暴 | 没有回调优先和轮询预算 |
| 失败就无限重试 | 队列永不收敛、成本失控 | 没有错误分类和重试预算 |
4.14 面试官可能追问的 10 个问题与资深回答
问题 1:为什么不能让前端请求一直等到视频生成完成?
资深回答:
生成通常是分钟级,持续占用 HTTP 连接会受到浏览器、负载均衡、网关和服务端多层超时限制。更重要的是,客户端连接生命周期不应决定业务任务生命周期。正确方式是同步完成认证、校验、额度预占和持久化创建,然后返回 202 + task_id。后续由 MQ 和 Worker 异步推进,客户端通过 SSE/WebSocket 获得低延迟通知,并以查询接口作为恢复路径。
问题 2:为什么选 Outbox,而不是先写数据库再直接发 RocketMQ?
资深回答:
先写数据库再发 MQ 会有“数据库成功、MQ 失败”的窗口;先发 MQ 再写数据库则会有“消费者看见消息、任务事实尚不存在”的窗口。Outbox 把业务事实和待发送事件放入同一 PostgreSQL 本地事务,之后由 Relay 异步发送。它仍可能重复发送,但不会丢失已提交的业务事件。消费者通过 Inbox 去重和业务唯一约束处理重复。RocketMQ 事务消息也可以考虑,但下游消费幂等仍不可省略,且 Outbox 对审计、补偿和人工重放通常更直观。
问题 3:供应商提交超时后,你会不会自动重试?
资深回答:
不能一概重试。需要区分失败阶段。如果本地参数构造失败或连接建立前明确失败,通常可以重试;如果请求可能已经写到供应商,只是响应读取超时,就必须进入 SUBMIT_UNKNOWN。优先使用供应商幂等键、client request ID 或任务列表对账。只有确认未创建任务后才重试,否则可能生成两个付费任务。无法确认时应延迟处理并告警,而不是用重试掩盖不确定性。
问题 4:有了回调为什么还需要轮询?
资深回答:
回调可能延迟、丢失、签名验证失败,供应商也可能根本不支持回调;轮询是恢复机制。但轮询不能固定高频执行,应有全局预算、自适应间隔和抖动。回调和轮询都只产生统一状态事件,由同一个 Reducer 去重和推进状态,避免两个入口直接竞争写任务表。
问题 5:如何处理回调乱序和重复?
资深回答:
首先保存原始事件并用供应商 event ID 去重;没有 event ID 时构造稳定事件指纹。然后在短事务中锁定任务或使用 version CAS,检查显式转换表、供应商更新时间和终态粘性。例如先收到 SUCCEEDED、后收到旧 RUNNING,后者只入审计,不允许状态回退。不能用简单数值 rank 解决所有问题,因为取消和重试是分支状态。
问题 6:用户取消与任务完成同时发生,谁赢?
资深回答:
用户点击取消先进入 CANCEL_REQUESTED,它表示意图,不是完成事实。若供应商确认取消,进入 CANCELED;若供应商已经完成或取消返回太晚,完成事实优先,任务继续回源,并记录 cancel_outcome=TOO_LATE。最终费用按供应商实际计费阶段处理。不能为了界面好看直接写 CANCELED,否则平台状态会与真实成本不一致。
问题 7:供应商返回成功时为什么不直接把任务置为成功?
资深回答:
供应商成功只代表生成结果存在,未必代表平台可长期使用。输出 URL 可能是临时的,文件可能损坏,编码可能不兼容,也可能在输出审核阶段被拒绝。平台自己的成功定义应是:结果已持久化到自有对象存储、媒体探测通过、至少一个可播放产物就绪、资产和任务引用已提交。这样用户看到成功后才有稳定 SLA。
问题 8:Redis 在这个状态机里承担什么职责?
资深回答:
Redis 适合租户限流、供应商并发槽位、短期租约、热点状态缓存和通知分发,但不保存任务终态、余额和唯一 provider_job_id 映射的唯一事实。Redis 宕机后系统可以降低调度速度或暂停新 dispatch,但不能丢任务。事实从 PostgreSQL 恢复,槽位由 Reconciler 重建。
问题 9:你如何保证 exactly-once?
资深回答:
我不会声称 MQ 或整个系统提供端到端 exactly-once。实际设计是至少一次传递,再通过 event_id 去重、唯一约束、任务版本、确定性对象键和追加式计费流水,使业务副作用按定义生效一次。对无法原子提交的供应商和对象存储操作,用 attempt、对账和补偿处理。这比依赖一个无法覆盖外部供应商的 exactly-once 宣称更可信。
问题 10:如何估算系统最多能接多少任务?
资深回答:
先看供应商并发而不是 API QPS。用 Little’s Law:在途量约等于到达率乘平均时长。若峰值 3 个任务/秒、平均 180 秒,就有约 540 个运行任务;按 75% 利用率需要约 720 个并发槽位。再估算轮询 QPS、输出带宽、媒体 Worker 并发、数据库状态写入和 SSE 连接。若供应商只有 400 并发,系统稳定吞吐约 2.22 个/秒,剩余请求只能排队或限流,不能靠扩容 API 服务解决。
4.15 三分钟口述稿
这个平台从用户点击生成到视频可播放,不能看成一次 HTTP 调用,而要看成一条长耗时、跨系统、带真实成本的异步工作流。
首先,参考图片和视频不经过 Go 服务中转。前端向 Asset Service 申请短期预签名地址,直接上传对象存储;大文件使用分片上传。上传完成后资产还要经过 magic number、ffprobe、时长分辨率限制、恶意文件检查和内容审核,只有 READY 资产才能用于生成。
用户提交生成请求时,Gateway 完成认证和限流,Generation Service 使用 tenant_id + Idempotency-Key 防止双击或网络重试造成重复生成。参数校验和输入审核通过后,在同一个 PostgreSQL 事务中完成额度预占、任务创建、状态事件和 Outbox 写入,然后立即返回 202 + task_id,不会同步等待模型。
Outbox Relay 将事件投递 RocketMQ。这里按至少一次理解,所以调度消费者必须用 event_id 去重。Scheduler 根据租户并发、模型能力、供应商容量、健康度、成本和地区选择路由,再创建独立的 attempt。供应商调用一定在数据库事务之外执行。
最关键的故障是供应商已经创建任务,但响应返回途中超时。此时不能立即重试,而要把 attempt 标为 UNKNOWN、任务标为 SUBMIT_UNKNOWN,再通过供应商幂等键、client request ID 或任务列表对账。否则可能产生两个付费任务。
供应商状态通过回调和轮询进入统一事件流。Callback Gateway 只负责验签、持久化和快速返回;轮询作为回调缺失的补偿。State Reducer 使用事件去重、任务版本和显式状态转换表处理重复与乱序,终态不能被旧事件覆盖。
供应商成功后,平台任务还不能算成功。Output Fetch Worker 必须把临时 URL 回源到自己的对象存储,计算 checksum、执行媒体探测;随后 Media Worker 生成代理视频、HLS、缩略图和波形。只有可播放资产就绪并完成输出审核,任务才进入 SUCCEEDED。
取消也分两步:先写 CANCEL_REQUESTED,再等待供应商确认。如果完成已经发生,就记录取消太晚并按实际费用结算。整个系统不依赖 MQ 或 Redis 的 exactly-once,而是通过 PostgreSQL 事实源、Outbox/Inbox、唯一约束、状态版本、确定性对象键和追加式计费账本实现可恢复的一致性。
4.16 十分钟深入讲解提纲
0:00—1:00:先定义问题
- AI 视频是分钟级、付费、异步、弱确定性任务;
- 用户一次点击,内部跨越上传、任务创建、调度、供应商、回调、回源、转码、通知;
- 目标不是全局分布式事务,而是可恢复的一致性。
1:00—2:00:控制面与媒体数据面
- 控制面只传任务 ID、状态、对象键;
- 媒体面让浏览器直传对象存储,Worker 流式回源;
- PostgreSQL、Redis、RocketMQ、对象存储的边界。
2:00—3:00:任务创建与 Outbox
Idempotency-Key + request_hash;- 参数和资产校验;
- 同一事务写额度预占、任务、状态事件、Outbox;
- 返回 202,不同步等待 MQ 或模型。
3:00—4:20:调度与 Attempt
- 四层并发准入;
- 供应商能力、成本、健康度、地区和用户锁定模型;
- Task 与 Attempt 分层;
- Redis 槽位是软准入,PostgreSQL 是事实源。
4:20—5:40:供应商提交结果未知
- 展开“对方成功、本地超时”时序;
- 说明为什么普通网络超时不能直接重试;
SUBMIT_UNKNOWN、幂等键、对账、延迟重试和成本告警。
5:40—6:50:回调、轮询和状态机
- 回调优先、轮询兜底;
- 原始事件持久化;
- Inbox 去重、版本 CAS、显式转换表、终态粘性;
- 回调先于提交落库、重复和乱序处理。
6:50—7:50:取消竞态
CANCEL_REQUESTED不等于CANCELED;- 取消确认、取消太晚、完成先到;
- 用户状态、供应商事实和计费结果如何保持一致。
7:50—8:40:输出回源与平台成功定义
- 临时 URL 风险;
- 防 SSRF、流式下载、checksum、ffprobe;
- raw 输出、代理视频、HLS、缩略图;
- 为什么供应商成功不等于平台成功。
8:40—9:25:幂等、重试和补偿
- 创建请求、MQ 消费、供应商调用、回调、资产、计费的幂等键;
- 错误分类和独立重试预算;
- 追加式账本、孤儿对象清理、重复供应商任务对账。
9:25—10:00:容量和高可用收尾
- Little’s Law 估算并发;
- 供应商容量、轮询 QPS、输出带宽、媒体 Worker;
- MQ、Redis、回调、对象存储故障时的降级;
- 总结:至少一次传递 + 幂等副作用 + 可补偿事实链。
4.17 本章设计检查清单
在面试或设计评审中,可以用以下问题快速验证方案是否完整:
- 客户端重复提交是否会重复生成?
- 数据库提交成功但 MQ 不可用时,任务是否可恢复?
- MQ 重投时,消费者是否会重复调用供应商?
- 供应商已受理但本地超时时,是否存在
SUBMIT_UNKNOWN和对账流程? - 是否保存每次 attempt,而不是覆盖最后一个 provider_job_id?
- 回调和轮询是否进入同一状态事件处理器?
- 乱序事件能否让终态回退?
- 取消意图和取消确认是否分开?
- 供应商成功后,是否先回源再标记平台成功?
- 临时输出 URL 过期前是否有优先回源机制?
- Redis 故障是否会造成任务事实或额度丢失?
- 每类重试是否评估重复生成和重复计费风险?
- 计费是否采用唯一业务键和追加式流水?
- 孤儿对象、孤儿回调和死信消息是否有治理流程?
- 容量估算是否从供应商并发、平均时长和媒体带宽出发?
参考资料
资料核验日期:2026-06-23。供应商模型、配额和 API 行为可能变化,生产接入时应再次核对当前版本文档。