接口返回了 202,这件事算办完了吗
导出接口返回
202 Accepted,前端提示「导出成功」。十分钟后用户来问文件在哪里:任务在后台失败了,没有人知道。另一次,用户点了导出,页面转了一会儿报超时,再点一次,后台跑起了两个一模一样的任务。还有一次服务重启之后,所有正在执行的任务都查不到了。
耗时较长的操作通常做成异步的:接口先登记任务并立即返回,真正的工作由后台完成。202 Accepted 的含义只有一个:请求已经被接收,尚未处理完。它既不表示成功,也不承诺一定会被处理。
从「已受理」到「办完了」之间还有一整套约定:怎么查结果、哪些状态算结束、重复提交怎么办、执行到一半进程没了怎么办。这些约定缺了任何一条,都会出现开头的那些情况。
本文用一个最小的任务接口把它们逐条排出来。后台的每一步由测试代码手动推进,结果是确定的。实验在 JDK 25.0.4.1 上运行,见文末。
一、受理、执行、完成是三件事
QUEUED)。它之后可能成功、失败,也可能因为执行它的进程退出而卡在 RUNNING。调用方要拿到结果,必须按任务标识查询,直到状态进入终态集合。服务端要让这张图成立,需要四样东西:登记时去重、任务表持久化、执行中的任务有租约、状态只能向前。实验的第一个场景:提交一个注定失败的任务。
POST /jobs → 202,Location: /jobs/job-1,{"id":"job-1","state":"QUEUED"}
(后台执行)
GET /jobs/job-1 → 200,{"id":"job-1","state":"FAILED","attempt":1,"error":"source table missing"}提交时的响应里没有任何关于成败的信息,也不可能有。由此得到接口约定的第一部分:
- 受理响应里必须带上查询这件事的办法:任务标识和状态地址(
Location)。只回一个「OK」的异步接口,调用方之后无从得知结果。 - 调用方不能把 202 展示成「成功」,只能展示成「已提交」。
- 失败必须是一个可以查到的状态,并带着原因。任务失败后只写了一行服务端日志,对调用方来说等于没有结果。
这同样适用于消息:消息发送成功只说明它进了队列,不说明消费者处理成功。需要告诉用户最终结果的业务,都要有一个能查询的状态。
二、哪些状态算结束
调用方轮询状态时需要一个停止条件。常见的写法是「状态不再是 RUNNING 就结束」。实验里任务刚提交、还在排队时查询:
查询到 QUEUED
「不是 RUNNING 就算结束」 → true (错:任务还没开始)
「属于终态集合才算结束」 → false用排除法判断结束,任何调用方不认识的状态都会被当成结束:排队中、重试中、取消中,以及服务端以后新增的任何状态。约定的第二部分:
- 服务端明确列出终态(这里是
SUCCEEDED与FAILED),并承诺任务进入终态后不再变化。 - 调用方只在状态属于终态集合时停止;遇到不认识的状态当作「未结束」继续等。
- 轮询要有总的截止时间和退避间隔。超过截止时间时结论是「不知道」,而不是「失败」,后续由对账或人工处理。等待本身的超时与取消,见 超时了、取消了,任务其实还在跑。
三、提交超时之后
提交请求本身也会超时。实验里服务端登记任务后故意过了 600 毫秒才响应,客户端 200 毫秒超时:
| 做法 | 第一次提交 | 重试的响应 | 服务端登记的任务数 |
|---|---|---|---|
| 不带幂等键 | 客户端超时 | 202,job-2 | 2 |
两次带同一个 Idempotency-Key | 客户端超时 | 202,job-1 | 1 |
客户端超时的时候,任务已经登记了,只是响应没有到。调用方无法区分「没提交上」和「提交上了但没收到回复」,这两种情况在它那边看起来完全一样。重试是合理的反应,于是产生了第二个任务。
解决办法是让「再提交一次」变得安全:调用方为每一次业务操作生成一个幂等键,重试时带同一个;服务端按键查找,已有任务就返回原来的那个。几个要点:
- 键由调用方生成,对应「一次业务意图」,不是「一次 HTTP 请求」。用户点两次按钮算一次还是两次,由调用方决定。
- 登记任务和记录幂等键要在同一个事务里,否则两者之间会出现新的窗口。
- 相同的键带着不同的参数再来,应当拒绝而不是返回旧任务。
幂等键的完整设计(存储、并发、过期)见 三个可复用的服务端组件:限流、幂等键与渐进发布。
四、进程重启之后
后台任务比请求活得久,一定会遇到执行到一半时进程重启。实验比较了三种做法,任务都在「已开始执行」时重启服务:
| 任务表 | 重启后查询 | 之后 |
|---|---|---|
| 在内存里 | 404 no such job | 任务消失。调用方分不清是任务标识不对还是任务丢了 |
| 持久化,没有租约 | 一直是 RUNNING | 没有任何进程在执行它,后台再取任务得到空。调用方永远等不到终态 |
| 持久化,执行中的任务带租约 | 租约过期后回到 QUEUED | 被重新取出,第二次执行成功 |
第二行最容易被忽略。把任务存进数据库解决了「丢失」,但 RUNNING 只是一个被写下的字段,写下它的进程已经不在了。状态要能反映现实,RUNNING 必须带着期限:执行者领取任务时获得一段租约,执行期间定期续期;租约过期而任务还没有进入终态,就认为执行者已经失效,任务重新排队或标记为失败。
重新排队意味着任务可能被执行两次(第一次也许已经做了一半)。任务本身要能重复执行:写结果用覆盖而不是追加,对外的副作用带上任务标识去重。
五、状态只能向前
任务有了多个上报来源(执行者、重试、迟到的回调)之后,状态更新的顺序不再有保证。
迟到的进度上报。 任务已经成功,之后又到了一条更早发出的「进度 80%」:
| 更新方式 | 写入 | 最终状态 |
|---|---|---|
| 无条件写入 | 成功 | RUNNING:一个已完成的任务又「执行中」了 |
| 只允许从非终态向前 | 被拒绝 | SUCCEEDED |
失效的执行者。 第一个执行者卡住(长时间垃圾回收、网络中断),租约过期,任务被第二个执行者接手;这时第一个执行者恢复过来,上报「完成」:
| 完成上报的检查 | 旧执行者的上报 | 结果 |
|---|---|---|
只检查任务处于 RUNNING | 被接受 | 任务被记为成功,而当前的执行者还没做完 |
| 同时检查执行序号 | 被拒绝 | 任务保持 RUNNING,等当前执行者(序号 2)上报 |
第二种情况里,状态检查是通过的:任务确实在 RUNNING,只不过是另一个执行者的 RUNNING。要区分「谁的」,每次领取任务时把执行序号加一,上报时带上自己领到的序号,服务端只接受当前序号的上报。这和分布式锁里的 fencing token 是同一个办法:过期的持有者自己并不知道自己过期了,只能由接收方拒绝。
落到存储上,这些检查都是带条件的更新:UPDATE … SET state = 'SUCCEEDED' WHERE id = ? AND state = 'RUNNING' AND attempt = ?,再看影响行数。
六、一份完整的约定
服务端
- 提交:返回 202、任务标识与状态地址;支持幂等键,登记任务与记录键在同一个事务里。
- 状态:任务表持久化;明确的终态集合;失败带原因;状态查询要做权限检查,任务标识不可猜测。
- 执行:领取任务获得租约与执行序号,定期续期;过期的任务重新排队或失败,并限制最大执行次数。
- 更新:所有状态变更都是带条件的,只能向前,上报要带执行序号。
- 保留:终态的任务与结果保留多久要写明,过期之后查询返回明确的「已过期」而不是「不存在」。
调用方
- 把 202 当作「已提交」。
- 提交带幂等键;提交超时后用同一个键重试。
- 轮询直到状态属于终态集合,带退避和总截止时间;超过截止时间按「未知」处理。
- 不认识的状态当作未结束。
测试
每一条都对应一个可以自动化的用例:提交后任务失败、提交超时后重试、任务排队时查询、执行中重启服务、终态之后重放旧的上报、旧执行者在被接手之后上报。它们都不是正常路径,只测「提交、等待、成功」的用例覆盖不到任何一条。
配套实验
- codesphere-labs/system-design/async-acceptance:JDK 25.0.4.1 上一个返回 202 的任务接口在任务失败、提交超时后重试、排队时轮询、进程重启、迟到上报与旧执行者上报六类场景下的结果,以及幂等键、租约、条件更新与执行序号各自的效果(验证记录)
参考资料