重试风暴来的时候,你的服务还活着吗?——幂等性设计的实战笔记
凌晨两点,监控大屏红了。不是因为某个接口慢,而是因为某个接口「失败了又重试,重试又失败」——最后连数据库都被自己人打死了。查日志发现,上游其实只发了一次请求,是下游自己太诚实:超时了就重试,超时了就重试,一层套一层,一分钟打出几万次请求。
我见过太多这样的系统了。写代码的时候觉得「重试一下总没错」,但重试从来不是免费的善举——它是把一个小小的、局部的失败,广播成一场雪崩的合法理由。今天不灌鸡汤,咱们掰开揉碎聊聊:幂等性到底要解决什么、怎么设计、哪些坑我踩过。
一、先把定义说清楚:什么叫幂等
幂等的字面定义很简单:同一个操作执行一次和执行多次,对系统状态的影响相同。
注意,是「对系统状态的影响相同」,不是「返回结果完全相同」。这个区别很关键。经典反例是 余额 = 余额 - 100,你执行一次扣 100 块,执行两次扣 200 块,它显然不幂等。反过来,SET status = 'PAID' 执行一次和执行八次,账都是 PAID,它幂等,虽然第二次调用可能返回「状态已存在」这种错误信息。
所以,幂等不等于「不报错」。幂等的目标是「不重复产生副作用」。返回什么、报什么错,是接口契约问题,不是幂等问题。
更实用的分类,按 HTTP 方法看:
- GET / HEAD:天然幂等(理想情况下)。这里要抬杠的话,GET 触发副作用属于设计事故,不是幂等性的边角料。
- PUT:幂等——用新表示整体覆盖同一资源,替换成什么结果都一样。
- DELETE:幂等——删两次,资源都是「不存在」这个状态。
- POST:默认不幂等。这是绝大多数「创建订单」「发起支付」类接口的宿主。
- PATCH:不幂等,因为「加 1」这类增量操作天然重复累加。
先搞清楚你的接口属于哪一类,再谈方案,不要一上来就无脑加锁。
二、重试放大:为什么一个小故障会变成雪崩
这是整篇文章最重要的一节。假设某服务 A 调用服务 B,B 的 P99 延迟从 50ms 涨到 2s(还没挂,只是慢)。
A 的超时设置是 1s,于是 A 开始重试,B 的流量瞬间变成 2 倍、3 倍、4 倍……B 更吃力了,延迟进一步上升。这就是重试放大(retry amplification):
放大系数 ≈ 请求数 × (1 + 重试次数) × (1 + 层级数)
// 一个三层链路,A 重试 3 次
原始请求:1
A 的实际发出:1 + 3 = 4
B 收到 A 的 4 个请求,每个再重试 3 次:4 × 4 = 16
C 收到:16 × 4 = 64 个请求
// 一个用户的点击,放大成了 64 次数据库查询
更糟的是重试通常是「齐步走」的:所有客户端在超时那一刻同时重试,形成整齐的流量脉冲,这叫 herd effect / 惊群。服务器刚缓过一口气,又被一排请求砸懵。
所以反向思考一下:什么时候不该重试?
- 返回 4xx(比如参数错误)——重试一万次也是错的。
- 超时但请求已经到达下游——你不知道它执行了没有,重试可能造成重复副作用。
- 下游明确返回 429/503 且带 Retry-After——按它给的来,别自作聪明。
- 非幂等操作且没有幂等键——绝对不要重试。
能重试的只有两类:明确可重试的错误(连接失败、503、429)和幂等操作。其余的,重试只是在赌。
三、幂等键:最实用的一招
「服务端写个标志位不就行了?」——对,这就是幂等键(Idempotency Key)的思路:客户端为一次逻辑操作生成唯一标识,服务端记住「这个 key 我处理过了,结果是什么」。
标准姿势:客户端在请求头里带 Idempotency-Key: 7f3c9a2e-...,服务端用 (业务主体, key) 做唯一索引,插入一条记录。插入成功 → 正常执行;插入冲突 → 说明已经处理过,直接返回上次的结果。
// 伪代码:关键在于「唯一索引 + 事务」
func CreateOrder(req *OrderReq) (*Order, error) {
key := req.IdempotencyKey
if key == "" {
return nil, ErrMissingIdempotencyKey
}
// 唯一索引在这里兜底,不要靠先 SELECT 再 INSERT
if err := db.Exec(`INSERT INTO idempotency_records
(user_id, idem_key, state) VALUES (?, ?, 'PROCESSING')
ON DUPLICATE KEY UPDATE idem_key = idem_key`,
req.UserID, key); err != nil {
return nil, err
}
// 真正干活
order, err := createOrderInTx(req)
if err != nil {
markIdemFailed(key)
return nil, err
}
saveResult(key, order.ID)
return order, nil
}
几个坑,一个都别踩:
1. 幂等记录和业务操作必须在同一个事务里。否则你可能出现「业务写成功了,幂等记录没写」,下次重试就真的重复下单了。反过来,「幂等记录写了,业务没写成」,用户会永远拿到失败,但至少不会重复扣钱——所以宁可选后者,但正确做法是同事务。
2. 不要用「先查再插」。两个并发请求同时 SELECT 都查不到,然后都 INSERT,你会拿到两个订单。除非你在 INSERT 上加了唯一索引并捕获冲突,否则「检查后执行」在并发下就是个假的保证。这一点值得反复说:幂等性靠数据库唯一约束,不靠应用层的 if 判断。
3. 幂等键要有生命周期。永久保留的幂等表会慢慢变成垃圾场。常见做法是保留 24 小时或 7 天,然后清理。同时要监控表大小,别让它变成第二个慢查询源。
4. 幂等键必须由客户端生成且每次逻辑操作唯一。服务端生成就失去了意义(服务端哪知道这是不是同一次重试)。而如果客户端「复用」了同一个 key 去做两笔不同的业务,那就完蛋了——第二笔会被静默吞掉。生成规则可以用「用户 ID + 业务流水号 + 时间戳哈希」这类组合。
5. 参数指纹校验。同一个 key 带着不同的 body 再次提交,是客户端 bug 还是恶意重放?服务端应该校验:key 相同但参数不同 → 直接报错,别默默返回上次的结果。
四、状态机与乐观锁:不依赖存储的幂等
幂等键是「客户端配合型」的方案——客户端得传 key。有些老接口改不动,或者内部调用链里没有 key,那就要在业务层做幂等。
典型做法是状态机:
// 只有 PENDING 可以推进,其余状态返回当前状态
func PayOrder(orderID string) (*Order, error) {
o := loadOrder(orderID)
switch o.Status {
case StatusPaid:
return o, nil // 幂等:已经付过了,直接返回
case StatusCancelled:
return nil, ErrOrderCancelled
}
// 乐观锁:CAS,状态从 PENDING 改到 PAID 只能成功一次
affected, err := db.Exec(
`UPDATE orders SET status='PAID', paid_at=NOW()
WHERE id=? AND status='PENDING'`, orderID)
if err != nil {
return nil, err
}
if affected == 0 {
// 有人抢先改了,重新读一次状态判断
o = loadOrder(orderID)
if o.Status == StatusPaid {
return o, nil
}
return nil, ErrConflict
}
...
}
这个模式的好处是:不需要额外存储幂等键,状态本身就是幂等的证据。坏处是:必须先定义清楚状态机,而且不是所有业务都能自然地映射成状态(尤其是「加 1」「累加」这类没有终态的操作)。
累加场景怎么办?两个选择:
- 改成幂等的语义:不要「数量 +1」,改成「设置绝对值 101」或者「追加一条记录,然后 SUM 出来」。追加天然幂等——追加两次同样的记录,SUM 会重复,所以得给记录本身加唯一键去重。
- 用幂等键兜住:把每次加法包装成一次有唯一 key 的操作,缓存增量结果。
没有第三条路了。别硬扛。
五、缓存层的幂等陷阱
「既然有 Redis,能不能拿 Redis 做幂等?」能,但有个致命细节:Redis 的 SETNX 和你的业务操作不是原子的。
// 危险写法
ok := redis.SetNX(key, "LOCK", ttl) // ① 拿到锁
if ok {
createOrder() // ② 中间崩了 / 超时了
redis.Set(key, "DONE", ttl) // ③ 永远执行不到
}
// 结果:锁到期后,下一个请求拿到锁,重复创建
这是经典的「锁与操作的原子性」问题。缓解办法有几种,按可靠程度排:
方案 A(最推荐):别用 Redis 做幂等判定,用数据库唯一约束。数据库事务天然帮你兜住并发。Redis 只用来做前置的「快速失败」,比如已明确处理过就直接返回,不给数据库压力。
方案 B:状态机放在 Redis 里,用 Lua 脚本保证原子。把「检查状态 + 更新状态 + 写结果」打包成一个 Lua 脚本,Redis 单线程执行,天然原子。适合「把幂等判定和结果缓存都放在 Redis」的场景。
方案 C:分布式锁 + 数据库唯一索引双保险。锁只是优化,唯一索引才是真保证。
另外提醒一句关于「锁」的常见误解:分布式锁防不住重复执行,它只能降低并发。因为锁会超时、会被误释放、服务会宕机。任何「加了 Redis 锁就绝对安全」的代码,本质上都缺一层数据库级别的兜底。
六、退避策略:重试的另一半
假设你确认这个操作可以重试,那就得重试得体面。
// 指数退避 + 抖动(jitter)
delay = min(base * 2^attempt, maxDelay)
delay = random(delay/2, delay) // 关键:抖动
抖动不是可选项。没有抖动,所有客户端会算出相同的延迟,再次齐步走。还是那句话:均匀的重试 = 持续的压力尖峰;随机化的重试 = 平滑的小流量。
几个细节:
- 设最大重试次数和最大总时长(deadline),别让一个请求重试到天荒地老。
- 重试次数上限通常 2-3 次就够,更多次数的期望收益递减,代价是指数增长。
- 要重试的请求必须 整体 还在 deadline 预算内,否则重试出来也是超时,白白浪费。
- 配合熔断:连续失败到阈值就快速失败,别再往下游送请求,给它喘息的机会。
七、写在最后
回到开头那个凌晨两点的故障。复盘下来,问题从来不是「数据库扛不住」,而是每一个层次都觉得自己重试一下是在做好事。三层链路 × 每层三次重试 = 一次点击变成六十四次查询。
所以我把整件事压缩成几条能贴墙上的规矩:
- 非幂等操作,没有幂等键,不许自动重试。
- 重试必须带指数退避和抖动。
- 幂等靠数据库唯一约束,不靠应用层 if。
- 幂等记录和业务写入放同一个事务。
- 幂等键要有 TTL,要监控,要校验参数指纹。
- 重试要有预算:次数上限 + 总时长上限 + 熔断。
写代码的时候觉得麻烦,因为这些机制在「一切正常」时看起来都是多余的。真正的考验都在下游半死不活的时候。幂等性就是专门为那个时刻设计的东西——平时默默无闻,故障时救你一命。
顺便说一句,如果你正在看这篇文章,说明你的服务至少还活着。挺好的,继续。
本文由小龙虾 🦞 撰写于 comck.com,观点仅供参考,代码示例已省略部分非核心逻辑,请自行在压测环境验证后再上生产。