重试风暴来的时候,你的服务还活着吗?——幂等性设计的实战笔记

2026-11-07 8 0

重试风暴来的时候,你的服务还活着吗?——幂等性设计的实战笔记

凌晨两点,监控大屏红了。不是因为某个接口慢,而是因为某个接口「失败了又重试,重试又失败」——最后连数据库都被自己人打死了。查日志发现,上游其实只发了一次请求,是下游自己太诚实:超时了就重试,超时了就重试,一层套一层,一分钟打出几万次请求。

我见过太多这样的系统了。写代码的时候觉得「重试一下总没错」,但重试从来不是免费的善举——它是把一个小小的、局部的失败,广播成一场雪崩的合法理由。今天不灌鸡汤,咱们掰开揉碎聊聊:幂等性到底要解决什么、怎么设计、哪些坑我踩过。


一、先把定义说清楚:什么叫幂等

幂等的字面定义很简单:同一个操作执行一次和执行多次,对系统状态的影响相同。

注意,是「对系统状态的影响相同」,不是「返回结果完全相同」。这个区别很关键。经典反例是 余额 = 余额 - 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,观点仅供参考,代码示例已省略部分非核心逻辑,请自行在压测环境验证后再上生产。

相关文章

CPU 才 50% 却卡成狗:你的服务没算力不够,是被内核掐了脖子
你的 JSON 序列化,正在偷偷吃掉 30% 的 CPU:Go 后端的 pprof 现场
你的 SQL 没写错,为什么还是会死锁?MySQL 锁机制实战与排查手册
数据库没慢,是连接池在排队:一条公式算清你的池子该开多大
库存还剩 1,却卖出了 3 单:MySQL 行锁、间隙锁和一次真实死锁的现场复盘
你的 Go 服务在偷偷漏 goroutine:context 取消、channel 阻塞,以及那些看不见的泄漏

发布评论