在分布式系统里,把请求做成幂等的,然后重试,是一句很让人安心、也很容易被过度简化的话。重试是为网络不可靠准备的正常路径,不应被当作异常分支。幂等性不是一个可以随手打开的开关,而是一项必须贯穿请求全链路的工程属性:客户端、网络、消息队列、处理器,以及处理器触发的每一个副作用都要纳入设计。任何一个环节出错,安全重试就可能在某个并不特殊的时刻变成静默重复扣费。
幂等意味着什么
如果一次操作被执行多次后的结果,与只执行一次相同,那么它就是幂等的。HTTP 将 PUT 和 DELETE 定义为幂等方法,而 POST 不属于这一类。但这一规范描述的是接口意图,不是实现保证。只有服务端代码确实按幂等方式实现,PUT 请求才真正幂等;HTTP 方法本身不会强制执行这种语义。语义上的幂等与实际实现的幂等之间的缺口,正是许多真实事故发生的地方。
重试问题
网络不可靠,所以系统通常需要重试;而应对这种不可靠性的系统,几乎都会选择至少一次投递,而不是成本高得多的端到端恰好一次处理。Kafka 的文档明确说明了这一取舍:在任意故障下保证消息恰好投递并处理一次,成本极高。因此,多数系统保证至少投递一次,并将去重责任交给消费者。
这意味着重复投递不是罕见的边缘情况,而是消息层预期会发生的行为。生产者可能在等待确认时超时,但确认实际上已经成功发送;消费者也可能在处理消息后、提交偏移量前崩溃,使 Broker 将消息重新投递给下一位可用消费者。
实际中,重复投递通常来自这类已经被广泛记录的故障模式,而不是某种罕见的生产事故。
幂等键不是万能方案
最常见的方案是幂等键(idempotency key):客户端为一次逻辑操作生成唯一标识,并在每次重试时携带它。服务端识别到重复请求后,返回第一次的结果,而不是再次执行操作。Stripe 的实现是许多工程师熟悉的参考案例,也展示了这种模式的细节。
服务端需要保存幂等键、响应结果,以及操作处于处理中或已完成的状态。更关键的是,这些记录必须与受保护的副作用原子写入;否则,本来用于防重的机制会成为新的重复来源。幂等键还需要保留窗口:窗口太短会漏掉重复请求,窗口太长会造成存储无界增长。
| 方案 | 工作方式 | 主要限制 |
|---|---|---|
| 客户端提供幂等键 | 客户端为每次逻辑操作生成唯一 ID,服务端据此去重 | 幂等键和结果必须与副作用一起原子存储 |
| 天然幂等的操作 | 将操作设计为可重复执行,例如将余额设为 X,而非增加 X | 真实业务操作并不总能这样表达 |
| 唯一约束去重 | 数据库拒绝插入相同自然键的第二条记录 | 只能保护其所在的写入,无法覆盖下游副作用 |
| 分布式锁 | 按资源 ID 加锁,阻止并发重复处理 | 增加延迟,锁不可用时还会引入新的故障模式 |
重试如何在无声中破坏状态
真正造成事故的场景往往并不显眼。问题通常藏在看起来已经幂等,与实际受到保证之间的缝隙里。
响应丢失,请求其实已成功
一笔支付已经扣款成功,但客户端尚未收到确认,网络就中断了。客户端看到的是超时,不是成功,于是发起重试。如果确认扣款成功之前没有持久化记录幂等键,或者重试抵达时幂等键保留窗口已过,第二个请求就会被当作新请求执行。客户可能被重复扣款,而其日志里只留下了一次超时。
幂等写入掩盖了非幂等副作用
创建订单的接口可以借助订单 ID 的唯一约束,让订单行插入具备幂等性。但同一个处理器如果还会发送确认邮件,或调用外部物流 API,这些副作用往往不受相同保护。数据库写入看起来可以安全重试,实际却可能把同一封邮件发送三次。
两次重试并发抵达
客户端库在响应缓慢后积极重试,原始请求和重试请求可能同时在途并相互竞争。如果查询幂等键和写入处理中状态不是一个原子动作,两个请求都可能在任何一方标记处理中之前通过去重检查。即使全程携带了幂等键,操作仍然会被执行两次。
不同逻辑操作复用了同一个键
如果幂等键来自并非真正唯一的值,例如精度不足的时间戳,或重启后归零的客户端计数器,两个不同请求就可能发生冲突。第二个无关操作会被静默视为第一个操作的重复项,因而根本不会执行。这通常比重复执行更难发现,因为外部看不到明显的失败信号。
| 场景 | 根因 | 缓解措施 |
|---|---|---|
| 响应丢失后重试 | 确认成功前未持久化幂等键 | 将幂等键和结果与副作用本身原子写入 |
| 幂等写入中的非幂等副作用 | 去重只覆盖数据库写入 | 将所有副作用放入同一幂等边界,或分别保证其幂等 |
| 并发重试彼此竞争 | 幂等键检查与设置不是原子操作 | 用一次原子比较并设置或唯一约束作为入口 |
| 不同操作发生键冲突 | 键并非按逻辑请求唯一生成 | 使用 UUID,或使用每次逻辑请求均唯一的值生成键 |
不同缓解方案需要在正确性保证、延迟开销和实现复杂度之间取舍。幂等键的保留窗口也同样存在权衡:窗口太短会漏掉重复请求,窗口太长会造成存储无界增长。
怎样设计真正安全的重试
在生产环境中信任一条重试链路之前,至少确认以下问题:
- 为每次逻辑操作生成唯一的幂等键,而不是使用时间戳或可重置的计数器。
- 确认幂等键的存储、查询与设置是否和受保护操作原子完成,而不是会发生竞争的独立步骤。
- 确认邮件、Webhook、下游 API 调用等副作用,是否位于同一幂等边界内,或是否分别具备幂等性。
- 确认幂等键的保留窗口是否足以覆盖真实的重试延迟,包括客户端退避。
- 在并发重试下验证,而不只是顺序执行的验证。
结论
只有沿着请求实际经过的每一层追踪,幂等性才不再像一个简单属性。至少一次投递意味着重复必然发生;幂等键是标准防线,但真正的事故通常来自保护假定覆盖的位置,与保护实际生效的位置不一致。响应丢失、保护边界外的副作用、并发重试竞争和不当的幂等键选择,是安全重试悄然失效的四种常见方式。它们不需要罕见故障,只需要重试机制本来就要应对的普通网络抖动与时序巧合。设计重点不在于把请求标签为幂等,而在于让同一次逻辑操作的所有可观察副作用都落在明确、可验证的保护边界内。
相关阅读:一次超时如何演变成平台级故障 · HTTP 超时与故障测试实战 · 自动化测试用例的原子性 · 原子操作组合与线程安全 · Kafka 生产者和消费者