一次重复回调引发的库存事故:Go 服务幂等设计实战
引子
很多后端同学都遇到过这个场景:线上流量很稳,监控没报错,但运营突然来问,为什么同一个订单扣了两次库存。排查一圈后发现,请求本身不是“错”,而是“重复到了”。
在单机时代,“重复请求”通常只是日志里多一行;但在分布式系统里,重试、超时补偿、消息重放、网关抖动都可能让同一业务动作执行多次。幂等做不好,业务结果就会悄悄偏离预期。
问题
先把问题说具体。下面这类路径非常常见:
- 用户支付成功
- 第三方支付平台回调业务服务
- 业务服务更新订单状态并扣减库存
- 业务服务返回成功
理论上这条链路只该执行一次。但现实里,第三方回调可能因为网络抖动重发,或者我们返回慢导致对方判定超时并再次回调。于是同一个订单号,在短时间内被并发处理多次。
症状通常有三种:
- 库存被重复扣减
- 用户权益(积分、优惠券)被重复发放
- 下游系统收到多条语义相同但 ID 不同的事件
背景
很多团队早期会先做“表层防重”,比如在代码里判断 if order.status == paid { return }。这能挡住一部分串行重复请求,但挡不住并发竞争:两个请求同时读到 unpaid,都继续往下执行。
更本质地看,幂等不是 if 判断,而是**对业务状态机的约束**:
- 哪个字段代表“动作已生效”
- 哪些操作必须在同一事务里完成
- 重复到达时返回什么结果(成功复用、静默丢弃、明确拒绝)
如果没有这套约束,系统就会把“重试语义”误当成“新请求语义”。
核心
1)先定义幂等边界:以“业务动作”而不是“HTTP 请求”为单位
以“支付成功扣库存”为例,幂等键应该绑定业务语义:
- 推荐:
biz_type + biz_id(如PAY_SUCCESS:order_123) - 不推荐:随机请求 ID(同一业务动作每次请求都不一样)
可以先落一张幂等记录表:
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)用“唯一约束 + 事务 + 状态机”三层防重
一个可落地的处理流程:
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_type、biz_id、idempotency_status),排障效率会显著提升。
总结
幂等不是“加一行判断”,而是一次完整的工程设计:
- 用业务语义定义幂等键
- 用数据库唯一约束做并发硬防线
- 用事务保证关键状态原子变化
- 用失败恢复和监控闭环兜底
如果你现在只做了“状态判断”,建议下一步补上“唯一约束 + 失败恢复”。这两件事,往往决定了系统在重试风暴里是“可控退化”,还是“静默错账”。