Loading

[31] RabbitMQ-高级特性

1. 消息可靠性

你用支付宝给商家支付,如果是个仔细的人,会考虑我转账的话,会不会把我的钱扣了,商家没有收到我的钱?

一般我们使用支付宝或微信转账支付的时候,都是扫码,支付,然后立刻得到结果,说你支付了多少钱,如果你绑定的是银行卡,可能这个时候你并没有收到支付的确认消息。往往是在一段时间之后,你会收到银行卡发来的短信,告诉你支付的信息。

支付平台如何保证这笔帐不出问题?

支付平台必须保证数据正确性,保证数据并发安全性,保证数据最终一致性。

支付平台通过如下几种方式保证数据一致性:

(1)分布式锁

这个比较容易理解,就是在操作某条数据时先锁定,可以用 Redis 或 ZooKeeper 等常用框架来实现。 比如我们在修改账单时,先锁定该账单,如果该账单有并发操作,后面的操作只能等待上一个操作的锁释放后再依次执行。

优点:能够保证数据强一致性。

缺点:高并发场景下可能有性能问题。

(2)消息队列

消息队列是为了保证最终一致性,我们需要确保消息队列有 ack 机制。客户端收到消息并消费处理完成后,客户端发送 ack 消息给消息中间件 如果消息中间件超过指定时间还没收到 ack 消息,则定时去重发消息。比如我们在用户充值完成后,会发送充值消息给账户系统,账户系统再去更改账户余额。

优点:异步、高并发

缺点:有一定延时、数据弱一致性,并且必须能够确保该业务操作肯定能够成功完成,不可能失败。

我们可以从以下几方面来保证消息的可靠性:

  1. 客户端代码中的异常捕获,包括生产者和消费者
  2. AMQP/RabbitMQ 的事务机制
  3. 发送端确认机制
  4. 消息持久化机制
  5. Broker 端的高可用集群
  6. 消费者确认机制
  7. 消费端限流
  8. 消息幂等性

1.1 异常捕获机制

先执行业务操作,业务操作成功后执行行消息发送,消息发送过程通过 try catch 方式捕获异常,在异常处理理的代码块中执行行回滚业务操作或者执行行重发操作等。这是一种最大努力确保的方式,并无法保证 100% 绝对可靠,因为这里没有异常并不代表消息就一定投递成功。

另外,可以通过 spring.rabbitmq.template.retry.enabled=true 配置开启发送端的重试。

1.2 事务机制

没有捕获到异常并不能代表消息就一定投递成功了。

一直到事务提交后都没有异常,确实就说明消息是投递成功了。但是,这种方式在性能方面的开销比较大,一般也不推荐使用。

1.3 发送端 - 确认机制

RabbitMQ 后来引入了一种轻量量级的方式,叫发送方确认(Publisher Confirm)机制。生产者将信道设置成 Confirm(确认)模式,一旦信道进入 Confirm 模式,所有在该信道上面发布的消息都会被指派一个唯一的 ID(从 1 开始),一旦消息被投递到所有匹配的队列之后(如果消息和队列是持久化的,那么确认消息会在消息持久化后发出),RabbitMQ 就会发送一个确认(Basic.Ack)给生产者(包含消息的唯一 ID),这样生产者就知道消息已经正确送达了。

RabbitMQ 回传给生产者的确认消息中的 deliveryTag 字段包含了确认消息的序号,另外,通过设置 channel.basicAck 方法中的 multiple 参数,表示到这个序号之前的所有消息是否都已经得到了处理了。生产者投递消息后并不需要一直阻塞着,可以继续投递下一条消息并通过回调方式处理 ACK 响应。如果 RabbitMQ 因为自身内部错误导致消息丢失等异常情况发生,就会响应一条 nack(Basic.Nack)命令,生产者应用程序同样可以在回调方法中处理理该 nack 命令。

发布确认默认是没有开启的,如果要开启需要调用方法 confirmSelect,每当你要想使用发布确认,都需要在 channel 上调用该方法。

Channel channel = connection.createChannel();
channel.confirmSelect();

a. 单个发布确认

这是一种简单的确认方式,它是一种同步确认发布的方式,也就是发布一个消息之后只有它被确认发布,后续的消息才能继续发布,waitForConfirmsOrDie(long) 这个方法只有在消息被确认的时候才返回,如果在指定时间范围内这个消息没有被确认那么它将抛出异常。

这种确认方式有一个最大的缺点就是:发布速度特别的慢,因为如果没有确认发布的消息就会阻塞所有后续消息的发布,这种方式最多提供每秒不超过数百条发布消息的吞吐量。当然对于某些应用程序来说这可能已经足够了。

public class PublisherConfirmProducer {

    public static void main(String[] args) throws Exception {
        // 建立连接
        ConnectionFactory factory = new ConnectionFactory();
        factory.setUri("amqp://root:123456@192.168.6.160:5672/%2f");
        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();

        // 向RabbitMQBroker发送 AMQP 命令,将当前通道标记为发送方确认通道
        AMQP.Confirm.SelectOk selectOk = channel.confirmSelect();

        // 声明绑定队列和交换机
        channel.queueDeclare("queue.pc", true, false, false, null);
        channel.exchangeDeclare("exchange.pc", "direct", true, false, null);
        channel.queueBind("queue.pc", "exchange.pc", "key.pc");

        // 发送消息
        String msg = "sync-";
        for (int i = 0; i < 10; i++) {
            channel.basicPublish("exchange.pc", "key.pc", null, (msg + i).getBytes());

            // 同步的方式等待MQ的确认消息
            try {
                channel.waitForConfirmsOrDie(5_000);
                System.out.println("消息被确认:message = " + msg);
            } catch (IOException e) {
                e.printStackTrace();
                System.err.println("消息被拒绝!message = " + msg);
            } catch (IllegalStateException e) {
                e.printStackTrace();
                System.err.println("在不是Publisher Confirms的通道上使用该方法");
            } catch (TimeoutException e) {
                e.printStackTrace();
                System.err.println("等待消息确认超时!message = " + msg);
            }

        }

        // 关闭连接
        channel.close();
        connection.close();
    }
}

waitForConfirm 方法有个重载的,可以自定义 timeout 超时时间,超时后会抛 TimeoutException。

类似的有几个 waitForConfirmsOrDie 方法,Broker 端在返回 nack(Basic.Nack) 之后该方法会抛出 java.io.IOException。

需要根据异常类型来做区别处理, TimeoutException 超时是属于第三状态(无法确定成功还是失败),而返回 Basic.Nack 抛出 IOException 这种是明确的失败。上面的代码主要只是演示 confirm 机制,实际上还是同步阻塞模式的,性能并不是太好。

b. 批量发布确认

实际上,我们也可以通过“批处理”的方式来改善整体的性能(即批量发送消息后仅调用一次 waitForConfirms 方法)。正常情况下这种批量处理的方式效率会高很多,当然这种方案仍然是同步的,也一样阻塞消息的发布。

缺点是如果发生了超时或 nack(失败)后那就需要批量量重发消息或者通知上游业务批量回滚(因为我们只知道这个批次中有消息没投递成功,而并不知道具体是哪条消息投递失败了,所以很难针对性处理),如此看来,批量重发消息肯定会造成部分消息重复。

/**
 * @Author tree6x7
 * @Date 2024/9/29
 * @Description 批量等待确认消息(同步)
 */
public class PublisherConfirmProducer2 {

    public static void main(String[] args) throws Exception {
        // 建立连接
        ConnectionFactory factory = new ConnectionFactory();
        factory.setUri("amqp://root:123456@192.168.6.160:5672/%2f");
        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();

        // 向RabbitMQBroker发送 AMQP 命令,将当前通道标记为发送方确认通道
        AMQP.Confirm.SelectOk selectOk = channel.confirmSelect();

        // 声明绑定队列和交换机
        channel.queueDeclare("queue.pc", true, false, false, null);
        channel.exchangeDeclare("exchange.pc", "direct", true, false, null);
        channel.queueBind("queue.pc", "exchange.pc", "key.pc");

        // 发送消息
        int batchSize = 100;
        int outstandingConfirms = 0;
        String msg = "batchSync-";
        for (int i = 0; i < 1101; i++) {
            channel.basicPublish("exchange.pc", "key.pc", null, (msg + i).getBytes());
            outstandingConfirms++;
            if (outstandingConfirms == batchSize) {
                // 对一个批次的数据同步等待Broker的确认消息
                channel.waitForConfirmsOrDie(5_000);
                System.out.println("批消息确认");
                outstandingConfirms = 0;
            }
        }
        if (outstandingConfirms > 0) {
            channel.waitForConfirmsOrDie(5_000);
            System.out.println("剩余批消息确认");
        }

        // 关闭连接
        channel.close();
        connection.close();
    }
}

c. 异步发布确认

另外,我们可以通过异步回调的方式来处理 Broker 的响应。addConfirmListener 方法可以添加 ConfirmListener 这个回调接口,这个 ConfirmListener 接口包含两个方法:handleAck 和 handleNack,分别用来处理 RabbitMQ 回传的 Basic.Ack 和 Basic.Nack。

如何处理异步未确认消息?

最好的解决的解决方案就是把未确认的消息放到一个基于内存的能被发布线程访问的队列,比如说用 ConcurrentLinkedQueue 这个队列在 confirm callbacks 与发布线程之间进行消息的传递。

/**
 * @Author tree6x7
 * @Date 2024/9/29
 * @Description 回调模式
 */
public class PublisherConfirmProducer3 {

  public static void main(String[] args) throws Exception {
    // 建立连接
    ConnectionFactory factory = new ConnectionFactory();
    factory.setUri("amqp://root:123456@192.168.6.160:5672/%2f");
    Connection connection = factory.newConnection();
    Channel channel = connection.createChannel();

    // 向RabbitMQBroker发送 AMQP 命令,将当前通道标记为发送方确认通道
    AMQP.Confirm.SelectOk selectOk = channel.confirmSelect();

    // 声明绑定队列和交换机
    channel.queueDeclare("queue.pc", true, false, false, null);
    channel.exchangeDeclare("exchange.pc", "direct", true, false, null);
    channel.queueBind("queue.pc", "exchange.pc", "key.pc");

    /*
    public interface ConfirmCallback {
      // 用作回调处理消息确认或拒绝的情况,其中deliveryTag用于标识需要确认的消息,
      // multiple表示是否确认所有小于等于当前deliveryTag的消息。
      void handle(long deliveryTag, boolean multiple) throws IOException;
    }
     */
    ConcurrentNavigableMap<Long, String> outstandingConfirms = new ConcurrentSkipListMap<>();
    ConfirmCallback ackCallback = (sequenceNumber, multiple) -> {
      if (multiple) {
        System.out.println("小于等于 " + sequenceNumber + " 的消息都被确认了");
        // 获取map集合的子集
        ConcurrentNavigableMap<Long, String> headMap = outstandingConfirms.headMap(sequenceNumber, true);
        // 清空outstandingConfirms中已经被确认的消息
        headMap.clear();
      } else {
        System.out.println(sequenceNumber + " 对应的消息被确认");
        String removed = outstandingConfirms.remove(sequenceNumber);
      }
    };
    ConfirmCallback nackCallback = (sequenceNumber, multiple) -> {
      if (multiple) {
        System.out.println("小于等于 " + sequenceNumber + " 的消息都不确认了");
        ConcurrentNavigableMap<Long, String> headMap = outstandingConfirms.headMap(sequenceNumber, true);
        // 补偿机制
        doSomething(headMap);
      } else {
        System.out.println(sequenceNumber + " 对应的消息不确认");
        // 补偿机制
        doSomething(sequenceNumber);
      }
    };
    // => 设置回调!
    channel.addConfirmListener(ackCallback, nackCallback);

    String message = "callback-";
    for (int i = 0; i < 1000; i++) {
      long nextPublishSeqNo = channel.getNextPublishSeqNo();
      channel.basicPublish("exchange.pc", "queue.pc", null, (message + i).getBytes());
      System.out.println("序列号为:" + nextPublishSeqNo + "的消息已经发送了:" 
                   + (message + i) + ",尚未确认。");
      outstandingConfirms.put(nextPublishSeqNo, (message + i));
    }

    // 关闭连接
    channel.close();
    connection.close();
  }

  private static void doSomething(ConcurrentNavigableMap<Long, String> headMap) {}

  private static void doSomething(Long sequenceNumber) {}
}

d. 发布确认扩展

RabbitMQ 发布确认机制确保消息从生产者成功传输到交换机和队列,提高系统可靠性。在 SpringBoot 项目中,通过配置 publisher-confirm-typepublisher-returns,启用发布确认和消息返回机制。配置 RabbitTemplate 的确认回调和返回回调,可以捕捉消息传输状态,处理不同传输结果。测试场景包括消息无法到达交换机、消息到达交换机但无法到达队列以及消息成功到达队列。通过合理设置和优化,可以确保高并发环境下的消息可靠传输,适用于金融支付、电商系统等对消息传输可靠性要求高的场景。

发布确认(Publisher Confirms)是 RabbitMQ 提供的一种机制,用于确保消息从生产者发送到 RabbitMQ 服务器并被成功处理。与事务机制不同,发布确认的性能开销更小,非常适合高吞吐量的场景。发布确认机制提供了两种类型的确认:

  • 消息到达交换机(Exchange)后的确认
  • 消息从交换机路由到队列(Queue)后的确认

(1)配置文件中添加发布确认相关配置

在 SpringBoot 项目中,通过配置文件来启用发布确认机制非常方便。以下是需要添加到 application.properties 中的配置:

# 消息到达交换机后会回调发送者
# 设置为 correlated 表示使用 CorrelationData 来关联确认与发送的消息
spring.rabbitmq.publisher-confirm-type=correlated
# 消息无法路由到队列时回调发送者
# 设置为 true 表示启用消息返回机制,当消息无法路由到队列时会触发回调
spring.rabbitmq.publisher-returns=true

(2)发布确认类型

在 Spring AMQP 中,发布确认类型通过 ConfirmType 枚举类来定义:

public enum ConfirmType {
    SIMPLE,     // 使用 RabbitTemplate#waitForConfirms() / waitForConfirmsOrDie()
    CORRELATED, // 使用 CorrelationData 关联确认与发送的消息
    NONE        // 不启用发布确认
}

(3)配置 RabbitTemplate

在仅开启了生产者确认机制的情况下,交换机接收到消息后,会直接给消息生产者发送确认消息,但如果发现该消息不可路由,那么消息会被直接丢弃,此时生产者是不知道消息被丢弃这个事件的。

那么如何让无法被路由的消息帮我想办法处理一下?最起码通知我一声,我好自己处理。这时可以通过设置 mandatory 参数在当消息传递过程中不可达目的地时将消息返回给生产者。

// --- 消息无法到达交换机 
rabbitTemplate.setConfirmCallback(...); // implements -> RabbitTemplate.ConfirmCallback

// --- 消息到达交换机但无法到达队列
// [mandatory] true:交换机无法将消息进行路由时,会将该消息返回给生产者;false:如果发现消息无法进行路由,则直接丢弃(默认)
 rabbitTemplate.setMandatory(true);
rabbitTemplate.setReturnCallback(...); // implements RabbitTemplate.ReturnCallback

为了使用发布确认机制,需要配置 RabbitTemplate,包括设置确认回调和返回回调:

@Slf4j
@Configuration
public class RabbitTemplateConfig {

    @Bean
    public RabbitTemplate createRabbitTemplate(ConnectionFactory connectionFactory) {
        RabbitTemplate rabbitTemplate = new RabbitTemplate();
        rabbitTemplate.setConnectionFactory(connectionFactory);

        // 设置mandatory为true,当找不到队列时,broker会调用basic.return方法将消息返还给生产者
        rabbitTemplate.setMandatory(true);

        // 设置确认回调
        rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
            if (ack) {
                log.info("消息已经到达Exchange");
            } else {
                log.info("消息没有到达Exchange");
            }
            if (correlationData != null) {
                log.info("相关数据:" + correlationData);
            }
            if (cause != null) {
                log.info("原因:" + cause);
            }
        });

        // 设置返回回调
        rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
            log.info("消息无法到达队列时触发");
            log.info("ReturnCallback:     " + "消息:" + message);
            log.info("ReturnCallback:     " + "回应码:" + replyCode);
            log.info("ReturnCallback:     " + "回应信息:" + replyText);
            log.info("ReturnCallback:     " + "交换机:" + exchange);
            log.info("ReturnCallback:     " + "路由键:" + routingKey);
        });

        return rabbitTemplate;
    }
}

(4)事务机制与发布确认机制的对比

事务机制和发布确认机制都是确保消息可靠投递的手段,但它们在实现和性能方面有明显区别:

  • 事务机制:通过 txSelect、txCommit 和 txRollback 实现,性能开销较大,不适合高并发场景。
  • 发布确认机制:通过异步确认消息是否成功到达交换机和队列,性能开销小,适合高并发场景。

1.4 持久化存储机制

持久化是提高 RabbitMQ 可靠性的基础,否则当 RabbitMQ 遇到异常时(如:重启、断电、停机等)数据将会丢失。主要从以下几个方面来保障消息的持久性:

  1. Exchange 的持久化。通过定义时设置 durable 参数为 true 来保证 Exchange 相关的元数据不不丢失。
  2. Queue 的持久化。通过定义时设置 durable 参数为 true 来保证 Queue 相关的元数据不不丢失(需要注意的是如果之前声明的队列不是持久化的,需要把原先队列先删除,或者重新创建一个持久化的队列,不然就会出现错误)。
  3. Msg 的持久化。通过将消息的投递模式(BasicProperties 中的 deliveryMode 属性)设置为 2 即可实现消息的持久化,保证消息自身不丢失。

RabbitMQ 中的持久化消息都需要写入磁盘(当系统内存不不足时,非持久化的消息也会被刷盘处理),这些处理理动作都是在“持久层”中完成的。持久层是一个逻辑上的概念,实际包含两个部分:

  1. 队列索引 rabbit_queue_index,rabbit_queue_index 负责维护 Queue 中消息的信息,包括消息的存储位置、是否已交给消费者、是否已被消费及 Ack 确认等,每个 Queue 都有与之对应的 rabbit_queue_index。
  2. 消息存储 rabbit_msg_store,rabbit_msg_store 以键值对的形式存储消息,它被所有队列列共享,在每个节点中有且只有一个。

虚拟主机路径下包含 queues、msg_store_persistent、msg_store_transient 这 3 个目录,这是实际存储消息的位置。其中 queues 目录中保存着 rabbit_queue_index 相关的数据,而 msg_store_persistent 保存着持久化消息数据,msg_store_transient 保存着非持久化相关的数据。

1.5 消费端 - 确认机制

如何保证消息被消费者成功消费?

前面我们讲了生产者发送确认机制和消息的持久化存储机制,然而这依然无法完全保证整个过程的可靠性,因为如果消息被消费过程中业务处理失败了但是消息已经出列了(被标记为已消费了),我们又没有任何重试,那结果跟消息丢失没什么分别。

RabbitMQ 在消费端会有 ACK 机制,即消费端消费消息后需要发送 ACK 确认报文给 Broker 端,告知自己是否已消费完成,否则可能会一直重发消息直到消息过期(AUTO 模式)。

这也是我们之前一直在讲的“最终一致性”、“可恢复性” 的基础。

一般而言,我们有如下处理手段:

  1. 采用 NONE(自动 ACK)模式,消费的过程中自行捕获异常,引发异常后直接记录日志并落到异常恢复表,再通过后台定时任务扫描异常恢复表尝试做重试动作。如果业务不自行处理则有丢失数据的风险;
  2. 采用 AUTO(根据异常情况 ACK)模式,不主动捕获异常,当消费过程中出现异常时会将消息放回 Queue 中,然后消息会被重新分配到其他消费者节点(如果没有则还是选择当前节点)重新被消费,默认会一直重发消息并直到消费完成返回 ACK 或者一直到过期;
  3. 采用 MANUAL(手动 ACK)模式,消费者自行控制流程并手动调用 Channel 相关的方法返回 ACK。

API:

/**
 * Retrieve a message from a queue using 'com.rabbitmq.client.AMQP.Basic.Get'
 *
 * @param queue the name of the queue
 * @param autoAck 
 *  - true if the server should consider messages acknowledged once delivered;
 *  - false if the server should expect explicit acknowledgements
 * @return a 'GetResponse' containing the retrieved message data
 * @throws java.io.IOException if an error is encountered
 */
GetResponse basicGet(String queue, boolean autoAck) throws IOException;

/**
 * Acknowledge one or several received messages. 用于肯定确认
 *
 * @param deliveryTag 
 * @param multiple
 *  - true to acknowledge all messages up to and including the supplied delivery tag;
 *  - false to acknowledge just the supplied delivery tag.
 * @throws java.io.IOException if an error is encountered
 */
void basicAck(long deliveryTag, boolean multiple) throws IOException;

/**
 * Reject one or several received messages. 用于否定确认
 *
 * @param deliveryTag
 * @param multiple
 *  - true to reject all messages up to and including the supplied delivery tag; 
 *  - false to reject just the supplied delivery tag.
 * @param requeue true if the rejected message(s) should be requeued rather than discarded/dead-lettered
 * @throws java.io.IOException if an error is encountered
 */
void basicNack(long deliveryTag, boolean multiple, boolean requeue) throws IOException;

/**
 * Reject a message. 拒绝消息
 * @param deliveryTag
 * @param requeue
 *   - true if the rejected message should be requeued rather than discarded/dead-lettered
 * @throws java.io.IOException if an error is encountered
 */
void basicReject(long deliveryTag, boolean requeue) throws IOException;

(1)手动应答的好处是可以批量应答并且减少网络拥堵。

multiple 的 true 和 false 代表不同意思。

  • true 代表批量应答 channel 上未应答的消息。比如说 channel 上有传送 tag 的消息 5,6,7,8 当前 tag 是 8 那么此时 5-8 的这些还未应答的消息都会被确认收到消息应答。可减少每个消息都发送确认带来的网络流量负载。
  • false 同上面相比,只会应答 tag=8 的消息 5,6,7 这三个消息依然不会被确认收到消息应答。

(2)消息重新入队

如果消费者由于某些原因失去连接(其通道已关闭,连接已关闭或 TCP 连接丢失),导致消息未发送 ACK 确认,RabbitMQ 将了解到消息未完全处理,并将对其重新排队。如果此时其他消费者可以处理,它将很快将其重新分发给另一个消费者。这样,即使某个消费者偶尔死亡,也可以确保不会丢失任何消息。

(3)示例代码

public class MyConsumer {
    public static void main(String[] args) throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setUri("amqp://root:123456@node1:5672/%2f");

        final Connection connection = factory.newConnection();
        final Channel channel = connection.createChannel();

        channel.queueDeclare("queue.ca", false, false, false, null);

        // false表示手动确认消息
        boolean autoAck = false;

        // 拉消息的模式
        // final GetResponse getResponse = channel.basicGet("queue.ca", autoAck);
        // channel.basicReject(getResponse.getEnvelope().getDeliveryTag(), true);

        // 推消息模式
        channel.basicConsume("queue.ca", autoAck, "myConsumer", new DefaultConsumer(channel) {
            @Override
            public void handleDelivery(String consumerTag,
                                       Envelope envelope,
                                       AMQP.BasicProperties properties,
                                       byte[] body) throws IOException {

                System.out.println(new String(body));

                // 确认消息
                channel.basicAck(envelope.getDeliveryTag(), false);

                // channel.basicNack(envelope.getDeliveryTag(), false, true);

                // channel.basicReject(envelope.getDeliveryTag(), true);
            }
        });

    }
}

上面是通过在消费端直接配置指定 ackMode,在一些比较老的 Spring 项目中一般是通过 xml 方式去定义、声明和配置的,不管是XML还是注解,相关配置、属性这些其实都是大同小异,触类旁通。然后需要注意的是 channel.basicAck 这几个手工 ACK 确认的方法。

SpringBoot 项目中支持如下的一些配置:

# 最大重试次数
spring.rabbitmq.listener.simple.retry.max-attempts=5
# 是否开启消费者重试
spring.rabbitmq.listener.simple.retry.enabled=true
# 重试间隔时间(单位毫秒)
spring.rabbitmq.listener.simple.retry.initial-interval=5000
# 重试超过最大次数后是否拒绝
spring.rabbitmq.listener.simple.default-requeue-rejected=false
# ack模式
spring.rabbitmq.listener.simple.acknowledge-mode=manual

1.6 消费端限流

在电商的秒杀活动中,活动一开始会有大量并发写请求到达服务端,需要对消息进行削峰处理,如何削峰?

当消息投递速度远快于消费速度时,随着时间积累就会出现“消息积压”。消息中间件本身是具备一定的缓冲能力的,但这个能力是有容量限制的,如果长期运行并没有任何处理,最终会导致 Broker 崩溃,而分布式系统的故障往往会发生上下游传递,连锁反应那就会很悲剧 ...

下面将从多个角度介绍 QoS 与限流,防止上面的悲剧发生。

a. 资源使用量阈值

RabbitMQ 可以对内存和磁盘使用量设置阈值。

当达到阈值后,生产者将被阻塞,直到对应项指标恢复正常。全局上可以防止超大流量、消息积压等导致的 Broker 被压垮。当内存受限或磁盘可用空间受限的时候,服务器都会暂时阻止连接,服务器将暂停从发布消息的已连接客户端的套接字读取数据。连接心跳监视也将被禁用。所有网络连接将在 rabbitmqctl 和管理插件中显示为“已阻止”,这意味着它们尚未尝试发布,因此可以继续或被阻止,这意味着
它们已发布,现在已暂停。兼容的客户端被阻止时将收到通知。

/etc/rabbitmq/rabbitmq.conf 中配置磁盘可用空间大小:

b. 对连接做流控

RabbitMQ 还默认提供了一种基于 credit flow 的 流控机制,面向每一个连接进行流控。当单个队列达到最大流速时,或者多个队列达到总流速时,都会触发流控。触发单个连接的流控可能是因为 Connection、Channel、Queue 的某一个过程处于 flow(过载)状态,这些状态都可以从监控平台看到。

c. QoS 保证机制

RabbitMQ 中有一种 QoS 保证机制,可以限制 Channel 上接收到的未被 Ack 的消息数量,如果超过这个数量限制 RabbitMQ 将不会再往消费端推送消息。这是一种流控手段,可以防止大量消息瞬时从 Broker 送达消费端造成消费端巨大压力(甚至压垮消费端)。

比较值得注意的是 QoS 机制仅对于消费端推模式有效,对拉模式无效,而且不支持 NONE ACK模式。

执行 channel.basicConsume() 方法之前通过 channel.basicQoS() 方法设置客户端最多接收未被 ACK 的消息的个数。消息的发送是异步的,消息的确认也是异步的。

注意:消费者端要把自动确认 autoAck 设置为 false,该方法才会有效果。

basicQos(int prefetchCount);
basicQos(int prefetchCount, boolean global);
basicQos(int prefetchSize, int prefetchCount, boolean global);
  • prefetchSize:可接收消息的大小。如果设置为 0,那么表示对消息本身的大小不限制;
  • prefetchCount:处理消息最大的数量。
  • global:是否针对整个 Connection 的,因为一个 Connection 可以有多个 Channel,如果是 false,则说明只是针对于这个 Channel 的。

在消费者消费慢的时候,可以设置 Qos 的 prefetchCount,它表示 Broker 在向消费者发送消息的时候,一旦发送了 prefetchCount 个消息而没有一个消息确认的时候,就停止发送。消费者确认一个,Broker 就发送一个,确认两个就发送两个。换句话说,消费者确认多少,Broker 就发送多少,消费者等待处理的个数永远限制在 refetchCount 个。如果对于每个消息都发送确认,增加了网络流量,此时可以批量确认消息。如果设置了 basicAck#multiple 为 true,消费者在确认的时候,比如说 id 是 8 的消息确认了,则在 8 之前的所有消息都确认了。

public class MyConsumer {
    public static void main(String[] args) throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setUri("amqp://root:123456@node1:5672/%2f");
        final Connection connection = factory.newConnection();
        final Channel channel = connection.createChannel();

        channel.queueDeclare("queue.qos", false, false, false, null);

        // 使用basic做限流,仅对消息推送模式生效。

        // 1. void basicQos(int prefetchCount)
        // a. 表示Qos是10个消息,最多有10个消息等待确认
        channel.basicQos(10);

        // 2. void basicQos(int prefetchCount, boolean global)
        // a. 表示最多10个消息等待确认。
        // b. global=true 表示只要是使用当前的channel的Consumer,该设置都生效。false表示仅限于当前Consumer。
        channel.basicQos(10, false);

        // 3. void basicQos(int prefetchSize, int prefetchCount, boolean global)
        // a. 第一个参数表示未确认消息的大小,Rabbit没有实现,不用管。
        channel.basicQos(1000, 10, true);

        channel.basicConsume("queue.qos", false, new DefaultConsumer(channel) {
            @Override
            public void handleDelivery(String consumerTag,
                                       Envelope envelope,
                                       AMQP.BasicProperties properties,
                                       byte[] body) throws IOException {
                // 可以批量确认消息,减少每个消息都发送确认带来的网络流量负载。
                // void basicAck(long deliveryTag, boolean multiple)
                channel.basicAck(envelope.getDeliveryTag(), true);
            }
        });

        channel.close();
        connection.close();

    }
}

生产者往往是希望自己产生的消息能快速投递出去,而当消息投递太快且超过了下游的消费速度时就容易出现消息积压/堆积。

所以,从上游来讲我们应该在生产端应用程序中也可以加入限流、应急开关等控制手段,避免超过 Broker 端的极限承载能力或者压垮下游消费者。

再看看下游,我们期望下游消费端能尽快消费完消息,而且还要防止瞬时大量消息压垮消费端(推模式),我们期望消费端处理速度是最快、最稳定而且还相对均匀(比较理想化)。提升下游应用的吞吐量和缩短消费过程的耗时,优化主要以下几种方式:

  1. 优化应用程序的性能,缩短响应时间(需要时间)
  2. 增加消费者节点实例(成本增加,而且底层数据库操作这些也可能是瓶颈)
  3. 调整并发消费的线程数(线程数并非越大越好,需要大量压测调优至合理值)

整合 SpringBoot 设置相关参数:

@Bean
public RabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory) {
    // SimpleRabbitListenerContainerFactory发现消息中有content_type有text就会默认将其
    // 转换为String类型的,没有content_type都按byte[]类型
    SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
    factory.setConnectionFactory(connectionFactory);
    // 设置并发线程数
    factory.setConcurrentConsumers(10);
    // 设置最大并发线程数
    factory.setMaxConcurrentConsumers(20);
    return factory;
}

1.7 消息可靠性保障

在讲高级特性的时候几乎已经都涉及到了,这里简单回顾总结下:

  1. 消息传输保障
  2. 各种限流、应急手段
  3. 业务层面的一些容错、补偿、异常重试等手段

消息可靠传输一般是业务系统接入消息中间件时首要考虑的问题,一般消息中间件的消息传输保障分为三个层级:

  1. At most once:最多一次。消息可能会丢失,但绝不会重复传输;
  2. At least once:最少一次。消息绝不会丢失,但可能会重复传输;
  3. Exactly once:恰好一次。每条消息肯定会被传输一次且仅传输一次。

RabbitMQ 支持其中的“最多一次”和“最少一次”。

其中“最少一次”投递实现需要考虑以下这个几个方面的内容:

  1. 消息生产者需要开启事务机制或者 Publisher Confirm 机制,以确保消息可以可靠地传输到 RabbitMQ 中。
  2. 消息生产者需要配合使用 mandatory 参数或者备份交换器来确保消息能够从交换器路由到队列中,进而能够保存下来而不会被丢弃。
  3. 消息和队列都需要进行持久化处理,以确保 RabbitMQ 服务器在遇到异常情况时不会造成消息丢失。
  4. 消费者在消费消息的同时需要将 autoAck 设置为 false,然后通过手动确认的方式去确认已经正确消费的消息,以避免在消费端引起不必要的消息丢失。

“最多一次”的方式就无须考虑以上那些方面,生产者随意发送,消费者随意消费,不过这样很难确保消息不会丢失。

“恰好一次”是 RabbitMQ 目前无法保障的。

考虑这样一种情况,消费者在消费完一条消息之后向 RabbitMQ 发送确认 Basic.Ack 命令,此时由于网络断开或者其他原因造成 RabbitMQ 并没有收到这个确认命令,那么 RabbitMQ 不会将此条消息标记删除。在重新建立连接之后,消费者还是会消费到这一条消息,这就造成了重复消费。

再考虑一种情况,生产者在使用 Publisher Confirm 机制的时候,发送完一条消息等待 RabbitMQ 返回确认通知,此时网络断开,生产者捕获到异常情况,为了确保消息可靠性选择重新发送,这样 RabbitMQ 中就有两条同样的消息,在消费的时候消费者就会重复消费。

1.8 消息幂等性处理

刚刚我们讲到,追求高性能就无法保证消息的顺序,而追求可靠性那么就可能产生重复消息,从而导致重复消费... 真是应证了那句老话:做架构就是权衡取舍。

RabbitMQ 层面有实现“去重机制”来保证“恰好一次”吗?

答案是并没有。而且这个在目前主流的消息中间件都没有实现。

借用淘宝沈洵的一句话:最好的解决办法就是不去解决。当为了在基础的分布式中间件中实现某种相对不太通用的功能,需要牺牲到性能、可靠性、扩展性时,并且会额外增加很多复杂度,最简单的办法就是交给业务自己去处理。

事实证明,很多业务场景下是可以容忍重复消息的。例如:操作日志收集,而对一些金融类的业务则要求比较严苛。一般解决重复消息的办法是,在消费端让消费消息的操作具备幂等性。

幂等性问题并不是消息系统独有,而是(分布式)系统中普遍存在的问题。例如:RPC 框架调用超后会重试,HTTP 请求会重复发起(用户手抖多点了几下按钮)。

幂等(Idempotence)是一个数学上的概念,它是这样定义的:如果一个函数 f(x) 满足:f(f(x)) = f(x),则函数 f(x) 满足幂等性。这个概念被拓展到计算机领域,被用来描述一个操作、方法或者服务。

一个幂等操作的特点是,其任意多次执行所产生的影响均与一次执行的影响相同。一个幂等的方法,使用同样的参数,对它进行多次调用和一次调用,对系统产生的影响是一样的。对于幂等的方法,不用担心重复执行会对系统造成任何改变。

举个简单的例子(在不考虑并发问题的情况下):

select * from xx where id=1
delete from xx where id=1

这两条 SQL 语句就是天然幂等的,它本身的重复执行并不会引起什么改变。而 update 就要看情况的:

update xxx set amount = 100 where id=1
pdate xxx set amount = amount + 100 where id=1

第一条语句执行 1 次和 100 次都是一样的结果(最终余额都还是 100),所以它是满足幂等性的。第二条就不满足幂等性。

业界对于幂等性的一些常见做法:

  1. 借助数据库唯一索引,重复插入直接报错,事务回滚。还是举经典的转账的例子,为了保证不重复扣款或者重复加钱,我们这边维护一张“资金变动流水表”,里面至少需要交易单号、变动账户、变动金额等 3 个字段。我们选择交易单号和变动账户做联合唯一索引(单号是上游生成的可保证唯一性),这样如果同一笔交易发生重复请求时就会直接报索引冲突,事务直接回滚。现实中,数据库唯一索引的方式通常做为兜底保证;
  2. 前置检查机制。这个很容易理解,并且有几种实现办法。还是引用上面转账的例子,当我在执行更改账户余额这个动作之前,我得先检查下资金变动流水表(或者 Tair 中)中是否已经存在这笔交易相关的记录了, select * from xxx where accountNumber=xxx and orderId=yyy,如果已经存在,那么直接返回,否则执行正常的更新余额的动作。为了防止并发问题,我们通常需要借助“排他锁”来完成。在支付宝有一条铁律叫:一锁、二判、三操作。当然,我们也可以使用乐观锁或 CAS 机制,乐观锁一般会使用扩展一个版本号字段做判断条件。
  3. 唯一 ID 机制,比较通用的方式。对于每条消息我们都可以生成唯一 ID,消费前判断 Tair 中是否存在(MsgId 做 Tair 排他锁的 key),消费成功后将状态写入 Tair 中,这样就可以防止重复消费了。

对于接口请求类的幂等性保证要相对更复杂,我们通常要求上游请求时传递一个类 GUID 的请求号(或 TOKEN)。

如果我们发现已经存在了并且上一次请求处理结果是成功状态的(有时候上游的重试请求是正常诉求,我们不能将上一次异常/失败的处理结果返回或者直接提示“请求异常”,如果这样重试就变得没意义了),则不继续往下执行,直接返回“重复请求”的提示和上次的处理结果(上游通常是由于请求超时等未知情况才发起重试的,所以直接返回上次请求的处理结果就好了)。

如果请求 ID 都不存在或者上次处理结果是失败/异常的,那就继续处理流程,并最终记录最终的处理结果。

这个请求序号由上游自己生成,上游通用需要根据请求参数、时间间隔等因子来生成请求 ID。同样也需要利用这个请求 ID 做分布式锁的 KEY 实现排他。

2. 可靠性分析

在使用任何消息中间件的过程中,难免会出现消息丢失等异常情况,这个时候就需要有一个良好的机制来跟踪记录消息的过程(轨迹溯源),帮助我们排查问题。

2.1 Firehose

在 RabbitMQ 中可以使用 Firehose 功能来实现消息追踪,Firehose 可以记录每一次发送或者消费消息的记录,方便 RabbitMQ 的使用者进行调试、排错等。

Firehose 的原理是将生产者投递给RabbitMQ 的消息,或者RabbitMQ 投递给消费者的消息按照指定的格式发送到默认的交换器上。这个默认的交换器的名称为 amq.rabbitmq.trace ,它是一个 topic 类型的交换器。发送到这个交换器上的消息的路由键为 publish.{exchangename}deliver.{queuename}。其中 exchangenamequeuename 为交换器和队列的名称,分别对应生产者投递到交换器的消息和消费者从队列中获取的消息。

开启 Firehose 命令:rabbitmqctl trace_on [-p vhost],其中 vhost 是可选参数,用来指定虚拟主机。对应的关闭命令为:rabbitmqctl trace_off [-p vhost]

Firehose 默认情况下处于关闭状态,并且 Firehose 的状态是非持久化的,会在 RabbitMQ 服务重启的时候还原成默认的状态。Firehose 开启之后多少会影响 RabbitMQ 整体服务性能,因为它会引起额外的消息生成、路由和存储。

2.2 rabbitmq_tracing

rabbitmq_tracing 插件相当于 Firehose 的 GUI 版本,它同样能跟踪 RabbitMQ 中消息的流入流出情况。rabbitmq_tracing 插件同样会对流入流出的消息进行封装,然后将封装后的消息日志存入相应的 trace 文件中。

可以使用 rabbitmq-plugins enable rabbitmq_tracing 命令来启动 rabbitmq_ tracing 插件;使用 rabbitmq-plugins disable rabbitmq_tracing 命令关闭该插件。

3. TTL 机制

在京东下单,订单创建成功,等待支付,一般会给 30min 的时间,开始倒计时。如果在这段时间内用户没有支付,则默认订单取消。这该如何实现?

(1)定期轮询

用户下单成功,将订单信息放入数据库,同时将支付状态放入数据库,用户付款更改数据库状态。定期轮询数据库支付状态,如果超过 30min 就将该订单取消。

优点:设计实现简单

缺点:需要对数据库进行大量的 IO 操作,效率低下。

(2)ScheduledExecutorService

优点:可以多线程执行,一定程度上避免任务间互相影响,单个任务异常不影响其它任务。

在高并发的情况下,不建议使用定时任务去做,因为太浪费服务器性能,不建议。

(3)RabbitMQ·TTL(Time to Live,过期时间)

(4)Quartz、Redis Zset、JCronTab、SchedulerX ...


任何消息中间件的容量和堆积能力都是有限的,如果有一些消息总是不被消费掉,那么需要有一种过期的机制来做兜底。

RabbitMQ 可以对消息和队列两个维度来设置 TTL。

(1)消息设置 TTL

设置消息过期时间使用参数:expiration,单位:ms(毫秒),当该消息在队列头部时(消费时),会单独判断这一消息是否过期。

(2)队列设置 TTL

设置队列过期时间使用参数:x-message-ttl,单位:ms(毫秒),即队列中所有消息都有相同的过期时间。

二者区别:

  • 如果使用第 2 中方式设置队列的 TTL 属性,那么一旦消息过期,就会被队列丢弃(如果配置了死信队列被丢到死信队列中);而第 1 种方式,消息即使过期,也不一定会被马上丢弃,因为消息是否过期是在即将投递到消费者之前判定的,如果当前队列有严重的消息积压情况,则已过期的消息也许还能存活较长时间;另外,还需要注意的一点是,如果不设置 TTL,表示消息永远不会过期,如果将 TTL 设置为 0,则表示除非此时可以直接投递该消息到消费者,否则该消息将会被丢弃。
  • 如果两种方法一起使用,则消息的 TTL 以两者之间较小数值为准。通常来讲,消息在队列中的生存时间一旦超过设置的TTL 值时,就会变成“死信”(Dead Message),消费者默认就无法再收到该消息。当然,“死信”也是可以被取出来消费的,下一小节我们会讲解。

原生 API 案例:

try (Connection connection = factory.newConnection(); Channel channel = connection.createChannel()) {
    // 创建队列(实际上使用的是AMQP default这个direct类型的交换器)
    // 设置队列属性
    Map < String, Object > arguments = new HashMap < > ();
    // 设置队列的TTL
    arguments.put("x-message-ttl", 30000);
    // 设置队列的空闲存活时间(如果消息队列没有消费者,则10s后消息过期,消息队列也删除)
    arguments.put("x-expires", 10000);
    // 声明队列
    channel.queueDeclare(QUEUE_NAME, false, false, false, arguments);
    // 发布消息
    for(int i = 0; i < 1000000; i++) {
        String message = "Hello World!" + i;
        channel.basicPublish("", 
                             QUEUE_NAME, 
                             new AMQP.BasicProperties().builder().expiration("30000").build(),
                             message.getBytes());
        System.out.println(" [X] Sent '" + message + "'");
    }
} catch (TimeoutException e) {
    e.printStackTrace();
} catch (IOException e) {
    e.printStackTrace();
}

此外,还可以通过命令行方式设置全局 TTL,执行如下命令:

默认规则:

  1. 如果不设置 TTL,则表示此消息不会过期;
  2. 如果 TTL 设置为 0,则表示除非此时可以直接将消息投递到消费者,否则该消息会被立即丢弃;

注意理解 message-ttl 、 x-expires 这两个参数的区别,有不同的含义。但是这两个参数属性都遵循上面的默认规则。一般 TTL 相关的参数单位都是毫秒(ms)。

4. 死信队列

4.1 死信来源

先从概念解释上搞清楚这个定义,死信,顾名思义就是无法被消费的消息,字面意思可以这样理解,一般来说,producer 将消息投递到 broker 或者直接到 queue 里了,consumer 从 queue 取出消息进行消费,但某些时候由于特定的原因导致 queue 中的某些消息无法被消费,这样的消息如果没有后续的处理,就变成了死信,有死信自然就有了死信队列。

当消息成为 Dead Message 后,可以被重新发送到另一个交换机,这个交换机就是 DLX(Dead Letter Exchange 死信交换机)。同时,绑定 DLX 的队列就称为“死信队列”。

消息成为死信的三种情况:

  1. 队列满了,无法再添加数据到 MQ 中
  2. 消费者拒绝消费消息(basicNack/basicReject),并且不把消息重新放入原目标队列(requeue=false)
  3. 原队列存在消息过期设置 TTL,消息到达超时时间未被消费。

对于 RabbitMQ 来说,DLX 是一个非常有用的特性。它可以处理异常情况下,消息不能够被消费者正确消费(消费者调用了 Basic.Nack 或 Basic.Reject)而被置入死信队列中的情况,后续分析程序可以通过消费这个死信队列中的内容来分析当时所遇到的异常情况,进而可以改善和优化系统。

4.2 API 说明

原生 API 案例:

try (Connection connection = factory.newConnection(); Channel channel = connection.createChannel()) {
    // 定义一个死信交换器(也是一个普通的交换器)
    channel.exchangeDeclare("exchange.dlx", "direct", true);
    // 定义一个正常业务的交换器
    channel.exchangeDeclare("exchange.biz", "fanout", true);
    Map < String, Object > arguments = new HashMap < > ();
    // 设置队列中消息的过期时间
    arguments.put("x-message-ttl", 10000);
    // 设置该队列所关联的死信交换器(当队列消息TTL到期后依然没有消费,则加入死信队列)
    arguments.put("x-dead-letter-exchange", "exchange.dlx");
    // 设置该队列所关联的死信交换器的routingKey,如果没有特殊指定,使用原队列的routingKey
    arguments.put("x-dead-letter-routing-key", "routing.key.dlx.test");
    channel.queueDeclare("queue.biz", true, false, false, arguments);
    channel.queueBind("queue.biz", "exchange.biz", "");
    channel.queueDeclare("queue.dlx", true, false, false, null);
    // 死信队列和死信交换器
    channel.queueBind("queue.dlx", "exchange.dlx", "routing.key.dlx.test");
    channel.basicPublish("exchange.biz", "", MessageProperties.PERSISTENT_TEXT_PLAIN, "dlx.test".getBytes());
} catch (Exception e) {
    e.printStackTrace();
}

SpringBoot 案例:

@Configuration
public class MQConfig {

    @Bean
    public Queue queue() {
        Map<String, Object> props = new HashMap<>();
        // 消息的生存时间 10s
        props.put("x-message-ttl", 10000);
        // 设置该队列所关联的死信交换器(当队列消息TTL到期后依然没有消费,则加入死信队列)
        props.put("x-dead-letter-exchange", "ex.go.dlx");
        // 设置该队列所关联的死信交换器的routingKey,如果没有特殊指定,使用原队列的routingKey
        props.put("x-dead-letter-routing-key", "go.dlx");
        Queue queue = new Queue("q.go", true, false, false, props);
        return queue;
    }

    @Bean
    public Queue queueDlx() {
        Queue queue = new Queue("q.go.dlx", true, false, false);
        return queue;
    }

    @Bean
    public Exchange exchange() {
        DirectExchange exchange = new DirectExchange("ex.go", true, false, null);
        return exchange;
    }

    @Bean
    public Exchange exchangeDlx() {
        DirectExchange exchange = new DirectExchange("ex.go.dlx", true, false, null);
        return exchange;
    }

    @Bean
    public Binding binding() {
        return BindingBuilder.bind(queue()).to(exchange()).with("go").noargs();
    }

    /**
     * 死信交换器绑定死信队列
     */
    @Bean
    public Binding bindingDlx() {
        return BindingBuilder.bind(queueDlx()).to(exchangeDlx()).with("go.dlx").noargs();
    }
}

4.3 完整案例

(1)生产者

public class DLXProducer {
    private static final String NORMAL_EXCHANGE = "normal_exchange";

    public static void main(String[] argv) throws Exception {
        try (Channel channel = RabbitMQUtils.getChannel()) {
            channel.exchangeDeclare(NORMAL_EXCHANGE, BuiltinExchangeType.DIRECT);
            // 设置消息的 TTL 时间
            AMQP.BasicProperties properties = new AMQP.BasicProperties().builder().expiration("10000").build();
            // 该信息是用作演示队列个数限制
            for (int i = 1; i < 11; i++) {
                String message = "info" + i;
                channel.basicPublish(NORMAL_EXCHANGE, "test-dlx", properties, message.getBytes());
                System.out.println("生产者发送消息:" + message);
            }
        }
    }
}

(2)消费正常队列的消费者

public class NormalConsumer {

    // 普通交换机名称
    private static final String NORMAL_EXCHANGE = "normal_exchange";
    // 死信交换机名称
    private static final String DEAD_EXCHANGE = "dead_exchange";

    // 声明死信队列
    private static final String DEAD_QUEUE = "dead-queue";
    // 声明普通队列
    private static final String NORMAL_QUEUE = "normal-queue";

    public static void main(String[] argv) throws Exception {
        Channel channel = RabbitMQUtils.getChannel();
        // 1. 声明死信和普通交换机类型为 direct
        channel.exchangeDeclare(NORMAL_EXCHANGE, BuiltinExchangeType.DIRECT);
        channel.exchangeDeclare(DEAD_EXCHANGE, BuiltinExchangeType.DIRECT);

        // 2. 声明死信队列
        channel.queueDeclare(DEAD_QUEUE, false, false, false, null);

        // 3. 声明正常队列,并绑定死信队列
        // 死信队列 绑定 死信交换机&routingKey
        channel.queueBind(DEAD_QUEUE, DEAD_EXCHANGE, "test-dlx");
        // 正常队列 绑定 死信队列信息
        Map<String, Object> params = new HashMap<>();
        // 正常队列 设置 死信交换机 参数key是固定值
        params.put("x-dead-letter-exchange", DEAD_EXCHANGE);
        // 正常队列 设置 死信routing-key 参数key是固定值
        params.put("x-dead-letter-routing-key", "test-dlx");
        // 声明正常队列
        channel.queueDeclare(NORMAL_QUEUE, false, false, false, params);
        // 绑定动作
        channel.queueBind(NORMAL_QUEUE, NORMAL_EXCHANGE, "test");

        // 4. 消息接收
        System.out.println("等待接收消息.....");
        DeliverCallback deliverCallback = (consumerTag, delivery) -> {
            String message = new String(delivery.getBody(), "UTF-8");
            if (message.equals("info5")) {
                System.out.println("Consumer01 接收到消息" + message + "并拒绝签收该消息");
                // requeue 设置为 false 代表拒绝重新入队,该队列如果配置了死信交换机将发送到死信队列中
                channel.basicReject(delivery.getEnvelope().getDeliveryTag(), false);
            } else {
                System.out.println("Consumer01 接收到消息" + message);
                channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
            }
        };
        boolean autoAck = false;
        channel.basicConsume(NORMAL_QUEUE, autoAck, deliverCallback, consumerTag -> {});
    }
}

(3)消费死信队列的消费者

public class DLXConsumer {
    private static final String DEAD_EXCHANGE = "dead_exchange";

    public static void main(String[] argv) throws Exception {
        Channel channel = RabbitMQUtils.getChannel();
        channel.exchangeDeclare(DEAD_EXCHANGE, BuiltinExchangeType.DIRECT);
        String deadQueue = "dead-queue";
        channel.queueDeclare(deadQueue, false, false, false, null);
        channel.queueBind(deadQueue, DEAD_EXCHANGE, "test-dlx");
        System.out.println("等待接收死信队列消息...");
        DeliverCallback deliverCallback = (consumerTag, delivery) -> {
            String message = new String(delivery.getBody(), "UTF-8");
            System.out.println("Consumer02 接收死信队列的消息:" + message);
        };
        channel.basicConsume(deadQueue, true, deliverCallback, consumerTag -> {});
    }
}

效果展示:

5. 延迟队列

延迟消息是指的消息发送出去后并不想立即就被消费,而是需要等(指定的)一段时间后才触发消费。

5.1 原生实现

例如下面的业务场景:在支付宝上面买电影票,锁定了一个座位后系统默认会帮你保留 15min 时间,如果 15min 后还没付款那么不好意思系统会自动把座位释放掉。怎么实现类似的功能呢?

  1. 可以用定时任务每分钟扫一次,发现有占座超过 15min 还没付款的就释放掉。但是这样做很低效,很多时候做的都是些无用功;
  2. 可以用分布式锁、分布式缓存的被动过期时间,15min 过期后锁也释放了,缓存 key 也不存在了;
  3. 还可以用延迟队列,锁座成功后会发送1条延迟消息,这条消息 15min 后才会被消费,消费的过程就是检查这个座位是否已经是“已付款”状态;

诸如此类的场景还有:

  1. 新创建的店铺,如果在 10 天内都没有上传过商品,则自动发送消息提醒。
  2. 用户注册成功后,如果 3 天内没有登陆则进行短信提醒。
  3. 用户发起退款,如果 3 天内没有得到处理则通知相关运营人员。
  4. 预定会议后,需要在预定的时间点前 10min 通知各个与会人员参加会议。

同样的,这也可以通过轮询“会议预定表”来实现,比如我每分钟跑一次定时任务看看当前有哪些会议即将开始了。当然也可以通过延迟消息来实现,预定会议以后系统投递一条延迟消息,而这条消息比较特殊不会立马被消费,而是延迟到指定时间后再触发消费动作(发通知提醒参会人准备)。不过遗憾的是,在 AMQP 协议和 RabbitMQ 中都没有相关的规定和实现。不过,我们似乎可以借助上一小节介绍的“死信队列”来变相的实现。

前面介绍了死信队列和 TTL 机制,至此利用 RabbitMQ 实现延时队列的两大要素已经集齐,接下来只需要将它们进行融合,再加入一点点调味料,延时队列就可以新鲜出炉了。想想看,延时队列,不就是想要消息延迟多久被处理吗,TTL 则刚好能让消息在延迟多久之后成为死信,另一方面,成为死信的消息都会被投递到死信队列里,这样只需要消费者一直消费死信队列里的消息就完事了,因为里面的消息都是希望被立即处理的消息。

【案例】创建两个队列 QA 和 QB,两者队列 TTL 分别设置为 10S 和 40S,然后在创建一个交换机 X 和死信交换机 Y,它们的类型都是 direct,创建一个死信队列 QD,它们的绑定关系如下:

第一条消息在 10S 后变成了死信消息,然后被消费者消费掉,第二条消息在 40S 之后变成了死信消息,然后被消费掉,这样一个延时队列就打造完成了。

不过,如果这样使用的话,岂不是每增加一个新的时间需求,就要新增一个队列,这里只有 10S 和 40S 两个时间选项,如果需要一个小时后处理,那么就需要增加 TTL 为一个小时的队列,如果是预定会议室然后提前通知这样的场景,岂不是要增加无数个队列才能满足需求?

在这里新增了一个队列 QC,该队列不设置 TTL 时间,绑定关系如下:

(1)新建 SpringBoot 微服务

<dependencies>
    <!--RabbitMQ 依赖-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>1.2.47</version>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
    <!--swagger-->
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger2</artifactId>
        <version>2.9.2</version>
    </dependency>
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger-ui</artifactId>
        <version>2.9.2</version>
    </dependency>
    <!--RabbitMQ 测试依赖-->
    <dependency>
        <groupId>org.springframework.amqp</groupId>
        <artifactId>spring-rabbit-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

(2)application.properties

spring.rabbitmq.host=182.92.234.71
spring.rabbitmq.port=5672
spring.rabbitmq.username=admin
spring.rabbitmq.password=123

(3)配置文件类代码

@Configuration
public class TtlQueueConfig {
    public static final String X_EXCHANGE = "X";
    public static final String QUEUE_A = "QA";
    public static final String QUEUE_B = "QB";
    public static final String QUEUE_C = "QC";
    public static final String Y_DEAD_LETTER_EXCHANGE = "Y";
    public static final String DEAD_LETTER_QUEUE = "QD";

    // 声明 xExchange
    @Bean("xExchange")
    public DirectExchange xExchange() {
        return new DirectExchange(X_EXCHANGE);
    }

    // 声明 xExchange
    @Bean("yExchange")
    public DirectExchange yExchange() {
        return new DirectExchange(Y_DEAD_LETTER_EXCHANGE);
    }

    // 声明队列 A ttl 为 10s 并绑定到对应的死信交换机
    @Bean("queueA")
    public Queue queueA() {
        Map<String, Object> args = new HashMap<>(3);
        //声明当前队列绑定的死信交换机
        args.put("x-dead-letter-exchange", Y_DEAD_LETTER_EXCHANGE);
        //声明当前队列的死信路由 key
        args.put("x-dead-letter-routing-key", "YD");
        //声明队列的 TTL
        args.put("x-message-ttl", 10000);
        return QueueBuilder.durable(QUEUE_A).withArguments(args).build();
    }

    // 声明队列 B ttl 为 40s 并绑定到对应的死信交换机
    @Bean("queueB")
    public Queue queueB() {
        Map<String, Object> args = new HashMap<>(3);
        //声明当前队列绑定的死信交换机
        args.put("x-dead-letter-exchange", Y_DEAD_LETTER_EXCHANGE);
        //声明当前队列的死信路由 key
        args.put("x-dead-letter-routing-key", "YD");
        //声明队列的 TTL
        args.put("x-message-ttl", 40000);
        return QueueBuilder.durable(QUEUE_B).withArguments(args).build();
    }

    // 声明队列 C 死信交换机
    @Bean("queueC")
    public Queue queueC() {
        Map<String, Object> args = new HashMap<>(3);
        //声明当前队列绑定的死信交换机
        args.put("x-dead-letter-exchange", Y_DEAD_LETTER_EXCHANGE);
        //声明当前队列的死信路由 key
        args.put("x-dead-letter-routing-key", "YD");
        //没有声明 TTL 属性
        return QueueBuilder.durable(QUEUE_C).withArguments(args).build();
    }

    // 声明死信队列 QD
    @Bean("queueD")
    public Queue queueD() {
        return new Queue(DEAD_LETTER_QUEUE);
    }

    // 声明队列 A 绑定 X 交换机
    @Bean
    public Binding queueA_BindingX(@Qualifier("queueA") Queue queueA,
                                   @Qualifier("xExchange") DirectExchange xExchange) {
        return BindingBuilder.bind(queueA).to(xExchange).with("XA");
    }

    // 声明队列 B 绑定 X 交换机
    @Bean
    public Binding queueB_BindingX(@Qualifier("queueB") Queue queue1B,
                                   @Qualifier("xExchange") DirectExchange xExchange) {
        return BindingBuilder.bind(queue1B).to(xExchange).with("XB");
    }

    // 声明队列 C 绑定 X 交换机
    @Bean
    public Binding queueC_BindingX(@Qualifier("queueC") Queue queueC,
                                   @Qualifier("xExchange") DirectExchange xExchange) {
        return BindingBuilder.bind(queueC).to(xExchange).with("XC");
    }

    // 声明死信队列 QD 绑定关系
    @Bean
    public Binding deadLetterBindingQAD(@Qualifier("queueD") Queue queueD,
                                        @Qualifier("yExchange") DirectExchange yExchange) {
        return BindingBuilder.bind(queueD).to(yExchange).with("YD");
    }

}

(4)消息生产者

@Slf4j
@RestController
@RequestMapping("ttl")
public class SendMsgController {
  
    @Autowired
    private RabbitTemplate rabbitTemplate;

    @GetMapping("sendMsg/{message}")
    public void sendMsg(@PathVariable String message) {
        log.info("当前时间:{},发送一条信息给两个 TTL 队列:{}", new Date(), message);
        rabbitTemplate.convertAndSend("X", "XA", "消息来自 ttl 为 10S 的队列: " + message);
        rabbitTemplate.convertAndSend("X", "XB", "消息来自 ttl 为 40S 的队列: " + message);
    }
  
    @GetMapping("sendExpirationMsg/{message}/{ttlTime}")
    public void sendMsg(@PathVariable String message, @PathVariable String ttlTime) {
        rabbitTemplate.convertAndSend("X", "XC", message, correlationData -> {
            correlationData.getMessageProperties().setExpiration(ttlTime);
            return correlationData;
        });
        log.info("当前时间:{},发送一条时长 {}ms TTL 信息给队列 C:{}", new Date(), ttlTime, message);
    }
}

(5)消息消费者

@Slf4j
@Component
public class DeadLetterQueueConsumer {
    @RabbitListener(queues = "QD")
    public void receiveD(Message message, Channel channel) throws IOException {
        String msg = new String(message.getBody());
        log.info("当前时间:{},收到死信队列信息:{}", new Date().toString(), msg);
    }
}

看起来似乎没什么问题,但是在最开始的时候,就介绍过如果使用在消息属性上设置 TTL 的方式,消息可能并不会按时“死亡“,因为 RabbitMQ 只会检查第一个消息是否过期,如果过期则丢到死信队列如果第一个消息的延时时长很长,而第二个消息的延时时长很短,第二个消息并不会优先得到执行

假如第一个过期时间很长,10s,第二个消息3s,则系统先看第一个消息,等到第一个消息过期,放到 DLX。此时才会检查第二个消息,但实际上此时第二个消息早已经过期了,但是并没有先于第一个消息放到 DLX。

5.2 延迟插件

插件原理

如果不能实现在消息粒度上的 TTL,并使其在设置的 TTL 时间及时死亡,就无法设计成一个通用的延时队列。那如何解决呢?

可以使用 rabbitmq_delayed_message_exchange 插件实现。

这里和 TTL 方式有个很大的不同就是 TTL 存放消息是在死信队列(Delay Queue)里,基于插件存放消息是在延时交换机(x-delayed-message Exchange)里。

  1. 生产者将「消息」和「路由键」发送指定的「延时交换机」上;
  2. 「延时交换机」存储「消息」等待消息到期,再根据「路由键」找到绑定自己的「队列」并把消息推送给它;
  3. 「队列」再把消息发送给监听它的「消费者」;

安装插件

(1)下载插件:https://github.com/rabbitmq/rabbitmq-delayed-message-exchange/releases

(2)安装&启用插件

$ cd /usr/lib/rabbitmq/lib/rabbitmq_server-3.8.8/plugins
$ rabbitmq-plugins enable rabbitmq_delayed_message_exchange

使用案例

在这里新增了一个队列 delayed.queue,一个自定义交换机 delayed.exchange,绑定关系如下:

(1)配置代码

在我们自定义的交换机中,这是一种新的交换类型,该类型消息支持延迟投递机制。消息传递后并不会立即投递到目标队列中,而是存储在 mnesia(一个分布式数据系统)表中,当达到投递时间时,才投递到目标队列中。

@Configuration
public class DelayedQueueConfig {
    public static final String DELAYED_QUEUE_NAME = "delayed.queue";
    public static final String DELAYED_EXCHANGE_NAME = "delayed.exchange";
    public static final String DELAYED_ROUTING_KEY = "delayed.routingkey";

    @Bean
    public Queue delayedQueue() {
        return new Queue(DELAYED_QUEUE_NAME);
    }

    // 自定义交换机 我们在这里定义的是一个延迟交换机
    @Bean
    public CustomExchange delayedExchange() {
        Map<String, Object> args = new HashMap<>();
        // 使用 x-delayed-type 指定交换器的类型
        args.put("x-delayed-type", "direct");
        // 使用 x-delayed-message 表示使用 rabbitmq_delayed_message_exchange 插件处理消息
        return new CustomExchange(DELAYED_EXCHANGE_NAME, "x-delayed-message", true, false, args);
    }

    @Bean
    public Binding bindingDelayedQueue(@Qualifier("delayedQueue") Queue queue,
                                       @Qualifier("delayedExchange") CustomExchange delayedExchange) {
        return BindingBuilder.bind(queue).to(delayedExchange).with(DELAYED_ROUTING_KEY).noargs();
    }
}

(2)消息生产者

@Configuration
public class DelayedQueueConfig {
    public static final String DELAYED_EXCHANGE_NAME = "delayed.exchange";
    public static final String DELAYED_ROUTING_KEY = "delayed.routingKey";

    @GetMapping("sendDelayMsg/{message}/{delayTime}")
    public void sendMsg(@PathVariable String message, @PathVariable Integer delayTime) {
        rabbitTemplate.convertAndSend(DELAYED_EXCHANGE_NAME, DELAYED_ROUTING_KEY, message,
                correlationData -> {
                    correlationData.getMessageProperties().setDelay(delayTime);
                    return correlationData;
                });
        log.info(" 当前时间:{}, 发送一条延迟 {} ms 的信息给队列 delayed.queue:{}", new Date(), delayTime, message);
    }
}

(3)消息消费者

public static final String DELAYED_QUEUE_NAME = "delayed.queue";

@RabbitListener(queues = DELAYED_QUEUE_NAME)
public void receiveDelayedQueue(Message message) {
  String msg = new String(message.getBody());
  log.info("当前时间:{},收到延时队列的消息:{}", new Date().toString(), msg);
}

效果如下:

延时队列在需要延时处理的场景下非常有用,使用 RabbitMQ 来实现延时队列可以很好的利用 RabbitMQ 的特性,如:消息可靠发送、消息可靠投递、死信队列来保障消息至少被消费一次以及未被正确处理的消息不会被丢弃。另外,通过 RabbitMQ 集群的特性,可以很好的解决单点故障问题,不会因为单个节点挂掉导致延时队列不可用或者消息丢失。

当然,延时队列还有很多其它选择,比如利用 Java 的 DelayQueue,利用 Redis 的 zset,利用 Quartz 或者利用 Kafka 的时间轮,这些方式各有特点,看需要适用的场景。

6. 备份交换机

有了 mandatory 参数和回退消息,我们获得了对无法投递消息的感知能力,有机会在生产者的消息无法被投递时发现并处理。但有时候,我们并不知道该如何处理这些无法路由的消息,最多打个日志,然后触发报警,再来手动处理。而通过日志来处理这些无法路由的消息是很不优雅的做法,特别是当生产者所在的服务有多台机器的时候,手动复制日志会更加麻烦而且容易出错。而且设置 mandatory 参数会增加生产者的复杂性,需要添加处理这些被退回的消息的逻辑。如果既不想丢失消息,又不想增加生产者的复杂性,该怎么做呢?

前面在设置死信队列的文章中,我们提到,可以为队列设置死信交换机来存储那些处理失败的消息,可是这些不可路由消息根本没有机会进入到队列,因此无法使用死信队列来保存消息。

在 RabbitMQ 中,有一种备份交换机的机制存在,可以很好的应对这个问题。

什么是备份交换机呢?

备份交换机可以理解为 RabbitMQ 中交换机的“备胎”,当我们为某一个交换机声明一个对应的备份交换机时,就是为它创建一个备胎,当交换机接收到一条不可路由消息时,将会把这条消息转发到备份交换机中,由备份交换机来进行转发和处理,通常备份交换机的类型为 Fanout ,这样就能把所有消息都投递到与其绑定的队列中,然后我们在备份交换机下绑定一个队列,这样所有那些原交换机无法被路由的消息,就会都进入这个队列了。当然,我们还可以建立一个报警队列,用独立的消费者来进行监测和报警。

@Configuration
public class ConfirmConfig {
    
    public static final String CONFIRM_EXCHANGE_NAME = "confirm.exchange";
    public static final String CONFIRM_QUEUE_NAME = "confirm.queue";
    public static final String BACKUP_EXCHANGE_NAME = "backup.exchange";
    public static final String BACKUP_QUEUE_NAME = "backup.queue";
    public static final String WARNING_QUEUE_NAME = "warning.queue";
   
    // 声明确认队列
    @Bean("confirmQueue")
    public Queue confirmQueue() {
        return QueueBuilder.durable(CONFIRM_QUEUE_NAME).build();
    }
    
    // 声明确认队列绑定关系
    @Bean
    public Binding queueBinding(@Qualifier("confirmQueue") Queue queue, 
                                @Qualifier("confirmExchange") DirectExchange exchange) {
        return BindingBuilder.bind(queue).to(exchange).with("key1");
    }
    
    // 声明备份 Exchange
    @Bean("backupExchange")
    public FanoutExchange backupExchange() {
        return new FanoutExchange(BACKUP_EXCHANGE_NAME);
    }
    
    // 声明确认 Exchange 交换机的备份交换机
    @Bean("confirmExchange")
    public DirectExchange confirmExchange() {
        ExchangeBuilder exchangeBuilder = ExchangeBuilder.directExchange(CONFIRM_EXCHANGE_NAME)
            .durable(true)
            .withArgument("alternate-exchange", BACKUP_EXCHANGE_NAME); // 设置该交换机的备份交换机
        return (DirectExchange) exchangeBuilder.build();
    }
    
    // 声明警告队列
    @Bean("warningQueue")
    public Queue warningQueue() {
        return QueueBuilder.durable(WARNING_QUEUE_NAME).build();
    }
    
    // 声明报警队列绑定关系
    @Bean
    public Binding warningBinding(@Qualifier("warningQueue") Queue queue, 
                                  @Qualifier("backupExchange") FanoutExchange backupExchange) {
        return BindingBuilder.bind(queue).to(backupExchange);
    }
    
    // 声明备份队列
    @Bean("backQueue")
    public Queue backQueue() {
        return QueueBuilder.durable(BACKUP_QUEUE_NAME).build();
    }
    
    // 声明备份队列绑定关系
    @Bean
    public Binding backupBinding(@Qualifier("backQueue") Queue queue, 
                                 @Qualifier("backupExchange") FanoutExchange backupExchange) {
        return BindingBuilder.bind(queue).to(backupExchange);
    }
}

生产者发布确认提到的 mandatory 参数与备份交换机是可以一起使用的,如果两者同时开启,消息究竟何去何从?谁优先级高?=> 备份交换机优先级高!

下图展示了 RabbitMQ 发布确认完整流程:

7. 优先级队列

在我们系统中有一个订单催付的场景,我们的客户在天猫下的订单,淘宝会及时将订单推送给我们,如果在用户设定的时间内未付款那么就会给用户推送一条短信提醒,很简单的一个功能对吧。但是,tmall 商家对我们来说,肯定是要分大客户和小客户的对吧,比如像苹果,小米这样大商家一年起码能给我们创造很大的利润,所以理应当然,他们的订单必须得到优先处理,而曾经我们的后端系统是使用 Redis 来存放的定时轮询,大家都知道 Redis 只能用 List 做一个简简单单的消息队列,并不能实现一个优先级的场景,所以订单量大了后采用 RabbitMQ 进行改造和优化,如果发现是大客户的订单给一个相对比较高的优先级,否则就是默认优先级。

(1)控制台页面添加

(2)队列代码中添加优先级

Map<String, Object> params = new HashMap();
params.put("x-max-priority", 10);
channel.queueDeclare("testPriority", true, false, false, params);

(3)消息代码中添加优先级,最大可以设置到 255,其中 0 表示最低优先级,255 表示最高优先级

// 设置队列的最大优先级(官网推荐 1-10 如果设置太高比较吃内存和 CPU)
AMQP.BasicProperties properties = new AMQP.BasicProperties().builder().priority(5).build();

【注意】队列需要设置为优先级队列,消息需要设置消息的优先级,消费者需要等待消息已经发送到队列中才去消费因为,这样才有机会对消息进行排序。

8. 惰性队列

RabbitMQ 从 3.6.0 版本开始引入了惰性队列的概念。惰性队列会尽可能的将消息存入磁盘中,而在消费者消费到相应的消息时才会被加载到内存中,它的一个重要的设计目标是能够支持更长的队列,即支持更多的消息存储。当消费者由于各种各样的原因(比如消费者下线、宕机亦或者是由于维护而关闭等)而致使长时间内不能消费消息造成堆积时,惰性队列就很有必要了。

默认情况下,当生产者将消息发送到 RabbitMQ 的时候,队列中的消息会尽可能的存储在内存之中,这样可以更加快速的将消息发送给消费者。即使是持久化的消息,在被写入磁盘的同时也会在内存中驻留一份备份。当 RabbitMQ 需要释放内存的时候,会将内存中的消息换页至磁盘中,这个操作会耗费较长的时间,也会阻塞队列的操作,进而无法接收新的消息。虽然 RabbitMQ 的开发者们一直在升级相关的算法,但是效果始终不太理想,尤其是在消息量特别大的时候。

队列具备两种模式:default 和 lazy。

默认的为 default 模式,在 3.6.0 之前的版本无需做任何变更。lazy 模式即为惰性队列的模式,可以通过调用 channel.queueDeclare 方法的时候在参数中设置,也可以通过 Policy 的方式设置,如果一个队列同时使用这两种方式设置的话,那么 Policy 的方式具备更高的优先级。如果要通过声明的方式改变已有队列的模式的话,那么只能先删除队列,然后再重新声明一个新的。在队列声明的时候可以通过“x-queue-mode”参数来设置队列的模式,取值为“default”和“lazy”。

Map<String, Object> args = new HashMap<String, Object>();
args.put("x-queue-mode", "lazy");
channel.queueDeclare("myqueue", false, false, false, args);

内存开销对比:在发送 1 百万条消息,每条消息大概占 1KB 的情况下,普通队列占用内存是 1.2GB,而惰性队列仅仅占用 1.5MB。

posted @ 2024-09-30 07:50  tree6x7  阅读(171)  评论(0)    收藏  举报