跳转至

一次重复回调引发的库存事故:Go 服务幂等设计实战

引子

很多后端同学都遇到过这个场景:线上流量很稳,监控没报错,但运营突然来问,为什么同一个订单扣了两次库存。排查一圈后发现,请求本身不是“错”,而是“重复到了”。

在单机时代,“重复请求”通常只是日志里多一行;但在分布式系统里,重试、超时补偿、消息重放、网关抖动都可能让同一业务动作执行多次。幂等做不好,业务结果就会悄悄偏离预期。

问题

先把问题说具体。下面这类路径非常常见:

  1. 用户支付成功
  2. 第三方支付平台回调业务服务
  3. 业务服务更新订单状态并扣减库存
  4. 业务服务返回成功

理论上这条链路只该执行一次。但现实里,第三方回调可能因为网络抖动重发,或者我们返回慢导致对方判定超时并再次回调。于是同一个订单号,在短时间内被并发处理多次。

症状通常有三种:

  • 库存被重复扣减
  • 用户权益(积分、优惠券)被重复发放
  • 下游系统收到多条语义相同但 ID 不同的事件

背景

很多团队早期会先做“表层防重”,比如在代码里判断 if order.status == paid { return }。这能挡住一部分串行重复请求,但挡不住并发竞争:两个请求同时读到 unpaid,都继续往下执行。

更本质地看,幂等不是 if 判断,而是**对业务状态机的约束**:

  • 哪个字段代表“动作已生效”
  • 哪些操作必须在同一事务里完成
  • 重复到达时返回什么结果(成功复用、静默丢弃、明确拒绝)

如果没有这套约束,系统就会把“重试语义”误当成“新请求语义”。

核心

1)先定义幂等边界:以“业务动作”而不是“HTTP 请求”为单位

以“支付成功扣库存”为例,幂等键应该绑定业务语义:

  • 推荐:biz_type + biz_id(如 PAY_SUCCESS:order_123
  • 不推荐:随机请求 ID(同一业务动作每次请求都不一样)

可以先落一张幂等记录表:

idempotency_record.sql
CREATE TABLE idempotency_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    biz_type VARCHAR(64) NOT NULL,
    biz_id VARCHAR(128) NOT NULL,
    status VARCHAR(16) NOT NULL DEFAULT 'processing',
    response_body TEXT,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_biz (biz_type, biz_id)
);

这个唯一索引是第一道硬约束:同一个业务动作只能“首次占坑”一次。

2)用“唯一约束 + 事务 + 状态机”三层防重

一个可落地的处理流程:

payment_callback.go
func (s *Service) HandlePaySuccess(ctx context.Context, orderID string) error {
    // 第一步:尝试插入幂等记录(占坑)
    ok, err := s.repo.TryCreateIdempotency(ctx, "PAY_SUCCESS", orderID)
    if err != nil {
        return err
    }

    // 说明该业务动作已处理过,直接返回历史成功
    if !ok {
        return nil
    }

    // 第二步:同一事务内完成关键业务更新
    return s.repo.WithTx(ctx, func(tx Tx) error {
        order, err := tx.GetOrderForUpdate(orderID)
        if err != nil {
            return err
        }

        if order.Status == "paid" {
            return nil
        }

        if err := tx.DeductInventory(order.Items); err != nil {
            return err
        }
        if err := tx.MarkOrderPaid(orderID); err != nil {
            return err
        }

        return tx.MarkIdempotencyDone("PAY_SUCCESS", orderID)
    })
}

这里有两个关键点:

  • TryCreateIdempotency 依赖唯一索引,天然防并发重复执行
  • 扣库存与订单状态变更放在同一事务,避免“扣了库存但订单没成功”的中间态

3)失败恢复:不要只考虑“成功路径”

真实线上一定会遇到“占坑成功,但事务失败”。如果不处理,幂等记录会卡在 processing,后续请求永远被拦截。

常见做法:

  • 幂等记录增加 status = processing|done|failed
  • processing 设置超时窗口(如 2 分钟)
  • 超时后允许重试抢占,或转人工补偿队列

实践建议

不要把 processing 记录永久当成“已成功”。 对超时未完成的记录必须有恢复策略,否则会形成隐性数据黑洞。

4)可观测性:看见“重复”比避免“重复”更重要

幂等能力上线后,建议至少补齐三类指标:

指标 含义 目标
idempotency_hit_total 命中重复请求次数 能反映上游重试压力
idempotency_processing_timeout_total 处理超时次数 及时发现卡单
idempotency_failed_total 业务失败次数 评估补偿成本

再配合结构化日志字段(biz_typebiz_ididempotency_status),排障效率会显著提升。

总结

幂等不是“加一行判断”,而是一次完整的工程设计:

  1. 用业务语义定义幂等键
  2. 用数据库唯一约束做并发硬防线
  3. 用事务保证关键状态原子变化
  4. 用失败恢复和监控闭环兜底

如果你现在只做了“状态判断”,建议下一步补上“唯一约束 + 失败恢复”。这两件事,往往决定了系统在重试风暴里是“可控退化”,还是“静默错账”。

评论