【背景】
做了 15 年分布式系统,消费端防重是我最常被问、也最容易翻车的一个点。
去年带团队做订单履约链路(Kafka 3.5 KRaft,3 broker,核心消费者 4 实例),一条消息从「订单创建」到「物流签收」要经过 5 个状态节点,每个节点是独立消费者组。我们先后踩了 3 个坑:
- 第 1 次:幂等表 TTL 设 7 天,一条延迟 9 天到达的补偿消息(上游对账任务重跑)绕过了幂等表直接写库,同一笔订单生成了两条发货记录。
- 第 2 次:业务唯一索引只建在 (order_id, sku_id) 上,没带 warehouse_id,一次跨仓调拨把两条合法记录误判为重复,静默丢单。
- 第 3 次:状态机校验写了,但并发窗口内两条消息同时读到「待发货」,双双通过校验后写库——「读-判断-写」不是原子操作,状态机形同虚设。
三次事故根因各不相同,但指向同一个问题:防重到底靠哪一层兜底?
【常见分歧 / 几种做法】
我见过 4 种主流姿势,团队里经常吵:
做法 A:幂等表一张打天下 Redis SETNX(key=msgId,TTL 7 天)或 MySQL 幂等表(UNIQUE(msg_id),定期清理)。
- 优点:一行代码,最简单。
- 缺点:msgId 变了就失效(上游重发换了 message key);TTL 边界谁来定;Redis 重启 / 集群切换后 key 丢了不报错,静默漏防。
做法 B:业务唯一索引兜底 业务表上建 UNIQUE 索引(如 order_id + sku_id + warehouse_id),插入冲突直接忽略。
- 优点:不依赖外部存储,数据自洽。
- 缺点:索引字段少一个就是一次丢单事故;对「状态流转」类场景无效(同一行 UPDATE 两次,索引不拦)。
做法 C:状态机校验 每次消费先 SELECT 当前状态,判断是否允许流转到目标状态,不允许则丢弃。
- 优点:语义最清晰,唯一能拦住「乱序」。
- 缺点:「读-判断-写」三步非原子,并发窗口内会漏;上 FOR UPDATE 锁等待代价大,不上就形同虚设。
做法 D:三层全上,各司其职 幂等表做快速去重(拦 99% 重复),唯一索引做安全网(拦幂等表漏的),状态机做语义校验(拦乱序)。
- 优点:覆盖最全。
- 缺点:三层逻辑分散,排查成本高;三个错误码(「幂等表命中」「索引冲突」「状态机拒绝」)怎么统一映射给上游?
【我的倾向与理由】
我选 D 但做减法,具体 4 条:
-
状态机是主防线,不是辅助。理由:它唯一能同时拦住「乱序」和「并发窗口内重复」——后两个是幂等表和唯一索引都管不到的。实现上用
UPDATE t SET status='已发货', version=version+1 WHERE order_id=? AND status='待发货' AND version=?,一条 CAS 把「读-判断-写」压成原子操作。我们实测 4 实例并发下 FOR UPDATE 锁等待 P99 从 2ms 飙到 45ms,而 CAS 更新 P99 稳定在 3ms,差一个数量级。 -
业务唯一索引是安全网,只防「状态机逻辑写错」这种开发事故,不防业务正常重复。索引字段必须带全业务主键(order_id + sku_id + warehouse_id + batch_no),少一个字段就是一次丢单。
-
幂等表降级为「性能优化」而非「正确性保障」。Redis SETNX TTL 30 天,命中则直接 ACK 跳过 DB 写入(省一次 SELECT + UPDATE);未命中则走完整状态机流程。Redis 挂了不降级、不报错,只是多打一次 DB——因为正确性不依赖它。
-
TTL 不纠结 7 天还是 30 天,因为幂等表不再是正确性依赖,TTL 只影响性能命中率。我们设 30 天,覆盖 99.7% 的补偿窗口(上游对账任务最大重跑间隔 28 天,留 2 天余量)。
【想听大家在什么场景下怎么选】
抛 5 个具体问题:
- 你们的消费端防重靠几层?每层分别防什么?出过几次「某一层漏了、另一层兜住了」的事故?
- 幂等表 TTL 怎么定的?有没有因为 TTL 太短(7 天)或太长(永久)踩过坑?Redis 集群切换后 key 丢失你们怎么兜?
- 状态机 + CAS UPDATE 和 SELECT FOR UPDATE,你们的并发量下哪个 P99 更好?给我一些真实数字。
- 三层都上了之后,错误码怎么统一给上游?「幂等表命中」「索引冲突」「状态机拒绝」——上游能不能区分「重复消息,忽略」和「非法状态流转,需要人工介入」?
- 有没有人用「消费端不防重,全部推给上游去重」的方案?什么规模、什么场景下你觉得可以接受?
期待看到不同规模(单机 / 4 实例 / 跨机房)下的真实取舍数字。