MQ高级 · RabbitMQ

可靠性

Pasted image 20260327095747

1. 生产者

生产者重连

由于网络波动,可能会出现客户端连接 MQ 失败的情况。 通过配置我们可以开启连接失败后的重连机制:

发信层 template.retry()

代码块JAVA · 17 行收起展开
spring:
  rabbitmq:
    host: 192.168.1.100
    port: 5672
    virtual-host: /
    username: admin
    password: 123456
    
    # ===== 重连配置 =====
    connection-timeout: 1s          # 建立连接超时时间
    template:
      retry:
        enabled: true               # 开启 RabbitTemplate 的重试
        initial-interval: 1000ms    # 首次重试等待 1s
        multiplier: 2               # 每次重试等待时间 * 2(指数退避) 下次等待=initial*mult
        max-attempts: 3             # 最多重试 3 次(含首次)
        max-interval: 10000ms       # 重试等待上限 10s

SpringAMQP 提供的重试机制是阻塞式的重试 即多次重试等待的过程中, 当前线程是被阻塞的, 会影响业务性能。

如果对于业务性能有要求,建议禁用重试机制。 如果一定要使用,请合理配置等待时长和重试次数,或使用异步线程来执行发送消息的代码。

连接层面的自动恢复

代码块JAVA · 26 行收起展开
/**
 * 连接层面的自动恢复配置
 * Spring AMQP 底层 AMQP Client 默认已开启 automatic recovery
 * 但生产环境建议显式配置,更可控
 */
@Configuration
public class RabbitConnectionConfig {

    @Bean
    public ConnectionFactory connectionFactory() {
        CachingConnectionFactory factory = new CachingConnectionFactory();
        factory.setHost("192.168.1.100");
        factory.setPort(5672);
        factory.setUsername("admin");
        factory.setPassword("123456");
        factory.setVirtualHost("/");

        // 连接缓存模式:CHANNEL(默认)vs CONNECTION
        // CHANNEL 模式:一个物理连接,多个 channel 复用
        // CONNECTION 模式:每次 getConnection() 可能返回新连接(适合高并发)
        factory.setCacheMode(CachingConnectionFactory.CacheMode.CHANNEL);
        factory.setChannelCacheSize(10); // channel 缓存数量

        return factory;
    }
}

生产者确认

发消息出去了,但消息真的到 Broker 了吗? 到了 Exchange 之后路由到 Queue 了吗?这就是 Publisher Confirms + Returns 要解决的问题。

  • 消息投递到了 MQ , 但是路由失败。此时会通过 PublisherReturn 返回路由异常原因, 然后返回 ACK, 告知投递成功
  • 临时消息投递到了 MQ , 并且入队成功, 返回 ACK, 告知投递成功
  • 持久消息投递到了 MQ , 并且入队完成持久化, 返回 ACK, 告知投递成功
  • 其他都是失败, 返回NACK
flowchart LR
    P[Publisher<br/>rabbitTemplate]

    E1[Topic Exchange]
    E2[Exchange2]

    Q1[Queue1<br/>non durable]
    Q2[Queue2<br/>durable]

    C1[Consumer1]
    C2[Consumer2]

    S[(Persistent Storage)]

    P --> E1
    P --> E2

    E1 --> Q1
    E1 --> Q2

    Q1 --> C1
    Q2 --> C2

    Q2 -. persist .-> S

    E1 -. ack .-> P
    E2 -. ack .-> P
    E2 -. return .-> P

三种 ConfirmType 模式需要特别理解: NONE 是默认值,完全不确认,性能最好,消息丢了也不知道。 CORRELATED 是真正的异步确认,每条消息有唯一 correlationId,Broker 处理后异步回调,性能好且可靠,生产推荐。 SIMPLE同步等待确认,发一条等一条,性能差,不推荐。

生产者确认代码实现

配置

代码块YAML · 6 行收起展开
spring:
  rabbitmq:
    publisher-confirm-type: correlated  # 开启 CORRELATED 异步确认
    publisher-returns: true             # 开启 Return 退回回调
    template:
      mandatory: true                   # 消息路由失败时退回而非丢弃(触发 Return)

配置类

代码块JAVA · 70 行收起展开
/**
 * Publisher Confirms + Returns 完整实现
 */
@Slf4j
@Configuration  
public class PublisherConfirmConfig implements RabbitTemplate.ConfirmCallback,
                                               RabbitTemplate.ReturnsCallback {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    /**
     * 注意:RabbitTemplate 是 prototype 的,但这里通过 @PostConstruct
     * 把 callback 注入进去,每个 RabbitTemplate 实例只设置一次
     */
    @PostConstruct
    public void init() {  //接口的子实现类, 所以init可以直接this
        rabbitTemplate.setConfirmCallback(this);
        rabbitTemplate.setReturnsCallback(this);
    }

    /**
     * Confirm 回调
     * 消息成功到达 Exchange → ack=true
     * Broker 内部错误     → ack=false, cause 说明原因
     *
     * @param correlationData 发送时传入的关联数据(含消息ID)
     * @param ack             是否确认成功
     * @param cause           失败原因(ack=true 时为 null)
     */
     
    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {
        String msgId = correlationData != null ? correlationData.getId() : "unknown";

        if (ack) {
            log.info("[Confirm ACK] 消息已到达Exchange, msgId={}", msgId);
            // 可选:更新数据库消息状态为"已投递"
        } else {
            log.error("[Confirm NACK] 消息投递Exchange失败, msgId={}, cause={}", msgId, cause);
            // 触发补偿:重发消息 / 告警
            // 注意:这里是异步线程,不能抛异常影响主流程
            handleSendFailed(msgId, cause);
        }
    }

    /**
     * Return 回调(路由失败时触发)
     * 消息到了 Exchange 但没有匹配的 Queue binding
     *
     * @param returned 退回的消息详情(含原始 Message、routingKey、replyText 等)
     */
    @Override
    public void returnedMessage(ReturnedMessage returned) {
        log.error("[Return] 消息路由失败被退回! exchange={}, routingKey={}, replyCode={}, replyText={}, body={}",
                returned.getExchange(),
                returned.getRoutingKey(),
                returned.getReplyCode(),
                returned.getReplyText(),
                new String(returned.getMessage().getBody()));
        // 处理:检查 routingKey / binding 配置是否正确
        // 生产建议:写入 DB 做人工复查,或者投递到专用的 unroutable 队列
    }

    private void handleSendFailed(String msgId, String cause) {
        // 示例:从 DB 捞出消息体重新发送
        // messageService.markFailed(msgId);
        // alertService.send("MQ投递失败: " + msgId);
    }
}

2. MQ

开启持久化和生产者确认时,RabbitMQ只有在消息持久化完成后才 会给生产者返回ACK回执

消息持久化

Transient 消息堆积时,内存压力触发”被迫写盘”这一步,写盘来不及就触发 flow control,严重时触发 memory alarm,Publisher 被 block,发布速率才会掉到 0。

Persistent 消息从头就在走写盘路径,内存压力来时只是”丢 RAM 缓存”,没有突发的额外 I/O,所以更不容易因为这个原因被 block。

持久化的代价是性能下降(写磁盘)。如果场景允许少量丢失(比如日志统计),可以关闭 deliveryMode=1 提升 TPS。但对于订单/支付等核心业务,必须三层全开。

Pasted image 20260327170534

code

代码块JAVA · 48 行收起展开
/**
 * 持久化相关配置代码
 */
@Configuration
public class PersistenceConfig {

    
    @Bean  // Exchange 持久化 
    public DirectExchange orderExchange() {
        // 三个参数:name, durable, autoDelete
        return new DirectExchange("order.direct", true, false);
    }

    
    @Bean  // Queue 持久化
    public Queue orderQueue() {
        return QueueBuilder
                .durable("order.queue")  // durable=true
                // .nonDurable()         // durable=false(重启后消失,不要用在生产)
                .build();
    }

    /**
     * 全局消息转换器配置
     * Jackson2JsonMessageConverter 序列化时默认设置 deliveryMode=PERSISTENT
     * 这是 Spring AMQP 2.x 之后的默认行为
     */
    @Bean
    public MessageConverter messageConverter() {
        Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter();
        // 创建时配置 ObjectMapper,处理日期/空值等
        DefaultJackson2JavaTypeMapper typeMapper = new DefaultJackson2JavaTypeMapper();
        typeMapper.setTrustedPackages("*"); // 反序列化时信任所有包
        converter.setJavaTypeMapper(typeMapper);
        return converter;
    }

    /**
     * 配置 RabbitTemplate 使用上面的转换器
     */
    @Bean
    public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory,
                                          MessageConverter messageConverter) {
        RabbitTemplate template = new RabbitTemplate(connectionFactory);
        template.setMessageConverter(messageConverter);
        return template;
    }
}

LazyQueue

  • 直接存磁盘, 读的时候再加载
  • 支持数百万数据的存储
  • 写磁盘有优化, 因此会快很多

普通 Queue 消息默认存在内存里,大量消息堆积时内存撑爆,Broker 触发流控(flow control)影响所有 Producer。LazyQueue 解决这个问题:

普通 Queue:消息 → 内存(paging机制,内存满了才 swap 到磁盘) Lazy Queue:消息 → 直接写磁盘(内存只保留索引),消费时再加载

code

代码块JAVA · 45 行收起展开
/**
 * LazyQueue 声明方式
 * RabbitMQ 3.6+ 支持 x-queue-mode=lazy
 * RabbitMQ 3.12+ LazyQueue 成为默认模式
 */
@Configuration
public class LazyQueueConfig {

    /**
     * 方式一:通过 QueueBuilder(推荐)
     */
    @Bean
    public Queue lazyOrderQueue() {
        return QueueBuilder
                .durable("order.lazy.queue")
                .lazy()  // 等价于 withArgument("x-queue-mode", "lazy")
                .build();
    }

    /**
     * 方式二:手动 withArgument(老版本 Spring AMQP 没有 .lazy() 方法时用)
     */
    @Bean
    public Queue lazyQueueManual() {
        return QueueBuilder
                .durable("order.lazy.queue.v2")
                .withArgument("x-queue-mode", "lazy")
                .build();
    }

    /**
     * 方式三:LazyQueue + 死信队列一起配
     * 生产上推荐这种完整配置
     */
    @Bean
    public Queue lazyQueueWithDLX() {
        return QueueBuilder
                .durable("order.main.queue")
                .lazy()
                .withArgument("x-dead-letter-exchange", "order.dlx.exchange")
                .withArgument("x-dead-letter-routing-key", "order.dlx")
                .withArgument("x-message-ttl", 300000)  // 5 分钟 TTL
                .build();
    }
}

3. 消费者

消费者确认

  1. queue投递给消费者, 有三种ack处理方式:
  • none:不处理。即消息投递给消费者后立刻ack,消息会立刻从MQ删除。非常不安全,不建议使用
  • manual:手动模式。需要自己在业务代码中调API,发送ack或reject,存在业务入侵,但更灵活
  • auto:自动模式。SpringAMQP利用AOP对我们的消息处理逻辑做了环绕增强,当业务正常执时则动返回ack.

2.当业务出现异常时,根据异常判断返回不同结果: 如果是业务异常,会自动返回nack 如果是消息处理或校验异常,动返回reject

代码块YAML · 6 行收起展开
spring:
	rabbitmq:
		listener:
			simple:
				prefetch: 1
				acknowledge-mode : none #none, 关闭ack; manual, 手动ack; auto, 自动ack

消费失败处理

在开启重试模式后,重试次数耗尽,如果消息依然失败,则需要有MessageRecoverer接来处理,它包含三种不同的实现:

  • RejectAndDontRequeueRecoverer:重试耗尽后,直接reject,丢弃消息。默认就是这种方式
  • ImmediateRequeueMessageRecoverer:重试耗尽后,返回nack,消息重新入队
  • RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机
Pasted image 20260327183140 #### RepublishMessageRecoverer 失败消息处理策略 将失败处理策略改为RepublishMessageRecoverer: 1首先,定义接收失败消息的交换机、队列及其绑定关系,此处略; 然后,定义RepublishMessageRecoverer.
code
配置
代码块YAML · 12 行收起展开
spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: auto   # 重试机制下一般配 auto,让 Spring 接管
        retry:
          enabled: true                  # 开启消费者本地重试
          initial-interval: 1000ms       # 首次重试等待 1s
          multiplier: 2                  # 指数退避:1s → 2s → 4s
          max-attempts: 3                # 最多重试 3 次(含首次消费,实际重试 2 次)
          max-interval: 10000ms          # 重试等待上限 10s
          stateless: true                # 无状态重试(不依赖事务),一般用这个
messageRecoverer

重试耗尽后的消息处理策略 — MessageRecoverer, 三种内置实现:

    1. RejectAndDontRequeueRecoverer(默认):直接丢弃 / 进 DLX
    1. ImmediateRequeueMessageRecoverer:重新入队(危险,一般不用)
    1. RepublishMessageRecoverer:重新发布到指定队列(推荐!)
代码块JAVA · 39 行收起展开

@Configuration
public class RetryConfig {

    /**
     * 推荐方案:RepublishMessageRecoverer
     * 重试耗尽后,把消息投递到一个专门的"失败队列"
     * 保留消息 + 异常信息 + 原始队列信息,方便人工排查
     */
    @Bean
    public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate) {
        // 参数1:失败后投递到的 exchange
        // 参数2:routing key
        return new RepublishMessageRecoverer(
                rabbitTemplate,
                "error.exchange",   // 专门收失败消息的 exchange
                "error.routing.key"
        );
    }

    /**
     * 配套声明:error exchange 和 error queue
     */
    @Bean
    public DirectExchange errorExchange() {
        return new DirectExchange("error.exchange");
    }

    @Bean
    public Queue errorQueue() {
        return QueueBuilder.durable("error.queue").build();
    }

    @Bean
    public Binding errorBinding() {
        return BindingBuilder.bind(errorQueue())
                .to(errorExchange())
                .with("error.routing.key");
    }
}
consumer
代码块JAVA · 20 行收起展开
/**
 * 配合重试机制的消费者写法(auto ack 模式)
 * Spring 会自动重试,无需手动 channel.basicNack
 */
@Slf4j
@Component
public class OrderConsumerWithRetry {

    @RabbitListener(queues = "order.queue")
    public void consume(OrderDTO order, Message message) {
        log.info("[消费] orderId={}", order.getOrderId());

        // 直接抛异常即可,Spring 的 RetryInterceptor 会捕获并重试
        // 重试次数耗尽后,MessageRecoverer 处理(进 error.queue)
        orderService.processOrder(order);

        // 正常返回 → Spring 自动 ack
        log.info("[完成] orderId={}", order.getOrderId());
    }
}

Spring AMQP 的本地重试是”在内存里重试”,不会把消息重新放回 Broker,所以不占用 Broker 资源, 重试期间消息一直是 Unacked 状态。重试耗尽才由 MessageRecoverer 决定最终命运。

4. 业务的幂等性

幂等是一个数学概念,函数表达式来描述是这样的:f(x)=f(f(x))。 在程序开发中,则是指同个业务,执次一次或多次对业务状态的影响是一致的

唯一消息id

给每个消息都设置一个唯一id,利用id区分是否是重复消息:

  1. 每一条消息都生成一个唯一的id,与消息一起投递给消费者。
  2. 消费者接收到消息后处理自己的业务,业务处理成功后将消息ID保存到数据库
  3. 如果下次又收到相同消息,去数据库查询判断是否存在,存在则为重复消息放弃处理。
Pasted image 20260327202244

业务判断

结合业务逻辑,基于业务本身做判断, 有些业务天然幂等性 以我们的业务为例:我们要在支付后修改订单状态为已支付,应该在修改订单状态前先查询订单状态,判断状态是否是未支付。只有未支付订单才需要修改,其它状态不做处理.

Pasted image 20260327202205

5. 延迟消息

延迟消息:生产者发送消息时指定一个时间,消费者不会立刻收到消息,而是在指定时间之后才收到消息。 场景比如: 外卖未支付取消订单

Pasted image 20260327204155

死信交换机

原来官方推出用作msg可靠性的兜底方案

死信

当一个队列中的消息满足下列情况之一时,就会成为死信(deadletter):

  • 消费者使用basic.reject或 basic.nack声明消费失败,并且消息的requeue参数设置为false
  • 消息是个过期消息(达到了队列或消息本身设置的过期时间),超时无人消费
  • 要投递的队列消息堆积满了,最早的消息可能成为死信

死信交换机

队列通过dead-letter-exchange属性指定了一个交换机, 那么该队列的私信会投递到此交换机, 称为死信交换机

Pasted image 20260327210829

延迟信息插件

消息投递到交换机后可以暂存一定时间, 到期再投递到队列

创建方式

代码块JAVA · 18 行收起展开
//注解创建
@RabbitListener( 
	bindings = @QueueBinding(
		value=@Queue(name=, durable=),
					exchange=@Exchange(name=,delayed=true),
					key=""
		)
	)
	
//bean创建
@Bean
public dirEx delayExchange(){
	new ExchangeBuilder
	.directExchange()
	.delayed()
	.durable(true)
	.build();
}

设置过期时间

发送消息时需要通过消息头x-delay来设置过期时间:

代码块JAVA · 13 行收起展开
//2.发送消息,利用消息后置处理器添加消息头
@Test
void testPublisherDelayMessage() {
//1.创建消息
String message = "hello, delayed message";
rabbitTemplate.convertAndSend("delay.direct","delay",message,new MessagePostProcessor(){
	@Override
	public Message postProcessMessage(Message message) throws AmqpException {
	//添加延迟消息属性
	message·getMessageProperties().setDelay(5000);
	return message;
	});
}

bug

延时的实现需要让CPU维护一个时 钟, 需要CPU不断计算, 是CPU密集型计算. 因此延迟时间不能太长, 延迟任务不能太多

取消超时订单

设置30分钟后检测订单支付状态实现起来非常简单,但是存在两个问题:

  • 如果并发较高,30分钟可能堆积消息过多,对MQ压很
  • 多数订单在下单后1分钟内就会付,但是却需要在MQ内等待30分钟,浪费资源

解决: 基于正态分布, 我们可以做出如下优化

  • 可以在订单确立后短时间内检查几次, 如果已付款就从dlq删掉, 这样可以排除大部分的订单
  • 把一段长时间的延迟拆成N段短时间的check Pasted image 20260327213640

注意这里查询支付状态可能是msg没送达队列, 需要主动查询真伪