RabbitMQ 如何防止重复消费?
结论:RabbitMQ 本身不提供去重能力,它的投递语义是至少一次(At-Least-Once)。所以"防止重复消费"实际是两件事:尽量少产生重复(ack 时机、消费超时、重投策略),以及在消费端做幂等(唯一键、状态机、去重表)。前者降低概率,后者兜底正确性。
一、重复是怎么产生的
┌──────────── ① ack 丢失 ────────────┐
Consumer ────┤ 处理完成,ack 在网络上丢了 │──> Broker 认为未确认,重投
├──────────── ② 处理超时 ────────────┤
│ 处理时间 > consumer_timeout(30min) │──> 连接被关闭,消息 requeue
├──────────── ③ 主动 nack/requeue ────┤
│ 业务异常,basicNack(requeue=true) │──> 同一条消息再次投递
└──────────── ④ 节点故障 ────────────┘
未确认消息在副本切换后重新投递
注意第 ④ 条:仲裁队列切换 Leader 时,未 ack 的消息会被重新投递给新消费者,这是"至少一次"语义的必然结果,不是 Bug。
二、先减少重复来源
| 来源 | 缓解手段 | 注意 |
|---|---|---|
| ack 丢失 | 无法彻底避免,靠幂等兜底 | 不要用 autoAck |
| 处理超时 | 提高 consumer_timeout 或缩小单条处理粒度 | 超时配置过大,故障恢复会变慢 |
| 无限重投 | 限制重试次数,超限进死信队列 | 否则同一条消息会打爆消费端 |
| 预取过多 | basicQos(prefetch) 控制未确认条数 | prefetch 太大,故障时重投量也大 |
| 多消费者竞争 | 同一队列多消费者是正常设计 | 重复与并发无关,只与 ack 有关 |
三、消费端幂等的四种做法
| 方案 | 实现 | 适用 |
|---|---|---|
| 唯一键插入 | 业务表建唯一索引,重复插入报错即忽略 | 创建类操作(下单、建账) |
| 去重表 | msg_id 唯一索引,处理前先插入 | 通用,需清理历史数据 |
| 状态机校验 | 只允许 待支付 → 已支付,重复消息因状态不符被拒 | 状态流转类业务 |
| 分布式锁 / SETNX | Redis SET key NX EX 抢占处理权 | 短时高频重复,注意锁超时 |
四、代码:去重表 + 手动 ack
channel.basicQos(20); // 未确认上限,控制重投规模
channel.basicConsume("order.pay.q", false, (tag, msg) -> {
String msgId = msg.getProperties().getMessageId(); // 生产端必须设置
try {
// 唯一索引冲突 = 已处理过,直接 ack
if (!dedupMapper.tryInsert(msgId)) {
channel.basicAck(tag, false);
return;
}
orderService.handle(new String(msg.getBody(), UTF_8));
channel.basicAck(tag, false);
} catch (BusinessException e) {
// 业务不可恢复:进死信,不要 requeue 造成死循环
channel.basicNack(tag, false, false);
} catch (Exception e) {
// 临时故障:重投,靠幂等保证不重复生效
channel.basicNack(tag, false, true);
}
});
tryInsert 必须与业务处理在同一个数据库事务里提交,否则会出现"去重记录写了、业务没执行"的假成功。
五、幂等键怎么选
| 候选键 | 唯一性 | 说明 |
|---|---|---|
| 业务流水号 | 强 | 首选,天然唯一且可追溯,跨系统对账也能用 |
| 消息 messageId | 中 | 依赖生产端自己设置,重投时不变,但跨生产者不保证唯一 |
| 消费位点 | 弱 | 重平衡或重置位点后失效,只适合做辅助判断 |
| Redis SETNX 键 | 弱 | 必须设过期时间,否则键无限膨胀 |
选键的原则是"定位一次业务意图",而不是"定位一次投递"。同一次投递重投多次,业务意图只应生效一次。
六、常见追问
- 用 messageId 去重可靠吗? 前提是生产端自己设置
messageId且全局唯一(比如业务流水号)。Broker 自动生成的 ID 在重投时不变,但跨生产者无法保证唯一,业务键更稳妥。 - deliveryTag 能当去重键吗? 不能。它是信道内的投递序号,每次重投都会变,重启后从头编号。
- 为什么不能用 autoAck 来"避免重复"? autoAck 在消息发出后立即确认,消费端崩溃时消息已经丢了,用丢消息换不重复,方向就错了。
- 重复消费一定有害吗? 取决于业务。查询类、覆盖写类天然幂等;扣款、发券、发短信必须显式去重,最好在数据库层用唯一约束兜底,而不是只靠应用判断。
小结
- RabbitMQ 的语义是至少一次,重复是协议行为而非异常。
- 减少重复靠 ack 时机、prefetch、重试上限;消除影响靠幂等。
- 幂等的第一道防线是数据库唯一约束,应用层判断只能作为优化。
- 临时故障重投、永久故障进死信,是消费端必须区分的两条路径。