消息持久化与确认机制
一个消息队列,最核心的功能就是消息的顺序收发,这个我们之前已经了解过了。而最核心的保证机制,则是在基础的功能之上,消息不丢,消息不重复发送。对于这两个功能,大部分消息队列应用都会通过持久化机制和消息确认机制来实现,我们今天先从 RabbitMQ 的相关功能说起。
持久化
为了效率,为了性能,消息队列产品基本都是内存型的一种数据库。是的,就像 Redis、MongoDB 和 ES 一样,它也是以内存做为主要的存储器的。试想,如果我们的消费者非常简单,能够快速地处理队列中的数据,那么其实只要生产者一发送到队列,消费者就马上拿走消费掉了。这种情况下,内存确实是最合适的场景,因为处理速度快,内存不会占用很大的空间。
但是,也要考虑到消费者业务复杂,无法快速处理的问题。而且,这也是我们要引入消息队列的最核心的问题。通常,就是为了将慢的、耗时的操作通过消息队列转换成异步操作,这是它最典型的应用场景。而如果生产速度非常快,但消费跟不上,就会产生消息堆积。我们应该尽可能快地去处理队列中的数据,可以开多线程、协程,甚至是在多台机器上起多个进程一起来进行消费。但是,还是有可能会跟不上生产者生产消息的速度。如果这个时候,断电了、重启了,只是使用内存的话就会导致消息的丢失。
这就是持久化的作用。说白了,和我们之前学习过的 Redis 的持久化是一样的概念。还记得 Redis 的持久化吧?有两种,RDB 和 AOF 。RabbitMQ 也是类似的以追加日志的形式进行数据持久化。但有一点需要注意的是,在 RabbitMQ 中,我们要持久化的应该是消息数据,同时,队列也可以持久化一下,而如果用到交换机了,交换机也是可以持久化的。
队列和交换机的持久化,其实就是当我们重启 RabbitMQ 实例后,对应的队列和交换机还在不在。如果不持久化的话,则队列和交换机部分也都是空的。
消息持久化则是真正的数据的持久化。
我们先来定义队列的持久化。
代码语言:javascript复制// $durable参数要设置成 true
$channel->queue_declare('hello', false, true, false, false);
然后,在消息对象实例化的时候,通过增加一个 delivery_mode 参数,指定消息持久化。
代码语言:javascript复制$msg = new AMQPMessage('Hello World!', ['delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT]);
这样,就完成了队列和消息的持久化的设置。现在你可以向队列中添加一条数据,然后重启一下 RabbitMQ ,再进行消费,看看是不是还能消费重启之前的数据。而如果不进行上述配置,则消费者是不会获取到任何数据的。这个测试大家自己测一下就好,等录视频的时候我再详细演示吧。
惰性队列
除了普通的持久化之外,RabbitMQ 还提供了一种叫做“惰性队列”的功能。惰性队列会尽可能的将消息存入磁盘中,而在消费者消费到相应的消息时才会被加载到内存中,它的一个重要设计目标是能够支持更长的队列,即支持更多的消息存储,毕竟磁盘的容量可是吊打内存的。当消费者由于各种各样的原因(比如消费者下线、宕机、或者由于维护而关闭等)致使长时间不能消费消息而造成堆积时,惰性队列就很必要了。
既然使用磁盘了,那么它肯定会带来性能的下降,这一点不用我说大家也能猜到。因此,如何权衡使用,还是要看具体的业务场景了。如果你的生产者速度非常快,但消费者因为各种业务逻辑而处理得非常慢,很容易造成大量的消息堆积,那么肯定就要使用惰性队列了。
它的配置也很简单,在定义队列时,添加一个 x-queue-mode 属性参数,设置为 lazy 即可。
代码语言:javascript复制$channel->queue_declare('hello', false,false,false,false,false,new AMQPTable([
'x-queue-mode'=>'lazy'
]));
ACK确认
除了持久化之外,大部分消息队列工具还有一个核心功能就是 ACK 确认。这个有点像是 TCP 三次握手哦,三次握手时,发送的也是 ACK 标识来标明确认的。好吧,不扯远了。消息队列的 ACK ,其实就是说,在默认情况下,如果一条消息被取走了,就像 Redis 里被 POP 了,那么这条消息就直接从队列中删除了。
但是,试想一个问题,那就是消费者处理失败了,出现异常了。这时,这条消息其实是没有被正确处理的。但是,它又已经从消息队列中被删除移走了,这就产生了消息的丢失。ACK 机制,实际上就是说,当消费者出现问题,或者消费者的连接中断后,这条消息如果没有被确认消费,那么它就会重新加回到原来的消费队列中再次被消费。
使用 RabbitMQ 来进行 ACK 配置的话非常简单,我们在生产者发送消息时,需要将第四个参数,no_ack 这个参数设置为 false 。
代码语言:javascript复制// $no_ack 要设置成 false
$channel->basic_consume('hello', '', false, false, false, false, $callback);
然后,在消费者这边,需要在回调函数中调用回调参数返回的 Message 对象的 ack() 方法。
代码语言:javascript复制$callback = function ($msg) {
echo '接收到数据: ', $msg->body, PHP_EOL;
$msg->ack();
};
这样就完成了 ACK 机制的应用。现在你可以尝试先注释掉回调函数中使用 ack() 方法的代码,然后使用消费者进行消费,消费完成之后直接关掉消费者再次打开。就会发现没有调用 ack() 方法的消息会一直被消费。这就是因为生产者如果发送的消息中 no_ack 设置为 false 了,那么这条消息就必须被调用 ack() 方法之后才会被认为它是被正常消费完的。否则,不管是客户端连接失败、报异常、还是超过指定的 rabbit.conf 文件中设置的超时时间,这条消息都会被重新放回到原来的队列中。
超时时间默认是 30 分钟,在 rabbit.conf 文件中通过 consumer_timeout 进行配置。
发布确认
除了消息的确认之外,还有发布确认。上面的 ACK 确认,确认的是消息是否被消费完成。而发布确认,则是说消息是否被发布到了队列中。这个概念的关键点在于 RabbitMQ 中,有交换机,有队列两层处理。我们要确保消息发送到了队列中,然后在队列中,有相应的持久化机制就可以保证消息不丢。
或者换句话说,从业务角度来看,我们的生产者业务代码,其实最核心的就是调用队列接口发送消息。如果发送失败,其实就是一个异常,这个异常大部分情况下是由网络问题引起的,这种问题是可以通过发布确认机制捕捉到的。这个机制,就是一个消息是否已经入队的确认,而不是消息被消费的确认。
发布确认有几种形式,包括单个确认、批量确认和回调确认三种。单个的性能效率比较低,但对于大部分应用来说其实也足够了。而回调函数则可以方便我们对于未确认及确认数据进行后续处理。
代码语言:javascript复制// ……………………
$channel->confirm_select(); // 开启发布确认
// 确认回调
$channel->set_ack_handler(
function (AMQPMessage $message){
echo '消息已经被发送成功啦!', $message->body, PHP_EOL;
}
);
// 失败回调
$channel->set_nack_handler(
function (AMQPMessage $message){
echo '消息发送失败啦,我们要做别的操作啦!', $message->body, PHP_EOL;
}
);
// ……………………
// ……………………
// ……………………
$channel->basic_publish($msg, '', 'hello'); // 将消息放入队列中
//$channel->wait_for_pending_acks(5); // 单个确认
// ……………………
如果出现了发布失败的消息,我们可以针对该消息进行特殊的处理,比如说记录到日志中,或者放到 MySQL 数据库中,或者再放到别的队列中由特定的消费者进行处理。正常情况下,消息都是可以正常发送成功的。
代码语言:javascript复制> php 4.rq.p.php
生产者向消息队列中发送信息:Hello World!消息已经被发送成功啦!Hello World!
Laravel 中使用 Redis 驱动
之前我们就说过,Redis 中的 List ,还有 PubSub 以及 Stream 这些功能,并不算是一个完备的消息队列应用。最主要的原因,就是 Redis 中没有 ACK 机制。
持久化机制就不说了,Redis 的 RDB 和 AOF 就是它的持久化机制,同样也可以对队列中的数据进行持久化。
而 ACK 机制的缺失,其实是可以通过业务代码来弥补的,比如说 Laravel 或者 TP 框架中队列相关的功能,就有一个重试的功能。它可能不是完全的 ACK 机制,但也可以视为是 ACK 机制的一个补充。我们可以在运行 Job 时指定重试次数。
代码语言:javascript复制php artisan queue:work --tries=3
这样,队列中的数据就有三次被重试执行的机会。我们可以在 Job 中直接抛出异常,模拟消费失败。
代码语言:javascript复制public function handle()
{
//
echo '接收到了消息:' .$this->msg, ' ',time(),PHP_EOL;
sleep(10);
throw new Exception();
}
然后观察存储在 Redis 中的队列数据。
代码语言:javascript复制{
"uuid": "4a38d37b-86e7-4755-a29a-f6843b7289cc",
"timeout": null,
"id": "mg3RA7n3JW7CB3WUllKXTT6sPUvdJ0rF",
"backoff": null,
"displayName": "App\Jobs\Queue4",
"maxTries": null,
"failOnTimeout": false,
"maxExceptions": null,
"retryUntil": null,
"job": "Illuminate\Queue\CallQueuedHandler@call",
"data": {
"command": "O:15:"App\Jobs\Queue4":11:{s:3:"msg";s:6:"xe6xb5x8bxe8xafx95";s:3:"job";N;s:10:"connection";N;s:5:"queue";N;s:15:"chainConnection";N;s:10:"chainQueue";N;s:19:"chainCatchCallbacks";N;s:5:"delay";N;s:11:"afterCommit";N;s:10:"middleware";a:0:{}s:7:"chained";a:0:{}}",
"commandName": "App\Jobs\Queue4"
},
"attempts": 2 // 注意这个字段会一直加
}
是的,最后一个字段 attempts 就是这条队列数据的重试次数。当超过我们指定的重试次数之后,就会返回异常。
代码语言:javascript复制[2022-12-31 03:57:03][mg3RA7n3JW7CB3WUllKXTT6sPUvdJ0rF] Processing: AppJobsQueue4
接收到了消息:测试 1672459023
[2022-12-31 03:57:13][mg3RA7n3JW7CB3WUllKXTT6sPUvdJ0rF] Processing: AppJobsQueue4
接收到了消息:测试 1672459033
[2022-12-31 03:57:23][mg3RA7n3JW7CB3WUllKXTT6sPUvdJ0rF] Failed: AppJobsQueue4
上述功能的实现,是以 Laravel 框架中的代码为准的,不过 TP 队列组件的实现也是类似的。具体的代码在 /vendor/laravel/framework/src/Illuminate/Queue/Worker.php 中,最终会调用下面这个方法。
代码语言:javascript复制// 如果给定作业已超过允许的最大尝试次数,则将其标记为失败。
markJobAsFailedIfAlreadyExceedsMaxAttempts()
总结
今天的内容,我们主要学习的是针对消息的持久化和确认机制,这两块也是各种消息队列系统用于解决消息丢失和重发的主要功能。我们也了解到了在 Laravel 框架中,使用 Redis 做队列驱动的话,其实是通过业务代码以及队列数据格式的特殊字段来实现类似功能的。不过,从这里大家也能看出 Redis 虽然能做队列,但是却又没有完整的队列所有的功能的特点了吧。
在 RabbitMQ 中,还有事务相关的功能,上面我们学习过的发布确认也可以看做是一种事务实现。因此,关于事务这一块的内容大家可以自己再去了解一下,主要使用到的是 channel->txSelect()、
好了,接下来我们还会看到几个这样的例子,学习还在继续哦。
测试代码:
https://github.com/zhangyue0503/dev-blog/blob/master/消息队列/source/4.rq.c.php
https://github.com/zhangyue0503/dev-blog/blob/master/消息队列/source/4.rq.p.php