幂等校验明明通过了,为什么消息还是会重复消费?
幂等校验通过,不等于业务副作用只会执行一次。把去重键、状态落库、提交可见性、ack 时序和外部副作用摆在同一个时序里,才能解释为什么“检查没问题”,重复消费却还是不断发生。
重复消费最让人困惑的地方,不是系统完全没做幂等,而是明明做了检查,事故还是落到了业务结果上。团队翻幂等表时经常能看到记录、状态也像对的,可订单还是扣重了、券还是发重了、回调还是打了两次。
问题通常不在“有没有检查”这件事本身,而在于检查通过的那个瞬间,并没有覆盖真正的副作用闭环。幂等校验往往只守住了消费前的一道门:
- 消费前查一次幂等表
- 用消息 ID 判断这条消息是否处理过
- 看某个业务状态是不是已经是 DONE
可重复消费真正发生的位置,往往在这几个瞬间之外:
- 两个消费者几乎同时通过检查
- 幂等状态还没提交可见,另一条消费已经进来了
- 数据库里的去重做了,外部副作用却没和它绑在一起
- 业务逻辑执行成功了,但 ack 失败或超时,消息又被重新投递
- 去重键本身选错了,挡住了“同一条消息”,却没挡住“同一笔业务”
现场更该问的是:
这次重复,重复的是消息投递、业务动作,还是外部副作用?幂等到底收住了哪一层,又漏掉了哪一层?
重复到底落在哪一层
| 你现在看到的现象 | 更适合先看哪条线 | 为什么 |
|---|---|---|
| 幂等校验明明做了,但重复消费、重复扣减、重复发券还是发生 | 这篇正好处理这个场景 | 重点是把幂等检查和副作用真正收住之间的缺口找出来 |
| 你还在判断这个场景到底该不该上分布式锁 | 先看《分布式锁什么时候该用,什么时候不该用》 | 先分清互斥和幂等,不要混用 |
| 你已经用了锁,现在是吞吐变差、热点 key 集中、持锁时间变长 | 先看《分布式锁没报错但吞吐变差,应该先查续约还是锁粒度?》 | 这时先去锁链路里找慢点更合适 |
| 你看到的是 MQ backlog 一直涨,团队想直接扩消费者 | 先看《消息越堆越多,为什么常常不是消费者数量不够,而是下游变慢了?》 | 这时要先把消费吞吐和下游等待分开 |
| 锁竞争并不高,但线程池、连接池、下游等待一起把吞吐拖差 | 先看《锁竞争不高却吞吐掉得厉害,先查线程切换还是下游等待?》 | 更像协作放大链,不是幂等边界本身 |
这篇主要在收窄幂等边界
如果你当前最困惑的是:明明已经做了幂等校验,为什么重复扣减、重复发券、重复回调还是发生,这篇文章就只讨论这个缺口。
这里不讨论“这个场景要不要上分布式锁”,也不把 backlog、线程池和等待链这些吞吐问题一起揉进来,只把边界画清楚:
幂等校验通过,只说明某个检查动作发生过;它并不自动等于整条消费链上的业务副作用已经被真正收住。
把这条边界立住,后面才不会把消息重复、业务重复和外部副作用重复混成一个词。
把重复样本按业务动作对齐
- 先把样本按业务主键对齐,不要只盯
messageId。 - 再看重复的是消息投递、业务动作,还是外部副作用。
- 然后判断第二次处理是在看到旧状态时进入,还是在看到新状态后还继续打了副作用。
- 如果第一次成功、ack 失败、第二次又打了外部调用,优先怀疑幂等边界没有覆盖外部副作用。
- 最后再决定问题更像原子性窗口、可见性窗口、去重键选错,还是 ack / 补偿 / TTL 生命周期问题。
一、先分清消息重复和副作用重复
很多现场一上来就说“重复消费了”,但往下拆,经常其实是两种完全不同的情况。
1. 消息被重复投递了,但副作用没有重复执行
例如:
- MQ 至少一次投递,消息确实又来了
- 消费者也确实再次收到
- 但数据库唯一约束或状态机成功挡住了第二次写入
这类场景里,真正的问题不是业务重复,而是:
- 重复投递为什么增多
- 去重成本为什么开始变高
- 为什么系统已经处在频繁重试或 ack 不稳的状态
2. 消息被重复投递了,副作用也真的重复执行了
例如:
- 同一笔订单被重复扣减
- 同一条库存变更被重复落库
- 同一笔营销发券被重复触发
- 同一条通知被重复发出
这时问题就不只在 MQ,而在于:
- 幂等边界没有覆盖真实副作用
- 或者幂等实现不是原子动作
3. 消息未必重复投递,但业务语义上重复处理了
这类更隐蔽。
例如:
- 两条不同消息代表同一笔业务动作
- 消息 ID 不同,但业务单号相同
- 消费端拿消息 ID 去重,看起来每条消息都“合法”
- 结果同一业务被执行了两次
这时候再强调“幂等校验通过了”其实没意义,因为你校验的是错的对象。
所以排查第一步一定要先回答:
现场真正重复的是消息、业务单元,还是外部副作用?
二、为什么“幂等校验通过”以后,重复消费还是会发生
这类问题最常见的根因,不是完全没做幂等,而是幂等只做到了局部。
1. 先查再执行,不是原子动作
这是最高频的一类。
典型写法通常是:
- 先查幂等表,看这条消息是否处理过
- 如果没有,就继续执行业务
- 业务做完以后,再写幂等状态
这套逻辑单线程看起来很自然,但在并发下有一个非常明显的窗口:
- 消费者 A 查到“未处理”
- 消费者 B 在几乎同一时间也查到“未处理”
- 两边都继续执行
- 最后都认为自己是第一次
也就是说,幂等检查通过,并不代表只有一个线程通过。
如果这一步没有落到:
- 唯一约束
- 原子插入
- compare-and-set 式状态推进
那么“检查通过”本身就是会并发重叠的。
2. 幂等状态落库了,但提交可见性晚于第二次消费
还有一种现场看起来更迷惑:
- 幂等表里最后确实有记录
- 但重复消费还是发生了
这类常见原因是:
- 第一次消费已经开始处理
- 状态写入还在事务里,尚未提交
- 第二次消费在这个提交窗口内进来
- 它看到的还是旧状态,于是继续执行
这类问题特别容易出现在:
- 消费逻辑事务较大
- 幂等状态和业务逻辑放在同一大事务里
- 提交前又夹了下游 RPC、发消息、复杂计算
从结果看,幂等表“最终是对的”;但从时序看,第二个消费者进来的那一刻,系统并没有一个对外可见的已处理状态。
3. 去重键选错了,挡住了消息,没挡住业务
这是另一类很高频、但经常被忽略的根因。
很多系统用的是:
- messageId
- traceId
- 投递流水号
去做幂等。
这只在一个前提下成立:
- 同一业务动作永远只对应一个唯一消息标识
可真实系统里,经常会出现:
- 同一业务补发了一条新消息
- 上游重试后生成了新消息 ID
- 回查、补偿、重投的消息 ID 都不同
- 但它们语义上指向的是同一业务单元
如果这时你还拿消息 ID 做幂等,就会得到一个很糟糕的状态:
- 每条消息都“第一次见”
- 但同一笔业务已经被做了两次
所以真正稳的幂等键,很多时候应该更靠近:
- 业务订单号
- 扣减流水号
- 幂等请求号
- 明确的一次性业务动作标识
而不是单纯消息中间件层的投递 ID。
4. 数据库去重做了,外部副作用没被一起保护
这类问题最容易在“看起来幂等已经生效”的情况下反复出现。
例如:
- 数据库里订单状态更新只做成功一次
- 但外部通知、券发放、积分发放重复执行了
- 或者本地表写成功一次,外部 HTTP 调用却重复打了两次
根因通常是:
- 本地幂等和外部副作用没有放在同一收敛机制里
- 第一次消费在外部调用后、ack 前异常
- 第二次消费再次进入,数据库层面看起来“没问题”,但外部动作还是又打了一次
这说明你的幂等只是保护了本地状态,没有保护整条副作用链。
5. 消费成功了,但 ack 失败或超时,消息又回来了
这类场景非常常见,也最容易和“幂等失效”混淆。
传导链通常是:
- 消费逻辑已经执行成功
- 结果 ack 因为网络抖动、提交失败、消费超时没成功
- MQ 认为这条消息还没确认
- 消息被重新投递
从 MQ 视角看,这是正常的至少一次语义。
从业务视角看,如果没有正确的幂等收敛,就会直接表现成重复消费。
所以排查时一定要把这两件事拆开:
- 是业务执行了两次
- 还是消息正常重投,而业务没有把第二次挡住
6. 幂等记录有 TTL、清理、状态回滚窗口
还有一类问题通常发生在系统跑了一段时间之后。
例如:
- 幂等记录只保留 10 分钟
- 延迟消息、补偿消息 30 分钟后重来
- 旧记录已经被清理
- 新消费再次通过检查
或者:
- 状态从 DONE 被错误回滚到 INIT
- 补偿任务误清理幂等表
- 多地多活、分库分表里不同分区的幂等状态不一致
这类问题不是“并发窗口”导致的,而是幂等生命周期本身没覆盖真实重复窗口。
三、最值钱的判断:重复是在“检查前”,还是“检查后”发生的
以后再碰到这类问题,我更建议先围绕这句话排:
第二次处理,到底是在看到旧状态后开始的,还是在看到新状态后仍然执行了副作用?
这能把问题快速收敛成两大类。
1. 第二次是在看到旧状态后开始的
更像:
- 并发检查窗口
- 提交可见性问题
- 去重状态写得太晚
- 幂等 TTL 或状态生命周期问题
2. 第二次是在看到新状态后仍然执行了副作用
更像:
- 业务副作用和去重状态脱节
- 外部调用没带业务幂等键
- 本地挡住了,外部没挡住
- 代码里存在重复回调、补偿重入或多路执行
这个分叉很关键,因为前者更像状态机和原子性问题,后者更像副作用收敛问题。
四、一条更实用的排查顺序
如果线上已经出现“幂等校验通过了,消息还是重复消费”,我更建议按下面顺序走。
第 1 步:按业务主键把重复样本串起来
不要只搜 messageId。
建议用这些维度对齐:
- 业务单号
- 消费时间点
- 消费实例
- 消费线程
- 消息 ID
- 副作用结果
只有按业务主键串起来,才看得出重复的是消息还是业务动作。
第 2 步:再看两个重复样本之间的时间差
重点看:
- 是几毫秒到几百毫秒内的重叠
- 还是几分钟、几十分钟后的再次进入
这个时间差很能说明问题:
- 很短的重叠,更像并发窗口或提交可见性
- 很长的重入,更像 ack 重投、补偿、TTL 清理或状态回滚
第 3 步:对照幂等状态推进顺序
重点确认:
- 幂等记录是在业务执行前写,还是执行后写
- 是插入唯一键,还是先查再写
- 状态是否有 RECEIVED / PROCESSING / DONE 这类中间态
- 第二次消费进来时,读到的到底是什么状态
第 4 步:把外部副作用单独拆出来看
重点确认:
- 哪些动作是本地库内完成的
- 哪些动作是发 HTTP、RPC、MQ、回调外部系统
- 外部动作有没有自己的业务幂等键
- 第一次和第二次是否打到了同一个外部目标
第 5 步:最后再回到 ack、重试、补偿链路
重点看:
- 消费端 ack 是否稳定
- 是否存在消费超时后重投
- 是否有死信重放、补偿任务、回查任务
- 是否有多个消费组或多路订阅在处理同一语义事件
五、一个典型案例:为什么幂等表里明明有记录,券还是发了两次
假设某个营销消费逻辑是这样的:
- 先查幂等表,没有记录就继续
- 写本地发券流水
- 调营销中心发券
- 成功后更新幂等表状态
- 最后 ack
表面上看,逻辑已经做了幂等。
但某次线上问题里,用户还是拿到了两张券。
现场现象
- 幂等表里最终只有一条 DONE 记录
- MQ 里同一业务语义的消息出现了两次消费日志
- 两次消费时间差大约 1.2 秒
继续看时序
发现第一次消费里:
- 发券 RPC 已经成功
- 但在更新幂等表和 ack 之前,实例发生超时回收
第二次消费重新进来时:
- 幂等表还没有最终 DONE
- 于是又继续执行了一次发券 RPC
结论
这次问题不是“没做幂等”,而是:
本地幂等状态和外部副作用没有在同一收敛边界内闭合。
结果就是:
- 幂等表最后看起来没问题
- 但真正对用户可见的副作用已经执行了两次
这类案例非常典型,也最能说明一句话:
幂等不是校验有没有通过,而是要看整条副作用链有没有被原子地收住。
六、关键误判
误判 1:幂等表里最终有记录,就说明重复一定不是幂等问题
错。
最终有记录,只能说明最后某个状态落下来了,不能说明重复进入时看到的也是这个状态。
误判 2:消息 ID 去重就等于业务幂等
错。
如果同一业务可能被不同消息重复表达,消息级去重根本不够。
误判 3:只要数据库唯一约束挡住了,本次重复消费就没风险
也不一定。
本地写库挡住,不代表外部 RPC、券发放、通知发送也一起挡住了。
误判 4:重复消费一定是 MQ 有问题
很多时候 MQ 只是正常重投,真正的问题是消费端没有把至少一次语义收敛成业务幂等。
误判 5:只看“有没有做幂等”,不看“幂等覆盖了哪一层”
这几乎是最常见的根因。
七、FAQ:这类问题里最常被问到的几个问题
1. 幂等最该按消息 ID 还是业务 ID 做?
如果你的目标是防业务副作用重复,通常更该围绕业务唯一动作标识设计,而不是只按消息 ID 做。
2. 消费成功但 ack 失败,算幂等问题还是 MQ 问题?
两边都有关系。
MQ 侧这是正常的至少一次重投语义;业务侧如果因此出现重复副作用,那就是幂等边界没有收住。
3. 先查再写为什么不稳?
因为在并发下,多个消费者可能同时看到“未处理”,这不是原子动作。
4. 外部调用怎么做幂等才更稳?
至少要让外部调用也能识别同一业务动作,例如:
- 明确的业务幂等键
- 可重放但只生效一次的请求号
- 结果可查询、可确认的收敛机制
如果你还要顺着这个重复现场继续追
如果你已经确认问题在幂等边界,但还要继续往下钻,建议按当前最强症状分支:
- 你真正还没决定这类场景该不该上分布式锁:先回《分布式锁什么时候该用,什么时候不该用》
- 你已经上了锁,但吞吐和持锁时间在恶化:转到《分布式锁没报错但吞吐变差,应该先查续约还是锁粒度?》
- 重复已经把异步队列、补偿任务、死信回流一起放大:去《消息越堆越多,为什么常常不是消费者数量不够,而是下游变慢了?》
- 你看到的是线程、连接、下游一起变慢,重复只是连锁反应的一段:去《锁竞争不高却吞吐掉得厉害,先查线程切换还是下游等待?》
八、如果你还没把范围彻底切干净
先留在并发和幂等这条线上
如果重复已经牵出别的故障线
- 上游重试把慢接口放大的典型链路怎么识别?
- 异步任务越堆越多,问题常常不在异步本身
- Spring Boot 定时任务为什么会重复执行、堆积,甚至把数据库拖慢?
- 一个接口的超时、重试、熔断参数,应该怎样整体设计?
如果你想顺着时序继续查
- 先把“消息重复投递”和“业务副作用重复执行”这两个层次分开。
- 如果你还在判断这类问题该不该上锁,继续看 分布式锁什么时候该用,什么时候不该用
- 如果重复消费已经伴随重试、超时和放大,再继续看 上游重试把慢接口放大的典型链路怎么识别?
- 如果重复已经演变成异步 backlog 或定时补偿堆积,再把 异步任务越堆越多,问题常常不在异步本身 和 Spring Boot 定时任务为什么会重复执行、堆积,甚至把数据库拖慢? 串起来看
九、最后总结:幂等校验通过,不代表整条消费链真的只会执行一次
这类问题最容易把团队带偏的地方就在于:
- 大家已经做了幂等检查
- 所以本能地认为重复一定不在消费链本身
但真实线上里,更有用的主线是:
把重复的是消息、业务动作还是外部副作用区分开,再回到第二次处理落在哪个时序窗口里,最后用去重键、状态推进、提交可见性和 ack 时序把证据串起来。
只要这条主线立住,很多原本会被一句“幂等明明做了”卡住的现场,最后都会收敛成一个更具体、更可修复的并发与状态收敛问题。