如何保证消息不被重复消费?
简化版
消息重复消费几乎无法从根本避免——因为 MQ 为了保证「不丢消息」采用「至少投递一次(At Least Once)」,加上网络抖动、消费者重启、ACK 丢失等,同一条消息会被投递多次。所以正确思路不是「杜绝重复投递」,而是让消费端幂等——同一条消息处理一次和处理多次结果相同。做法:给每条消息一个唯一 ID,消费前先查这个 ID 处理过没(用去重表/Redis),处理过就跳过;或依赖数据库唯一索引、状态机、乐观锁等把重复挡在数据层。
详细版
为什么会重复消费:
- MQ 的投递语义通常是 At Least Once(至少一次):为了不丢消息,宁可重复投递也不漏投。
- 消费者处理完消息,在发送 ACK 时网络抖动/消费者重启,Broker 没收到 ACK,就认为消费失败,重新投递——于是同一条消息被处理两次。
- 生产端重试也会导致同一条消息发送多次。
核心思路:消费端幂等(而非追求「精确一次投递」)。做法:
- 唯一 ID + 去重表/Redis:每条消息带全局唯一 ID(业务 ID 或 MQ 的 msgId)。消费前查「这个 ID 处理过没」——处理过直接跳过,没处理过才执行并记录 ID。
- 数据库唯一索引:如果消费是「插入」类操作,用业务唯一键建唯一索引,重复插入自动失败。
- 状态机 / 乐观锁:更新类操作用「带条件的 update」(
where status=...或where version=...),重复消费更新 0 行,天然幂等。
完整版教学
一、为什么不追求「精确一次投递」
理想情况是「Exactly Once(精确一次)」——每条消息不多不少正好被消费一次。但在分布式环境下,精确一次投递极难实现(要 MQ、网络、消费者端到端配合,代价极高,且很多 MQ 不保证)。原因还是那个分布式老问题——网络的不确定性:消费者处理完消息发 ACK,如果 ACK 在网络中丢了,Broker 无法区分「消费者没处理」和「处理了但 ACK 丢了」,为了不丢消息只能重投。所以主流 MQ 提供的是 At Least Once(至少一次)——保证不丢,但可能重复。
既然重复无法从投递层根除,正确的工程思路是「拥抱重复,靠消费端幂等消化它」:不管一条消息被投递几次,只要消费端保证「处理一次和处理多次效果相同」,重复就不会造成业务问题。这和「接口幂等」是同一套思想。
记忆点:MQ 是「至少一次」投递,重复不可避免。别妄图杜绝重复投递,要让消费端幂等——用唯一 ID 去重,或靠数据库唯一索引/状态机把重复挡在数据层。
二、唯一 ID + 去重表(最通用)
最通用的幂等方案:给每条消息一个全局唯一标识(优先用业务唯一 ID 如订单号,其次用 MQ 生成的 msgId),消费时:
- 先查「去重表 / Redis」里这个 ID 有没有记录。
- 有 → 说明处理过了,直接跳过(返回成功、ACK 掉)。
- 没有 → 执行业务逻辑,并把这个 ID 记入去重表。
关键点:「记录 ID」和「业务操作」要在同一个数据库事务里(用去重表方案时),否则可能「业务成功但 ID 没记下」,下次重复执行。用 Redis 去重(SET id NX)性能更好,但要考虑 Redis 与业务库的一致性(Redis 挂了/过期怎么办),关键业务建议用数据库去重表更稳妥。
三、依赖数据库天然幂等
很多时候不用单独的去重表,直接利用数据库特性更简单:
- 插入类:用业务唯一键建唯一索引。重复消费导致的重复插入会触发唯一约束冲突,捕获后忽略即可。
- 更新类:用状态机(
update order set status='paid' where order_no=? and status='unpaid')或乐观锁(where version=?)。重复消费时,因为状态已变或版本已变,update 影响 0 行,天然不会重复生效。 - 天然幂等的操作:如「把某字段设为固定值」(
set status=1)本身重复执行也无副作用,不用额外处理。
优先用这些「数据层天然幂等」的方式,比额外维护去重表更简洁可靠。
四、一个常见坑:先查后处理
和接口幂等一样,消费端做幂等时别用「先查有没有处理过,没有再处理」这种非原子的组合——并发下两条重复消息可能同时查到「没处理过」,然后都执行。要把去重落到数据库唯一约束或带条件的原子更新上,或者对同一业务 ID 加锁串行处理。check-then-act 在并发下不可靠。
五、消费重试与死信
消费失败会重试(重新投递),这也是重复的来源之一。要配套设计:
- 限制重试次数:一直失败不能无限重投,超过次数(如 16 次)进死信队列,人工介入排查。
- 重试要幂等:每次重试都可能是「其实上次处理了一部分」,所以业务处理本身要幂等,避免重试造成数据错乱。
- 区分可重试和不可重试错误:业务逻辑错误(如参数非法)重试也没用,应直接进死信;网络/临时故障才值得重试。
六、常见误区与追问
这道题不能只背概念,要把「消息幂等消费」放回真实分布式系统里解释:参与方是谁、状态怎么流转、失败后怎么恢复,以及它在一致性、性能、可用性之间做了什么取舍。
| 回答层次 | 要讲清的内容 | 容易漏掉的边界 |
|---|---|---|
| 核心结论 | 幂等消费保证同一消息重复投递时业务只生效一次,常用消息 ID、业务唯一键和状态机 | 不要停在名词解释 |
| 流程机制 | 消费者收到消息 -> 提取 messageId 或业务 key -> 查询去重表或唯一索引 -> 未处理则执行业务 -> 记录处理结果 -> 重复消息直接返回成功 | 说明触发方、存储方、确认点和兜底 |
| 工程取舍 | 同一 paymentEventId 重投 3 次,只能给订单加一次支付成功状态 | MQ 解耦削峰但带来最终一致、重复消费和可观测性要求 |
消息幂等消费 面试拆解:
1. 消费者收到消息
2. 提取 messageId 或业务 key
3. 查询去重表或唯一索引
4. 未处理则执行业务
5. 记录处理结果
6. 重复消息直接返回成功
记忆钩子:先拆生产者、Broker、消费者、offset、重试和幂等,再说明丢失、重复、顺序和积压边界;回答时要紧扣「消息幂等消费」这道题,不要把相邻概念混成一段泛泛的分布式套话。
- 误区:MQ 能保证绝不重复。 多数 MQ 保证至少一次投递,重复是正常情况。
- 误区:消费代码不报错就幂等。 重复扣款、重复发券即使不报错也是业务错误。
- 误区:只用 Redis 去重永远可靠。 Redis key 过期或丢失后可能重复,关键业务要用数据库唯一约束。
- 追问:幂等 key 怎么选? 优先业务唯一号,如订单号、支付流水、消息全局 ID。
- 追问:去重记录保存多久? 至少覆盖消息最大重试和补偿窗口。
- 追问:并发重复消息怎么办? 唯一索引或事务状态机保证只有一个成功。
七、加强记忆
消息重复消费无法从投递层根除——MQ 是「至少一次(At Least Once)」投递,网络抖动/ACK 丢失/消费者重启/生产重试都会导致重复。正确思路是消费端幂等(拥抱重复而非杜绝):给消息唯一 ID,消费前查去重表/Redis,处理过就跳过(记录 ID 与业务操作须同事务);或依赖数据库唯一索引(插入类)、状态机/乐观锁(更新类)把重复挡在数据层,优先用这类天然幂等方式。避免「先查后处理」的非原子组合。配套限制重试次数 + 死信队列 + 处理本身幂等。