支付、创建订单或发放优惠券时,最难处理的错误往往不是明确的 400,而是超时。客户端没收到响应,并不表示服务端没执行:请求可能在提交数据库后丢失了响应。此时立即再发一个 POST,一次网络故障就可能变成两笔业务副作用。

可靠重试需要两个独立条件:当前失败值得重试,并且这项操作重复执行仍安全。

超时代表结果未知

客户端观察到的结果可以分为三类:明确成功、明确拒绝,以及结果未知。连接建立失败通常意味着请求没有到达;但写入超时、连接重置或网关超时无法证明服务端是否提交。客户端不能靠“没收到 2xx”推断失败,服务端也不能靠“方法是 POST”拒绝所有重试。

RFC 9110 把多次相同请求具有同一预期效果称为幂等。GET、PUT、DELETE 等方法在协议语义上是幂等的,POST 则不自动具备这个性质。但方法名不是业务证明:一个实现错误的 GET 仍可能产生副作用;重复 PUT 虽保持资源最终状态,也可能留下多条审计记录。重试策略必须理解具体端点的契约。

一个键代表一次业务意图

客户端为一次逻辑操作生成高熵键,例如 UUID,并在所有传输重试中复用。同一个用户主动再次购买,应生成新键。键不能按请求参数自动哈希:同样参数可能代表两次真实购买,调用方才知道两次调用是不是同一意图。

服务端幂等账本至少记录:租户或调用方、操作类型、键、规范化请求指纹、处理状态、结果引用或响应,以及过期时间。唯一约束应覆盖 (caller, operation, key)。收到请求时执行以下状态机:

不存在 -> 原子占位并执行业务
已完成且指纹相同 -> 返回原结果
已存在但指纹不同 -> 拒绝键复用
正在处理 -> 返回约定的处理中结果,或等待后重读

业务写入和幂等记录应在同一数据库事务中提交。若副作用跨越数据库,例如发送邮件或投递消息,可在同一事务写入 outbox,再由消费者使用稳定事件 ID 去重。仅在内存缓存中记键,进程重启就会丢失最需要的保护。

幂等键与并发控制解决不同问题

幂等键回答“这是同一次创建请求吗”,不能防止两个不同意图覆盖彼此。更新资源时同时使用 If-Match、ETag 或显式版本号:只有客户端读取的版本仍为当前版本才写入,否则返回前置条件失败,让调用方重新读取并合并。

重试必须有预算

客户端只对经过审核的暂时性失败重试,例如连接中断、超时、408、429 和部分 5xx。不要把所有 5xx 永久循环,也不要自动重试相同凭据的认证错误。服务端提供 Retry-After 时应尊重它;该字段可以是秒数或 HTTP 日期。

没有服务端提示时,采用带随机抖动的指数退避:

limit = min(cap, base * 2^attempt)
delay = random(0, limit)

抖动让大量客户端不会在整齐的时间点再次压垮服务。每次调用还需要总 deadline、最大尝试次数与重试预算;剩余时间不足以完成下一次尝试就直接停止。交互请求与后台作业应使用不同预算。

重试最好只由一层负责。若 SDK、业务服务和队列各重试三次,一次底层故障可能放大成数十次调用。记录每次尝试的触发原因、等待时间、剩余预算和最终结果,才能发现隐藏的重试乘法。

“恰好一次”必须延伸到下游

API 层返回同一响应,不等于整个流程只执行一次。订单事务之后若直接调用支付、邮件和消息系统,任何一步的超时都重新引入未知结果。每个下游调用都需要稳定的业务标识、幂等契约或可查询的对账状态。

更现实的目标是:同一业务意图最终只产生一个可观察结果,并且系统能对不确定状态进行对账。幂等键缩小重复窗口,事务与 outbox 连接本地状态,消费者去重保护异步边界,定期对账修复仍然存在的异常。

用最坏的断点测试

普通成功测试无法证明幂等。至少覆盖这些场景:服务端提交后、响应发送前主动断开连接;两个相同键并发到达;相同键携带不同请求体;占位后进程崩溃;键刚过期又被发送;Retry-After 超过剩余 deadline;成百上千客户端同时收到 503。

关键断言不是“第二次返回 200”,而是只有一个订单、一次扣款和一条可发送事件;重复调用得到同一个资源标识;冲突请求不能偷偷复用旧结果;失败尝试不会无限占住 in_progress。故障注入还应验证恢复任务能识别并处理长期未完成记录。

上线前逐项确认:端点按业务语义标注重试安全性;非幂等操作要求稳定键;服务端原子保存请求指纹与结果;并发更新带版本前置条件;客户端遵守 Retry-After、抖动、deadline 和最大次数;只有一层自动重试;跨系统副作用具备 outbox、下游去重或对账路径。

重试不是循环语句,而是一份端到端协议。客户端控制何时再试,服务端识别同一意图,存储层守住业务不变量,下游继续传递稳定身份。只有这些边界同时成立,暂时性网络故障才会被吸收,而不是被放大成重复交易。