怎么保证消息一定能发送到 RabbitMQ?

#中间件#面试八股

怎么保证消息一定能发送到 RabbitMQ?

结论:没有任何配置能"保证一定送到",能做的是让失败可被发现、可被补偿:发布确认(Publisher Confirm)证明消息到了 Broker,mandatory + return 证明路由到了队列,本地消息表(Outbox)记录发送状态,定时任务扫描未确认记录重发,最终对账兜底。四者构成闭环,缺一环就有静默丢失。

一、发送失败的四类情况

失败类型现象检测手段
连不上 BrokerIOException / 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 / 备用交换机负责兜住路由失败,避免消息被静默丢弃。
  • 重发必然带来重复,幂等是这套方案能成立的前提。
笔记加载中…