Java

幂等校验明明通过了,为什么消息还是会重复消费?

幂等校验通过,不等于业务副作用只会执行一次。把去重键、状态落库、提交可见性、ack 时序和外部副作用摆在同一个时序里,才能解释为什么“检查没问题”,重复消费却还是不断发生。

  • 幂等
  • 消息消费
  • 重复消费
  • 并发
  • 稳定性治理
18 分钟阅读

重复消费最让人困惑的地方,不是系统完全没做幂等,而是明明做了检查,事故还是落到了业务结果上。团队翻幂等表时经常能看到记录、状态也像对的,可订单还是扣重了、券还是发重了、回调还是打了两次。

问题通常不在“有没有检查”这件事本身,而在于检查通过的那个瞬间,并没有覆盖真正的副作用闭环。幂等校验往往只守住了消费前的一道门:

  • 消费前查一次幂等表
  • 用消息 ID 判断这条消息是否处理过
  • 看某个业务状态是不是已经是 DONE

可重复消费真正发生的位置,往往在这几个瞬间之外:

  • 两个消费者几乎同时通过检查
  • 幂等状态还没提交可见,另一条消费已经进来了
  • 数据库里的去重做了,外部副作用却没和它绑在一起
  • 业务逻辑执行成功了,但 ack 失败或超时,消息又被重新投递
  • 去重键本身选错了,挡住了“同一条消息”,却没挡住“同一笔业务”

现场更该问的是:

这次重复,重复的是消息投递、业务动作,还是外部副作用?幂等到底收住了哪一层,又漏掉了哪一层?

重复到底落在哪一层

你现在看到的现象更适合先看哪条线为什么
幂等校验明明做了,但重复消费、重复扣减、重复发券还是发生这篇正好处理这个场景重点是把幂等检查和副作用真正收住之间的缺口找出来
你还在判断这个场景到底该不该上分布式锁先看《分布式锁什么时候该用,什么时候不该用先分清互斥和幂等,不要混用
你已经用了锁,现在是吞吐变差、热点 key 集中、持锁时间变长先看《分布式锁没报错但吞吐变差,应该先查续约还是锁粒度?这时先去锁链路里找慢点更合适
你看到的是 MQ backlog 一直涨,团队想直接扩消费者先看《消息越堆越多,为什么常常不是消费者数量不够,而是下游变慢了?这时要先把消费吞吐和下游等待分开
锁竞争并不高,但线程池、连接池、下游等待一起把吞吐拖差先看《锁竞争不高却吞吐掉得厉害,先查线程切换还是下游等待?更像协作放大链,不是幂等边界本身

这篇主要在收窄幂等边界

如果你当前最困惑的是:明明已经做了幂等校验,为什么重复扣减、重复发券、重复回调还是发生,这篇文章就只讨论这个缺口。

这里不讨论“这个场景要不要上分布式锁”,也不把 backlog、线程池和等待链这些吞吐问题一起揉进来,只把边界画清楚:

幂等校验通过,只说明某个检查动作发生过;它并不自动等于整条消费链上的业务副作用已经被真正收住。

把这条边界立住,后面才不会把消息重复、业务重复和外部副作用重复混成一个词。

把重复样本按业务动作对齐

  1. 先把样本按业务主键对齐,不要只盯 messageId
  2. 再看重复的是消息投递、业务动作,还是外部副作用。
  3. 然后判断第二次处理是在看到旧状态时进入,还是在看到新状态后还继续打了副作用。
  4. 如果第一次成功、ack 失败、第二次又打了外部调用,优先怀疑幂等边界没有覆盖外部副作用。
  5. 最后再决定问题更像原子性窗口、可见性窗口、去重键选错,还是 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 是否稳定
  • 是否存在消费超时后重投
  • 是否有死信重放、补偿任务、回查任务
  • 是否有多个消费组或多路订阅在处理同一语义事件

五、一个典型案例:为什么幂等表里明明有记录,券还是发了两次

假设某个营销消费逻辑是这样的:

  1. 先查幂等表,没有记录就继续
  2. 写本地发券流水
  3. 调营销中心发券
  4. 成功后更新幂等表状态
  5. 最后 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. 外部调用怎么做幂等才更稳?

至少要让外部调用也能识别同一业务动作,例如:

  • 明确的业务幂等键
  • 可重放但只生效一次的请求号
  • 结果可查询、可确认的收敛机制

如果你还要顺着这个重复现场继续追

如果你已经确认问题在幂等边界,但还要继续往下钻,建议按当前最强症状分支:

八、如果你还没把范围彻底切干净

先留在并发和幂等这条线上

如果重复已经牵出别的故障线

如果你想顺着时序继续查

  1. 先把“消息重复投递”和“业务副作用重复执行”这两个层次分开。
  2. 如果你还在判断这类问题该不该上锁,继续看 分布式锁什么时候该用,什么时候不该用
  3. 如果重复消费已经伴随重试、超时和放大,再继续看 上游重试把慢接口放大的典型链路怎么识别?
  4. 如果重复已经演变成异步 backlog 或定时补偿堆积,再把 异步任务越堆越多,问题常常不在异步本身Spring Boot 定时任务为什么会重复执行、堆积,甚至把数据库拖慢? 串起来看

九、最后总结:幂等校验通过,不代表整条消费链真的只会执行一次

这类问题最容易把团队带偏的地方就在于:

  • 大家已经做了幂等检查
  • 所以本能地认为重复一定不在消费链本身

但真实线上里,更有用的主线是:

把重复的是消息、业务动作还是外部副作用区分开,再回到第二次处理落在哪个时序窗口里,最后用去重键、状态推进、提交可见性和 ack 时序把证据串起来。

只要这条主线立住,很多原本会被一句“幂等明明做了”卡住的现场,最后都会收敛成一个更具体、更可修复的并发与状态收敛问题。