怎么保证消息一定能发送到 RabbitMQ?
结论:没有任何配置能"保证一定送到",能做的是让失败可被发现、可被补偿:发布确认(Publisher Confirm)证明消息到了 Broker,
mandatory+ return 证明路由到了队列,本地消息表(Outbox)记录发送状态,定时任务扫描未确认记录重发,最终对账兜底。四者构成闭环,缺一环就有静默丢失。
一、发送失败的四类情况
| 失败类型 | 现象 | 检测手段 |
|---|---|---|
| 连不上 Broker | IOException / ShutdownSignalException | 捕获异常 + 本地表落库,稍后重发 |
| 交换机不存在 | 信道异常关闭(404) | 启动时校验拓扑,禁止运行时变更交换机名 |
| 路由不到队列 | 消息被静默丢弃 | mandatory=true + basic.return,或备用交换机 |
| 确认超时/丢失 | 消息实际到了但没收到 confirm | 定时扫描"发送中"状态重发,靠消费端幂等兜底 |
第四类最容易被忽略:confirm 丢失并不等于消息丢失,盲目重发会产生重复,所以重发必须配合幂等,而不是"重发就一定安全"。
二、发送可靠性闭环
① 业务事务 ② 发送 ③ confirm
┌──────────┐ ┌────────────┐ ┌──────────────┐
│ 订单表 │──>│ 本地消息表 │──>│ Broker │
│ +消息表 │ │ status=待发 │ │ exchange/queue│
└──────────┘ └─────┬──────┘ └──────┬───────┘
│ │ ack
│ ④ 更新 status=已发
│<─────────────────┘
⑤ 定时任务扫描 status=待发 且超过 N 秒
→ 重发(指数退避,最多 5 次)→ 仍失败则告警人工介入
顺序很重要:先把消息写入本地表,再发 MQ。如果先发 MQ 再写表,进程崩溃就会出现"消息发了但业务回滚"的脏数据。
三、代码实现
@Transactional
public void createOrder(Order order) {
orderMapper.insert(order);
outboxMapper.insert(Outbox.pending(order.getId(), toJson(order))); // 同库同事务
}
// 发送线程:只发 status=待发 的记录
@Scheduled(fixedDelay = 5000)
public void flushOutbox() {
for (Outbox o : outboxMapper.listPending(100)) {
try {
CorrelationData cd = new CorrelationData(o.getId());
rabbitTemplate.convertAndSend("order.ex", "order.created",
o.getPayload(), m -> {
m.getMessageProperties().setDeliveryMode(PERSISTENT);
m.getMessageProperties().setMessageId(o.getId());
return m;
}, cd);
// confirm 回调里再更新状态,这里只推进重试次数
outboxMapper.markSent(o.getId());
} catch (Exception e) {
outboxMapper.incrRetry(o.getId(), e.getMessage());
}
}
}
// Spring AMQP:confirm 与 return 回调
rabbitTemplate.setMandatory(true);
rabbitTemplate.setConfirmCallback((cd, ack, cause) -> {
if (!ack) { outboxMapper.markFailed(cd.getId(), cause); }
});
rabbitTemplate.setReturnsCallback(returned ->
log.error("routing failed: {} {}", returned.getRoutingKey(),
returned.getReplyText()));
四、和"备用交换机"配合
mandatory 只把路由失败的消息退回给生产者,如果生产者没处理 return,消息还是丢了。更稳的做法是声明备用交换机(Alternate Exchange),把路由不到的消息转存到一个专门队列,再定期扫描处理:
Map<String, Object> args = Map.of("alternate-exchange", "order.unrouted.ex");
channel.exchangeDeclare("order.ex", BuiltinExchangeType.TOPIC, true, false, args);
channel.exchangeDeclare("order.unrouted.ex", BuiltinExchangeType.FANOUT, true);
channel.queueDeclare("order.unrouted.q", true, false, false, null);
channel.queueBind("order.unrouted.q", "order.unrouted.ex", "");
五、常见追问
- confirm 模式下重发会不会产生重复消息? 会。确认丢失时消息可能已经到了,重发就会出现两条,必须用业务唯一键做消费端幂等。
- 为什么不用事务保证发送? 事务只覆盖"发布到 Broker",路由失败照样丢,且吞吐低一两个数量级;confirm + 本地表才是工程解。
- 本地消息表和业务表必须同库吗? 必须同库,才能用本地事务保证"业务成功则消息记录一定存在";跨库就只能引入分布式事务,得不偿失。
- 发送失败的记录一直重试会不会打爆 Broker? 要设最大重试次数与退避间隔,超过阈值转人工,并监控"待发"记录的增长曲线。
小结
- "一定送到"是伪命题,可工程化的是"发送状态可追踪、失败可补偿"。
- 本地消息表 + confirm + 定时重发是生产标准组合,业务表与消息表必须同事务。
mandatory/ 备用交换机负责兜住路由失败,避免消息被静默丢弃。- 重发必然带来重复,幂等是这套方案能成立的前提。