超时的请求,你永远不知道它到没到
调用支付接口,等了十秒没有响应,客户端抛出超时异常。此刻服务端的状态是完全未知的:
- 请求根本没发出去,什么都没发生;
- 请求到了服务端,业务已经处理完,但响应在回程丢了;
- 请求到了服务端,正在处理中。
超时只说明「我没等到回复」,不说明「服务端没做事」。这时候盲目重试,就是重复下单、重复扣款、重复发短信的来源。
而同样是超时,如果把方法换成 PUT 或 DELETE,重试就很安全——因为它们是幂等的。这个差别直接决定了重试策略该怎么写。
幂等性决定了重试的边界
回顾一下属性的含义:幂等方法执行一次与执行多次的效果相同。PUT 用请求体整体替换资源,替换三次和替换一次结果一样;DELETE 删掉资源后再删一次,资源仍然是不存在的状态。
POST 不是幂等的。它的语义是「请服务端在目标集合下创建一个新资源」,每调用一次就产生一份新资源。所以从协议层面看,POST 的重试是不安全的。
但这不意味着 POST 接口就没法重试了——只是不能靠协议自动重试,得在业务层补上幂等能力。
三种可行的做法
第一种:把动作改写成资源操作。 与其设计 POST /pay,不如设计 PUT /orders/{orderId}/payment。订单号是客户端已知的唯一标识,重复提交只是把同一笔支付的状态再写一遍,天然幂等。这是最干净的做法,代价是需要重新设计接口形态。
第二种:幂等键。 客户端在发起请求时生成一个全局唯一的键(UUID、雪花 ID、业务单号均可),通过 Idempotency-Key 请求头或请求体字段传过去。服务端记录「这个键已经处理过」,重复请求直接返回首次的处理结果,不再执行业务逻辑。
POST /api/orders HTTP/1.1
Host: example.com
Idempotency-Key: 9f2c1e7a-4b8d-4f31-9c2e-7a5d1b3e8f60
Content-Type: application/json
{ "sku": "A1001", "qty": 2 }
服务端处理要点有三个:幂等键要有唯一索引,靠数据库约束而不是「先查再插」来保证并发安全;记录要保存首次的响应内容与状态码,重复请求原样返回;记录要有过期时间,避免无限增长。
第三种:状态机加条件更新。 让业务状态自身具备幂等语义。例如支付接口先检查订单是否已支付,已支付则直接返回成功。这种方式对并发不友好(两个请求可能同时通过检查),必须配合乐观锁或数据库唯一约束才可靠。
服务端要区分「重复」和「新的合法请求」
幂等键机制有一个容易出错的地方:键的重复使用不一定都是重试。用户可能真的想下两笔同样的订单。如果只用「请求内容哈希」做键,就会把合法的第二笔订单误判成重试。
解决办法是让键的生成方明确表达意图:由客户端为每一次「独立的用户操作」生成一个新键。用户在页面上点了两次提交按钮,客户端应该复用同一个键;用户刷新页面后重新下单,应该生成新键。
客户端重试的推荐策略
即便有了幂等键,重试也不能无脑循环。几条实践建议:
只对可恢复的错误重试。 连接超时、连接被重置、502、503、504 适合重试;400、401、403、404、422 这类业务错误重试多少次都是同样结果,不该重试。
用指数退避加抖动。 第一次等 100 毫秒,第二次 200 毫秒,第三次 400 毫秒,同时给每次等待加上随机抖动,避免大量客户端在同一时刻集中重试把服务端压垮。
限制次数并保证总时长可控。 通常 3 次足够。要结合接口自身的超时设置,别让用户在前端等上一分钟。
非幂等请求重试前先确认状态。 稳妥做法是重试前先查询一次资源状态(用幂等的 GET),确认上一次请求是否已经生效,再决定要不要重发。
检查清单
- 支付、下单、扣库存这类接口是否具备幂等能力
- 幂等键是否有唯一索引保护,而不只是应用层查重
- 首次响应是否被记录并用于重复请求的回放
- 幂等记录是否有过期清理策略
- 客户端重试是否区分了可重试错误与业务错误
- 重试是否使用指数退避加抖动,并设置了次数上限