【踩坑】同一接口被重试三次还能保证正确?幂等设计从「知道」到「做对」
背景
去年我们一个内部 API,在网关超时重试 + 客户端自动重试的双重加持下,同一笔库存扣减被执行了 3 次。
监控上看一切正常:HTTP 200、日志无异常、数据库连接池也很绿。
唯一的问题是——货没了,订单还是「待支付」。
这类 bug 不性感,但极其常见。2026 年了,微服务、K8s、可观测性三件套很多团队都有,幂等仍然是后端面试爱问、线上又爱炸的「老题」。
三个层次:别停在「接口幂等」四个字
L1:HTTP 层 —— 重试安全
GET / PUT(带完整资源)天然相对安全POST 创建资源必须带 Idempotency-Key(Stripe、很多支付网关都这么干)- 客户端重试时 key 不变,服务端用 key + 用户维度做唯一约束
POST /api/orders
Idempotency-Key: 7b3c9f2a-4e1d-4a8b-9c0e-1f2a3b4c5d6e
Content-Type: application/json
{"sku": "PC530-TOOL-01", "qty": 1}
服务端伪代码:
def create_order(idem_key, user_id, payload):
existing = Order.objects.filter(idem_key=idem_key, user_id=user_id).first()
if existing:
return existing # 直接返回第一次的结果
with transaction.atomic():
return Order.objects.create(idem_key=idem_key, user_id=user_id, **payload)
L2:业务层 —— 状态机而不是 if-else
「扣库存 + 写订单 + 发消息」如果散在三个 service 里,幂等键只能防 重复创建,防不了 半成功。
更稳的做法:显式状态机 + 只允许合法跃迁。
CREATED → PAID → FULFILLED
↘ CANCELLED
每个跃迁一个 handler,重复调用 pay(order_id) 时,若已是 PAID,返回成功而非再扣一次。
L3:基础设施层 —— Outbox 模式
「DB 提交成功但 MQ 没发出去」是经典分布式难题。
Transactional Outbox:业务数据和「待发送事件」在同一事务写入;独立 worker 扫描 outbox 表投递消息。
消费端再配合 at-least-once + 幂等消费,链路才闭环。
我们踩过的两个反直觉坑
| 坑 | 教训 |
|---|---|
| 幂等键只用 UUID,不含 user_id | A 用户的 key 被 B 用户复用,返回了别人的订单 |
| 幂等记录永不过期 | 表膨胀;且无法区分「真重复」与「key 泄漏复用」 |
现在的策略:(user_id, idem_key) 联合唯一 + 30 天 TTL 归档。
2026 年的 pragmatic 清单
- 所有 POST 写操作:文档里写清楚是否幂等、key 放哪、有效期多久
- 网关重试策略:对非幂等 POST 默认不重试,或只对特定错误码重试
- 集成测试:专门写「同一请求连打 3 次,状态与副作用只发生 1 次」
- 可观测性:幂等命中(cache hit)单独打点,别和正常流量混在一起
- 别过度设计:单体 + 单库的项目,一个表 + 唯一索引往往就够
结语
潮流会变:今天 Kafka,明天可能是 Temporal,后天又是 AI Agent 编排。
不变的是:网络会丢包、客户端会重试、人会 double-click。
后端工程师的体面,很多时候就藏在那张 幂等表 和几条 状态跃迁规则 里。
你们项目里最有意思的一次「重复请求」事故是什么?欢迎来补刀。