【RabbitMQ】面试系列 · 第二期:高级特性与可靠性保障

第二期:高级特性与可靠性保障------消息不丢失的硬核全链路拆解

免责声明:本文中出现的"CloudMart"是一个虚构的电商教学系统,仅用于串联技术知识点,与任何真实公司或产品无关。


目录

  • [开篇:CloudMart 的噩梦------一笔订单,两条重复扣款](#开篇:CloudMart 的噩梦——一笔订单,两条重复扣款)
  • 理论速览:可靠性全链路三道防线
    • [防线一:发送端------Confirm + Return](#防线一:发送端——Confirm + Return)
    • 防线二:Broker------三层持久化
    • [防线三:消费端------手动 ACK + 重试策略](#防线三:消费端——手动 ACK + 重试策略)
  • [实战案例:CloudMart 可靠性落地](#实战案例:CloudMart 可靠性落地)
  • [源码走读:Spring AMQP 可靠性核心实现](#源码走读:Spring AMQP 可靠性核心实现)
    • [RabbitTemplate 的 Confirm 回调链路](#RabbitTemplate 的 Confirm 回调链路)
    • [SimpleMessageListenerContainer 的 ACK 处理](#SimpleMessageListenerContainer 的 ACK 处理)
  • [死信队列 DLX:消息的"最终归宿"](#死信队列 DLX:消息的"最终归宿")
    • 三大来源详解
    • [TTL:队列级 vs 消息级](#TTL:队列级 vs 消息级)
    • [实战案例:CloudMart 订单超时自动取消](#实战案例:CloudMart 订单超时自动取消)
  • 延迟队列:两种实现方案对比
    • [实战案例:CloudMart 支付倒计时提醒](#实战案例:CloudMart 支付倒计时提醒)
  • [消费端流量控制:prefetch 与重试](#消费端流量控制:prefetch 与重试)
    • [实战案例:CloudMart 秒杀限流](#实战案例:CloudMart 秒杀限流)
  • [事务机制 vs Confirm 模式](#事务机制 vs Confirm 模式)
    • [横向对比:RabbitMQ vs Kafka 可靠性模型](#横向对比:RabbitMQ vs Kafka 可靠性模型)
  • [面试追问:消息可靠性 15 连问](#面试追问:消息可靠性 15 连问)
  • 可靠性验证清单
  • 必背速查:可靠性配置清单

开篇:CloudMart 的噩梦------一笔订单,两条重复扣款

凌晨 2 点 14 分,CloudMart 支付系统告警响起。客服工单涌入:订单 ORD-20260606-1999 被扣了两次款,用户只下了一单。

初步排查发现三条链路断裂:

  1. 发送端 :支付服务调用 rabbitTemplate.convertAndSend() 发送扣款消息。方法返回无异常,但 ConfirmCallback 收到的是 NACK------消息实际未到达 Broker 就被丢弃。支付服务没有校验 Confirm 结果,直接更新了支付状态为"已扣款"。
  2. Broker 侧 :扣款队列声明时没有设置 durable=true。凌晨 Broker 例行重启后,队列消失,积压的未消费消息全部丢失。重启后重新声明的队列是全新的,之前那条"假成功"对应的支付状态已写入 DB,形成脏数据。
  3. 消费端 :库存服务配置了 acknowledge-mode: auto。退款消息到达后,invokeListener() 内部抛出 InventoryRestoreException(Redis 连接超时),Spring 自动返回 NACK 并触发重试。但重试三次后 Redis 仍未恢复,消息被转入 DLX------库存恢复失败,订单却早已标记为"退款成功"。

根本原因三道防线全部失守------发送端没有用 Confirm + Return 闭环校验,Broker 没有开启三层持久化(尤其是 Queue 持久化),消费端依赖自动 ACK 而非手动控制。

这个场景就是面试中最经典的**"消息可靠性"问题的全貌**。接下来我们逐层拆解,把这三道防线讲透。


理论速览:可靠性全链路三道防线

RabbitMQ 的消息从发送到消费,会经过三个关键卡点。任何一个卡点出了问题,都可能导致消息丢失:

防线一:发送端------Confirm + Return

生产者发送消息给 Broker 时,默认情况下 basicPublish即发即忘(fire-and-forget) :方法返回不代表消息到达 Broker,更不意味着消息被持久化到了磁盘。要解决这个问题,需要引入 Publisher ConfirmReturn 回调

Publisher Confirm:生产者将 Channel 设为 Confirm 模式后,Broker 会对每一条消息给出回执------ACK(成功)或 NACK(失败)。Confirm 有三种使用策略:

策略 方式 吞吐量 可靠性 适用场景
单条同步 channel.waitForConfirms() 极低 几乎不用
批量同步 channel.waitForConfirmsOrDie() 中(整批失败重发) 非关键业务
异步回调 setConfirmCallback() 推荐,生产首选

Return 回调 :当消息被 Broker 接收但无法路由到任何队列时,Broker 会将消息 Return 给生产者。必须设置 mandatory=true 才能触发。

java 复制代码
// Spring Boot 配置:同时启用 Confirm 和 Return
rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
    if (ack) {
        // Broker 确认接收,更新本地消息表状态为"已发送"
        messageLogService.updateStatus(correlationData.getId(), SendStatus.SENT);
    } else {
        // NACK:记录失败,触发重试或告警
        log.error("消息发送NACK: id={}, cause={}", correlationData.getId(), cause);
        messageLogService.updateStatus(correlationData.getId(), SendStatus.FAILED);
    }
});

rabbitTemplate.setReturnsCallback(returned -> {
    // 消息无法路由,Return 回来
    log.error("消息被Return: msg={}, replyCode={}, replyText={}, exchange={}, routingKey={}",
            returned.getMessage(), returned.getReplyCode(), returned.getReplyText(),
            returned.getExchange(), returned.getRoutingKey());
    messageLogService.recordReturn(returned);
});

关键面试点 :Confirm 的 ACK 是异步的convertAndSend() 方法返回后,ACK 可能还没回来。所以如果你的业务需要"确认发送成功后才做下一步",必须在 confirmCallback 里写后续逻辑,而不是在 convertAndSend() 之后。

防线二:Broker------三层持久化

即便消息成功到达 Broker,默认情况下 Broker 重启后消息也会丢失------因为默认 Exchange、Queue 和 Message 都不是持久化的。

三层持久化缺一不可

层次 配置方式 丢失后果
Exchange ExchangeBuilder.durable(true) 重启后交换机消失,消息无法路由
Queue QueueBuilder.durable(true) 重启后队列消失,消息无处存放
Message deliveryMode=PERSISTENT 重启后消息丢失(队列和交换机还在)
java 复制代码
// 正确的三层持久化声明
@Configuration
public class ReliableConfig {

    @Bean
    public DirectExchange orderExchange() {
        return ExchangeBuilder.directExchange("cloudmart.order.ex")
                .durable(true)   // ① Exchange 持久化
                .build();
    }

    @Bean
    public Queue orderQueue() {
        return QueueBuilder.durable("cloudmart.order.queue")  // ② Queue 持久化
                .build();
    }

    @Bean
    public Binding orderBinding() {
        return BindingBuilder.bind(orderQueue()).to(orderExchange()).with("order.create");
    }
}

// 发送消息时指定持久化
MessageProperties props = new MessageProperties();
props.setDeliveryMode(MessageDeliveryMode.PERSISTENT);  // ③ Message 持久化
Message msg = new Message(orderJson.getBytes(StandardCharsets.UTF_8), props);
rabbitTemplate.send("cloudmart.order.ex", "order.create", msg);

更简洁的方式 :使用 MessagePostProcessor 一行搞定:

java 复制代码
rabbitTemplate.convertAndSend(ex, rk, order,
    msg -> {
        msg.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT);
        return msg;
    });

⚠️ 面试陷阱 :设置 deliveryMode=PERSISTENT 并不等于"写入磁盘"。Broker 有自己的刷盘策略------消息可能还在操作系统的 Page Cache 里,Broker 崩溃同样会丢。若需要更高的持久化保证,需要配合 镜像队列(Mirrored Queue)Quorum Queue

防线三:消费端------手动 ACK + 重试策略

消息到达消费者后,默认的自动 ACK 模式(acknowledge-mode: none)在消息发出后立即删除,消费失败也无法恢复。生产环境必须使用手动 ACKmanual)。

yaml 复制代码
spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: manual   # 手动 ACK
        retry:
          enabled: true
          max-attempts: 3
          initial-interval: 1000ms
          multiplier: 2.0         # 指数退避:1s → 2s → 4s
java 复制代码
@RabbitListener(queues = "cloudmart.order.queue")
public void handleOrder(Message message, Channel channel,
                        @Header(AmqpHeaders.DELIVERY_TAG) long tag) {
    try {
        Order order = objectMapper.readValue(message.getBody(), Order.class);
        inventoryService.deduct(order);  // 库存扣减
        channel.basicAck(tag, false);    // 手动确认:单条
    } catch (BusinessException e) {
        // 业务异常(如库存不足)→ 拒绝且不重入队列
        channel.basicNack(tag, false, false);
    } catch (Exception e) {
        // 系统异常(网络抖动等)→ 拒绝且重新入队
        channel.basicNack(tag, false, true);
    }
}

三种 ACK 方法的语义必须精确区分:

方法 语义 应用场景
basicAck(tag, false) 确认单条消息 正常处理完成
basicNack(tag, false, true) 拒绝且重新入队 临时故障,稍后重试
basicNack(tag, false, false) 拒绝且不重入 业务失败,走死信队列
basicReject(tag, false) 拒绝单条 等价 basicNack(tag, false, false)

实战案例:CloudMart 可靠性落地

案例一:订单创建------保证生产端不丢消息

CloudMart 的订单创建是一个典型的可靠性场景:用户下单后,订单服务需要将订单消息发送给库存服务和物流服务。要求零丢失。

架构设计

  1. 创建本地消息表 msg_log,记录每一条待发送消息
  2. 使用 Confirm 异步回调更新消息状态
  3. 定时任务扫描失败消息,进行补偿重发
java 复制代码
// 订单服务:发送订单创建消息
@Transactional
public void createOrder(OrderCreateDTO dto) {
    // 1. 保存订单
    Order order = orderRepository.save(dto.toOrder());
    // 2. 记录消息日志(与订单在同一事务中)
    MessageLog msgLog = new MessageLog();
    msgLog.setMessageId(UUID.randomUUID().toString());
    msgLog.setExchange("cloudmart.order.ex");
    msgLog.setRoutingKey("order.create");
    msgLog.setStatus(SendStatus.PENDING);
    msgLog.setPayload(JSON.toJSONString(order));
    messageLogRepository.save(msgLog);
    // 3. 发送消息
    CorrelationData correlationData = new CorrelationData(msgLog.getMessageId());
    rabbitTemplate.convertAndSend("cloudmart.order.ex", "order.create",
            order, correlationData);
}
java 复制代码
// Confirm 回调:更新消息状态
rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
    String msgId = correlationData.getId();
    if (ack) {
        messageLogRepository.updateStatus(msgId, SendStatus.SENT);
    } else {
        messageLogRepository.updateStatus(msgId, SendStatus.FAILED);
        log.error("消息发送失败 msgId={}, cause={}", msgId, cause);
    }
});

// 补偿任务:每 5 分钟扫描失败消息重发
@Scheduled(fixedDelay = 300000)
public void retryFailedMessages() {
    List<MessageLog> failed = messageLogRepository
            .findByStatusAndCreateTimeBefore(SendStatus.FAILED,
                    LocalDateTime.now().minusMinutes(5));
    for (MessageLog log : failed) {
        // 重试次数超过上限 → 人工介入
        if (log.getRetryCount() >= 3) {
            alertService.sendAlert("消息重试 3 次仍失败: " + log.getMessageId());
            messageLogRepository.updateStatus(log.getMessageId(), SendStatus.MANUAL);
            continue;
        }
        // 重发消息
        rabbitTemplate.convertAndSend(log.getExchange(), log.getRoutingKey(),
                log.getPayload(),
                new CorrelationData(log.getMessageId()));
        messageLogRepository.incrementRetryCount(log.getMessageId());
    }
}

案例二:库存扣减------消费端幂等 + 手动 ACK

库存扣减是最容易出问题的消费端场景。即便 Confirm 保证了消息到达 Broker,消费者也可能因网络抖动多次收到同一条消息。必须做幂等

java 复制代码
@RabbitListener(queues = "cloudmart.order.queue")
public void handleOrderCreate(Message message, Channel channel,
                              @Header(AmqpHeaders.DELIVERY_TAG) long tag) throws Exception {
    String messageId = message.getMessageProperties().getMessageId();

    // 幂等校验:检查 messageId 是否已处理
    if (idempotentService.hasProcessed(messageId)) {
        log.warn("重复消息,跳过: messageId={}", messageId);
        channel.basicAck(tag, false);
        return;
    }

    try {
        Order order = objectMapper.readValue(message.getBody(), Order.class);

        // 扣减库存(内部用 SELECT FOR UPDATE + 版本号 CAS)
        inventoryService.deduct(order.getSkuId(), order.getQuantity());

        // 标记 messageId 已处理(Redis SET NX,24h 过期)
        idempotentService.mark(messageId);

        // 手动确认
        channel.basicAck(tag, false);

    } catch (InsufficientStockException e) {
        // 库存不足 ------ 业务失败,拒绝且不重入,走死信
        log.warn("库存不足: {}", e.getMessage());
        channel.basicNack(tag, false, false);
    }
}

源码走读:Spring AMQP 可靠性核心实现

RabbitTemplate 的 Confirm 回调链路

Spring AMQP 的 Confirm 机制建立在 RabbitMQ 原生的 Publisher Confirm 之上。关键类是 RabbitTemplatePublisherCallbackChannelImpl(位于 spring-rabbit 模块中,源码路径 org.springframework.amqp.rabbit.connection.PublisherCallbackChannelImpl)。

复制代码
完整调用链:

发送端:
RabbitTemplate.doSend()
  → 包装 channel 为 PublisherCallbackChannelImpl(若未包装)
  → PublisherCallbackChannelImpl.addPendingConfirm()
    将 (sequenceNumber, correlationData) 存入 SortedMap<Long, PendingConfirm>
    其中 sequenceNumber 由 AtomicLong publisherSequence 自增产生
  → Channel.basicPublish(exchange, routingKey, mandatory, props, body)

Broker 异步返回 ACK/NACK:
  → PublisherCallbackChannelImpl.handleAck(long deliveryTag, boolean multiple)
    当 multiple=true 时调用 pendingConfirms.headMap(deliveryTag + 1).clear()
    单个确认时直接从 pendingConfirms.remove(deliveryTag)
    → 取出 PendingConfirm.correlationData
    → 遍历 confirmListeners,逐个调用 handleAck(correlationData, ack, cause)
    → 最终触发 RabbitTemplate.confirmCallback.confirm(...)

核心数据结构的线程安全设计------pendingConfirmsTreeMap 而非 ConcurrentHashMap,所有访问通过 synchronized 块保护:

java 复制代码
// PublisherCallbackChannelImpl (Spring AMQP 2.4.x)
private final SortedMap<Long, PendingConfirm> pendingConfirms =
        new TreeMap<>();

private static class PendingConfirm {
    final CorrelationData correlationData;
    final long timestamp;
}

@Override
public void addPendingConfirm(RabbitTemplate template, PendingConfirm pendingConfirm) {
    synchronized (this.pendingConfirms) {
        // sequenceNumber 是 RabbitMQ Client 分配的 publishSequence
        this.pendingConfirms.put(template.getPublishSequence(), pendingConfirm);
    }
}

关键设计细节multiple=true 是性能优化的核心。RabbitMQ 在批量确认时不会逐条回复,而是回复"该序号及之前的所有消息全部确认"。Spring 利用 SortedMap.headMap() 的 O(log n) 特性高效清理已确认条目,避免 O(n) 遍历。

为什么用 TreeMap + synchronized 而不是 ConcurrentHashMap 原因有二:(1) 确认消息的顺序递增特性需要范围查询------headMap(deliveryTag + 1).clear() 是一次批量清理,NavigableMap 接口的原生支持让这层操作直接从 O(n) 降到 O(log n);(2) ConcurrentSkipListMap 虽然也是有序并发容器,但其 headMap() 返回的是实时视图 而非独立快照,在遍历过程中如果 Broker 并发推送新的 ACK,会触发 ConcurrentModificationException。Spring 选择显式 synchronized 块 + 普通 TreeMap,是权衡了并发粒度和正确性后的工程决策。

SimpleMessageListenerContainer 的消费与 ACK 链路

SimpleMessageListenerContainer(包路径 org.springframework.amqp.rabbit.listener)本质是一个 并发消费调度框架 ,通过 BlockingQueueConsumer 的 worker 线程不断从 Channel 拉取消息。

复制代码
消费主链路:

SimpleMessageListenerContainer.start()
  → 创建 N 个 BlockingQueueConsumer(concurrentConsumers 指定)
  → 每个 BlockingQueueConsumer 内部持有
    - Channel(独立 TCP 连接,通过 CachingConnectionFactory 获取)
    - InternalConsumer(tagConsumer,负责 basicConsume 回调)
    - LinkedBlockingQueue<Delivery>(容量 = prefetchCount,核心限流器)
  → 每个 consumer 启动 AsyncMessageProcessingConsumer 线程
    → 循环调用 BlockingQueueConsumer.nextMessage(timeout)
      → 队列空时阻塞在 LinkedBlockingQueue.poll()
      → 拿到 Delivery 组装 Message,调用 invokeListener()
    → 消息处理完后,触发 basicAck()
    → 回到循环取下一个

auto 模式的 ACK 触发时机 ------关键在于 AbstractMessageListenerContainer.executeListener()

java 复制代码
// AbstractMessageListenerContainer (Spring AMQP 2.4.x 简化)
private void executeListener(Channel channel, Message message) {
    try {
        doInvokeListener(channel, message);   // 反射调用 @RabbitListener 方法
        channel.basicAck(deliveryTag, false); // 无异常 → 自动 ACK
    } catch (Exception e) {
        if (retryTemplate != null) {
            // 配置了重试模板 → 按策略重试
            retryTemplate.execute(context -> {
                doInvokeListener(channel, message);
                return null;
            }, recoveryCallback); // 重试耗尽后的恢复策略
        } else {
            // 无重试配置 → 直接 NACK 并重新入队(requeue=true)
            channel.basicNack(deliveryTag, false, true);
        }
    }
}

auto 模式的致命陷阱 :如果 @RabbitListener 方法内部 catch(Exception) { log.error(...) } 把异常吞掉了,doInvokeListener 正常返回,Spring 就会自动 basicAck------Broker 以为消费成功,实际业务处理失败。

防范方案 :auto 模式下务必配置全局 ErrorHandlerFatalExceptionStrategy,在误吞异常时强制抛出不重试异常(如 AmqpRejectAndDontRequeueException)。更推荐直接使用 manual 模式,把 ACK 控制权完全交给业务代码。

manual 模式的精确控制 ------实现 ChannelAwareMessageListener,拿到原始 Channel 和 deliveryTag

java 复制代码
@Component
public class InventoryListener implements ChannelAwareMessageListener {
    @Override
    public void onMessage(Message message, Channel channel) {
        long tag = message.getMessageProperties().getDeliveryTag();
        try {
            processBusinessLogic(message);
            channel.basicAck(tag, false);               // 成功 → ACK
        } catch (RecoverableException e) {
            channel.basicNack(tag, false, true);         // 可恢复 → 重新入队
        } catch (FatalException e) {
            channel.basicNack(tag, false, false);        // 不可恢复 → DLX
        }
    }
}

重试拦截器底层利用 Spring Retry 的 RetryTemplate

java 复制代码
RetryTemplate.builder()
    .maxAttempts(3)                              // 含首次,共 3 次
    .exponentialBackoff(1000, 2.0, 10000)        // 1s → 2s → 4s,上限 10s
    .retryOn(AmqpRejectAndDontRequeueException.class) // false=不重试的异常
    .build();

面试要点 :说清楚 auto 的 executeListener 如何判断 ACK(异常驱动)、manual 如何拿 deliveryTag 精确控制、异常分类(Recoverable vs Fatal)对应的重试与 DLX 策略。


死信队列 DLX:消息的"最终归宿"

三大来源详解

消息变成死信(Dead Letter)有三种情况:

来源一:消息被拒绝basicReject / basicNackrequeue=false)。典型场景:消费者发现消息格式非法,无法处理,直接拒绝。

来源二:消息 TTL 过期 。消息在队列中存活时间超过 x-message-ttl 设定值,且没有被消费。

来源三:队列溢出 。队列达到 x-max-lengthx-max-length-bytes 上限,超出的消息(默认从队头丢弃)转入 DLX。

关键细节 :原始消息进入 DLX 时,会自动添加 x-death 头信息,记录死信原因、时间戳、原始队列和交换机。排查问题时可以直接读 x-death 知道这条消息为什么变成了死信。

x-death 头结构示例(从死信消息的 Header 中解析):

json 复制代码
{
  "x-death": [{
    "reason": "expired",
    "count": 1,
    "exchange": "cloudmart.order.ex",
    "queue": "cloudmart.order.timeout.queue",
    "routing-keys": ["order.create"],
    "time": {"$date": "2026-06-06T12:30:00.000Z"}
  }]
}

reason 的三种典型值:rejected(被拒绝)、expired(TTL 过期)、maxlen(队列溢出)。线上排查直接从 Dead Letter 消息的 Header 里读出最后一条 x-death,就能拿到完整的原路由信息。

TTL:队列级 vs 消息级

两种 TTL 设置方式的本质区别:

维度 队列级 TTL 消息级 TTL
设置方式 x-message-ttl 参数 setExpiration(String)
作用范围 队列内所有消息统一 每条消息可独立设置
删除机制 惰性删除(到队头才删) 惰性删除(到队头才删)
配置复杂度

核心陷阱 :两种 TTL 都是惰性删除------消息虽然过期了,但只要它不在队列头部,就不会被检查和删除。这意味着如果你用消息级 TTL 实现"不同消息不同延迟",排在后面的短 TTL 消息可能被前面的长 TTL 消息堵住,实际存活时间远超设定值。

实战案例:CloudMart 订单超时自动取消

CloudMart 订单创建后 30 分钟未支付自动取消。实现方案:

java 复制代码
@Configuration
public class OrderTimeoutConfig {

    // 死信交换机
    @Bean
    public DirectExchange dlxExchange() {
        return ExchangeBuilder.directExchange("cloudmart.dlx.ex")
                .durable(true).build();
    }

    // 死信队列
    @Bean
    public Queue dlxQueue() {
        return QueueBuilder.durable("cloudmart.dlx.queue").build();
    }

    @Bean
    public Binding dlxBinding() {
        return BindingBuilder.bind(dlxQueue())
                .to(dlxExchange()).with("order.timeout");
    }

    // 订单队列:30分钟 TTL,过期转入 DLX
    @Bean
    public Queue orderTimeoutQueue() {
        return QueueBuilder.durable("cloudmart.order.timeout.queue")
                .ttl(30 * 60 * 1000)                    // 30分钟
                .deadLetterExchange("cloudmart.dlx.ex")  // 指定 DLX
                .deadLetterRoutingKey("order.timeout")   // 死信路由键
                .build();
    }
}

// 死信消费者:订单超时 → 检查支付状态 → 关单
@RabbitListener(queues = "cloudmart.dlx.queue")
public void handleTimeoutOrder(Message message, Channel channel,
                               @Header(AmqpHeaders.DELIVERY_TAG) long tag) throws Exception {
    Order order = objectMapper.readValue(message.getBody(), Order.class);
    Order latest = orderRepository.findById(order.getId());

    if (latest.getStatus() == OrderStatus.UNPAID) {
        // 仍未支付 → 关单 + 恢复库存
        orderRepository.updateStatus(order.getId(), OrderStatus.CANCELLED);
        inventoryService.restore(latest.getSkuId(), latest.getQuantity());
        log.info("订单超时自动取消: {}", order.getId());
    }
    // 已支付 → 忽略
    channel.basicAck(tag, false);
}

延迟队列:两种实现方案对比

延迟队列的核心需求是:"消息发送后,在指定时间后才投递给消费者"。RabbitMQ 没有原生延迟队列,但有两种实现方案。

方案对比

选择建议:只有一种延迟时间 → TTL+DLX 够用;多种延迟时间 → 延迟插件避免时序 bug。

实战案例:CloudMart 支付倒计时提醒

CloudMart 需要支付倒计时提醒:下单后 5 分钟、15 分钟、25 分钟各提醒一次。

用延迟插件最合适:

java 复制代码
// 声明延迟交换机
@Bean
public CustomExchange delayExchange() {
    Map<String, Object> args = new HashMap<>();
    args.put("x-delayed-type", "topic");  // 底层交换机类型
    return new CustomExchange("cloudmart.pay.delay.ex",
            "x-delayed-message", true, false, args);
}

// 发送延迟消息(5分钟后提醒)
public void sendPayReminder(Order order, int delayMinutes) {
    rabbitTemplate.convertAndSend("cloudmart.pay.delay.ex",
            "pay.remind", order,
            msg -> {
                msg.getMessageProperties().setDelay(delayMinutes * 60 * 1000);
                return msg;
            });
}

// 下单后依次发送三个提醒
sendPayReminder(order, 5);   // 5 分钟后提醒
sendPayReminder(order, 15);  // 15 分钟后提醒
sendPayReminder(order, 25);  // 25 分钟后提醒

消费端流量控制:prefetch 与重试

prefetch 的本质

prefetch 不是"限制消费速率",而是限制消费者同时持有的未确认消息数量prefetch=1 意味着每一条消息都必须在 ACK 之后才推送下一条,天然形成 Round-Robin 公平分发。

yaml 复制代码
spring:
  rabbitmq:
    listener:
      simple:
        prefetch: 1       # 公平分发,每条 ACK 后拿新消息
        concurrency: 5    # 5个并发消费者
prefetch 值 分发行为 适用场景
1 ACK → 下一条,公平 Round-Robin 任务耗时不均
10-50 一次性推送一批 高吞吐均匀任务
250(默认) 一股脑推送 几乎不用

重试策略

重试由 Spring Retry 驱动。关键参数:

yaml 复制代码
retry:
  enabled: true
  max-attempts: 3          # 最多重试 3 次
  initial-interval: 1000   # 第一次重试 1 秒后
  multiplier: 2.0          # 指数退避:1s → 2s → 4s
  max-interval: 10000      # 最大间隔 10s

重试耗尽后的处理:

java 复制代码
// 重试耗尽 MessageRecoverer → 发到死信队列
@Bean
public MessageRecoverer messageRecoverer(RabbitTemplate rabbitTemplate) {
    return new RepublishMessageRecoverer(rabbitTemplate,
            "cloudmart.error.ex", "error.retry.exhausted");
}

实战案例:CloudMart 秒杀限流

java 复制代码
@Configuration
public class SecKillConfig {

    @Bean
    public SimpleRabbitListenerContainerFactory secKillFactory(
            ConnectionFactory connectionFactory) {
        SimpleRabbitListenerContainerFactory factory =
                new SimpleRabbitListenerContainerFactory();
        factory.setConnectionFactory(connectionFactory);
        factory.setPrefetchCount(10);   // 每次最多拿 10 条
        factory.setConcurrentConsumers(3);
        factory.setMaxConcurrentConsumers(10);  // 动态扩容上限
        factory.setAcknowledgeMode(AcknowledgeMode.MANUAL);
        return factory;
    }
}

@RabbitListener(queues = "cloudmart.seckill.queue",
        containerFactory = "secKillFactory")
public void handleSecKill(Message message, Channel channel,
                          @Header(AmqpHeaders.DELIVERY_TAG) long tag) throws Exception {
    // 秒杀扣减逻辑(库存预热到 Redis,无需回源 DB)
    String skuId = ...; // 从消息体解析
    Long stock = redisTemplate.opsForValue().decrement("seckill:stock:" + skuId);
    if (stock == null || stock < 0) {
        channel.basicNack(tag, false, false);  // 卖完了,拒绝
        return;
    }
    // 异步写入订单
    orderService.createSecKillOrder(skuId);
    channel.basicAck(tag, false);
}

事务机制 vs Confirm 模式

AMQP 提供了事务机制(txSelect / txCommit / txRollback),但它和 Confirm 是互斥的------一个 Channel 不能同时开启事务和 Confirm。

维度 事务模式 Confirm 模式
保证 原子性(一批消息要么全发要么全不发) 每条消息独立 ACK
性能 吞吐量下降约 100~250 倍(官方文档《Using transactions》实测数量级,具体与消息大小/批次数相关) 异步非阻塞,性能接近无 Confirm
使用场景 几乎不用 生产唯一推荐
Spring Boot 配置 publisher-confirm-type: none publisher-confirm-type: correlated

结论:事务机制在 RabbitMQ 中基本没有生产价值。Confirm 模式 + 本地消息表/回调已经足够覆盖发送端可靠性需求。面试时被问到事务直接说"不用,用 Confirm"即可。

横向对比:RabbitMQ vs Kafka 可靠性模型

对比基于 2025-2026 年 RabbitMQ 3.12/3.13 与 Kafka 3.x 主流版本,随版本迭代部分特性可能调整。

面试中经常被问"RabbitMQ 和 Kafka 谁更可靠",这里给出精确回答:

维度 RabbitMQ Kafka
可靠性模型 推送模型(Broker push),依赖消费端 ACK 拉取模型(Consumer pull),依赖 offset 提交
消息持久化 三层持久化(Exchange/Queue/Message),逐条刷盘 分段日志(Segment Log),顺序写磁盘 + Page Cache
消费确认 逐条 ACK / NACK,支持重新入队 offset 提交,不支持单条回退
重复消费风险 高(NACK 重入队、网络重连) 低(offset 精确控制),但 rebalance 期间可能重复
消息丢失风险 高(需手动三道防线) 低(ISR + min.insync.replicas),默认持久化
事务支持 AMQP 原生事务(性能差) Kafka Transactions(幂等 + 事务,性能可控)
典型场景 业务指令、RPC、延迟任务 日志采集、流处理、事件溯源

核心区别:RabbitMQ 设计目标是"灵活路由 + 单条精确控制",可靠性靠应用层配合(Confirm + 持久化 + 手动 ACK);Kafka 设计目标是"高吞吐 + 天然持久化",可靠性靠 ISR 副本机制和分段日志保证。选型时不是比谁更可靠,而是看业务对"单条消息的精确投递控制"和"天然顺序持久化"哪个需求更强。


面试追问:消息可靠性 15 连问

  1. RabbitMQ 如何保证消息不丢失? → 三道防线:发送方 Confirm + Broker 三层持久化 + 消费方手动 ACK。
  2. Confirm 的 ACK 是同步还是异步? → 异步。convertAndSend() 返回不代表 ACK 已到。
  3. 三层持久化缺一层会怎样? → 对应层重启后丢失,消息无法路由或存储。
  4. deliveryMode=PERSISTENT 就保证写入磁盘了吗? → 不保证。只在刷盘后才真正持久化,需镜像队列或 Quorum Queue 进一步加强。
  5. 自动 ACK 和手动 ACK 的区别? → 自动 ACK:消息发出即删;手动 ACK:必须显式调用 basicAck
  6. basicNack(tag, false, false)basicNack(tag, false, true) 的区别? → 第三个参数:false 不重新入队(走 DLX),true 重新入队(重试)。
  7. 消息变成死信有哪三种情况? → 被拒绝且不重入 / TTL 过期 / 队列溢出。
  8. TTL + DLX 实现延迟队列有什么坑? → 时序陷阱:惰性删除导致后面短 TTL 消息被前面长 TTL 消息堵住。
  9. 队列级 TTL 和消息级 TTL 能不能混用? → 能混用,取二者较小值。
  10. 延迟插件和 TTL+DLX 怎么选? → 多种延迟 → 插件;一种延迟 → TTL+DLX 也行。
  11. prefetch=1 有什么优缺点? → 优点:公平分发,防止消费者过载;缺点:单条 ACK 降低吞吐。
  12. 重试次数耗尽了怎么办? → MessageRecoverer 转发到死信队列,人工介入或定时补偿。
  13. 怎么保证消息不重复消费? → 消费端幂等:messageId + Redis SET NX / 数据库唯一约束。
  14. 事务机制和 Confirm 模式能同时开启吗? → 不能,互斥。Confirm 性能远优于事务。
  15. 消息可靠性方案有没有 100% 保证? → 没有。理论上 Broker 断电时 Page Cache 中的数据仍可能丢失。可以引入本地消息表 + 定时补偿兜底,做到 99.99% 的 at-least-once。exactly-once 需要业务层的幂等配合。

可靠性验证清单

配置写完了,怎么验证三道防线真的生效?以下是可直接执行的验证步骤:

验证目标 操作步骤 预期结果
Confirm 回调 发送消息到不存在的 Exchange 3 秒内 ConfirmCallback 收到 NACK
消息持久化 发送一个 delivery-mode=2 的消息后执行 rabbitmqctl stop_app && rabbitmqctl start_app 消息仍在原队列中
Queue 持久化 不设 durable=true 声明队列,执行 rabbitmqctl stop_app && rabbitmqctl start_app 队列消失,rabbitmqctl list_queues 找不到
Manual ACK 消费端收到消息后在代码中 Thread.sleep(60) 秒(模拟业务耗时),在此期间不调用 basicAck 消息状态为 Unacked,Consumer 重启后 re-queue 回到 Ready
NACK 重入队 消费端故意抛 RecoverableExceptionbasicNack(tag, false, true) 消息立即回到队列头部,被下一个 Consumer 重新消费
DLX 转入 消费端抛 FatalExceptionbasicNack(tag, false, false) 消息出现在 DLX 队列中,`rabbitmqctl list_queues
prefetch 生效 设置 spring.rabbitmq.listener.simple.prefetch=1,发送 3 条消息,第一条 sleep 60s 其余 2 条停留在 Ready 状态,Consumer Unacked 始终为 1

关键命令rabbitmqctl list_queues name messages_ready messages_unacknowledged 可实时观察队列状态。验证 DLX 时执行 rabbitmqctl list_queues name messages | grep dlx


下期预告:第三期「应用问题与面试精讲」------ 消息堆积诊断与处理、集群搭建、高可用架构、RabbitMQ vs Kafka 终极对比。

相关推荐
代码小库13 小时前
免费制作简历 + 免费简历押题
面试
Aphasia31113 小时前
手写KeepAlive组件
前端·react.js·面试
牛客企业服务13 小时前
2026人才选拔新基准:AI能力考核如何重构企业招聘竞争力?
面试·ai面试·ai能力·ai coding·ai能力考核
Raink老师14 小时前
【AI面试临阵磨枪-94】Skill 安全:注入、越权、数据泄露、恶意代码、沙箱?
数据库·安全·面试
zzz_236816 小时前
【Spring】面试突击系列(三):Spring Web MVC 深度解析
前端·spring·面试
li星野17 小时前
FAISS 详解:原理、使用与面试指南——向量检索的基石
面试·职场和发展·faiss
智慧物业老杨17 小时前
电动自行车安全管理数智化解决方案:从风险防控到证据闭环
安全·rabbitmq
zzz_236818 小时前
【Spring】面试突击系列(一):IoC 与 DI 深度解析
java·spring·面试
I Promise3418 小时前
智驾APA_HPA可行驶区域检测算法工程师面试问题整理可参考
算法·面试·职场和发展