RabbitMQ 如何保证消息可靠性

作者 | 黄永灿

后端开发攻城狮,关注服务端技术与性能优化。

RabbitMQ 的消息发送机制

在讨论 RabbitMQ 的消息可靠性之前,我们先来回顾一下消息在 RabbitMQ 中是怎么流转的。

  1. 消息生产端将消息发送给 Exchange

  2. Exchange 根据 RoutingKey 把消息路由至具体 Queue

  3. 消息消费端监听 Queue 并消费消息

那么,这几个步骤有哪些地方可能会掉链子导致消息丢失呢?

  1. Producer 到 Exchange 这一步发生网络故障或是丢包导致 Exchange 没收到消息

  2. 消息在 Exchange 没有匹配到 Queue

  3. 消息在 Queue 中还没被消费时 RabbitMQ 挂了

  4. 消息在 Queue 传输给 Consumer 的时候出现网络故障,或是消息消费失败了

如何保证消息的可靠性

接下来,我们会把整个消息传输过程拆分成三个步骤来讨论分别如何保证每一步的可靠性。

第一步:生产端到RabbitMQ

方案一:事务机制

为了保证这一步的可靠性,AMQP 协议在建立之初就提供了事务机制。RabbitMQ 客户端中与事务机制相关的方法有三个:channel.txSelect、channel.txCommit 以及 channel.txRollback。channel.txSelect 用于将当前的信道设置成事务模式,channel.txCommit 用于提交事务,而 channel.txRollback 用于事务回滚。在通过channel.txSelect 方法开启事务之后,我们便可以发布消息给 RabbitMQ 了,如果事务提交成功,则消息一定到达了 RabbitMQ 中,如果在事务提交执行之前由于 RabbitMQ 异常崩溃或者其他原因抛出异常,这个时候我们便可以将其捕获,进而通过执行 channel.txRollback 方法来实现事务回滚。

try {
  // 开启事务
  channel.txSelect();
  // 发送消息
  channel.basicPublish(exchange, routingKey, props, body);
  // 事务提交
  channel.txCommit();
} catch(Exception e) {
  // 事务回滚
  channel.txRollback();
  e.printStackTrace();
}
方案二:Confirm机制

上面的事务机制虽然保证了消息投递端的可靠性,但因为每次投递都开启了事务,所以性能较低,一般不推荐使用,接下来要讲的 Confirm 机制就比较好的兼顾了性能以及可靠性。

PS:事务机制和 Confirm 机制是互斥的,两者不能共存,会导致 RabbitMQ 报错。

开启 Confirm 机制后,所有在该信道上面发布的消息都会被指派一个唯一的ID(从 1 开始),一旦消息被投递到所有匹配的队列之后,RabbitMQ 就会发送一个确认(Basic.Ack)给生产者(包含消息的唯一 ID),这就使得生产者知晓消息已经正确到达了目的地了。RabbitMQ 回传给生产者的确认消息中的 deliveryTag 包含了确认消息的序号,此外 RabbitMQ 也可以设置 channel.basicAck 方法中的 multiple 参数,表示到这个序号之前的所有消息都已经得到了处理。

// 开启confirm机制
channel.confirmSelect();

// 添加回调监听器
channel.addConfirmListener(new ConfirmListener() {
  @Override
  public void handleAck(long deliveryTag, boolean multiple) throws IOException {
    // TODO 消息投递成功
  }
  
  @Override
  public void handleNack(long deliveryTag, boolean multiple) throws IOException {
        // TODO 消息投递失败
  }
});

上述两种机制保证了消息能正确的被投递到 RabbitMQ,但需要注意的是,这里所指的 “RabbitMQ” 仅仅是到 Exchange,如果 Exchange 没有匹配到队列的话,那么消息依然会丢失。

所以在使用这两种机制的时候,需要保证发送时的 RoutingKey 一定要能匹配到队列,否则就需要配合 mandatory 参数或者备份交换器一起使用来提高消息传输的可靠性。

mandatory

当 mandatory 参数设为 true 时,如果 Exchange 无法根据自身的类型和路由键找到一个符合条件的队列的话,那么RabbitMQ 会调用 Basic.Return 命令将消息返回给生产者。而 mandatory 参数设置为 false 时,出现上述情形的话,消息直接被丢弃。那么生产者如何获取到没有被正确路由到合适队列的消息呢?这时候可以通过调用 channel.addReturnListener 来添加 ReturnListener 监听器实现。

// 添加回调监听器
channel.addReturnListener(new ReturnListener() {
  @Override
  public void handleReturn(int replyCode, String replyText, String exchange,
  String routingKey, AMQP.BasicProperties properties, byte[] body) throws IOException {
        // TODO 消息routingKey未匹配到队列
    }
});

备份交换器

生产者在发送消息的时候如果不设置 mandatory 参数,那么消息在未被路由的情况下将会丢失,如果设置了 mandatory 参数,那么需要添加 ReturnListener 的编程逻辑,生产者的代码将变得复杂化。如果你不想复杂化生产者的编程逻辑,又不想消息丢失,那么可以使用备份交换器,这样可以将未被路由的消息存储在 RabbitMQ 中,再在需要的时候去处理这些消息。可以通过在声明交换器(调用 channel.exchangeDeclare 方法)的时候添加 alternate-exchange 参数来实现。

备份交换器其实和普通的交换器没有太大的区别,为了方便使用,建议设置为 fanout 类型。需要注意的是消息被重新发送到备份交换器时的 RoutingKey 和从生产者发出的 RoutingKey 是一样的。备份交换器的实质就是原有交换器的一个“备胎”,所有无法正确路由的消息都发往这个备份交换器中,可以为所有的交换器设置同一个备份交换器,不过这里需要提前确保的是备份交换器已经正确的绑定了队列。如果备份交换器和 mandatory 参数一起使用,那么 mandatory 参数无效。

第二步:RabbitMQ

通过上一步,我们已经可以保证消息从生产端到 RabbitMQ 的可靠性,而消息在 RabbitMQ 中的可靠性又该如何保证呢?

持久化

首先我们肯定会想到持久化,持久化可以提高 RabbitMQ 的可靠性,以免在 RabbitMQ 意外宕机时数据不会丢失,RabbitMQ 的 Exchange、Queue 以及 Message 都是支持持久化的,Exchange 和 Queue 通过在声明的时候将 durable 参数置为 true 即可实现,而消息的持久化则需要将投递模式(BasicProperties 中的 deliveryMode 属性)设置为2(PERSISTENT)。但需要注意的是,必须同时将 Queue 和 Message 持久化才能保证消息不丢失,仅设置 Queue 持久化,重启之后 Message 会丢失,反之仅设置消息的持久化,重启之后 Queue 消失,既而 Message 也丢失。

集群

上述持久化的操作保证了消息在 RabbitMQ 宕机时不会丢失,但却不能避免单机故障且无法修复(比如磁盘损毁)而引起的消息丢失,并且在故障发生时 RabbitMQ 不可用。这时就需要引入集群,由于 RabbitMQ 是基于 Erlang 编写的,所以其天生支持分布式,而不需要像 Kafka 那样要通过 Zookeeper 来实现,RabbitMQ Cluster 集群共有两种模式。

普通模式

普通模式下,集群中的 RabbitMQ 会同步 Vhost、Exchange、Binding、Queue 的元数据(即其本身的数据,例如名称、属性等)以及 Message 结构,而不会同步 Message 数据,也就是说,如果集群中某台机器 RabbitMQ 宕掉了,则该节点上的 Message 不可用,直至该节点恢复。

镜像模式

镜像队列相当于配置了副本,绝大多数分布式的东西都有多副本的概念来确保 HA(High Availability)。在镜像队列中,如果主节点(master)在此特殊时间内挂掉,可以自动切换到从节点(slave),这样有效的保证了高可用性,除非整个集群都挂掉。

第三步:RabbitMQ到消费者

在做了前面两步后,基本上已经可以保证消息供给侧不出漏子了,最后一步就是确保消息被正确的消费。

为了保证消息从队列可靠地达到消费者,RabbitMQ 提供了消息确认机制(message acknowledgement)。消费者在订阅队列时,可以指定 autoAck 参数,当 autoAck 等于 fals e时,RabbitMQ 会等待消费者显式地回复确认信号后才从内存(或者磁盘)中移去消息(实质上是先打上删除标记,之后再删除)。当 autoAck 等于 true 时,RabbitMQ 会自动把发送出去的消息置为确认,然后从内存(或者磁盘)中删除,而不管消费者是否真正的消费到了这些消息。

这时大家可能会问,如果 RabbitMQ 在等待回调的过程中,消费者服务挂掉怎么办?

对于 RabbitMQ 而言,队列中的消息分成了两个部分:一部分是等待投递给消费者的消息;一部分是已经投递给消费者,但是还没有收到消费者确认信号的消息。如果 RabbitMQ 一直没有收到消费者的确认信号,并且消费此消息的消费者已经断开连接,则 RabbitMQ 会安排该消息重新进入队列,等待投递给下一个消费者。RabbitMQ 判断此消息是否需要重新投递的唯一依据是消费该消息的消费者连接是否已经断开,这种设计允许消费者消费一条消息很久很久。

如果消息消费失败,也可以调用 Basic.Reject 或者 Basic.Nack 来拒绝当前消息,但需要注意的是,如果只是简单的拒绝那么消息将会丢失,需要将相应的 requeue 参数设置为 true,RabbitMQ 才会将这条消息重新存入队列。而如果 requeue 参数设置为 false 的话,RabbitMQ 立即会把消息从队列中移除,而不会把它发送给新的消费者。

// 确认消息
channel.basicAck(deliveryTag, multiple);
// 拒绝消息
channel.basicNack(deliveryTag, multiple, requeue);
// 拒绝消息
channel.basicReject(deliveryTag, requeue)

PS:basicNack 和 basicReject 作用基本相同,主要差别在于前者可以拒绝多条,后者只能拒绝单条,另外basicNack 不是 AMQP 0-9-1 标准。

死信队列

死信队列和上文提到的备份交换机有类似之处,同样也是声明一个 Exchange,如果一个消息因为被拒绝、过期或是队列已满等情况变成了死信,那么它会被重新发送到这个 Exchange 并路由到死信队列。而判断一个消息是否是死信主要有如下几条:

  1. 消费方拒绝消息时没有将 requeue 设置为 true

  2. 消息在队列中过期了(队列的过期时间可以通过 x-message-ttl 参数控制,或者发送消息时声明,同时存在取小值)

  3. 队列已经满了

使用方式也跟备份交换机很像,只不过这个是在申明队列的时候设置 x-dead-letter-exchange 参数。

消息补偿机制

除了以上的保障措施之外,为了防止生产者发送消息失败或者接收 RabbitMQ confirm 的时候网络断掉等,我们还需要一套完善的消息补偿机制,接下来我们会介绍目前业界主流的两种方案。

消息落库,对消息进行状态标记

Step 1:首先生产端处理完业务数据,然后在发送消息前先持久化到消息记录表

Step 2:发送消息给 RabbitMQ,采用 confirm 机制

Step 3:监听 RabbitMQ 的 confirm 回调

Step 4:根据 message id 以及回调的信息更新消息状态

Step 5:分布式定时任务获取未发送成功的消息,然后判断重试次数是否大于 N 次,N 为业务系统定义的最大重试次数

Step 6:将重试次数 < N 次的消息重新发送给 RabbitMQ

Step 7:将重试次数 > N 次的消息降级为人工处理

这种方案能保证即使消息发送失败了,或者其中某一环突然掉链子了,但只要消息成功入库了,就能通过定时任务重试机制发送给 RabbitMQ,然后消费端再通过 ack 机制去消费消息,需要注意的是,消费端的消费逻辑必须幂等。

延迟投递,做二次确认,回调检查

Step 1:发送消息给 RabbitMQ

Step 2:发送一条一样的延迟消息给 Callback Server

Step 3:消费者消费消息

Step 4:如果消息消费成功了,则将消息发送给 Callback Server,注意这里不是采用 ack 机制

Step 5:Callback Server 监听到消费者发来的消息,知道有条消息消费成功了,然后将消息记录到 DB

Step 6:Callback Server 监听生产端发来的延迟消息

Step 7:Callback Server 到数据库查询消息是否存在

Step 8:如果消息存在,则不做处理,如果消息不存在,则发起一个 RPC 请求到生产端,告诉它消息发送失败了,需要重新发送一遍,然后生产端重新发送即时和延迟两条消息出去,按照流程再走一遍。

上面那种方案的弊端是显而易见的,在整个消息发送以及确认流程中,每条消息都需要入库再发送,然后收到 confirm 去更新消息状态,如果在高并发场景下,数据库毫无疑问会成为一个瓶颈。而相较于上面的方案,延迟投递二次确认的方案对数据库的操作更少,并且对正常消息的发送和消费过程不会有太多干预,但是如果在没有消费者或者消费者全部断开连接的情况下会造成大量重复消息积压,所以一般在高并发场景下配合一些的策略使用。

总结

以上,我们介绍了 RabbitMQ 在保证可靠性方面的一些机制,以及在发生极端情况下的两种消息补偿方案,但就像是其他软件方案一样,即便采取了以上所有措施,我们仍然无法完全保证其 100% 的完全可靠,并且也并不是所有情况下都需要保证其消息的高可靠性,至于该怎么应用还需要视具体的业务而定。

全文完


以下文章您可能也会感兴趣:

我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。

  • 2
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值