pub/sub和Stream
Redis中的订阅、发布实现了发布与订阅消息范式,发布者不是计划发送消息给特定的订阅者,而是把消息发布到不同的频道,发布者不需要知道是哪些订阅者订阅了消息。订阅者对一个或多个频道感兴趣,只需接收感兴趣的消息。
Redis通过publish、subscribe等命令实现了发布与订阅功能,该功能提供了两种消息机制,分别是发布到订阅频道的方式(Mode)和模式匹配(Pattern Match)频道的方式(即允许客户端订阅符合某个模式的频道,例如通过正则表达式匹配的频道)。
在Redis的发布与订阅模式中,有三个部分:
- Publisher(发布者):发送消息到频道中,每次只能往一个频道发送一条消息。
- Subscriber(订阅者):订阅频道,订阅者可以同时订阅多个频道。
- Channel(频道):将发布者发布的消息转发给当前订阅此频道的订阅者。
1、频道的订阅与消息发送
Redis的subscribe命令可以让客户端订阅任意数量的频道,只要有新消息发送到被订阅的频道,消息就会被发送给所有订阅指定频道的客户端。
下图所示表示客户端1、客户端2、客户端3同时订阅了一个频道(channel)。

当这个频道接收到消息时,会把当前消息发送给订阅当前频道的客户端1、客户端2和客户端3,如图所示。

2、发布与订阅的使用
如下是发布与订阅的相关命令:
bash
# 发布消息到指定的频道
publish channel message
# 订阅指定的一个或多个频道的消息
subscribe channel [channel ...]
# 订阅一个或多个符合指定模式的频道
Psubscribe pattern [pattern ...]
# 退订指定的频道
unsubscribe [channel [channel ...]]
# 退订所有指定模式的频道
punsubscribe [pattern [pattern ...]]
# 查看订阅与发布系统状态
PUBSUB subcommand [argument [argument ...]]
范例如下:
1)启动两个客户端,订阅mychat频道。
bash
# 订阅频道mychat
psubscribe mychat
2)启动一个客户端,往mychat频道发送消息,则之前两个订阅mychat频道的客户端会接收到此消息。
bash
# 给mychat频道发送消息
publish mychat "this is meassge1"
publish mychat "this is meassge2"
publish mychat "this is meassge3"
执行结果如图所示,表示两个客户端订阅了一个频道。当此频道有数据时订阅此频道的客户端才会收到此条消息。

3、发布与订阅的原理
每个Redis服务器进程维持着一个标识服务器状态的redisServer结构,这里面保存着订阅的频道以及订阅模式的消息,源代码如下:
c
struct redisServer {
// ...
dict *pubsub_channels; // 订阅频道
list *pubsub_patterns; // 订阅模式
// ...
};
其中字典的键为正在被订阅的频道,字典的值则是一个链表,链表中保存了所有订阅这个频道的客户端。客户端1订阅了频道1和频道2;客户端2订阅了频道1和频道3;客户端3订阅了频道3,如图所示。

当客户端调用subscribe命令时,程序就将客户端与要订阅的频道在pubsub_channels字典中关联起来,比如客户端4同时执行subscribe channel1 channel2 channel3时,pubsub_channels就会变成如图所示的示意图。

当我们了解pubsub_channels字典的结构之后,publish命令的实现就非常简单了:当调用public channel message命令时,程序首先会根据channel定位到字典的键,然后将消息发送给字典值对应链表中的所有客户端。比如执行下面的命令:
bash
public mychat "this is meassge1"
首先会从pubsub_channels字典中订阅mychat频道的客户端,比如在上图中会找到订阅mychat频道的client2、client3、client4,然后把当前消息发送给这三个客户端。
4、订阅模式
当使用publish命令发送消息到某个频道时,所有订阅该频道的客户端都会收到消息,如果有某些模式和这个频道匹配,那么所有订阅这个频道的客户端都会收到消息。
在下图中,client1、client2、client3订阅了主题test.*,表示只要频道名称前面包含test.的频道接收到的消息都会发送给client1、client2、client3这三个客户端。

当频道test.abc和频道test.a.b收到消息后,就会把收到的消息发送给这三个客户端,如下图所示。因为这两个频道名称都和test.*匹配。

当给频道发送消息时,会根据正则表达式或通配符(例如test.*)给相应的订阅者推送消息,如图6所示。

RedisServer的结构图如图所示,当发送消息给频道时,则会遍历public_patterns所存储的链接数据,找到匹配到的客户端,把消息发送给这些匹配到的客户端。

5、pub和sub使用范例
如果我们在项目中需要记录不同的项目日志,比如一种是数据库日志、一种是文本日志,而想使用异步方式提高程序性能,就可以使用Redis简单的pub和sub方式。这只是一种选择,结合具体的项目场景可以自行选择是否使用。范例结构图如图所示,利用Redis来做日志收集和处理。

6、与其他发布和订阅比较
6.1、List
与Redis中的List对比,基于List实现的消息队列需要结合lpush+brpop命令来实现。
多消费者组:当多个客户端同时消费同一个List消息队列时,消费者A1使用brpop消费的数据就从List中弹出,消费者A2再也读不到该数据,而在发布和订阅模式中,多个订阅者可以订阅相同的频道,频道内的数据会分发到各个订阅者,而不会出现某个订阅者消费了之后另一个订阅者读不到该数据的情况。断点消费:对于List的消息队列来说,消费者断开后重连,仍然可以从List中断点消费还没消费的数据。在发布和订阅中,如果订阅者断开重连,则会丢失断开期间发布者发布的数据,导致无法恢复。
6.2、Kafka
持久化:Kafka会将数据持久化到磁盘内,而Redis的发布和订阅做不到。断点消费:当订阅者断开后重连时断开期间发布者发布的消息会丢失,而Kafka中会记录每个消费者消费的主题(topic)的偏移量(offset),因此Kafka可以从断开的偏移量继续消费。偏移量:Kafka的消费者可以指定从某个偏移量开始重新消费,而Redis发布和订阅根本不会记录订阅者消费的偏移量。消费方式:在Redis发布订阅中,数据消费情况是由发布者控制的,当发布者把消息发布到频道中后,只有当前连接了频道的订阅者才能消费数据,断开后重连的订阅者会失去那部分数据。Kafka中消费进度是由消费者控制的,消费者从主题中拉取数据并记录消费的偏移量。消费者组:在Kafka中,不同的消费者组中的消费者消费相同的主题时会各自维护一个偏移量,因此不会出现A消费之后的数据B就消费不到的情况。Redis中订阅者订阅相同的频道也不会出现这类情况。
7、Stream概述
尽管Redis通过实现发布与订阅(pub/sub)模式来达到了多播消息队列的目的,但是在实际的消息队列应用领域几乎找不到特别合适的应用场景,因为它的缺点十分明显:
- 第一,没有确认(ack)机制,也不保证数据的连续性。发布与订阅模式中的生产者传递过来一个消息,Redis会直接找到相应的消费者传递过去。如果没有消费者,那么消息会被直接丢弃。如果开始有三个消费者,其中一个突然挂掉了,过一会等它再重连时,重连期间的消息对于这个消费者来说就彻底丢失了。
- 第二,不持久化消息,如果Redis停机后重启,发布与订阅的消息是不会持久化的,Redis宕机就相当于一个消费者都没有,所有的消息都会被直接丢弃。
Redis 5.0版本之后新增了Stream数据结构,这个功能给Redis带来了持久化的消息队列。
Redis Stream从概念上来说就像是一个仅追加内容的消息链表,把所有加入的消息都一个一个串起来,每个消息都有一个唯一的ID,而Kafka的消费模式就借鉴了Redis Stream。
下图就是一个典型的Stream结构。每个Stream都有唯一的名称,即Redis的键(key)。

Consumer Group:消费者组字段,是记录流状态的一种数据结构。消费者既可以选择使用XREAD命令进行独立消费,也可以加入一个消费者组进行组内消费。同一个消费者组内的消费者共享所有的Stream消息,同一条消息只会有一个消费者消费到,可以应用在分布式的场景中来保证消息的唯一性。last_delivered_id:用来表示消费者组在Stream上消费位置的游标信息。每个消费者组都有一个Stream内唯一的名称,消费者组不会自动创建,需要使用XGroupCrate命令来显式创建,并且需要指定从哪一个消息ID开始消费,以初始化last_delivered_id这个变量。如果读者知道Kafka,则可以将这个字段理解为消息偏移量。pending_ids:这个数组也被称为PEL(PendingEntries List,待处理消息列表)。每个消费者内部都有的一个状态变量,用来表示已经被客户端获取,但还没有确认(ack: acknowledge character,确认字符)的消息。记录的目的是为了保证客户端至少消费了一次消息,而不会因在网络传输中丢失而不对消息进行处理。如果客户端没有确认,那么这个数组中的消息ID就会越来越多,当某个消息被确认,该数组就会减少其中对应的消息ID。

7.1、消息队列相关命令
消息队列相关命令如下:
xadd:向队列末尾添加消息,如果指定的队列不存在,就创建一个队列。
key:队列名称,如果不存在就创建一个。id:消息id,使用*表示由Redis生成,可以自定义,但是要保证递增性。field value:记录。
bash
redis> xadd mystream * name Sara surname OConnor
"1601372323627-0" # 消息ID
redis> xadd mystream * field1 value1 field2 value2 field3 value3
"1601372323627-1"
redis> xlen mystream # 消息长度
(integer) 2
redis> xrange mystream - + # -表示最小值, +表示最大值
1) 1) "1601372323627-0"
2) 1) "name"
2) "Sara"
3) "surname"
4) "OConnor"
2) 1) "1601372323627-1"
2) 1) "field1"
2) "value1"
3) "field2"
4) "value2"
5) "field3"
6) "value3"
xtrim:对流进行修剪,限制长度。
bash
xtrim key MAXLEN [~] count
key:队列名称。maxlen:长度。count:数量。
bash
127.0.0.1:6379> xadd mystream * field1 A field2 B field3 C field4 D
"1601372434568-0"
127.0.0.1:6379> xtrim mystream MAXLEN 2
(integer) 0
127.0.0.1:6379> xrange mystream - +
1) 1) "1601372434568-0"
2) 1) "field1"
2) "A"
3) "field2"
4) "B"
5) "field3"
6) "C"
7) "field4"
8) "D"
127.0.0.1:6379>
xdel:删除消息。
bash
Xdel key id [id ...]
key:队列名称。id:消息id。
bash
127.0.0.1:6379> xadd mystream * a 1
1538561698944-0
127.0.0.1:6379> xadd mystream * b 2
1538561700640-0
127.0.0.1:6379> xadd mystream * c 3
1538561701744-0
127.0.0.1:6379> xdel mystream 1538561700640-0
(integer) 1
127.0.0.1:6379> xrange mystream - +
1) 1) 1538561698944-0
2) 1) "a"
2) "1"
2) 1) 1538561701744-0
2) 1) "c"
2) "3"
xlen:获取流包含的元素数量,即消息长度。
bash
xlen key
key:队列名称。
bash
127.0.0.1:6379> xadd mystream * item 1
"1601372563177-0"
127.0.0.1:6379> xadd mystream * item 2
"1601372563178-0"
127.0.0.1:6379> xadd mystream * item 3
"1601372563178-1"
127.0.0.1:6379> xadd mystream
(integer) 3
xrange:获取消息列表,会自动过滤已经删除的消息。
bash
xrange key start end [COUNT count]
key:队列名。start:开始值,-表示最小值。end:结束值,+表示最大值。count:数量。
bash
127.0.0.1:6379> xadd writers * name Virginia surname Woolf
"1601372577811-0"
127.0.0.1:6379> xadd writers * name Jane surname Austen
"1601372577811-1"
127.0.0.1:6379> xadd writers * name Toni surname Morrison
"1601372577811-2"
127.0.0.1:6379> xadd writers * name Agatha surname Christie
"1601372577812-0"
127.0.0.1:6379> xadd writers * name Ngozi surname Adichie
"1601372577812-1"
127.0.0.1:6379> xlen writers
(integer) 5
127.0.0.1:6379> xrange writers - + COUNT 2
1) 1) "1601372577811-0"
2) 1) "name"
2) "Virginia"
3) "surname"
4) "Woolf"
2) 1) "1601372577811-1"
2) 1) "name"
2) "Jane"
3) "surname"
4) "Austen"
xread:以阻塞或非阻塞方式获取消息列表。
bash
xread [count count] [block milliseconds] streams key [key ...] id [id ...]
count:数量。milliseconds:可选,阻塞毫秒数,没有设置就表示采用非阻塞模式。key:队列名。id:消息id。
bash
# 从Stream头部读取两条消息
127.0.0.1:6379> xread count 2 streams mystream writers 0-0 0-0
1) 1) "mystream"
2) 1) 1) 1526984818136-0
2) 1) "duration"
2) "1532"
3) "event-id"
4) "5"
5) "user-id"
6) "7782813"
2) 1) 1526999352406-0
2) 1) "duration"
2) "812"
3) "event-id"
4) "9"
5) "user-id"
6) "388234"
2) 1) "writers"
2) 1) 1) 1526985676425-0
2) 1) "name"
2) "Virginia"
3) "surname"
4) "Woolf"
2) 1) 1526985685298-0
2) 1) "name"
2) "Jane"
3) "surname"
4) "Austen"
7.2、消费者组相关命令
消费者组相关命令如下:
xgroup create:创建消费者组。
bash
xgroup [create key groupname id-or-$] [setid key groupname id-or-$] [destroy key groupname] [delconsumer key groupname consumername]
key:队列名称,如果不存在就创建一个。groupname:组名。$:表示从尾部开始消费,只接收新消息,当前Stream消息会全部忽略。
bash
127.0.0.1:6379> xgroup create mystream consumer-group-name 0-0
# 从头开始消费
127.0.0.1:6379> xgroup create mystream consumer-group-name $
# 从尾部开始消费
xreadgroup group:读取消费者组中的消息。
bash
xreadgroup group group consumer [count count] [block milliseconds] [noack]
streams key [key ...] id [id ...]
group:消费者组名consumer:消费者名。count:读取数量。milliseconds:阻塞毫秒数。key:队列名。id:消息id。
bash
xreadgroup group consumer-group-name consumer-name count 1 streams mystream >
xack:将消息标记为"已处理"。xgroup setid:为消费者组设置新的最后传送消息ID。xgroup delconsumer:删除消费者。xgroup destroy:删除消费者组。xpending:显示待处理消息的相关信息。xclaim:转移消息的归属权。xinfo:查看流和消费者组的相关信息。xinfo groups:打印消费者组的信息。xinfo stream:打印流信息。
8、Stream实例
8.1、阻塞方式的消息消费和生产模式
当消费者收不到数据时阻塞状态的实例如图所示。

设置消费消息的最长等待时间,如果超时还没有消息,就返回nil。例如,设置最长等待1000毫秒,如果超过1000毫秒还没有消息发送过来,就返回nil,如图所示。
8.2、组内消费模式
Stream提供的xreadgroup命令可以进行消费者组的组内消费,需要提供消费者组名称、消费者名称和起始消息ID。它与xread一样,也可以阻塞以等待新消息。读到新消息后,对应的消息ID就会进入消费者的PEL(待处理消息列表)结构中,客户端处理完毕后使用xack命令通知服务器本条消息已经处理完毕,该消息ID就会从PEL中移除。
bash
# 首先创建两个消费者组
127.0.0.1:6379> xgroup create mytopic cg1 0-0 # 表示从头开始消费
127.0.0.1:6379> xgroup create mytopic cg2 $# $表示从尾部开始消费,只接收新消息
8.2.1、同组消费模式
当同一个组有两个消费者时,会轮询接收到该订阅主题(topic)的信息,如图所示。

首先开启消费者组cg1的消费者c1和消费者c2以阻塞的形式消费mytopic的消息,再次启动消息生产方,给mytopic队列中增加数据。因为c1和c2是同一个组的,所以以轮询的方式每次只给消费者c1或c2发送消息,两个消费者消费消息的总数等于队列中消息的总数。
当同一个组有两个消费者时,会以轮询方式接收到该订阅主题的消息,如图所示。

8.2.2、不同组消费模式
不同组消费模式如图所示,当不同组各有一个消费者时,两个消费者会接收到该订阅主题的相同消息。

首先开启消费者组cg1的消费者cg1_c1和消费者组cg2的消费者cg2_c1以阻塞的形式消费mytopic的消息,再次启动消息生产方,给mytopic队列中增加消息。因为c1和c2不是同一个组的,所以是以广播的方式每次给消费者c1和c2发送相同的消息。
当不同组各有一个消费者时,两个消费者会接收到该订阅主题的相同消息,如图所示。

同一组cg1的消费者会轮询消费队列中的消息,当消费者组cg2只有一个消费者时,cg2_c1会消费所有消息,如图所示。最终cg1组和cg2组各自消费的总消息数量总是等于队列中消息的总数量。

8.3、消息确认
消息确认(ack)操作的范例代码执行如下:
bash
127.0.0.1:6379> xinfo consumers mytopic cg1 # 查看cg1组的消费信息
1) 1) "name"
2) "c1"
3) "pending"
4) (integer) 10
5) "idle"
6) (integer) 1467304
2) 1) "name"
2) "c2"
3) "pending"
4) (integer) 1
5) "idle"
6) (integer) 1498802
3) 1) "name"
2) "cg1_c1"
3) "pending"
4) (integer) 2 # 表示消费者cg1_c1目前两条消息还没有确认
5) "idle"
6) (integer) 839780
127.0.0.1:6379> xack mytopic cg1 1611472829174-0 # 根据消费返回的id确认消息
(integer) 1
127.0.0.1:6379> xack mytopic cg1 1611472846453-0 # 消息确认
(integer) 1
进行消息确认之后,会发送改变的消息确认的数量,如图所示。
