消息队列的消息存取需求
在分布式系统中,当两个组件要基于消息队列进行通信时,一个组件会把要处理的数据以消息的形式传递给消息队列,然后,这个组件就可以继续执行其他操作了;远端的另一个组件从消息队列中把消息读取出来,再在本地进行处理。
我们一般把消息队列中发送消息的组件称为生产者(例子中的组件 1),把接收消息的组件称为消费者(例子中的组件 2),下图展示了一个通用的消息队列的架构模型:
在使用消息队列时,消费者可以异步读取生产者消息,然后再进行处理。这样一来,即使生产者发送消息的速度远远超过了消费者处理消息的速度,生产者已经发送的消息也可以缓存在消息队列中,避免阻塞生产者,这是消息队列作为分布式组件通信的一大优势。
不过,消息队列在存取消息时,必须要满足三个需求,分别是消息保序、处理重复的消息和保证消息可靠性。
- 消息必须是顺序的
- 可能因为网络拥塞而出现消息重传的情况
- 消费者收到消息后,可能会因为故障或者宕机而导致消息没有处理完成的情况。当消费者重启后,必须重新读取消息再次进行处理
redis的list和streams两种数据类型,就可以满足消息队列的这三个需求
基于list的消息队列解决方案
消息保序
-
list本身就是按照先进先出的顺序对数据进行存取的。所以,如果使用list作为消息队列保存消息的话,就已经能够满足消息保序的需求了。
-
具体来说,生产者可以使用 LPUSH 命令把要发送的消息依次写入 List,而消费者则可以使用 RPOP 命令,从 List 的另一端按照消息的写入顺序,依次读取消息并进行处理。
-
可以支持多个生产者和多个消费者并发进出消息,每个消费者拿到的消息都是不同的列表元素
队列空了怎么办?
- 在生产者往list里写入数据时,list并不会主动的通知消费者有新消息写入,如果消费者想要及时处理消息,就需要在程序中不停的调度RPOP
- 这就会导致消费者程序的 CPU 一直消耗在执行 RPOP 命令上,带来不必要的性能损失:空轮询不仅拉高了客户端的CPU消耗,redis的QPS也会被拉高,如果这样空轮询的客户端有几十个,redis的慢查询可能会显著增多
为了解决这个问题,Redis 提供了 BRPOP 命令。
- 阻塞读在队列没有数据的时候,会立即进入休眠状态,一旦数据来了,则立即醒过来
127.0.0.1:6379> blpop notify-queue 100
1) "notify-queue"
2) "banana"
- 在以上实例中,操作会被阻塞,如果指定的列表 key notify-queue存在数据则会返回第一个元素,否则在等待100秒后会返回 nil 。
空闲连接自动断开
上面的方案还是有缺陷:
- 如果线程一直阻塞在那里,redis的客户端连接就成了闲置连接,闲置过久,服务端一般会断开连接(??????待验证),减少闲置资源占用。这个时候blpop和brpop会抛出异常
- 所以客户端消费者必须小心,如果捕获道异常,还要重试
处理重复消息
处理重复消息的关键在于:消费者程序本身必须能对重复消息进行判断
- 一方面,消息队列要能给每一个消息提供全局唯一的ID号;另一方面,消费者程序要把已经处理过的消息的ID号记录下来
- 当收到一条消息后,消费者程序就可以对比收到的消息ID和记录的已经处理过的消息ID来判断当前收到的消息有没有经过处理。如果已经处理过,那么就不用处理了。这样的处理特性也叫做幂等性。幂等性就是指,对于同一条消息,消费者收到一次的处理结果和收到多次的处理结果是一致的。
不过,list本身是不会为每个消息生成ID号的,所以,消息的全局唯一ID号就需要生产者在发送消息前自行生成。生成之后,我们在用Lpush把消息插入 List 时,需要在消息中包含这个全局唯一 ID。
例如,我们执行以下命令,就把一条全局 ID 为 101030001、库存量为 5 的消息插入了消息队列:
LPUSH mq "101030001:stock:5"
(integer) 1
如何保证消息可靠
当消费者程序从list中读取一条消息后,list就不会再留存这条消息了。所以,如果消费者程序在处理消息的过程中出现了故障或者宕机,就会导致消息没有处理完成,那么,消费者程序再次启动后,就没法再次从list中读取消息了。
为了留存消息,list类型提供了 BRPOPLPUSH 命令,这个命令的作用是让消费者程序从一个list中读取消息,同时,redis会把这个消息再插入到另一个list(备份)留存。这样,如果消费者程序读了消息但没能正常处理,等它重启后,就可以从备份 List 中重新读取消息并进行处理了。
问题
list作为消息队列时,可能会遇到一个问题:生产者消息发送很快,会消费者处理消息的速度比较慢,这就导致了list里的消息越积越多,给redis带来了很大的压力。
这个时候,我们希望启动多个消费者程序组成一个消费组,一起分担处理list中的消息。但是,list类型并不支持消费者的实现。为此,redis从5.0开始提供了streams类型
和 List 相比,Streams 同样能够满足消息队列的三大需求。而且,它还支持消费组形式的消息读取。
基于streams的消息队列方案
streams是redis专门为消息队列设计的数据类型,它提供了丰富的消息队列操作命令:
- XADD:插入消息,保证有序,可以自动生成全局唯一ID
- XREAD:用于读取消息,可以按照ID读取数据
- XREADGROUP:按照消费组形式读取消息
- XPENDING 和 XACK:XPENDING 命令可以用来查询每个消费组内所有消费者已读取但尚未确认的消息,而 XACK 命令用于向消息队列确认消息处理已完成。
(1)XADD
XADD 命令可以往消息队列中插入新消息,消息的格式是键 - 值对形式。对于插入的每一条消息,Streams 可以自动为其生成一个全局唯一的 ID。
- 比如说,我们执行下面的命令,就可以往名称为 mqstream 的消息队列中插入一条消息,消息的键是 repo,值是 5。其中,消息队列名称后面的
*
,表示让 Redis 为插入的数据自动生成一个全局唯一的 ID,例如“1599203861727-0”。当然,我们也可以不用*
,直接在消息队列名称后自行设定一个 ID 号,只要保证这个 ID 号是全局唯一的就行。不过,相比自行设定 ID 号,使用*会更加方便高效。
$ XADD mqstream * repo 5
"1599203861727-0"
- 可以看到,消息的全局唯一 ID 由两部分组成,第一部“1599203861727”是数据插入时,以毫秒为单位计算的当前服务器时间,第二部分表示插入消息在当前毫秒内的消息序号,这是从 0 开始编号的。例如,“1599203861727-0”就表示在“1599203861727”毫秒内的第 1 条消息
(2)XREAD
当消费者需要读取消息时,可以直接使用XREAD命令从消息队列中读取
- XREAD在读取消息时,可以指定一个消息ID,并从这个消息ID的下一条消息开始进行读取
- 比如,我们可以执行下面的命令,从 ID 号为 1599203861727-0 的消息开始,读取后续的所有消息(示例中一共 3 条)。
XREAD BLOCK 100 STREAMS mqstream 1599203861727-0
1) 1) "mqstream"
2) 1) 1) "1599274912765-0"
2) 1) "repo"
2) "3"
2) 1) "1599274925823-0"
2) 1) "repo"
2) "2"
3) 1) "1599274927910-0"
2) 1) "repo"
2) "1"
另外,消费者也可以在调用 XRAED 时设定 block 配置项,实现类似于 BRPOP 的阻塞读取操作。当消息队列中没有消息时,一旦设置了 block 配置项,XREAD 就会阻塞,阻塞的时长可以在 block 配置项进行设置。
- 举个例子,我们来看一下下面的命令,其中,命令最后的“$”符号表示读取最新的消息,同时,我们设置了 block 10000 的配置项,10000 的单位是毫秒,表明 XREAD 在读取最新消息时,如果没有消息到来,XREAD 将阻塞 10000 毫秒(即 10 秒),然后再返回。下面命令中的 XREAD 执行后,消息队列 mqstream 中一直没有消息,所以,XREAD 在10 秒后返回空值(nil)。
XREAD block 10000 streams mqstream $
(nil)
(10.00s)
(3)XGROUP和XREADGROUP
Streams本身可以使用XGROUP创建消费组,创建消费组之后,Streams可以使用XREADGRAOP命令让消费组内的消费者读取消息
- 例如,我们执行下面的命令,创建一个名为 group1 的消费组,这个消费组消费的消息队列是 mqstream。
XGROUP create mqstream group1 0
OK
- 然后,我们再执行一段命令,让 group1 消费组里的消费者 consumer1 从 mqstream 中读取所有消息,其中,命令最后的参数“>”,表示从第一条尚未被消费的消息开始读取。因为在 consumer1 读取消息前,group1 中没有其他消费者读取过消息,所以,consumer1 就得到 mqstream 消息队列中的所有消息了(一共 4 条)。
XREADGROUP group group1 consumer1 streams mqstream >
1) 1) "mqstream"
2) 1) 1) "1599203861727-0"
2) 1) "repo"
2) "5"
2) 1) "1599274912765-0"
2) 1) "repo"
2) "3"
3) 1) "1599274925823-0"
2) 1) "repo"
2) "2"
4) 1) "1599274927910-0"
2) 1) "repo"
2) "1"
- 需要注意的是,消息队列中的消息一旦被消费组里的一个消费者读取了,就不能再被该消费组内的其他消费者读取了。比如说,我们执行完刚才的 XREADGROUP 命令后,再执行下面的命令,让 group1 内的 consumer2 读取消息时,consumer2 读到的就是空值,因为消息已经被 consumer1 读取完了,如下所示:
XREADGROUP group group1 consumer2 streams mqstream 0
1) 1) "mqstream"
2) (empty list or set)
使用消费者的目的是让组内的多个消费者共同分担读取消息,所以,我们通常会让每个消费者读取部分消息,从而实现消息读取复制在多个消费者间是均衡分布的。
- 比如,下面命令可以让group2 中的 consumer1、2、3 各自读取一条消息。
XREADGROUP group group2 consumer1 count 1 streams mqstream >
1) 1) "mqstream"
2) 1) 1) "1599203861727-0"
2) 1) "repo"
2) "5"
XREADGROUP group group2 consumer2 count 1 streams mqstream >
1) 1) "mqstream"
2) 1) 1) "1599274912765-0"
2) 1) "repo"
2) "3"
XREADGROUP group group2 consumer3 count 1 streams mqstream >
1) 1) "mqstream"
2) 1) 1) "1599274925823-0"
2) 1) "repo"
2) "2"
为了保证消费者在发生故障或者宕机再次重启后,仍然可以读取未处理完的消息,streams会自动使用内部队列列(也称为 PENDING List)留存消费组里每个消费者读取的消息,直到消费者使用XACK命令通知Streams“消费者已经处理完成”。如果消费者没有成功处理消息,它就不会给streams发送XACK命令,消息仍会留存。此时,消费者可以在重启后,用 XPENDING 命令查看已读取、但尚未确认处理完成的消息。
- 例如,我们来查看一下 group2 中各个消费者已读取、但尚未确认的消息个数。其中,XPENDING 返回结果的第二、三行分别表示 group2 中所有消费者读取的消息最小 ID 和最大 ID。
XPENDING mqstream group2
1) (integer) 3
2) "1599203861727-0"
3) "1599274925823-0"
4) 1) 1) "consumer1"
2) "1"
2) 1) "consumer2"
2) "1"
3) 1) "consumer3"
2) "1"
- 如果我们还需要进一步查看某个消费者具体读取了哪些数据,可以执行下面的命令:
XPENDING mqstream group2 - + 10 consumer2
1) 1) "1599274912765-0"
2) "consumer2"
3) (integer) 513336
4) (integer) 1
- 可以看到,consumer2 已读取的消息的 ID 是 1599274912765-0。
- 一旦消息 1599274912765-0 被 consumer2 处理了,consumer2 就可以使用 XACK 命令通知 Streams,然后这条消息就会被删除。当我们再使用 XPENDING 命令查看时,就可以看到,consumer2 已经没有已读取、但尚未确认处理的消息了。
XACK mqstream group2 1599274912765-0
(integer) 1
XPENDING mqstream group2 - + 10 consumer2
(empty list or set)