分布式系统里有一句被说烂了的话:"失败就重试" 。但真正踩过坑的人知道,重试本身才是很多事故的制造者------它会把一次偶发失败,放大成一次数据事故。
这篇文章只讲一件事:什么时候可以重试,什么时候重试等于自毁。
一、重试为什么危险:它假设了"没做成"
重试的隐含前提是:上一次操作没有生效。
但网络超时、连接被重置、进程被杀------这些"失败信号"只说明你没收到结果,不说明对方没做。请求可能已经到达、已经执行、只是响应在回程丢了。
于是重试就变成了**"再执行一次"**,而不是"再试一次"。
- 转账:扣两次钱;
- 下单:下两个单;
- 发消息:对方收到两条。
**这类问题的学名叫"至少一次(at-least-once)语义",它是绝大多数 RPC 与消息中间件的默认行为。**注意是"至少",不是"恰好"。
二、判据:这个操作"执行两次"会怎样?
判断能不能无脑重试,只需要问一个问题:
这个操作重复执行一次,结果会和执行一次一样吗?
- 一样 ⇒ 它是幂等 的(如
SET status = 'paid'、DELETE WHERE id=1),可以放心重试; - 不一样 ⇒ 它是非幂等 的(如
balance = balance - 100、INSERT),必须加保护才能重试。
注意:UPDATE ... SET x = 5 是幂等的,而 UPDATE ... SET x = x + 1 不是------同一个动词,差别在"赋绝对值"还是"基于当前值"。
三、四种常用的保护手段
1. 幂等键(Idempotency Key) 调用方生成一个全局唯一键随请求发送,服务端首次见到就执行并记录结果,之后再见到同一个键直接返回上次的结果。这是最通用的一种,Stripe 等支付 API 用的就是这个。
要点:键要由调用方 生成(服务端生成没有意义),并且要落库 、要有过期策略。
2. 唯一约束 把"业务上不允许重复"这件事交给数据库 :UNIQUE(order_id, payment_seq)。写入冲突就说明已经做过------这不是错误,是重复请求被正确拦下。
3. 状态机 + 条件更新 不要让"状态"可以被随意改写。把操作表达成**"从 A 状态迁移到 B 状态"**:
sql
UPDATE orders SET status='paid' WHERE id=? AND status='pending';
看受影响行数 :1 表示这次真的迁移了;0 表示它已经被处理过(或状态不对)------两种都不该报错,而应各自有分支。
4. 去重层 / 消费位点 消息队列场景下,消费端要么用位点 (只处理一次),要么用去重表 (记录已处理的消息 ID)。不要指望"消息只投递一次"。
四、几个容易忽略的细节
- 重试要有上限和退避:固定间隔重试会在故障时形成"重试风暴",把下游打死。指数退避 + 抖动(jitter)是标配;
- 区分"可重试"与"不可重试" :5xx / 超时通常可重试;而 400 / 业务校验失败重试一万次也不会变对;
- 超时不等于失败 :多数 RPC 的读超时只代表"我没等到响应",不代表服务端没执行------这正是幂等键存在的理由;
- 幂等键的粒度:按"一次业务意图"生成,而不是按"一次 HTTP 请求"生成,否则重试时键会变,保护就失效了。
五、一句话总结
重试的正确前提不是"失败了",而是"重复执行无害"。
所以顺序应该是:先让操作幂等,再允许重试------反过来做,就是拿运气当架构。
本文讨论的是通用的分布式系统设计原则,不涉及任何具体系统或业务。