目录
1、发布和订阅
1-1、操作命令
1-1-1、发布消息
1-1-2、订阅消息
1-1-3、查询订阅情况
1-1-4、查看频道订阅数
1-2、使用场景和缺点
2、Redis Stream
2-1、常用操作命令
2-1-1、生产端
2-1-2、消费端
2-1-2-1、单消费者
2-1-2-2、消费组
3、Redis中几种消息队列实现的总结
3-1、基于List的 LPUSH+BRPOP 的实现
3-2、基于Sorted-Set的实现
3-3、PUB/SUB,订阅/发布模式
3-4、基于Stream类型的实现
4、消息队列问题
4-1、Stream 消息太多怎么办?
4-2、消息如果忘记 ACK 会怎样?
4-3、PEL 如何避免消息丢失?
4-4、死信问题
4-5、Stream 的高可用
4-6、分区 Partition
5、Stream小结
Redis提供了基于“发布/订阅”模式的消息机制,此种模式下,消息发布者和订阅者不进行直接通信,发布者客户端向指定的频道(channel)发布消息,订阅该频道的每个客户端都可以收到该消息。 (个人觉得专业的事交给专业的技术来做,所以简单了解即可)
Redis主要提供了发布消息、订阅频道、取消订阅以及按照模式订阅和取消订阅等命令。
发布消息,返回值是接收到信息的订阅者数量,如果是0说明没有订阅者,这条消息就丢了(再启动订阅者也不会收到)
# 发布消息
publish channel message
# 示例
publish topic1 hello
订阅者可以订阅一个或多个频道,如果此时另一个客户端发布一条消息,当前订阅者客户端会收到消息。
# 订阅消息
subscribe channel [channel ...]
# 示例
subscribe topic1
客户端在执行订阅命令之后进入了订阅状态(类似于监听),只能接收subscribe、psubscribe,unsubscribe、 punsubscribe的四个命令。
此时用另一个客户端给 topic1 发送消息
而订阅者会消息到该消息
# 1、订阅者客户端,进入阻塞状态(监听中。。。)
127.0.0.1:6379> subscribe topic1
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "topic1"
3) (integer) 1
1) "message"
2) "topic1"
# 2、发布者客户端
127.0.0.1:6379> publish topic1 jordan
(integer) 1
# 3、订阅者客户端,监听到该topic1的消息
127.0.0.1:6379> subscribe topic1
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "topic1"
3) (integer) 1
1) "message"
2) "topic1"
3) "jordan"
换个角度想就是,订阅者可以监听任何topic,发布者只需要对这些topic发送消息即可,发布者在发送之前需确认有没有订阅者监听该topic,如果没有订阅者监听,那么消息发送后就会丢失。
# 查看活跃的频道,支持 * 匹配
pubsub channels [pattern]
# 示例
pubsub channels *1
# 查看频道订阅数
pubsub numsub channel
# 示例
pubsub numsub topic1
也可以通过 help看具体的参数运用
- 需要消息解耦又并不关注消息可靠性的地方都可以使用发布订阅模式。
- PubSub 的生产者传递过来一个消息,Redis会直接找到相应的消费者传递过去。如果一个消费者都没有,那么消息直接丢弃。如果开始有三个消费者,一个消费者突然挂掉了,生产者会继续发送消息,另外两个消费者可以持续收到消息。但是挂掉的消费者重新连上的时候,这断连期间生产者发送的消息,对于这个消费者来说就是彻底丢失了。
- 所以和很多专业的消息队列系统(例如Kafka、RocketMQ)相比,Redis 的发布订阅很粗糙,例如无法实现消息堆积和回溯。但胜在足够简单,如果当前场景可以容忍的这些缺点,也不失为一个不错的选择。
- 正是因为 PubSub 有这些缺点,它的应用场景其实是非常狭窄的。从Redis5.0 新增了 Stream 数据结构,这个功能给 Redis 带来了持久化消息队列。
Redis5.0 最大的新特性就是多出了一个数据结构 Stream,它是一个新的强大的支持多播的可持久化的消息队列,Redis的作者声明Redis Stream地借鉴了 Kafka 的设计。
Redis Stream 的生产者如上图所示:
- 每一个Stream都有一个消息链表,将所有加入的消息都串起来,每个消息都有一个唯一的 ID 和对应的内容。消息是持久化的,Redis 重启后,内容还在。
- 每个 Stream 都有唯一的名称,它就是 Redis 的 key,在我们首次使用xadd指令追加消息时自动创建。
- 当创建一个名为 “stream1”的 stream,并且会返回一个ID,而值就会存放在content中,消息内容就是键值对,形如 hash 结构的键值对。
- 消息 ID 的形式是timestampInMillis - sequence,例如 1692277178451-5 ,它表示当前的消息在毫米时间戳1692277178451时产生,并且是该毫秒内产生的第 5 条消息。消息 ID 可以由服务器自动生成(*代表默认自动),也可以由客户端自己指定,但是形式必须是整数-整数,而且必须是后面加入的消息的 ID 要大于前面的消息 ID。
Redis Stream 的消费群组如上图所示:
- 每个 Stream 都可以挂多个消费组,每个消费组会有个游标 last_delivered_id 在 Stream 数组之上往前移动,表示当前消费组已经消费到哪条消息了。
- 每个消费组都有一个 Stream 内唯一的名称,消费组不会自动创建,它需要单独的指令xgroup create 进行创建,需要指定从 Stream 的某个消息 ID 开始消费,这个 ID 用来初始化 last_delivered_id 变量。
如上图所示:
- 每个消费组 (Consumer Group) 的状态都是独立的,相互不受影响。也就是说同一份 Stream 内部的消息会被每个消费组都消费到。
- 同一个消费组 (Consumer Group) 可以挂接多个消费者 (Consumer),这些消费者之间是竞争关系,任意一个消费者读取了消息都会使游标 last_delivered_id 往前移动。每个消费者有一个组内唯一名称。
- 消费者 (Consumer) 内部会有个状态变量 pending_ids ,它记录了当前已经被客户端读取,但是还没有 ack 的消息。如果客户端没有 ack,这个变量里面的消息 ID 会越来越多,一旦某个消息被 ack,它就开始减少。这个 pending_ids 变量在 Redis 官方被称之为PEL,也就是 Pending Entries List,这是一个很核心的数据结构,它用来确保客户端至少消费了消息一次,而不会在网络传输的中途丢失了没处理。
xadd第一次对于一个stream使用可以生成一个stream的结构
# 名称为“streamprod”的stream,*号表示服务器自动生成ID,后面顺序跟着一堆 key/value
xadd streamprod * name jordan num 23
- 1692278367590-0 则是生成的消息 ID,由两部分组成:时间戳-序号。时间戳时毫秒级单位,是生成消息的Redis服务器时间,它是个64位整型。序号是在这个毫秒时间点内的消息序号。它也是个64位整型。
- 为了保证消息是有序的,因此Redis生成的ID是单调递增有序的。由于ID中包含时间戳部分,为了避免服务器时间错误而带来的问题(例如服务器时间延后了),Redis的每个Stream类型数据都维护一个latest_generated_id属性,用于记录最后一个消息的ID。若发现当前时间戳退后(小于latest_generated_id所记录的),则采用时间戳不变而序号递增的方案来作为新消息ID(这也是序号为什么使用int64的原因,保证有足够多的的序号),从而保证ID的单调递增性质。
- 强烈建议使用Redis的方案生成消息ID,因为这种时间戳+序号的单调递增的ID方案,几乎可以满足你全部的需求。但ID是支持自定义的。
# xrange 获取消息列表,会自动过滤已经删除的消息
xrange key start end [COUNT count]
# 示例:其中-表示最小值 , + 表示最大值
xrange streamprod - +
# 示例:也可以指定范围
xrange streamprod 1692278719133-0 1692278731956-0
# 示例:也可以用 + - 配合ID 使用
xrange streamprod - 1692278725370-0
xrange streamprod 1692278725370-0 +
# xlen 消息长度
xlen streamprod
# del streamprod 删除整个 Stream
del streamprod
# xdel可以删除指定的消息(指定ID)
xdel key ID [ID ...]
# 示例
xdel streamprod 1692278725370-0
虽然Stream中有消费者组的概念,但是可以在不定义消费组的情况下进行 Stream 消息的独立消费,当 Stream 没有新消息时,甚至可以阻塞等待。Redis 设计了一个单独的消费指令xread,可以将 Stream 当成普通的消息队列 (list) 来使用。使用 xread 时,我们可以完全忽略消费组 (Consumer Group) 的存在,就好比 Stream 就是一个普通的列表 (list)。
# 表示从 Stream 头部读取 1 条消息,0-0 指从头开始
127.0.0.1:6379> xread count 1 streams streamprod 0-0
# 表示从 Stream 头部读取 2 条消息,0-0 指从头开始
127.0.0.1:6379> xread count 2 streams streamprod 0-0
# 表示从 Stream 头部读取4条消息,0-0 指从头开始(但是只有3条,所以就全部展示)
127.0.0.1:6379> xread count 4 streams streamprod 0-0
# 也可以指定从streams的消息Id开始(不包括命令中的消息id)
xread count 4 streams streamprod 1692279872738-0
# $代表从尾部读取,上面的意思就是从尾部读取最新的一条消息,此时默认不返回任何消息
xread count 1 streams streamprod $
# block后面的数字代表阻塞时间,单位毫秒,0代表一直阻塞
xread block 0 count 1 streams streamprod $
1、客户端2阻塞读
2、客户端1往streamprod中写入一条消息
3、客户端2中可以看到看到阻塞解除了,返回了新的消息内容,而且还显示了一个等待时间,这里我们等待了20.20s
一般来说客户端如果想要使用 xread 进行顺序消费,一定要记住当前消费到哪里了,也就是返回的消息 ID。下次继续调用 xread 时,将上次返回的最后一个消息 ID 作为参数传递进去,就可以继续消费后续的消息。不然很容易重复消息,基于这点单消费者基本上没啥运用场景,也不深入研究。
Stream 通过xgroup create指令创建消费组 (Consumer Group),需要传递起始消息 ID 参数用来初始化last_delivered_id变量。
消费组创建:
# 消费组创建,0-表示从头开始消费
xgroup create streamprod c1 0-0
# 消费组创建,$ 表示从尾部开始消费,只接受新消息,当前 Stream 消息会全部忽略
xgroup create streamprod c2 $
用xinfo命令来查看streamprod的情况(消息长度、消费组数量、最后生效的消费ID)
xinfo stream streamprod
查看 streamprod 的消费组的情况
xinfo groups streamprod
消息消费
- 有了消费组,自然还需要消费者,Stream提供了 xreadgroup 指令可以进行消费组的组内消费,需要提供消费组名称、消费者名称和起始消息 ID。
- 它同 xread 一样,也可以阻塞等待新消息。读到新消息后,对应的消息 ID 就会进入消费者的PEL(正在处理的消息) 结构里,客户端处理完毕后使用 xack 指令通知服务器,本条消息已经处理完毕,该消息 ID 就会从 PEL 中移除。
# c1 代表 消费组名称
# consumer1 代表消费组中的消费者
# count 1 代表消费一条信息
# streams streamprod 代表 名称为 streamprod 的 streams
# " > " 表示从当前消费组的 last_delivered_id 后面开始读,每当消费者读取一条消息,last_delivered_id 变量就会前进。
# 前面我们定义c1的时候是从头开始消费的,自然就获得streamprod中第一条消息再执行一次上面的命令
xreadgroup GROUP c1 consumer1 count 1 streams streamprod >
设置阻塞等待
# block 0 代表一直阻塞,直到接收到消息
xreadgroup GROUP c1 consumer1 count 10 block 0 streams streamprod >
1、客户端1 设置阻塞
2、客户端2 发送消息
3、客户端1 接收到消息
查看消费组状态,因为我们一直操作的是c1,所以c2没有变化,里面也就没有消费者
如果同一个消费组有多个消费者,我们还可以通过 xinfo consumers 指令观察每个消费者的状态
xinfo consumers streamprod c1
可以看到目前c1这个消费者有 8 条待 ACK 的消息,空闲了 1670153 ms 没有读取消息。
如果我们确认一条消息
xack streamprod c1 1692524175971-0
就可以看到待确认消息变成了 7 条
xack允许带多个消息id,比如 同时Stream还提供了命令 XPENDING 用来获消费组或消费内消费者的未处理完毕的消息。
xpending streamprod c1
优点:
- 足够简单,消费消息延迟几乎为零,但是需要处理空闲连接的问题。
- 如果线程一直阻塞在那里,Redis客户端的连接就成了闲置连接,闲置过久,服务器一般会主动断开连接,减少闲置资源占用,这个时候blpop和brpop或抛出异常,所以在编写客户端消费者的时候要小心,如果捕获到异常,还有重试。
缺点:
- 做消费者确认ACK麻烦,不能保证消费者消费消息后是否成功处理的问题(宕机或处理异常等),通常需要维护一个Pending列表,保证消息处理确认;不能做广播模式,如pub/sub,消息发布/订阅模型;不能重复消费,一旦消费就会被删除;不支持分组消费。
多用来实现延迟队列,当然也可以实现有序的普通的消息队列,但是消费者无法阻塞的获取消息,只能轮询,不允许重复消息。
优点:
- 典型的广播模式,一个消息可以发布到多个消费者;多信道订阅,消费者可以同时订阅多个信道,从而接收多类消息;消息即时发送,消息不用等待消费者读取,消费者会自动接收到信道发布的消息。
缺点:
- 消息一旦发布,不能接收。换句话就是发布时若客户端不在线,则消息丢失,不能寻回;不能保证每个消费者接收的时间是一致的;若消费者客户端出现消息积压,到一定程度,会被强制断开,导致消息意外丢失。通常发生在消息的生产远大于消费速度时;可见,Pub/Sub 模式不适合做消息存储,消息积压类的业务,而是擅长处理广播,即时通讯,即时反馈的业务。
基本上已经有了一个消息中间件的雏形,可以考虑在生产过程中使用。
从我们上面对Stream的使用表明,Stream已经具备了一个消息队列的基本要素,生产者API、消费者API,消息Broker,消息的确认机制等等,所以在使用消息中间件中产生的问题,这里一样也会遇到。
- 要是消息积累太多,Stream 的链表岂不是很长,内容会不会爆掉?xdel 指令又不会删除消息,它只是给消息做了个标志位。
- Redis 自然考虑到了这一点,所以它提供了一个定长 Stream 功能。在 xadd 的指令提供一个定长长度 maxlen,就可以将老的消息干掉,确保最多不超过指定长度。
Stream 在每个消费者结构中保存了正在处理中的消息 ID 列表 PEL,如果消费者收到了消息处理完了但是没有回复 ack,就会导致 PEL 列表不断增长,如果有很多消费组的话,那么这个 PEL 占用的内存就会放大。所以消息要尽可能的快速消费并确认。
在客户端消费者读取 Stream 消息时,Redis 服务器将消息回复给客户端的过程中,客户端突然断开了连接,消息就丢失了。但是 PEL 里已经保存了发出去的消息 ID。待客户端重新连上之后,可以再次收到 PEL 中的消息 ID 列表。不过此时 xreadgroup 的起始消息 ID 不能为参数>,而必须是任意有效的消息 ID,一般将参数设为 0-0,表示读取所有的 PEL 消息以及自last_delivered_id之后的新消息。
如果某个消息,不能被消费者处理,也就是不能被XACK,这是要长时间处于Pending列表中,即使被反复的转移给各个消费者也是如此。此时该消息的delivery counter(通过XPENDING可以查询到)就会累加,当累加到某个我们预设的临界值时,我们就认为是坏消息(也叫死信,DeadLetter,无法投递的消息),由于有了判定条件,我们将坏消息处理掉即可,删除即可。删除一个消息,使用XDEL语法,注意,这个命令并没有删除Pending中的消息,因此查看Pending,消息还会在,可以在执行执行XDEL之后,XACK这个消息标识其处理完毕。
Stream 的高可用是建立主从复制基础上的,它和其它数据结构的复制机制没有区别,也就是说在 Sentinel 和 Cluster 集群环境下 Stream 是可以支持高可用的。不过鉴于 Redis 的指令复制是异步的,在 failover 发生时,Redis 可能会丢失极小部分数据,这点 Redis 的其它数据结构也是一样的。
Redis 的服务器没有原生支持分区能力,如果想要使用分区,那就需要分配多个 Stream,然后在客户端使用一定的策略来生产消息到不同的 Stream。
- Stream 的消费模型借鉴了Kafka 的消费分组的概念,它弥补了 Redis Pub/Sub 不能持久化消息的缺陷。但是它又不同于 kafka,Kafka 的消息可以分 partition,而 Stream 不行。如果非要分 parition 的话,得在客户端做,提供不同的 Stream 名称,对消息进行 hash 取模来选择往哪个 Stream 里塞。
- 关于 Redis 是否适合做消息队列,业界一直是有争论的。很多人认为,要使用消息队列,就应该采用 Kafka、RabbitMQ 这些专门面向消息队列场景的软件,而 Redis 更加适合做缓存。 根据这些年做 Redis 研发工作的经验,我的看法是:Redis 是一个非常轻量级的键值数据库,部署一个 Redis 实例就是启动一个进程,部署 Redis 集群,也就是部署多个 Redis 实例。而 Kafka、RabbitMQ 部署时,涉及额外的组件,例如 Kafka 的运行就需要再部署ZooKeeper。相比 Redis 来说,Kafka 和 RabbitMQ 一般被认为是重量级的消息队列。 所以,关于是否用 Redis 做消息队列的问题,不能一概而论,我们需要考虑业务层面的数据体量,以及对性能、可靠性、可扩展性的需求。如果分布式系统中的组件消息通信量不大,那么,Redis 只需要使用有限的内存空间就能满足消息存储的需求,而且,Redis 的高性能特性能支持快速的消息读写,不失为消息队列的一个好的解决方案。