返回文章列表

AiVedio:端到端生成时序与任务状态机

从浏览器提交、素材直传、任务创建、Outbox、调度、供应商调用、回调轮询、结果回源、媒体处理到通知播放,拆解 AI 视频生成的端到端时序与状态机。

第 04 章:端到端生成时序与任务状态机

本章主题:从浏览器提交提示词、参考图或参考视频开始,一直到生成结果被可靠回源、转码并通过 CDN 播放,完整拆解控制面时序、媒体数据面时序、任务状态机,以及重复、乱序、超时、取消和计费竞态。

核心结论:AI 视频生成不是一次普通 HTTP 调用,而是一条跨浏览器、对象存储、数据库、消息队列、调度器、第三方供应商和媒体处理集群的长事务工作流。平台无法依赖分布式事务把所有参与方一次性提交,只能通过本地事务、持久化状态机、幂等键、事件去重、重试分类和补偿对账实现“最终可解释、最终可恢复”。


4.1 本章要解决的业务问题

用户看到的操作通常只有两个:上传参考素材、点击“生成”。但平台内部至少需要解决以下问题:

  1. 大文件不能经过业务服务中转:参考视频可能达到数百 MB,生成结果也可能很大。若全部经过 Go API,会同时消耗入口带宽、出口带宽、连接、内存和临时磁盘。
  2. 生成任务是分钟级异步任务:HTTP 请求不能一直占用连接等待模型完成,浏览器断开也不能导致后台任务丢失。
  3. 任务创建与消息投递必须一致:不能出现数据库已经扣额度并创建任务,但 RocketMQ 没有消息;也不能出现消息已投递,但数据库中没有任务事实。
  4. 供应商调用存在“结果未知”窗口:请求超时不等于供应商失败。供应商可能已经创建了付费任务,只是响应在返回途中丢失。
  5. 回调和轮询可能重复、乱序、并发到达:平台必须把它们统一为状态事件,而不是让两个入口直接覆盖任务状态。
  6. 供应商成功不等于用户可播放:供应商临时 URL 仍可能失效、下载失败、文件损坏、编码不兼容或内容复审失败。
  7. 取消不是一次简单状态更新:用户点击取消时,任务可能尚未调度、正在提交、供应商运行中、已经完成但回调尚未到达,或者正在回源。
  8. 每次重试都可能产生真实成本:重试策略必须回答“是否会重复生成、是否会重复扣费、如何发现和冲正”。
  9. 系统必须可恢复和可解释:任何任务都应能回答“当前在哪一步、最后一次尝试发生了什么、下一步由谁处理、为什么失败、钱如何结算”。

本章不追求在所有组件之间实现理论上的全局 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 不能替代 SUCCEEDEDprogress=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 Workerffprobe、转码、代理视频、缩略图、波形、输出审核任务可重入;产物键确定;资源隔离
Notification GatewaySSE / 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:已确定不可重试,或者重试预算耗尽;
  • SUCCEEDEDCANCELEDFAILED_FINALEXPIRED 为业务终态。

4.5.2 Attempt 状态

每次供应商调用尝试独立维护:

NEW → SENDING → ACKED → RUNNING → SUCCEEDED
                  │         ├────→ FAILED
                  │         └────→ CANCELED
                  └────→ UNKNOWN
NEW / SENDING ──────────────→ FAILED_BEFORE_ACCEPT

FAILED_BEFORE_ACCEPT 只应在平台能够较高置信度确认“请求未被供应商受理”时使用,例如本地参数序列化失败、连接建立前被拒绝、供应商明确返回可安全重试且未创建任务的错误。普通读超时不能直接归入该状态。

4.5.3 状态转换表

当前状态事件目标状态必要条件与副作用
CREATEDvalidation_startedVALIDATING追加状态事件
VALIDATINGvalidation_passedQUOTA_RESERVED额度预占流水成功
QUOTA_RESERVEDqueuedQUEUEDOutbox 已写入同一事务
QUEUEDdispatch_claimedDISPATCHING任务未取消、版本匹配、创建 attempt
DISPATCHINGprovider_acceptedSUBMITTED保存唯一 provider_job_id
DISPATCHINGsubmit_ambiguousSUBMIT_UNKNOWN进入对账,不立即创建新 attempt
SUBMIT_UNKNOWNprovider_job_foundSUBMITTED绑定对账发现的 provider_job_id
SUBMIT_UNKNOWNconfirmed_not_createdRETRY_WAIT只有确认未创建后才允许重试
SUBMITTEDprovider_runningRUNNING允许跳过该状态直接接收成功事件
SUBMITTED/RUNNINGprovider_succeededOUTPUT_FETCHING发布回源事件
OUTPUT_FETCHINGoutput_persistedPOST_PROCESSING自有对象存储已有原始输出
POST_PROCESSINGplayable_asset_readySUCCEEDED写结算、通知事件
非终态retryable_failureRETRY_WAITnext_retry_at、错误分类、重试预算
RETRY_WAITretry_dueQUEUED创建下一 attempt 前再次检查取消和截止时间
QUEUED 前状态cancel_requestedCANCELED无供应商成本时直接终止并释放预占
DISPATCHING/SUBMITTED/RUNNINGcancel_requestedCANCEL_REQUESTED异步调用供应商取消
CANCEL_REQUESTEDcancel_confirmedCANCELED按供应商计费语义结算或退款
CANCEL_REQUESTEDcompletion_wonOUTPUT_FETCHINGcancel_outcome=TOO_LATE,完成事实优先
任意非终态final_failureFAILED_FINAL结算或释放额度,通知用户

4.5.4 状态转换规则

  1. 只允许显式转换:不能用 status_rank 简单比较大小,因为取消和重试不是一条线性链。
  2. 短事务串行化单任务更新:可以使用 SELECT ... FOR UPDATE,也可以使用 WHERE id=? AND version=? 的 CAS。前者实现简单,后者更适合高并发状态入口。
  3. 每次转换递增 version:事件携带其观察到的任务版本,版本冲突时重新读取并重新判断,而不是直接覆盖。
  4. 终态粘性:迟到的 RUNNING 不能覆盖 SUCCEEDED;迟到的旧成功事件也不能自动推翻已经确认的 CANCELED,应转入异常审计。
  5. 允许跳跃但不允许伪造:有些供应商第一次回调就是 SUCCEEDED,可从 SUBMITTED 直接进入 OUTPUT_FETCHING;但不能从 CREATED 直接进入 SUCCEEDED
  6. 进度与状态解耦progress 作为展示字段,可采用 max(current, observed) 或按供应商规则归一化,但不驱动终态。
  7. 副作用由 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 预签名上传

  1. 客户端提交文件大小、扩展名、声明类型和可选 checksum。
  2. Asset Service 验证租户权限和上传配额,生成不可预测的 asset_id 与服务端对象键。
  3. 小文件返回单次预签名上传地址;大文件创建 multipart upload,返回分片凭证。
  4. 客户端直接上传对象存储,失败分片独立重试。
  5. 客户端调用 complete;服务端核对对象大小、分片 ETag 和 checksum。
  6. 资产进入 VALIDATING,异步执行 magic number、媒体探测、时长/分辨率限制、恶意文件检查和内容审核。
  7. 验证通过后置为 READY。生成请求只能引用 READY 资产。

预签名 URL 本质上是短期 bearer token,必须限制对象键、HTTP 方法、有效期、大小和必要的 checksum。分片上传结束后要完成或中止,否则残留分片会持续占用存储空间。

4.7.2 创建生成任务

  1. API Gateway 完成认证、租户限流和基础请求体限制。
  2. Generation Service 对参数归一化,例如宽高比、时长、分辨率、模型别名和 seed。
  3. tenant_id + Idempotency-Key 查重:
    • 不存在:继续;
    • 存在且 request_hash 相同:返回原任务;
    • 存在但请求内容不同:返回 409。
  4. 校验输入资产状态和归属,执行输入审核。
  5. 根据模型、时长、分辨率和用户等级估算费用。
  6. 在同一个 PostgreSQL 事务中:
    • 写入 RESERVE 额度流水;
    • 创建任务;
    • 追加初始状态事件;
    • generation.queued Outbox。
  7. 事务提交后立即返回 202 Accepted,不等待 MQ,不等待供应商。

4.7.3 Outbox 投递和调度

  1. Relay 批量领取 Outbox 并发送 RocketMQ。
  2. Scheduler 消费并先做 Inbox 去重。
  3. Scheduler 重新读取任务事实,确认任务仍为 QUEUED、未取消、未过期。
  4. 执行四层准入:
平台总并发
  └─ 租户并发
      └─ 供应商并发
          └─ 模型并发
  1. 根据能力、健康度、延迟、成本、地区、用户是否锁定模型进行路由。
  2. 获取 Redis 并发槽位;槽位带 TTL,并由心跳或状态转换续约/释放。
  3. 在 PostgreSQL 中创建 attempt,将任务变更为 DISPATCHING

Redis 槽位丢失或过期不能直接让任务成功或失败。后台 Reconciler 应比较 PostgreSQL 活跃 attempt 和 Redis 槽位,修正泄漏或过量发放。

4.7.4 提交供应商

  1. Adapter 从对象存储生成供应商可访问的短期输入 URL,或使用供应商上传 API。
  2. 使用 attempt_id 派生 provider_request_key
  3. 事务外调用供应商;HTTP Client 配置独立的连接超时、TLS 超时、响应头超时和总预算。
  4. 供应商明确返回 provider_job_id 后,短事务保存映射并将任务置为 SUBMITTED
  5. 若调用结果不明确,写入 SUBMIT_UNKNOWN,交给对账器处理。

4.7.5 回调和轮询

  1. 优先使用供应商回调降低轮询压力;轮询作为无回调供应商和回调缺失的恢复机制。
  2. Callback Gateway 验签后先持久化原始事件,再快速返回 2xx。
  3. Polling Service 按任务年龄自适应间隔,例如运行初期较快、长任务逐步放慢,并加入随机抖动。
  4. 两类入口都转换为统一 provider.status.observed 事件。
  5. State Reducer 完成去重、版本校验和合法状态转换。

4.7.6 输出回源、转码和播放

  1. 供应商成功后,任务进入 OUTPUT_FETCHING
  2. Output Fetch Worker 获取输出 URL,并在受控网络环境中流式下载。
  3. 下载过程中限制最大字节数、最低/最高速率、总时长、重定向次数;同时计算 checksum。
  4. 写入自有对象存储 raw 区,执行 ffprobe 和基本完整性检查。
  5. 创建 output asset,发布媒体处理事件。
  6. Media Worker 生成代理 MP4、HLS/CMAF、缩略图、波形和编辑器所需元数据。
  7. 输出审核通过且至少一个播放产物 READY 后,任务进入 SUCCEEDED
  8. Billing Service 根据供应商计费语义进行结算或释放差额。
  9. 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:不知道任务是否已创建

错误做法是立即重试。正确处理:

  1. attempt 进入 UNKNOWN,任务进入 SUBMIT_UNKNOWN
  2. 若供应商支持幂等键,使用相同 key 查询或重放;
  3. 若支持按 client request ID 查询,先对账;
  4. 若只能列举近期任务,则按时间窗口、请求指纹、输入资产和模型匹配;
  5. 若仍无法确认,延迟重试并触发成本风险告警;
  6. 重试前必须创建新的 attempt,并保留旧 attempt;
  7. 日后发现重复任务时,通过供应商账单对账和平台调整流水处理。

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 各边界的幂等策略

边界幂等键重复时的行为
创建任务 APItenant_id + Idempotency-Key同请求返回原 task;不同请求返回 409
Outbox → RocketMQevent_id可重复发送
MQ 消费consumer_group + event_id唯一冲突后直接 ACK
Scheduler 创建 attempttask_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/404URL 过期有条件不重复生成先重新 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_p95submit_unknown_countoutput_url_expiry_risk 往往比单纯 CPU 使用率更能反映业务风险。


4.11 高可用和降级方式

4.11.1 组件级高可用

组件高可用策略故障时行为
API / Generation Service多实例、跨可用区、无状态、优雅停机数据库不可用时拒绝新任务,不能先返回成功后补写
PostgreSQL多可用区主备、WAL 备份、PITR、连接池隔离作为事实源,故障期间新任务应失败关闭;已运行任务由恢复扫描接续
Redis主从/集群、短 TTL、可重建限流和调度降级;不丢任务事实
RocketMQ多 Broker、副本、消费组、DLQOutbox 暂存待发事件;积压超过阈值后限制新任务
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,没有 OutboxDB 成功但消息失败,任务永久卡住无可靠恢复点
认为 RocketMQ 不会重复消费重复提交供应商、重复扣费将传递语义误当业务语义
HTTP 超时后立即重试提交产生两个付费任务忽略“对方成功、本地未知”窗口
只保存最后一个 provider_job_id历史尝试丢失、无法对账不能解释重复费用和切换供应商
回调直接覆盖 status乱序回调导致状态回退缺少事件去重和转换表
用进度 100 代表成功文件尚未回源就展示成功供应商成功与平台可用混淆
将供应商临时 URL 直接给前端URL 过期、权限泄露、播放不可控平台没有自己的资产事实源
用户点击取消就写 CANCELED实际任务仍在运行并收费取消意图与取消确认混淆
回调线程中下载并转码回调超时,供应商重复发送入口职责过重、故障耦合
将任务状态只放 Redis缓存故障后无法恢复和审计Redis 被错误当作事实数据库
MQ 消息携带完整视频或大 promptBroker 压力、重试成本巨大控制面与数据面没有分离
所有任务固定 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 行为可能变化,生产接入时应再次核对当前版本文档。

  1. Runway API Output Formats:输出 URL 为临时地址,应下载到自有存储
  2. Runway SDK:异步任务轮询、抖动、退避和终态
  3. Amazon S3:预签名 URL 上传与权限边界
  4. Amazon S3:Multipart Upload 可独立重试失败分片
  5. Apache RocketMQ:消费重试和死信队列
  6. Google Cloud Veo:长运行操作和输出到 Cloud Storage
  7. Runway API Usage Tiers:组织/模型并发限制与 THROTTLED 状态