消息队列如何保证消息不被重复消费?
简化版
消息重复几乎不可避免(生产重试、消费 ACK 丢失都会导致重投),所以业界不追求「绝对不重复投递」,而是保证消费逻辑幂等——即同一条消息处理一次和处理多次,结果一样。实现幂等的核心手段:给每条消息一个全局唯一 ID,消费前先检查这个 ID 是否已处理过(查数据库/Redis 去重表),处理过就跳过;或者利用数据库唯一约束、乐观锁版本号、状态机等业务层机制,天然抵抗重复。
详细版
为什么消息会重复(无法根除):
- 生产者发消息后没收到 ACK(其实已到 Broker),重发一次 → 重复。
- 消费者处理完消息、还没提交 ACK 就宕机,重启后消息被重投 → 重复。
- 网络抖动、Rebalance 等也会导致重投。
幂等的主流实现:
// 方案一:唯一 ID + 去重表(Redis 或 DB)
void consume(Message msg) {
String msgId = msg.getId();
// 用 Redis setnx 原子判断是否已处理
if (!redis.setnx("consumed:" + msgId, 1, 24*3600)) {
return; // 已处理过,直接跳过
}
doBusiness(msg); // 执行业务
}
- 唯一 ID + 去重表:每条消息带唯一 ID,处理前查/占位,重复的跳过。
- 数据库唯一约束:如订单号建唯一索引,重复插入直接失败,天然去重。
- 乐观锁 / 版本号:
update ... where version=?,重复更新因版本不匹配失败。 - 状态机:业务状态只能单向流转(如「待支付→已支付」),重复的「支付」在已支付状态下不生效。
完整版教学
一、先接受现实:重复投递无法根除
很多人第一反应是「怎么让 MQ 不重复投递消息」——但这几乎做不到(或代价极高)。原因:
- 生产端重复:生产者发消息到 Broker,Broker 其实收到了,但返回 ACK 时网络断了。生产者以为失败,重发一次——消息重复。
- 消费端重复:消费者处理完消息,正要提交 ACK/offset 时宕机。重启后,Broker 认为这条没被确认,重新投递——消息重复。
- 还有 Consumer Group Rebalance、网络抖动等都会造成重投。
MQ 为了「不丢消息」,采用的是 at-least-once(至少一次) 投递语义——宁可重复投,也不能漏投。所以重复是「不丢」的必然副产物。既然重复无法根除,换个思路:让消费逻辑无论执行几次,结果都一样——这就是幂等。
二、什么是幂等:执行多次 == 执行一次
幂等(Idempotent) 指一个操作执行一次和执行多次的效果相同。数学上如 f(f(x)) = f(x)。
- 天然幂等的操作:
set status = 'paid'(设置成固定值,做几次都一样)、delete where id=1(删了就没了,再删无影响)。 - 非幂等的操作:
count = count + 1(每执行一次就加一次,多次执行结果不同)、insert(多次插入产生多条重复数据)。
保证幂等,就是要让「本来非幂等」的业务操作,变得「重复执行也安全」。
三、方案一:唯一 ID + 去重表(最通用)
给每条消息分配一个全局唯一 ID(业务 ID 如订单号,或 MQ 提供的消息 ID)。消费时:
- 先拿这个 ID 去去重表/去重缓存里查/占位;
- 如果已存在(说明处理过了)→ 直接跳过,不重复执行业务;
- 如果不存在→ 记录这个 ID,然后执行业务。
关键是**「查重 + 占位」要原子**,否则并发下两条重复消息可能都查到「不存在」然后都执行。常用:
- Redis
SETNX:setnx("consumed:msgId", 1)原子地「不存在才设置」,返回成功才执行业务。快,但要处理「业务执行失败但占位已设」的回滚。 - 数据库去重表:把 msgId 作为唯一键插入一张去重表,插入成功才执行业务(利用唯一约束,见方案二)。更可靠,可和业务操作放同一事务。
四、方案二:数据库唯一约束(天然去重)
如果业务本身有唯一标识,可以直接利用数据库唯一索引去重。例如「创建订单」消息,订单号 order_no 建唯一索引:
- 消费时
insert订单,若这条消息重复,第二次 insert 会因唯一键冲突失败,捕获异常、忽略即可。 - 数据库保证了「同一个订单号只能插入一次」,天然幂等,不用额外去重表。
这是最简洁可靠的方案(把幂等下沉到数据库约束),适合「插入类」操作。
五、方案三:乐观锁 / 状态机(适合更新类)
对「更新类」操作,用乐观锁或状态机:
- 乐观锁(版本号):
update account set balance=?, version=version+1 where id=? and version=?。重复消息带的是旧版本号,第二次更新因version不匹配、影响行数为 0 而失败,天然幂等。 - 状态机:业务状态单向流转,且更新时校验前置状态。如订单「待支付 → 已支付」,处理支付时
update ... where status='待支付'。重复的支付消息到来时,订单已是「已支付」,条件不满足、更新 0 行,不会重复扣款。
六、常见误区与追问
| 考点 | 正确口径 |
|---|---|
| 重复原因 | 生产重试、Broker 重投、消费者处理成功但 ack 失败 |
| 幂等目标 | 同一消息处理多次,业务结果仍等同一次 |
| 常见手段 | 唯一业务键、去重表、状态机、乐观锁 |
messageId/orderId = unique key
begin
insert into consume_log(key) values(?)
if duplicate -> return success
update order status with state check
commit
MQ 通常更容易保证至少一次投递,最终不重复生效要靠消费者幂等。
- 误区:MQ 开启可靠投递后就不会重复。 可靠投递常意味着至少一次,网络抖动和 ack 丢失仍会导致重投。
- 误区:用 messageId 去重一定可靠。 如果业务重试生成新 messageId,仍要用订单号等业务唯一键兜底。
- 误区:幂等就是简单查一下是否处理过。 查和写必须原子化,否则并发重复消息会同时通过检查。
- 追问:去重表怎么设计? 用业务唯一键建唯一索引,插入成功才执行业务,重复键直接返回成功。
- 追问:状态机如何防重复? 只允许从未处理到已处理等合法状态流转,重复更新影响行数为 0。
- 追问:消费者失败后应该 ack 吗? 可重试错误不 ack 或抛异常让 MQ 重投;不可恢复错误要进死信并告警。
七、加强记忆
消息重复无法根除(生产重试、消费 ACK 丢失导致 at-least-once 投递),所以不追求「不重复投递」,而是保证消费逻辑幂等——处理一次和多次结果相同。主流实现:① 唯一 ID + 去重表(消息带全局唯一 ID,消费前用 Redis SETNX 或 DB 去重表原子查重占位,重复的跳过——最通用);② 数据库唯一约束(如订单号唯一索引,重复插入冲突失败——最简洁);③ 乐观锁版本号 / 状态机(where version=? 或校验前置状态,适合更新类)。核心思路:MQ 保证「至少一次不丢」,业务保证「幂等不重」,二者配合。口诀:重复躲不掉,就让重复执行也无害。