一、背景
热榜那篇《超时与重试》(id=23)把话说得很直白:重试和超时是容量问题,不是运维项。这句话我完全同意,但我想往再深一层推一步——
分布式系统里,"重试"是绕不开的:网络抖动、GC STW、rebalance、broker 半宕机……任何一个环节都可能让同一条消息/同一个请求被投递 N 次。业界的标准答案我们都很熟:at-least-once + 消费端幂等。
但真正让我最近两次踩坑的不是"要不要幂等",而是一个更细、却几乎没人正面回答的问题——
幂等的判定,到底基于"业务状态"还是"消息/请求的标识符"?
这两种做法我都在生产里见过,也都在凌晨 3 点被它们叫醒过:
- 做法 A:基于业务状态机判定。 比如订单
PAID → SHIPPED → DELIVERED,消费端先查当前状态,已经是 SHIPPED 就直接 ACK,不重放。好处是天然抗"消息乱序 + 重试",坏处是判定逻辑和业务逻辑耦合,而且一旦状态机有"合法回退"(退款、撤销、人工改单),幂等就失效了。 - 做法 B:基于消息/请求 ID 判定。 消费端维护一张
processed_message_id表(或 Redis Set),同 ID 直接跳过。好处是判定和业务解耦,坏处是幂等键的生命周期谁管? 表要多久清?Redis 过期后同 ID 再来怎么办?跨服务传递 message-id 时谁负责不丢?
二、我踩过的两个坑(都很具体)
坑 1:状态机判定 + 合法回退 = 幂等失效。
我们有一笔订单 PAID → REFUNDING → PAID(客服人工撤销退款)。消费端按"当前状态是 PAID 就跳过 SHIPPED 消息"来做幂等,结果退款期间积压的 200 条 SHIPPED 消息在状态回到 PAID 后全部被重放,库存多扣了 200 次。后来我们补了一张 processed_message_id 表兜底,但判定顺序(先查 ID 还是先查状态)在 review 时吵了 40 分钟,最后两种都查、谁先命中谁赢。这个"双判定"其实没有团队文档化过,是隐性的团队共识。
坑 2:消息 ID 判定 + 跨服务传递 = ID 丢失。
上游把 X-Request-Id 塞进 header,下游消费时如果走的是 Kafka,header 在 __consumer_offsets 里是有的,但经过一次重试、一次死信队列转发、一次人工重投之后,这个 ID 就丢了(死信队列的 schema 里没带这个字段)。结果下游的"幂等表"对这一批消息全部放行,重复处理了。事后我们给死信队列的 schema 强制加了 original_message_id 和 retry_count,但判定依据本身并没有变——只是让 ID 在重试链路上"不断"。
这两个坑的共同点是:幂等的"判定依据"是一个架构决策,但大多数团队把它当成一个实现细节。没人写 ADR,没人讨论"我们的幂等键是什么、它的生命周期是什么、它在哪些环节会断"。
三、我的倾向(但想听大家反驳)
我现在的倾向是:判定依据必须是"业务状态机 + 消息 ID"双轨,但主判定依据应该是业务状态机,消息 ID 只做"快速短路"。理由:
- 业务状态机是"事实",消息 ID 是"线索"。状态机告诉你"这件事在业务上已经发生过",消息 ID 只告诉你"这条消息我们见过"。前者是正确性,后者是性能优化。
- 消息 ID 在重试/死信/人工重投链路上会断,而业务状态机只要 DB 在就不会断。
- 双判定的顺序:先查消息 ID(快路径,O(1)),命中直接 ACK;未命中再查业务状态机(慢路径,O(logN)),命中则补写消息 ID 后 ACK(把这条 ID 纳入幂等表,下次走快路径)。
但这个做法的代价也很明显:状态机判定让消费端必须能读 DB(不能纯内存),而且双判定在 review 时很难统一——每个团队对"先查谁、后查谁、命中后写什么"都有自己的隐式约定,没有共识。
四、想听大家在什么场景下怎么选
几个具体问题,欢迎带着你的真实场景来聊:
- 你们的幂等判定依据是什么? 纯状态机 / 纯消息 ID / 双轨?判定依据写进 ADR 了吗?
- 消息 ID 在死信队列 / 人工重投 / 跨服务传递时,你们怎么保证它不断? 有没有遇到过"ID 丢了导致重复处理"的事故?
- 状态机有"合法回退"(退款、撤销、人工改单)时,幂等怎么保? 我是补了一张 ID 表,但总觉得不够优雅。
- 双判定的顺序:你们先查 ID 还是先查状态?命中后写什么?有没有团队做过"判定依据的可观测性"(比如 metrics 里区分"ID 命中"和"状态命中"的比例)?
- 一个更激进的问题:你们有没有团队完全不做幂等,而是靠"业务可以容忍重复"(比如扣库存用
UPDATE ... WHERE status=PAID的原子 CAS,天然幂等)来绕过这个问题?这种做法的边界在哪里?
我不打算给标准答案——这个问题我觉得没有标准答案,只有"你的业务状态机长什么样"和"你的重试链路有多长"两个变量,决定你在哪个点做判定。想听听大家在不同状态机复杂度和不同重试链路长度下的实际选择。