Skip to content

接口返回了 202,这件事算办完了吗 ​

导出接口返回 202 Accepted,前端提示「导出成功」。十分钟后用户来问文件在哪里:任务在后台失败了,没有人知道。另一次,用户点了导出,页面转了一会儿报超时,再点一次,后台跑起了两个一模一样的任务。还有一次服务重启之后,所有正在执行的任务都查不到了。

耗时较长的操作通常做成异步的:接口先登记任务并立即返回,真正的工作由后台完成。202 Accepted 的含义只有一个:请求已经被接收,尚未处理完。它既不表示成功,也不承诺一定会被处理。

从「已受理」到「办完了」之间还有一整套约定:怎么查结果、哪些状态算结束、重复提交怎么办、执行到一半进程没了怎么办。这些约定缺了任何一条,都会出现开头的那些情况。

本文用一个最小的任务接口把它们逐条排出来。后台的每一步由测试代码手动推进,结果是确定的。实验在 JDK 25.0.4.1 上运行,见文末。

一、受理、执行、完成是三件事 ​

QUEUED已登记RUNNING带租约与执行序号SUCCEEDED终态FAILED终态租约过期:重新排队,执行序号加 1POST 返回 202提交超时后重试不带幂等键:登记了 2 个任务;带同一个 Idempotency-Key:只有 1 个进程重启任务表在内存里:查询返回 404;持久化但没有租约:永远停在 RUNNING迟到的上报无条件写入:SUCCEEDED 被改回 RUNNING;只允许向前:写入被拒绝旧执行者租约过期后才上报完成:只查状态会被接受,带执行序号才能拒绝调用方:把「属于终态集合」当作结束,而不是「不再是 RUNNING」;轮询要有总的截止时间超时没有拿到 202 时,结果是未知:用同一个幂等键重试,或者按业务标识查询
图 1 · 返回 202 的那一刻,任务只是被登记了(QUEUED)。它之后可能成功、失败,也可能因为执行它的进程退出而卡在 RUNNING。调用方要拿到结果,必须按任务标识查询,直到状态进入终态集合。服务端要让这张图成立,需要四样东西:登记时去重、任务表持久化、执行中的任务有租约、状态只能向前。

实验的第一个场景:提交一个注定失败的任务。

text
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 就结束」。实验里任务刚提交、还在排队时查询:

text
查询到 QUEUED
「不是 RUNNING 就算结束」        → true    (错:任务还没开始)
「属于终态集合才算结束」          → false

用排除法判断结束,任何调用方不认识的状态都会被当成结束:排队中、重试中、取消中,以及服务端以后新增的任何状态。约定的第二部分:

  • 服务端明确列出终态(这里是 SUCCEEDED 与 FAILED),并承诺任务进入终态后不再变化。
  • 调用方只在状态属于终态集合时停止;遇到不认识的状态当作「未结束」继续等。
  • 轮询要有总的截止时间和退避间隔。超过截止时间时结论是「不知道」,而不是「失败」,后续由对账或人工处理。等待本身的超时与取消,见 超时了、取消了,任务其实还在跑。

三、提交超时之后 ​

提交请求本身也会超时。实验里服务端登记任务后故意过了 600 毫秒才响应,客户端 200 毫秒超时:

做法第一次提交重试的响应服务端登记的任务数
不带幂等键客户端超时202,job-22
两次带同一个 Idempotency-Key客户端超时202,job-11

客户端超时的时候,任务已经登记了,只是响应没有到。调用方无法区分「没提交上」和「提交上了但没收到回复」,这两种情况在它那边看起来完全一样。重试是合理的反应,于是产生了第二个任务。

解决办法是让「再提交一次」变得安全:调用方为每一次业务操作生成一个幂等键,重试时带同一个;服务端按键查找,已有任务就返回原来的那个。几个要点:

  • 键由调用方生成,对应「一次业务意图」,不是「一次 HTTP 请求」。用户点两次按钮算一次还是两次,由调用方决定。
  • 登记任务和记录幂等键要在同一个事务里,否则两者之间会出现新的窗口。
  • 相同的键带着不同的参数再来,应当拒绝而不是返回旧任务。

幂等键的完整设计(存储、并发、过期)见 三个可复用的服务端组件:限流、幂等键与渐进发布。

四、进程重启之后 ​

后台任务比请求活得久,一定会遇到执行到一半时进程重启。实验比较了三种做法,任务都在「已开始执行」时重启服务:

任务表重启后查询之后
在内存里404 no such job任务消失。调用方分不清是任务标识不对还是任务丢了
持久化,没有租约一直是 RUNNING没有任何进程在执行它,后台再取任务得到空。调用方永远等不到终态
持久化,执行中的任务带租约租约过期后回到 QUEUED被重新取出,第二次执行成功

第二行最容易被忽略。把任务存进数据库解决了「丢失」,但 RUNNING 只是一个被写下的字段,写下它的进程已经不在了。状态要能反映现实,RUNNING 必须带着期限:执行者领取任务时获得一段租约,执行期间定期续期;租约过期而任务还没有进入终态,就认为执行者已经失效,任务重新排队或标记为失败。

重新排队意味着任务可能被执行两次(第一次也许已经做了一半)。任务本身要能重复执行:写结果用覆盖而不是追加,对外的副作用带上任务标识去重。

五、状态只能向前 ​

任务有了多个上报来源(执行者、重试、迟到的回调)之后,状态更新的顺序不再有保证。

迟到的进度上报。 任务已经成功,之后又到了一条更早发出的「进度 80%」:

更新方式写入最终状态
无条件写入成功RUNNING:一个已完成的任务又「执行中」了
只允许从非终态向前被拒绝SUCCEEDED

失效的执行者。 第一个执行者卡住(长时间垃圾回收、网络中断),租约过期,任务被第二个执行者接手;这时第一个执行者恢复过来,上报「完成」:

完成上报的检查旧执行者的上报结果
只检查任务处于 RUNNING被接受任务被记为成功,而当前的执行者还没做完
同时检查执行序号被拒绝任务保持 RUNNING,等当前执行者(序号 2)上报

第二种情况里,状态检查是通过的:任务确实在 RUNNING,只不过是另一个执行者的 RUNNING。要区分「谁的」,每次领取任务时把执行序号加一,上报时带上自己领到的序号,服务端只接受当前序号的上报。这和分布式锁里的 fencing token 是同一个办法:过期的持有者自己并不知道自己过期了,只能由接收方拒绝。

执行者 1执行者 2任务表领取 · attempt = 1卡住(GC、断网)上报完成 · attempt=1领取 · attempt = 2执行中RUNNING · 1租约过期 → QUEUEDRUNNING · 2仍是 RUNNINGUPDATE … SET state='SUCCEEDED' WHERE id = ? AND state = 'RUNNING' AND attempt = 1 → 影响 0 行过期的持有者不知道自己过期了,只能由接收方拒绝(与分布式锁的 fencing token 同理)
图 2 · 实测(JDK 25.0.4.1,步骤由测试代码推进):执行者 1 领到序号 1 后卡住,租约过期,任务回到队列被执行者 2 以序号 2 领走。执行者 1 恢复后上报完成,任务确实在 RUNNING,只检查状态会接受这次上报;条件里同时带上执行序号,旧的上报影响 0 行,任务保持 RUNNING,等序号 2 上报

落到存储上,这些检查都是带条件的更新:UPDATE … SET state = 'SUCCEEDED' WHERE id = ? AND state = 'RUNNING' AND attempt = ?,再看影响行数。

六、一份完整的约定 ​

服务端

  1. 提交:返回 202、任务标识与状态地址;支持幂等键,登记任务与记录键在同一个事务里。
  2. 状态:任务表持久化;明确的终态集合;失败带原因;状态查询要做权限检查,任务标识不可猜测。
  3. 执行:领取任务获得租约与执行序号,定期续期;过期的任务重新排队或失败,并限制最大执行次数。
  4. 更新:所有状态变更都是带条件的,只能向前,上报要带执行序号。
  5. 保留:终态的任务与结果保留多久要写明,过期之后查询返回明确的「已过期」而不是「不存在」。

调用方

  1. 把 202 当作「已提交」。
  2. 提交带幂等键;提交超时后用同一个键重试。
  3. 轮询直到状态属于终态集合,带退避和总截止时间;超过截止时间按「未知」处理。
  4. 不认识的状态当作未结束。

测试

每一条都对应一个可以自动化的用例:提交后任务失败、提交超时后重试、任务排队时查询、执行中重启服务、终态之后重放旧的上报、旧执行者在被接手之后上报。它们都不是正常路径,只测「提交、等待、成功」的用例覆盖不到任何一条。


配套实验

  • codesphere-labs/system-design/async-acceptance:JDK 25.0.4.1 上一个返回 202 的任务接口在任务失败、提交超时后重试、排队时轮询、进程重启、迟到上报与旧执行者上报六类场景下的结果,以及幂等键、租约、条件更新与执行序号各自的效果(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权