题干与适用场景
一个 B2B 分析平台需要提供报表导出 API。单次导出运行 5 分钟到 3 小时,结果文件最大 20 GiB;每天 09:00 附近可能集中收到 10,000 次提交。调用方既有浏览器,也有其他公司的服务。API 网关会在 30 秒后断开同步请求,调用方可能因为没有收到响应而重试。
用户需要查看排队、运行、成功或失败状态,在可取消阶段请求取消,并在成功后下载结果。题目中的时长、文件大小、峰值和超时都是面试案例假设,不是行业阈值。任务队列怎样分片不是本题中心;核心问题是怎样给调用方一个在重试、进程崩溃和状态竞态下仍然清晰的异步 HTTP 契约。
RFC 9110 规定,202 Accepted 只表示请求已被接收处理,处理尚未完成,最终甚至可能不执行;响应应该描述当前状态并指向状态监视器。当前公开的高级 REST API 面试题也直接要求设计长时间运行操作,覆盖状态查询、失败、取消和幂等。因此这道题适合后端工程师、API 平台工程师和需要设计跨服务契约的高级全栈工程师,核心能力归为 backend。
面试官考察点
第一,看候选人是否理解 202 的边界。它不是“后台一定会成功”,也不能把尚未完成的任务伪装成 200 OK 的最终结果。强回答会同步拒绝无效请求,成功接单后返回稳定的操作资源,并把执行结果作为后续状态。
第二,看能否把长任务建模为资源,而不是只返回一个队列消息 ID。操作资源需要租户所有者、请求指纹、状态、进度、结果或错误、版本、创建和过期时间;其状态机必须定义允许的转换、终态和取消竞态。
第三,看接单与执行之间有没有“已返回 202,但任务永远没有入队”的缺口。数据库操作记录和待发布任务应在同一事务中落盘,再由 outbox 发布;消费者和执行阶段仍需幂等,因为消息至少一次投递、执行者崩溃和租约接管都可能造成重复。
第四,看客户端契约是否能承受真实流量。Location 告诉客户端到哪里查询,Retry-After、退避、抖动和条件请求控制轮询;服务间通知可以用签名 webhook,浏览器实时进度可以用 SSE,但两者都不能取代可恢复查询的操作资源。
最后看安全与验证。不可猜的 ID 不能代替对象级授权,结果链接不能绕过租户边界;强回答还会测试丢失接单响应、重复提交、发布失败、执行者崩溃、取消与完成竞态、轮询洪峰和过期清理。
回答前需要澄清的问题
- 什么必须同步验证? 身份、租户权限、请求结构、输入是否存在和明显配额超限应在接单前检查。若数据是否完整只能在数小时扫描后得知,它就是异步执行失败,而不是接单时承诺成功。
- 重复请求代表重试还是两个独立任务? 调用方能提供
Idempotency-Key时,同一租户、接口、键和请求指纹应重放同一个操作;业务确实允许两个相同导出时,调用方必须使用不同键。 - 进度是否可度量? 能预先得到分片总数时可以返回完成单元和总单元;无法估计时只返回阶段与心跳时间,不能用随意增长到 99% 的百分比制造确定性。
- 结果是什么资源? 小结果可以嵌入操作响应,大文件应成为独立受保护资源。题设采用短期下载凭证,并把操作元数据、结果对象和下载凭证的保留期分开。
- 取消意味着什么? 只停止尚未发生的工作,还是必须撤销已经产生的副作用?若步骤不可逆,契约必须说取消是尽力而为,并定义补偿、部分结果和最终状态。
- 调用方能接收什么通知? 浏览器通常不能提供回调地址,适合轮询或 SSE;服务间调用可选 webhook。网络限制和可接受延迟会改变通知方式,但不改变状态资源是真相源。
- 并行操作是否冲突? 同一报表配置能否并行导出,配置删除或更新时在途操作怎样处理?答案决定是否串行化、快照输入、拒绝冲突或允许旧版本继续运行。
- 需要保留多久? 本题假设终态操作保留 7 天、结果对象保留 24 小时、每个下载凭证有效 15 分钟。这些是产品契约选择,需要结合审计、成本与重取结果能力调整。
30 秒回答框架
“我会把执行和 HTTP 请求分开,但不只返回一个 job ID。接单接口先验证权限和可立即发现的错误,再用一个事务写入 operation 与 outbox;成功后返回 202、Location 和建议轮询间隔。客户端通过受授权的 operation 资源读取稳定状态、进度、结构化错误和结果链接,同一幂等键及相同请求重放同一 operation。执行者按 operation ID 幂等处理,状态转换用版本条件保护。轮询采用 Retry-After、退避和抖动,服务间可加签名 webhook,取消则进入 cancel_requested 并处理与完成的竞态。最后我会故障注入丢响应、重复消息、执行者崩溃、取消竞态和过期清理,证明不会出现幽灵任务、重复结果或越权下载。”
分步骤深入解答
第一步定义两个资源:导出请求表达用户要创建的结果,操作资源表达这次执行的生命周期。提交接口可以是 POST /v1/report-exports,查询接口是 GET /v1/report-operations/{operation_id}。接单响应如下:
HTTP/1.1 202 Accepted
Location: /v1/report-operations/op_7f3a
Retry-After: 5
Content-Type: application/json
{
"id": "op_7f3a",
"status": "queued",
"statusUrl": "/v1/report-operations/op_7f3a",
"cancelUrl": "/v1/report-operations/op_7f3a"
}202 只承诺“已接收处理”。请求结构错误、没有权限或输入根本不存在时立即返回对应的 4xx,不创建操作。需要耗时计算才能发现的业务失败记录在操作资源中。Location 和 Retry-After 是本 API 的客户端契约;不能声称 RFC 9110 强制每个 202 都使用这两个头。
第二步定义操作记录和状态机。最小记录包含 id、tenantid、idempotencykey、requestfingerprint、status、进度、结果引用、结构化错误、version、创建/更新时间和 expiresat。推荐状态转换是:
queued -> running -> succeeded
-> failed
queued -> cancel_requested -> canceled
running -> cancel_requested -> canceled | succeeded | failed取消与完成可以同时发生,所以 cancel_requested 不是终态。执行者提交结果时执行带 version 和允许前态的条件更新;只有一个转换获胜。若副作用已经不可逆,取消可能最终变为 succeeded 或 failed,不能为了满足按钮语义伪造 canceled。状态响应可以使用下面的结构:
{
"id": "op_7f3a",
"status": "running",
"progress": {
"completedUnits": 37,
"totalUnits": 100
},
"result": null,
"error": null,
"lastUpdatedAt": "2026-07-18T23:18:11Z",
"expiresAt": "2026-07-25T23:08:11Z"
}只有工作单元有真实分母时才返回这组进度。执行失败时,读取操作本身仍可以返回 200,把失败放在终态和结构化 error 中;这表示“状态资源读取成功,所代表的执行失败”。如果团队选择让状态端点返回执行对应的 4xx,也必须在所有 SDK 中统一,不能同时混用两种语义。
第三步保证接单、重试和执行正确。以 (tenantid, route, idempotencykey) 建唯一约束,并保存规范化请求的指纹。同键同指纹返回原操作及当前状态;同键不同指纹返回明确冲突,防止调用方误把键复用于另一份报表。幂等记录至少保留到客户端不会再重试,并与操作保留策略协调。
在同一数据库事务中插入操作记录和 outbox 事件,然后才返回 202。独立发布器把 outbox 送入队列并可重复发送;消费者以 operation ID 去重。每个执行阶段也要有幂等写入或 fencing token,避免工作节点完成外部写入后崩溃、另一节点接管时生成两份可见结果。这里只需要证明接单到队列的边界,不必把完整调度器设计塞进 API 答案。
第四步控制查询与通知流量。初始响应和后续状态响应提供合理的 Retry-After;客户端采用指数退避、上限和抖动,服务端用 ETag 配合条件请求减少未变化的响应体。若状态查询超过配额,返回限流信息,而不是允许 10,000 个调用方每秒轮询。
浏览器需要低延迟进度时可以订阅 SSE,断线后仍凭 operation ID 查询;服务间调用可以注册签名 webhook,投递端重试并让接收方去重。两种推送都可能丢失、延迟或重复,因此操作资源仍是恢复和核对的真相源。成功时可以在操作体中返回结果链接;若选择重定向到独立结果资源,使用已文档化的 303 语义,并确认 SDK 不会把原 POST 重放到结果地址。
第五步处理授权、取消和保留。每次读取、取消和获取结果都按 tenant_id、调用身份和操作权限做对象级授权;随机 ID 只降低枚举便利,不是授权。下载服务再次校验结果归属,再签发题设中有效 15 分钟的凭证,操作响应不保存长期公开地址。
DELETE /v1/report-operations/{id} 可以表达取消请求:可取消时返回当前 cancel_requested 表示已接收,不可取消或已到终态时返回稳定、可重试的结果。工作节点在阶段边界检查取消标记,终止未开始的步骤并清理临时对象;已经提交的外部副作用按预先定义的补偿规则处理。终态 7 天后删除操作,已知曾存在但过期的 ID 可以返回 410 Gone,未知或无权访问的 ID 按安全策略返回 404。
第六步用故障而不是 happy path 验收。至少覆盖:服务器提交事务后丢失 202 响应,客户端重试只能得到同一 operation;outbox 发布器在发送前后崩溃,最终仍有任务且只有一个可见结果;执行者写完结果后丢确认,接管者不能覆盖已提交终态;取消与完成同时到达,状态机只产生一个合法终态;状态无变化时轮询遵守退避和条件请求;跨租户读取、取消和下载全部失败;清理后元数据、结果和幂等键按契约过期。
可复用的判断规则是:202 解决连接等待,operation 资源解决可观察性,原子接单与幂等状态机才解决正确性。
高质量示范回答
“我先区分接单成功和执行成功。这个任务最长 3 小时,不能占着 30 秒网关连接,所以 POST /v1/report-exports 只做身份、租户权限、请求结构、输入存在性和明显配额检查。通过后,它在一个事务里创建 operation 和 outbox,提交成功才返回 202。响应带 Location 指向操作资源,并用 Retry-After 给出首次查询间隔。202 不代表报表一定成功。
operation 会保存租户、幂等键、请求指纹、状态、可验证的进度、结果或错误、版本和过期时间。状态从 queued 到 running,再到 succeeded 或 failed;取消先进入 cancel_requested,因为工作节点可能恰好已经提交结果。同一租户和接口下,同一个幂等键加相同请求返回同一个 operation;键相同但请求不同就报冲突。这样即使 202 响应丢了,重试也不会生成第二份报表。
队列采用至少一次投递假设。outbox 可以重复发布,消费者按 operation ID 去重,各阶段的外部写入也要幂等或带 fencing token。状态更新使用版本条件,避免旧执行者在租约被接管后覆盖新结果。失败发生在接单前就直接返回 4xx;运行中失败则保存终态和结构化错误,让客户端能区分网络失败、状态读取失败和报表执行失败。
客户端按 Retry-After、指数退避和抖动查询,状态端点支持 ETag。浏览器要实时显示进度可以加 SSE,合作方服务可以用签名 webhook,但断线或重复通知后都回到 operation 查询。结果文件不放公开 URL;成功后由下载接口重新授权并签发 15 分钟凭证。题设里终态操作保留 7 天、文件保留 24 小时,过期规则进入公开契约。
最后我会专门测试事务提交后响应丢失、outbox 重发、执行者写结果后崩溃、取消和完成竞态、10,000 个调用方同时轮询以及跨租户访问。验收标准不是能在后台跑完一次,而是这些故障下仍然没有幽灵任务、重复可见结果、非法状态转换和越权下载。”
常见错误
- 返回 202 后直接启动内存线程 → 进程重启会留下永远找不到的任务,而且接单与启动之间没有原子边界 → 持久化 operation 与 outbox 后再确认接单。
- 把 202 当成最终成功 → HTTP 明确保留任务最终不执行或失败的可能 → 把最终结果、错误和状态监视器放入后续契约。
- 只返回队列消息 ID → 缺少租户所有权、稳定状态、错误、结果和保留策略 → 建立独立、受授权的 operation 资源。
- 客户端固定每秒轮询 → 峰值提交会变成持续读洪峰 → 提供 Retry-After,并使用退避、抖动、ETag 和配额。
- 用随机 operation ID 代替授权 → 泄漏日志、浏览器历史或内部链接后仍可越权 → 每次状态、取消和结果访问都做对象级授权。
- 幂等键只放 Redis 短锁 → 锁过期、崩溃和结果重放仍会创建重复操作 → 用持久唯一约束、请求指纹和可重放响应。
- 点击取消就立即标记 canceled → 执行者可能已经提交不可逆副作用 → 先进入 cancel_requested,用条件转换和补偿决定合法终态。
- 总是返回虚构百分比 → 不可预测的阶段会长时间卡在 99%,误导调用方 → 能度量时返回完成单元,不能度量时返回阶段和更新时间。
- webhook 成功就删除操作 → webhook 可能丢失、重复或接收方暂时故障 → 保留有期限的 operation 作为恢复真相源。
追问及应对
追问一:数据库事务已经提交,但 202 响应在网络中丢失,客户端又提交一次怎么办?
客户端复用原 Idempotency-Key。服务端用租户、接口和键定位已存在的 operation,校验请求指纹相同后重放当前表示与 Location,不再插入 operation 或 outbox。若键相同但请求体不同,返回冲突并要求新键。测试要断言数据库只有一条操作记录、执行队列即使重复投递也只有一个可见结果。
追问二:任务已经运行 90%,用户要求取消,工作节点同时完成了结果提交,哪个状态获胜?
预先定义允许的转换,并用版本条件更新决定唯一赢家。若结果事务先从 running 提交为 succeeded,后到的取消读取终态并返回 succeeded;若取消先进入 cancelrequested,执行者必须在提交前检查是否仍允许完成。不可逆步骤可能让 cancelrequested 合法转为 succeeded 或 failed;契约不能承诺绝对撤销。
追问三:进度无法估算,但产品坚持显示百分比怎么办?
先说明伪百分比会制造错误预期。可以显示已完成阶段、当前阶段、最近心跳和历史区间给出的非承诺时间范围;只有存在稳定总工作量时才给 completedUnits / totalUnits。若某些阶段可度量,就分别展示阶段内进度,不把不同成本的阶段简单平均。
追问四:合作方不想轮询,是否应该只提供 webhook?
webhook 可以降低正常路径延迟和查询量,但不能成为唯一恢复机制。回调会遇到 DNS、证书、防火墙、签名轮换、重复和乱序问题。服务端对 webhook 签名并重试,事件带 operation ID 和版本,接收方去重;合作方遗漏通知后仍可用 operation 资源对账。浏览器没有稳定回调地址时继续用轮询或 SSE。
追问五:同一报表生成了 20 GiB 文件,operation API 应直接返回下载链接吗?
操作只返回结果资源引用。下载前重新校验租户和权限,再签发题设中 15 分钟有效的凭证;不要把长期对象存储地址写进 operation。文件保留 24 小时而操作元数据保留 7 天,因此文件过期后 operation 仍可说明结果曾成功但制品已过期,并提供重新生成动作。
追问六:任务量激增,状态查询流量比执行流量还大,先改哪里?
先检查客户端是否遵守 Retry-After、指数退避、上限和抖动,再启用 ETag 条件请求、租户配额与限流。需要更低延迟的浏览器连接可以通过 SSE 合并更新,服务间用 webhook,但仍保留低频查询。若大量客户端同一秒开始轮询,响应中的建议间隔也要加入抖动,避免把 09:00 提交峰值平移成状态端点的周期尖峰。