OpenAI 视频生成题的完整解答:异步任务 API、任务状态机、基于租约的拉取式调度、fencing token、检查点与抢占、带真实预计等待时间的准入控制、按模型版本划分的 worker 池、结果交付以及故障处理。
作者:The Forward Deployed 编辑团队已审阅
本文属于 OpenAI 系统设计题库。该题目对面试轮次具有代表性。分析与解答为本站原创。
题目描述
用户提交一个 prompt,立即得到一个任务 ID。他们可以查看进度、取消任务,并最终下载视频。在 API 背后,调度器将任务分配给 GPU worker,每个 worker 同一时间只处理一个视频。Worker 可能被抢占、可能崩溃、也可能断网。已接受的任务绝不能丢失。
澄清问题
- 生成一个视频需要多久? 练习设定:平均约 4 分钟 GPU 时间,长视频最多 15 分钟。
- 提交量有多大? 每天 50 万次,峰值每秒 10 次。
- 每个 GPU worker 处理一个视频? 是的,且每个 worker 只加载一个模型版本。
- GPU 有哪些类型? 一部分是预留 GPU,另一部分是更便宜的可抢占 GPU,后者可能在短暂预警后消失。
- 延迟目标是多少? 任务创建需在 500 ms 内确认。有可用容量时,30 秒内完成调度。
- 优先级如何划分? 付费档和免费档。
难点在哪里
任务耗时长、成本高,而且运行在你无法保证始终在线的机器上。一个跑在可抢占 GPU 上的四分钟任务,很有可能在半途失去机器。从零重启会浪费 GPU 分钟数,而这是系统中最昂贵的资源。
与此同时,需求常常超过供给。GPU 池小于峰值需求,而且当可抢占容量被回收时还会进一步缩小。用户愿意等待,但他们必须知道要等多久,并且队列必须保持公平。
此外,分布式故障非常微妙。一个断网的 worker 看起来像死掉了,于是它的任务被交给另一个 worker。随后第一个 worker 恢复了,并试图写入它的结果。稍有不慎,两个 worker 都会“完成”同一个任务,其中一个覆盖另一个的结果。
因此,核心矛盾是 GPU 利用率与可靠性之间的权衡。使用便宜的可抢占容量并让每块 GPU 保持忙碌可以省钱;但每一个这样的选择都会让故障更频繁,所以恢复机制必须既廉价又正确。
flowchart LR C([Client]):::user -->|submit| API[Job API]:::svc --> DB[(Jobs)]:::store W1[GPU worker]:::svc -->|lease a job| DB W2[GPU worker]:::svc -->|lease a job| DB W1 --> OS[(Checkpoints + videos)]:::store C -->|status, download| API classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
核心思想。 元数据系统很小。GPU 时间才是成本。设计时要确保一个 worker 丢失只损失几秒钟的工作,绝不丢失整个任务,也绝不产生重复结果。
核心概念
异步任务
一个需要几分钟才能完成的请求不应一直占用 HTTP 连接。API 接受任务后返回一个 ID,客户端通过轮询或订阅来获取状态。
租约
租约是对任务的一段限时占用。持有租约的 worker 必须通过心跳续约。如果它停止了,租约就会过期,任务重新变为可用。租约把“这个 worker 是否已死?”这个无法回答的问题变成了一个超时问题。
防护令牌
防护令牌是一个每次任务被租出时都会递增的数字。worker 的每次写入都携带它的令牌,存储层会拒绝携带旧令牌的写入。失去租约的 worker 即使醒来并尝试写入,也无法再修改该任务。
检查点
检查点保存任务的中间状态,使新的 worker 可以从中恢复。它把任何故障造成的损失限制在上次检查点以来的时间内。
Little 定律
进行中的任务数等于到达率乘以平均时长。它用于确定 GPU 池的规模。
核心思想。 异步 API、租约化工作、防护写入、检查点化进度,以及用于容量规划的 Little 定律。
需求
在继续阅读之前。 列出需求。然后说出你会写进与用户合同中的那一条保证。
1.1 功能需求
- 提交一个包含提示词、模型版本、时长和分辨率的任务;获得一个任务 ID。
- 获取状态、进度和预计时间;任务完成后下载视频。
- 取消排队中或运行中的任务。
- 将每个任务分配给一台已加载正确模型的 GPU worker。
- 在 worker 丢失、抢占和网络故障的情况下恢复,且不丢失已接受的任务。
1.2 非功能需求
- 已接受任务的持久性。 一个已接受的任务最终总会完成、以某个原因失败,或被取消。
- 结果有且只有一个。 一个任务绝不会产生两个相互竞争的结果。
- 调度延迟在有可用容量时低于 30 秒。
- 有界的损失。 每次抢占损失的 GPU 工作不超过 30 秒。
- 诚实的预计完成时间。 用户看到的等待估计值符合实际情况。
1.3 约束与性质
不丢任务、每个任务只有一个结果,这是性质。 GPU 容量是约束。 它稀缺、多变且部分可被抢占,它决定了调度、检查点和准入控制的设计。
粗略估算
2.1 并发与 GPU
峰值到达率:每秒 10 个任务。平均 GPU 时间:240 秒。Little 定律:10 × 240 = 峰值时有 2,400 个任务在运行,因此大约需要 2,400 台繁忙的 GPU worker,加上 10% 的热缓冲,约 2,650 台。每日平均值更低:500,000 / 86,400 ≈ 每秒 5.8 个,即约 1,400 台繁忙的 worker。
2.2 控制平面
- 任务创建:峰值时每秒 10 个。
- 心跳:每 10 秒 2,400 个 worker = 每秒 240 个。
- 进度更新:随心跳一起发送。
- 状态轮询:如果 50,000 个用户每 5 秒轮询一次,就是每秒 10,000 次读取,由缓存提供服务。
单个索引良好的关系型数据库即可处理写入。状态读取走缓存。
2.3 存储
- 最终视频:500,000 × 20 MB = 每天 10 TB。
- 检查点:每个几百 MB,运行期间每 30 秒写入一次:2,400 × 300 MB / 30 秒 ≈ 峰值 24 GB/s。这是很大的写入带宽;把检查点保存在同一可用区的快速对象存储上,每个任务只保留最近两个,任务完成后删除。
2.4 丢失的工作
没有检查点时,一个 4 分钟的任务在随机时刻被抢占,平均损失 2 分钟。每 30 秒做一次检查点后,平均只损失 15 秒。如果 5% 的任务被抢占,检查点每天可节省约 25,000 × 105 秒 ≈ 730 GPU 小时。
核心要点。 峰值 2,400 块 GPU,一个小型控制平面,每天 10 TB 的输出,以及每天节省数百 GPU 小时的检查点。
API 设计
继续阅读之前。 客户端的提交请求超时了。它进行重试。你如何避免生成两次视频?
客户端在提交时附带一个幂等键。服务器将其与任务 ID 一起存储。使用相同键的重试会返回已有任务,而不是创建新任务。
POST /v1/videos
Idempotency-Key: <uuid>
{prompt, model_version, seconds, resolution, priority?}
-> 202 {job_id, status: "queued", eta_seconds}
-> 429 {reason: "too_many_queued"} per-user cap
-> 503 {reason: "capacity", retry_after} admission rejected (free tier under load)
GET /v1/videos/:id
-> {status, progress: 0.0-1.0, eta_seconds, error?, result_url?}
POST /v1/videos/:id/cancel -> {status}
GET /v1/videos/:id/events -> SSE stream of status and progress内部 worker API:
POST /internal/lease {worker_id, model_version, gpu_type}
-> {job_id, attempt, params, checkpoint_ref?, lease_expires_at} | 204 no work
POST /internal/heartbeat {job_id, attempt, progress}
-> {lease_expires_at, cancel: bool} | 409 lease lost
POST /internal/checkpoint {job_id, attempt, checkpoint_ref, step}
POST /internal/complete {job_id, attempt, result_ref, metrics}
POST /internal/fail {job_id, attempt, error, retryable}每次 worker 调用都携带尝试次数,即 fencing token。
数据模型
jobs id, user_id, tier, priority, idempotency_key, status: queued | leased | running | uploading | succeeded | failed | cancelled, model_version, params_json, attempt int, -- fencing token,每次租约 +1 worker_id, lease_expires_at, checkpoint_ref, checkpoint_step, progress, result_ref, error, created_at, started_at, finished_at index (status, model_version, priority, created_at) -- 调度器选取 index (status, lease_expires_at) -- 清扫器 unique (user_id, idempotency_key) workers id, model_version, gpu_type, preemptible, status, last_heartbeat_at
高层设计
5.1 同步端点
客户端调用
POST /generate
并等待几分钟以获取视频。代理会超时,连接断开后客户端无法恢复,服务器崩溃会丢失工作且不留任何记录。
5.2 修复 1:异步任务 API 和持久化队列
将任务写入数据库,返回 202 和任务 ID,让 worker 处理。任务可以在任何 API 服务器崩溃后存活。
5.3 修复 2:worker 通过租约拉取任务
空闲 worker 请求与其已加载模型匹配的工作。调度器通过一次条件更新将最匹配的任务租出。worker 通过心跳保持租约。清扫器将过期的租约归还队列。
flowchart LR W[Idle worker<br/>model v3 loaded]:::svc -->|lease request| S[Scheduler]:::new S -->|UPDATE ... SET status=leased, attempt=attempt+1<br/>WHERE status=queued ... LIMIT 1| DB[(Jobs)]:::store W -->|heartbeat every 10 s| S SW[Sweeper]:::new -->|expired leases back to queued| DB classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717; classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;
5.4 修复 3:每次写入都加防护
每个 worker 的写入都带上自己的 attempt 编号。数据库只在
WHERE id = ? AND attempt = ?
时才应用该写入。僵尸 worker 的写入匹配不到任何行。
5.5 修复 4:检查点与恢复
Worker 每 30 秒以及在收到抢占警告时,将生成状态保存到对象存储。新租约包含最新的检查点,新 worker 从该检查点恢复。
5.6 修复 5:准入与预计等待时间
API 根据该任务前面的队列长度和近期完成速率,估算每个新任务的等待时间并返回;当估算超过阈值时,拒绝免费层任务。
5.7 组合后的设计
sequenceDiagram
autonumber
actor U as User
participant A as Job API
participant D as Jobs DB
participant W as GPU worker
participant O as Object storage
U->>A: POST /videos (idempotency key)
A->>A: admission: ETA under threshold?
A->>D: insert job (queued)
A-->>U: 202 {job_id, eta}
W->>A: lease {model v3}
A->>D: conditional update: leased, attempt=2
A-->>W: job, attempt 2, checkpoint (if any)
loop every 10 s
W->>A: heartbeat {attempt 2, progress}
A-->>W: lease extended, cancel=false
end
W->>O: checkpoint every 30 s
W->>O: upload video
W->>A: complete {attempt 2, result_ref}
A->>D: UPDATE ... WHERE attempt = 2
U->>A: GET /videos/:id
A-->>U: succeeded, signed result_url核心思想。 异步提交、带租约的拉取、每次写入都加防护、用检查点限制损失,并以诚实的预计等待时间进行准入。
深入探讨
6.1 精确理解租约与防护
继续阅读之前。 Worker A 持有任务 42,attempt 为 7。A 的网络中断了 45 秒。请推演会发生什么,包括 A 恢复之后的情况。
- A 的心跳不再到达调度器。30 秒后,租约过期。
- 清扫器将任务 42 重新置为 queued。attempt 暂时仍为 7。
- Worker B 租下任务 42。条件更新将 attempt 设为 8,worker 设为 B。B 从最新检查点恢复。
- A 的网络恢复。A 发送 attempt 为 7 的心跳。调度器看到该任务的 attempt 是 8,返回 409,租约已丢失。A 停止工作并丢弃本地状态。
- 如果 A 改为尝试以 attempt 7 完成任务,更新语句 WHERE attempt = 7 匹配不到任何行。写入被拒绝。B 的结果是唯一的结果。
对象存储的写入也要加防护:A 将检查点和结果写入包含 attempt 编号的路径,例如
jobs/42/attempt-7/
。B 的检查点则写入
attempt-8
。任务行指向当前 attempt 的路径,因此 A 迟到的上传只是孤立文件,会被清理任务移除。
区分答案优劣的关键:任务的所有权
弱:假设超时的 worker 已死亡
重新分配任务,并允许返回的 worker 覆盖结果。
良好:带心跳的租约
使用会过期的租约和清扫器,将任务从失联的 worker 手中重新分配。
优秀:租约加上处处防护
每次租约时递增 attempt 编号,在数据库中拒绝过期写入,并按 attempt 为存储写入划分命名空间,因此只有当前所有者才能完成任务。
6.2 检查点与抢占
阅读前须知。 检查点会消耗存储带宽。作业应该多久保存一次检查点?
将写入检查点的成本与预期损失的工作量进行权衡。如果写入一个检查点需要 2 秒且抢占很少发生,每 60 秒一次可能就足够了。如果可抢占 GPU 很常见,每 30 秒一次可将平均损失控制在 15 秒左右。还要在收到抢占警告时保存检查点——云服务商通常会在回收机器前不久发出警告:这样在有警告的抢占中,损失几乎降为零。
检查点必须保持一致性。先将其写入临时路径,只有在上传成功后才在作业上记录它。如果某个工作节点在上传中途死掉,之前的检查点仍然是有效的那个。
检查点只对拥有相同模型版本和兼容硬件的工作节点有用。应将模型版本和步数与检查点一起存储。
6.3 准入控制与 ETA
阅读前须知。 三分之一的可抢占 GPU 将在十分钟内被回收。这对用户意味着什么变化?
完成率下降,因此新作业的 ETA 估计会上升:ETA ≈(队列中排在前面的作业数 ÷ 近期每秒完成数)+ 预期运行时间。在提交时显示它,并在状态查询中更新它。
当估计值超过某个阈值(例如 30 分钟)时,API 会拒绝新的免费层作业并给出明确提示,同时继续接受付费作业。对每个用户排队作业设置上限(例如 5 个),可防止单个用户占满队列。要明确说明在资源短缺期间,排队等待时间可能超过正常目标;系统承诺的是诚实,而不是无限容量。
6.4 调度策略
队列按优先级排序,再按等待时长排序。纯优先级策略会在长时间繁忙期饿死免费层作业,因此要加入老化机制:作业等待越久,其有效优先级越高。按模型版本和最低 GPU 显存将作业匹配到工作节点;长的高分辨率视频可能需要更大的 GPU。
让工作节点保持加载同一个模型版本。加载权重需要几分钟,因此每个作业都切换版本会浪费大部分 GPU 时间。自动扩缩器根据每个版本的队列深度在工作节点之间调整版本分配,并在退役某个版本前先排空其队列。
6.5 取消与进度
取消操作会在作业上设置一个标志。排队中的作业直接转为已取消。运行中作业的工作节点会在下一次心跳响应中看到
cancel: true
该标志,在 10 秒内停止,释放 GPU,并删除其检查点。该作业在应用尝试次数检查后变为已取消。
进度信息随心跳传输:已完成步数占总步数。将其写入缓存而非数据库,让客户端每隔几秒轮询一次,或通过 SSE 订阅。
6.6 交付
工作节点将完成的视频上传到对象存储中其尝试路径下,然后调用 complete。API 返回一个短期有效的签名 URL,通过 CDN 提供服务。视频在保留期过后会过期,除非用户将其保存。
6.7 故障
| 故障 | 影响 | 恢复 |
|---|---|---|
| Worker 崩溃 | 心跳停止 | 租约过期,作业在另一个 worker 上从检查点恢复 |
| 带警告的抢占 | Worker 保存检查点并退出 | 作业恢复,几乎没有丢失的工作 |
| 网络分区 | Worker 盲目继续运行 | 租约过期;fencing 拒绝其后续写入 |
| 毒作业 | 使每个 worker 崩溃 | 达到尝试次数上限,然后标记为失败并附带错误信息 |
| 调度器崩溃 | 短时间内没有新租约 | 状态保存在数据库中;另一个实例继续运行 |
| 上传失败 | 视频未存储 | 在租约过期前从本地磁盘重试 |
| 数据库故障转移 | 数秒的错误 | Worker 重试心跳;租约时长足以度过该期间 |
6.8 调度器的选取查询
在继续阅读之前。 2,000 个空闲 worker 同时请求作业。如何防止它们争抢同一行?
一种朴素的选取方式——“选取最早的排队作业,然后更新它”——会让每个 worker 都选中同一行,除一个之外其余的更新全部失败。在大规模场景下,这种争用浪费了调度器的大部分时间。
使用一种跳过其他事务持有行的锁定读取:
WITH next AS (
SELECT id FROM jobs
WHERE status = 'queued' AND model_version = :v
ORDER BY effective_priority DESC, created_at
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE jobs SET status = 'leased', attempt = attempt + 1,
worker_id = :w, lease_expires_at = now() + interval '30 seconds'
FROM next WHERE jobs.id = next.id
RETURNING jobs.*;每个 worker 无需等待即可获取不同的行。` 上的索引
(status, model_version, effective_priority, created_at)
使选取操作成为一次简短的索引扫描。
在更高的速率下,将就绪队列移入每个模型版本专用的队列,例如按优先级排序的 Redis 有序集合,同时保留数据库作为状态变更的事实记录。数据库中的租约更新仍是权威步骤;队列只是加速器。
6.9 每个视频的成本
作为练习:每个视频 4 GPU 分钟。如果一小时 GPU 成本为 C,那么一个视频的 GPU 时间成本约为 C / 15。可抢占 GPU 的成本可能只有预留 GPU 的 40%。每 30 秒保存一次检查点且抢占率为 5% 的情况下,抢占造成的损失约为 15 秒 × 5% 的工作量,远低于 1%。因此,将大部分集群迁移到可抢占容量可将 GPU 成本削减一半以上,而检查点机制正是实现这一点的安全保障。
为付费流量保留一个预留的 GPU 核心,其余部分使用可抢占容量。当可抢占容量被回收时,准入控制优先提高免费用户的 ETA。
区分答案的关键:规模与成本
弱:单行争用
每个 worker 都竞争同一排队行;未讨论成本。
良好:skip-locked 选取
使用 skip-locked 读取或独立队列来分发不同的作业。
强:成本感知的容量规划
量化每个视频的成本,说明检查点如何使可抢占 GPU 变得安全,并将容量划分为预留核心和更便宜的弹性层。
变体
7.1 多 GPU 作业
长视频或高分辨率视频可能需要同时使用多个 GPU。将一组 worker 一起租约,或将作业作为一个 worker 调度到多 GPU 机器上。不完整的组会浪费 GPU,因此要为组作业显式预留容量。
7.2 多个区域
按区域运行队列,并将提交路由到预计完成时间最短的区域,同时遵守数据驻留要求。检查点保留在区域内,因此恢复任务意味着必须留在其所在区域。
7.3 预览
先提供快速的低分辨率预览。在较小的 GPU 上运行,快速返回,让用户自行决定是否花费完整的任务。
7.4 当流量达到十倍时
在每秒 100 次提交的情况下,峰值并发达到约 24,000 个 GPU,这已超出单个区域的容量。将集群拆分到多个区域,每个区域一个队列,按预计完成时间路由任务,并将检查点保留在区域内。调度器的选择变为按区域、按模型的队列。输出的存储量每天达到约 100 TB,因此默认保留期缩短,较旧的视频转移到更便宜的存储。
可迁移的模式
这就是一个带有围栏、可检查点的租约式任务队列。同样的设计适用于 CI 流水线、视频转码、批量 ML 推理以及竞价实例上的数据处理。这套配方可以照搬:带幂等性的异步提交、基于租约的拉取式调度、每次写入都使用围栏令牌、根据故障率确定检查点大小,以及如实告知用户等待时间的准入机制。
回顾:30 秒答案
- 带幂等键的异步 API。 返回 202 和任务 ID;重试绝不重复。
- 带租约的拉取式调度。 工作节点发送心跳;清扫器将无响应的任务重新入队。
- 每次写入都使用尝试编号进行围栏。 数据库更新和存储路径皆如此。
- 每 30 秒以及在抢占警告时进行检查点保存。 丢失的工作保持很小。
- 以诚实的预计完成时间准入。 当等待时间增长时削减免费流量;用 Little 定律确定资源池大小。
测验
+为什么工作节点拉取任务,而不是由调度器推送?
调度器需要一份准确、实时的可用工作节点列表,而该列表会随着抢占和崩溃不断变化。拉取让每个空闲的工作节点请求与其已加载模型匹配的工作。
+尝试编号用于防范什么?
防止租约已过期但仍在运行的工作节点,在另一个工作节点已接手该任务后写入结果。它的写入带有旧的尝试编号,因此会被拒绝。
频率应使预期的丢失工作相对于检查点成本保持较小。在频繁抢占的情况下,大约每 30 秒一次可将平均损失控制在 15 秒左右。此外,在收到抢占警告时也要保存检查点。
+峰值时有多少 GPU 在工作,你是如何得出这个数字的?
Little 定律:每秒 10 个任务乘以每个任务 240 秒,等于 2,400 个进行中的任务,因此大约有 2,400 个忙碌的 GPU,外加一个预热缓冲。
+为什么让每个工作节点固定在一个模型版本上?
加载模型权重需要几分钟。如果每个任务都切换版本,GPU 的大部分时间将花在加载而不是生成上。
+FOR UPDATE SKIP LOCKED 为调度器带来了什么?
+FOR UPDATE SKIP LOCKED 对调度器有什么作用?
每个 worker 的选取会跳过已被其他事务锁定的行,因此并发 worker 无需互相等待即可领取不同的任务。
+为什么检查点让可抢占 GPU 可以安全使用?
它将 GPU 被回收时损失的工作量限制在几秒内,因此可抢占容量的折扣远超少量重复工作的代价。