消息重复消费怎么解决?
更新时间:2026-09-18。问题驱动短答页。重复是常态,不是异常:Kafka/RabbitMQ 在可靠性模式下普遍是「至少一次(at-least-once)」投递。
一句话回答
重复无法在消息层完全杜绝(生产者重试、消费者处理成功但提交 offset/ack 前崩溃、rebalance 重放都会重发),正确做法是让消费逻辑幂等——同一条消息处理多次和处理一次结果相同。常用唯一约束去重表、Redis SETNX、业务状态机三种手段。
重复从哪来
| 环节 | 触发 |
|---|---|
| 生产端 | 发送超时但实际已成功,重试导致重复入队 |
| 消费端 | 业务处理完、还没提交 offset/ack 进程就挂了,重启后又消费一遍 |
| Kafka | rebalance 后分区位移回放 |
| RabbitMQ | 手动 ack 模式下消费后未及时 ack,消息重新投递 |
因为「处理业务」和「提交位移」不是原子操作,中间任意一步崩溃都会造成重复——这是 at-least-once 的固有代价。
三种幂等手段
1. 唯一键去重表(最可靠)
给每条消息一个全局唯一 biz_id(或用「订单号+事件类型」拼),消费时先在去重表插入:
-- 与业务更新放在同一个本地事务里
INSERT INTO msg_dedup(msg_id) VALUES (?); -- 唯一索引,重复则报冲突
UPDATE account SET amount = amount - 10 WHERE id = ?;
-- 同一事务提交:要么都成功,要么都回滚重复消息插入去重表时撞唯一键,整个事务回滚,业务不重复执行。
2. Redis SETNX 去重(高频、轻量)
SET msg:dedup:{msgId} 1 NX EX 86400
→ 返回 OK:第一次,继续处理
→ 返回 nil:重复,直接丢弃适合吞吐高的场景;但 Redis 去重与业务不是同一事务,严格场景仍要落库唯一约束兜底。
3. 业务状态机(天然幂等)
让更新只在特定状态下生效,重复执行不改变结果:
UPDATE `order` SET status='PAID'
WHERE order_id=? AND status='UNPAID'; -- 已是 PAID 时影响行数为 0支付、订单类用「条件更新 + 看影响行数」即可幂等,不必额外去重表。
设计要点
- 先处理、后提交:先执行业务并落库去重标记,成功后再提交 offset/ack(at-least-once)。若先提交后处理,崩溃就会丢消息。
- 去重标记与业务同事务:保证「标记已处理」和「业务变更」原子,否则标记成功业务失败会导致漏处理。
- 幂等 key 的粒度要对:同一业务动作重试要拿到相同 id(生产者重试时复用同一 id),否则去重表认不出来。
- 保留期匹配:去重记录的 TTL/保留时间要覆盖消息可能重投的最长窗口。
坑
- 试图靠 MQ 的「恰好一次」解决:exactly-once 限制多、跨业务库时仍需端到端幂等,工程上默认按 at-least-once + 幂等设计。
- Redis 去重成功但业务库写失败:标记在、业务没做,会漏处理,需要本地事务或最终一致补偿。
- 用消息的 offset 当幂等键:分区扩容/重放后 offset 会变,应该用业务 ID。
- 消费端异步多线程后提前提交:处理未完成就提交,既可能重复也可能丢,提交点要对齐处理完成。
接口侧的幂等(token/唯一键/状态机)见 接口幂等设计 与 接口幂等怎么保证;Kafka 积压见 Kafka 消费积压怎么处理;分布式下最终一致见 分布式事务方案怎么选。
总结
消息重复消费源于生产者重试和「处理成功但提交 offset/ack 前崩溃」及 rebalance,at-least-once 投递下无法在中间件层根除,正解是消费端幂等:用业务唯一 ID 的去重表并与业务更新放同一事务(最可靠)、高频场景用 Redis SETNX 去重但需落库唯一约束兜底、支付订单类用带状态条件的 UPDATE 靠影响行数天然幂等;原则是先处理后提交、去重标记与业务同事务、用稳定业务 ID 而非 offset 当幂等键,并让去重记录保留期覆盖消息重投窗口。