背景
我在维护一个订单履约系统,链路大致是:
网关(去重层) → 订单服务(创建) → MQ(事件) → 履约/库存/结算(消费)
去年一次大促复盘,根因不是"幂等没做",而是幂等做了三遍、三遍口径不一致:
- 网关层:基于
X-Idempotency-Key(客户端生成的 UUID)做 Redis 去重,TTL 设 24h; - 订单服务:基于
orderId(自增)做唯一索引,天然幂等; - 履约消费端:基于
messageId做去重表(MySQL,TTL 7 天清理)。
结果:一次网关 Redis 抖动(主从切换 3 秒),同一个请求带着同一个 UUID 穿透到订单服务;订单服务因为 orderId 是自增的,生成了一条新订单;MQ 投递了事件,消费端用 messageId 去重成功——但此时下游已经多扣了一次库存。
三套幂等,三套 key,三套 TTL,三套"谁说了算"。最终我们花了两周才把口径对齐。
常见分歧(我在不同团队见过至少四种做法)
| 方案 | 幂等判定方 | 幂等键来源 | 典型 TTL/保留 | 适用场景 | |------|-----------|-----------|-------------|---------| | A. 调用方生成 key,被调用方存去重表 | 被调用方 | 客户端 UUID / requestId | 7–30 天 | 支付、下单等"一次性"操作 | | B. 被调用方基于业务语义天然幂等 | 被调用方 | 业务主键(订单号、流水号) | 永久(唯一索引) | 状态机推进、结算 | | C. 网关/接入层统一去重,后端不管 | 网关 | 客户端 UUID + 接口路径 | 分钟~小时级 | 读多写少、防重复提交 | | D. MQ exactly-once + 消费端无幂等 | 消息中间件 | 事务 ID / messageId | 依赖 broker 语义 | 纯 MQ 消费、无外部调用 |
我的观察:大多数团队不是"缺一种",而是"叠了两种以上、且不知道哪层兜底"。
我的倾向(以及为什么)
我倾向于一个原则:幂等的"最终判定权"应该归被调用方,且优先用业务语义(方案 B);技术层幂等(方案 A/C)只做"快速短路",不承担正确性。
理由有三:
- 业务语义幂等是"结构性的":订单状态机
CREATED → PAID → SHIPPED,重复PAID→SHIPPED直接返回成功,不需要查任何去重表,没有 TTL 问题。 - 技术层幂等是"缓存性的":UUID 去重表本质是缓存,TTL 一过就失效;Redis 主从切换、MQ rebalance、网关扩缩容——任何一层抖动都可能让"已去重"的判定丢失。
- 叠加越多,排查越难:上面那个事故,如果只有一层幂等,复盘 30 分钟就能定位;三层叠加,光搞清楚"是哪一层放过的"就花了一天。
但我不想把它说成"方案 B 永远正确"——比如一个"发起退款"接口,业务主键是 refundId,如果客户端重复提交但 refundId 不同(每次生成新的),方案 B 就失效了,这时候你确实需要方案 A 的"请求级幂等"兜底。
想听大家聊的
- 你的系统里,幂等到底做了几层?哪层是"真兜底"、哪层只是"快速短路"? 能不能用一句话描述清楚每层的职责边界?
- TTL 怎么定的? 去重表/缓存保留 7 天、30 天、还是永久?依据是什么(合规要求?业务窗口?存储成本?),有没有因为 TTL 设短/设长踩过坑?
- MQ 消费端,你们用 messageId 去重还是业务 key 去重? 如果是 messageId,rebalance 导致同一条消息被两个 consumer 各消费一次(极端 case),你怎么兜?
- 有没有人尝试过"幂等即状态机"的设计——把幂等判定完全融进状态迁移的 guard 条件里,不单独维护去重表?效果如何,有没有遇到"guard 条件本身有 bug"的情况?
- 网关层去重,你们用 Redis 还是本地 Caffeine + 版本号? 网关无状态多副本下,"同一个 key 先后打到两个实例"这个窗口,你们怎么处理的?
抛砖引玉,期待听到不同规模、不同技术栈下的实践。特别是"幂等做了但口径没对齐"的踩坑故事,比"我们做得很好"的分享更有价值。