基于 Redis 的 Stream 类型的完美消息队列解决方案

Redis 5.0 带来了 Stream 类型,从字面上看是流类型,但其实从功能上看,应该是 Redis 对消息队列(MQ,Message Queue)的完善实现。用过 Redis 做消息队列的都了解,基于 Reids 的消息队列实现有很多种,例如:PUB/SUB,订阅/发布模式,基于 List 的 LPUSH+BRPOP 的实现,基于 Sorted-Set 的实现,每一种实现,都有典型的特点和问题,Redis 5.0 中发布的 Stream 类型,也用来实现典型的消息队列。该 Stream 类型的出现,几乎满足了消息队列具备的全部内容。

本 Chat 将介绍如何使用 Redis 的 Stream 类型实现完美的消息队列功能,涉及的内容有:

  1. 说明
  2. 追加新消息、XADD、生产消息
  3. 从消息队列中获取消息、XREAD、消费消息
  4. 消息 ID 说明
  5. 消费者组模式、Consumer Group
  6. Pending 等待列表
  7. 消息转移
  8. 坏消息问题、Dead Letter、死信问题
  9. 信息监控、XINFO
  10. 命令一览
  11. Stream 数据结构、RadixTree、基数树
  12. 相关产品

1 概述

Redis5.x 带来了 Stream 类型。从字面上看是流类型,但其实从功能上看,应该是 Redis 对消息队列(MQ,Message Queue)的完善实现。基于 Redis 做消息队列的方案有很多种,例如:

  • PUB/SUB,订阅/发布模式
  • 基于 Lis 类型t的 LPUSH+BRPOP 的实现
  • 基于 Sorted-Set 的实现

每一种实现,都有典型的特点和问题,这个在 Redis 实现消息队列一文中有介绍。基于Redis实现消息队列http://www.hellokang.net/redis/message-queue-by-redis.html

Redis5.x 中实现的 Stream 类型,也用来实现典型的消息队列。Stream 类型的出现,几乎满足了消息队列应具备的全部内容。本文介绍如何使用 Stream 类型来实现消息队列。内容包括但不限于:

  • 消息队列的作用
  • 消息 ID 的序列化生成
  • 消息遍历
  • 消息的阻塞和非阻塞读取
  • 消息的分组消费
  • 未完成消息的处理
  • 消息队列监控
  • 高可用(单点问题)

2 消息队列是什么,解决什么问题

什么是消息队列

消息队列,Message Queue,主要用来在多个任务间传递数据(消息)。有生产者(Productor)和消费者(Consumer)两种角色。

  • 生产者,将数据(消息)放入消息队列
  • 消费者,从消息队列中提取数据(消息)

enter image description here

生产者和消费者都可能是多个

消息队列解决什么问题

项目中使用消息队列,通常用于解决如下问题:

  • 应用解耦
  • 异步执行
  • 削峰限流
应用解耦

指的是若应用 A 和 应用 B 之间需要做数据交互,那么将数据放在消息队列中,而不是两个应用直接交互数据,此时可以保证应用A,B之间的相互独立,分别确保交互的数据结构正确即可。

应用解耦

异步执行

指的是若操作 A 和操作 B 需要做数据交互,但是两个操作处理数据的速度相差较大,例如典型的 IO 请求(包括磁盘和网络),此时应该将操作设计为异步执行的,也就是不需要等待其他的操作执行完毕,当前操作就可以继续执行。那此时也可以使用消息队列。在操作A执行完毕后,将结果放入队列即可完成任务,不需要等待操作 B。而操作 B 直接去队列中提取消息进行处理即可。这样,慢的操作还可以并发执行,进而提升响应速度。

应用解耦

削峰限流

指的是我们的系统会遇到突然的并发访问量很高的时候,典型的业务就是双11这类,大促销这种。此时,由于系统的承载有限,同时需要解决大量的请求,就需要消息队列。需求产生时,并不是立即解决,而是将需求放入消息队列,并立即告知用户,请求正在排队处理中。同时业务逻辑处理程序,从队列中依次获取消息数据,尽可能快速处理消息。既保证了系统不会因为大量请求而崩溃,也保证了用户的请求可以尽快的处理。

OK,消息队列的介绍到此为止,下面就进入到本文核心,如何使用 Redis 的 Stream 类型,来实现典型的消息队列。Stream 几乎实现了消息队列的全部细节。先看如何生产消息。

3 追加新消息,XADD,生产消息

XADD,命令用于在某个 stream(流数据)中追加消息,演示如下:

127.0.0.1:6379> XADD memberMessage * user kang msg Hello"1553439850328-0"127.0.0.1:6379> XADD memberMessage * user zhong  msg nihao"1553439858868-0"

其中语法格式为:

XADD key ID field string [field string ...]

需要提供 key,消息ID 方案,消息内容,其中消息内容为 key-value 型数据。ID,最常使用*,表示由 Redis 生成消息ID,这也是强烈建议的方案。field string [field string], 就是当前消息内容,由1个或多个 key-value 构成。

上面的例子中,在 memberMemsages 这个 key 中追加了 user kang msg Hello 这个消息。Redis 使用毫秒时间戳和序号生成了消息ID。此时,消息队列中就有一个消息可用了。

4 从消息队列中获取消息,XREAD,消费消息

XREAD,从 Stream 中读取消息,演示如下:

127.0.0.1:6379> XREAD streams memberMessage 01) 1) "memberMessage"   2) 1) 1) "1553439850328-0"         2) 1) "user"            2) "kang"            3) "msg"            4) "Hello"      2) 1) "1553439858868-0"         2) 1) "user"            2) "zhong"            3) "msg"            4) "nihao"

上面的命令是从消息队列 memberMessage 中读取所有消息。XREAD 支持很多参数,语法格式为:

XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...]

其中:

  • [COUNT count],用于限定获取的消息数量
  • [BLOCK milliseconds],用于设置 XREAD 为阻塞模式,默认为非阻塞模式
  • ID,用于设置由哪个消息ID开始读取。使用0表示从第一条消息开始。(本例中就是使用0)此处需要注意,消息队列ID是单调递增的,所以通过设置起点,可以向后读取。在阻塞模式中,可以使用$,表示最新的消息ID。(在非阻塞模式下$无意义)。

XRED 读消息时分为阻塞和非阻塞模式,使用 BLOCK 选项可以表示阻塞模式,需要设置阻塞时长。非阻塞模式下,读取完毕(即使没有任何消息)立即返回,而在阻塞模式下,若读取不到内容,则阻塞等待。

一个典型的阻塞模式用法为:

127.0.0.1:6379> XREAD block 1000 streams memberMessage $(nil)(1.07s)

我们使用 Block 模式,配合$作为ID,表示读取最新的消息,若没有消息,命令阻塞!等待过程中,其他客户端向队列追加消息,则会立即读取到。

因此,典型的队列就是 XADD 配合 XREAD Block 完成。XADD 负责生成消息,XREAD 负责消费消息。

5 消息ID说明

XADD 生成的 1553439850328-0,就是 Redis 生成的消息ID,由两部分组成:时间戳-序号。时间戳是毫秒级单位,是生成消息的 Redis 服务器时间,它是个64位整型(int64)。序号是在这个毫秒时间点内的消息序号,它也是个64位整型。较真来说,序号可能会溢出,but真可能吗?

可以通过multi批处理,来验证序号的递增:

127.0.0.1:6379> MULTIOK127.0.0.1:6379> XADD memberMessage * msg oneQUEUED127.0.0.1:6379> XADD memberMessage * msg twoQUEUED127.0.0.1:6379> XADD memberMessage * msg threeQUEUED127.0.0.1:6379> XADD memberMessage * msg fourQUEUED127.0.0.1:6379> XADD memberMessage * msg fiveQUEUED127.0.0.1:6379> EXEC1) "1553441006884-0"2) "1553441006884-1"3) "1553441006884-2"4) "1553441006884-3"5) "1553441006884-4"

由于一个 Redis 命令的执行很快,所以可以看到在同一时间戳内,是通过序号递增来表示消息的。

为了保证消息是有序的,因此 Redis 生成的ID是 单调递增 有序的。由于ID中包含时间戳部分,为了避免服务器时间错误而带来的问题(例如服务器时间延后了),Redis 的每个 Stream 类型数据都维护一个latestgeneratedid 属性,用于记录最后一个消息的 ID。若发现当前时间戳退后(小于 latestgeneratedid 所记录的),则采用时间戳不变而序号递增的方案来作为新消息ID(这也是序号为什么使用 int64 的原因,保证有足够多的的序号),从而保证 ID 的单调递增性质。

强烈建议使用 Redis 的方案生成消息 ID,因为这种时间戳+序号的单调递增的 ID 方案,几乎可以满足你全部的需求。但同时,记住 ID 是支持自定义的,别忘了!

6 消费者组模式,consumer group

当多个消费者(consumer)同时消费一个消息队列时,可以重复的消费相同的消息,就是消息队列中有10条消息,三个消费者都可以消费到这10条消息。

但有时,我们需要多个消费者配合协作来消费同一个消息队列,就是消息队列中有10条消息,三个消费者分别消费其中的某些消息,比如消费者A消费消息1、2、5、8,消费者B消费消息4、9、10,而消费者C消费消息3、6、7。也就是三个消费者配合完成消息的消费,可以在消费能力不足,也就是消息处理程序效率不高时,使用该模式。该模式就是消费者组模式。如下图所示:

消费者组模式

消费者组模式的支持主要由两个命令实现:

  • XGROUP,用于管理消费者组,提供创建组,销毁组,更新组起始消息ID等操作
  • XREADGROUP,分组消费消息操作

进行演示,演示时使用5个消息,思路是:创建一个 Stream 消息队列,生产者生成5条消息。在消息队列上创建一个消费组,组内三个消费者进行消息消费:

# 生产者生成10条消息127.0.0.1:6379> MULTI127.0.0.1:6379> XADD mq * msg 1 # 生成一个消息:msg 1127.0.0.1:6379> XADD mq * msg 2127.0.0.1:6379> XADD mq * msg 3127.0.0.1:6379> XADD mq * msg 4127.0.0.1:6379> XADD mq * msg 5127.0.0.1:6379> EXEC 1) "1553585533795-0" 2) "1553585533795-1" 3) "1553585533795-2" 4) "1553585533795-3" 5) "1553585533795-4"# 创建消费组 mqGroup127.0.0.1:6379> XGROUP CREATE mq mqGroup 0 # 为消息队列 mq 创建消费组 mgGroupOK# 消费者A,消费第1条127.0.0.1:6379> XREADGROUP group mqGroup consumerA count 1 streams mq > #消费组内消费者A,从消息队列mq中读取一个消息1) 1) "mq"   2) 1) 1) "1553585533795-0"         2) 1) "msg"            2) "1"# 消费者A,消费第2条127.0.0.1:6379> XREADGROUP GROUP mqGroup consumerA COUNT 1 STREAMS mq > 1) 1) "mq"   2) 1) 1) "1553585533795-1"         2) 1) "msg"            2) "2"# 消费者B,消费第3条127.0.0.1:6379> XREADGROUP GROUP mqGroup consumerB COUNT 1 STREAMS mq > 1) 1) "mq"   2) 1) 1) "1553585533795-2"         2) 1) "msg"            2) "3"# 消费者A,消费第4条127.0.0.1:6379> XREADGROUP GROUP mqGroup consumerA count 1 STREAMS mq > 1) 1) "mq"   2) 1) 1) "1553585533795-3"         2) 1) "msg"            2) "4"# 消费者C,消费第5条127.0.0.1:6379> XREADGROUP GROUP mqGroup consumerC COUNT 1 STREAMS mq > 1) 1) "mq"   2) 1) 1) "1553585533795-4"         2) 1) "msg"            2) "5"

上面的例子中,三个在同一组 mpGroup 消费者 A、B、C 在消费消息时(消费者在消费时指定即可,不用预先创建),有着互斥原则,消费方案为,A->1, A->2, B->3, A->4, C->5。语法说明为:

XGROUP CREATE mq mqGroup 0,用于在消息队列 mq 上创建消费组 mpGroup,最后一个参数0,表示该组从第一条消息开始消费。(意义与XREAD的0一致)。除了支持CREATE外,还支持SETID设置起始ID,DESTROY销毁组,DELCONSUMER删除组内消费者等操作。

XREADGROUP GROUP mqGroup consumerA COUNT 1 STREAMS mq >,用于组mqGroup内消费者consumerA在队列mq中消费,参数>表示未被组内消费的起始消息,参数count 1表示获取一条。语法与XREAD基本一致,不过是增加了组的概念。

可以进行组内消费的基本原理是,STREAM 类型会为每个组记录一个最后处理(交付)的消息ID(lastdeliveredid),这样在组内消费时,就可以从这个值后面开始读取,保证不重复消费。

以上就是消费组的基础操作。除此之外,消费组消费时,还有一个必须要考虑的问题,就是若某个消费者,消费了某条消息,但是并没有处理成功时(例如消费者进程宕机),这条消息可能会丢失,因为组内其他消费者不能再次消费到该消息了。下面继续讨论解决方案。

7 Pending 等待列表

为了解决组内消息读取但处理期间消费者崩溃带来的消息丢失问题,STREAM 设计了 Pending 列表,用于记录读取但并未处理完毕的消息。命令XPENDIING 用来获消费组或消费内消费者的未处理完毕的消息。演示如下:

127.0.0.1:6379> XPENDING mq mqGroup # mpGroup的Pending情况1) (integer) 5 # 5个已读取但未处理的消息2) "1553585533795-0" # 起始ID3) "1553585533795-4" # 结束ID4) 1) 1) "consumerA" # 消费者A有3个      2) "3"   2) 1) "consumerB" # 消费者B有1个      2) "1"   3) 1) "consumerC" # 消费者C有1个      2) "1"127.0.0.1:6379> XPENDING mq mqGroup - + 10 # 使用 start end count 选项可以获取详细信息1) 1) "1553585533795-0" # 消息ID   2) "consumerA" # 消费者   3) (integer) 1654355 # 从读取到现在经历了1654355ms,IDLE   4) (integer) 5 # 消息被读取了5次,delivery counter2) 1) "1553585533795-1"   2) "consumerA"   3) (integer) 1654355   4) (integer) 4# 共5个,余下3个省略 ...127.0.0.1:6379> XPENDING mq mqGroup - + 10 consumerA # 在加上消费者参数,获取具体某个消费者的Pending列表1) 1) "1553585533795-0"   2) "consumerA"   3) (integer) 1641083   4) (integer) 5# 共3个,余下2个省略 ...

每个Pending的消息有4个属性:

  1. 消息ID
  2. 所属消费者
  3. IDLE,已读取时长
  4. delivery counter,消息被读取次数

上面的结果我们可以看到,我们之前读取的消息,都被记录在 Pending 列表中,说明全部读到的消息都没有处理,仅仅是读取了。那如何表示消费者处理完毕了消息呢?使用命令 XACK 完成告知消息处理完成,演示如下:

127.0.0.1:6379> XACK mq mqGroup 1553585533795-0 # 通知消息处理结束,用消息ID标识(integer) 1127.0.0.1:6379> XPENDING mq mqGroup # 再次查看Pending列表1) (integer) 4 # 已读取但未处理的消息已经变为4个2) "1553585533795-1"3) "1553585533795-4"4) 1) 1) "consumerA" # 消费者A,还有2个消息处理      2) "2"   2) 1) "consumerB"      2) "1"   3) 1) "consumerC"      2) "1"127.0.0.1:6379> 

有了这样一个 Pending 机制,就意味着在某个消费者读取消息但未处理后,消息是不会丢失的。等待消费者再次上线后,可以读取该 Pending 列表,就可以继续处理该消息了,保证消息的有序和不丢失。

此时还有一个问题,就是若某个消费者宕机之后,没有办法再上线了,那么就需要将该消费者 Pending 的消息,转义给其他的消费者处理,就是消息转移。请继续。

8 消息转移

消息转移的操作时将某个消息转移到自己的 Pending 列表中。使用语法XCLAIM来实现,需要设置组、转移的目标消费者和消息ID,同时需要提供IDLE(已被读取时长),只有超过这个时长,才能被转移。演示如下:

# 当前属于消费者A的消息1553585533795-1,已经15907,787ms未处理了127.0.0.1:6379> XPENDING mq mqGroup - + 101) 1) "1553585533795-1"   2) "consumerA"   3) (integer) 15907787   4) (integer) 4# 转移超过 3600s 的消息 1553585533795-1 到消费者B的 Pending 列表127.0.0.1:6379> XCLAIM mq mqGroup consumerB 3600000 1553585533795-11) 1) "1553585533795-1"   2) 1) "msg"      2) "2"# 消息 1553585533795-1 已经转移到消费者B的Pending中。127.0.0.1:6379> XPENDING mq mqGroup - + 101) 1) "1553585533795-1"   2) "consumerB"   3) (integer) 84404 # 注意IDLE,被重置了   4) (integer) 5 # 注意,读取次数也累加了1次

以上代码,完成了一次消息转移。转移除了要指定ID外,还需要指定 IDLE,保证是长时间未处理的才被转移。被转移的消息的 IDLE 会被重置,用以保证不会被重复转移,以为可能会出现将过期的消息同时转移给多个消费者的并发操作,设置了 IDLE,则可以避免后面的转移不会成功,因为 IDLE 不满足条件。例如下面的连续两条转移,第二条不会成功。

127.0.0.1:6379> XCLAIM mq mqGroup consumerB 3600000 1553585533795-1127.0.0.1:6379> XCLAIM mq mqGroup consumerC 3600000 1553585533795-1

这就是消息转移。至此我们使用了一个 Pending 消息的 ID,所属消费者和 IDLE 的属性,还有一个属性就是消息被读取次数,delivery counter,该属性的作用由于统计消息被读取的次数,包括被转移也算。这个属性主要用在判定是否为错误数据上。请继续看:

9 坏消息问题,Dead Letter,死信问题

正如上面所说,如果某个消息,不能被消费者处理,也就是不能被 XACK,这是要长时间处于 Pending 列表中,即使被反复的转移给各个消费者也是如此。此时该消息的 delivery counter 就会累加(上一节的例子可以看到),当累加到某个我们预设的临界值时,我们就认为是坏消息(也叫死信,DeadLetter,无法投递的消息),由于有了判定条件,我们将坏消息处理掉即可,删除即可。删除一个消息,使用XDEL语法,演示如下:

# 删除队列中的消息127.0.0.1:6379> XDEL mq 1553585533795-1(integer) 1# 查看队列中再无此消息127.0.0.1:6379> XRANGE mq - +1) 1) "1553585533795-0"   2) 1) "msg"      2) "1"2) 1) "1553585533795-2"   2) 1) "msg"      2) "3"

注意本例中,并没有删除 Pending 中的消息因此你查看 Pending,消息还会在。可以执行XACK标识其处理完毕!

9 信息监控,XINFO

Stream提供了XINFO来实现对服务器信息的监控,可以查询:

查看队列信息

127.0.0.1:6379> Xinfo stream mq 1) "length" 2) (integer) 7 3) "radix-tree-keys" 4) (integer) 1 5) "radix-tree-nodes" 6) (integer) 2 7) "groups" 8) (integer) 1 9) "last-generated-id"10) "1553585533795-9"11) "first-entry"12) 1) "1553585533795-3"    2) 1) "msg"       2) "4"13) "last-entry"14) 1) "1553585533795-9"    2) 1) "msg"       2) "10"

消费组信息

127.0.0.1:6379> Xinfo groups mq1) 1) "name"   2) "mqGroup"   3) "consumers"   4) (integer) 3   5) "pending"   6) (integer) 3   7) "last-delivered-id"   8) "1553585533795-4"

消费者组成员信息

127.0.0.1:6379> XINFO CONSUMERS mq mqGroup1) 1) "name"   2) "consumerA"   3) "pending"   4) (integer) 1   5) "idle"   6) (integer) 189498942) 1) "name"   2) "consumerB"   3) "pending"   4) (integer) 1   5) "idle"   6) (integer) 30927193) 1) "name"   2) "consumerC"   3) "pending"   4) (integer) 1   5) "idle"   6) (integer) 23683256

至此,消息队列的基本操作说明大体结束!下面继续看单点问题

10 高可用,单点失效问题

由于消息队列在多个应用间负责数据的交互,可见一定不能是单点,一旦单点失效,意味着整体都会崩溃。此时配合 Redis 的 Replication 和 Sentinel 功能,可以完成解决单点问题。其中:

  • Replication,复制技术,主从复制,用于将某个主 Redis 的内容复制到某个(些)从 Redis 中
  • Sentinel,哨兵技术,用于监控 Redis 的运行状态,并可以在主服务器出现问题时,从多个从服务器中选择一个作为新的主服务器。

高可用架构

关于主从复制和HA高可用的使用,请移步:http://www.hellokang.net/redis/

关于 消息队列的话题,就先说到这里。下面的是关于 Stream 类型的其他问题。

11 Stream 类型命令一览

命令说明
XACK结束 Pending
XADD生成消息
XCLAIM消息转移
XDEL删除消息
XGROUP消费组管理
XINFO得到消费组信息
XLEN消息队列长度
XPENDINGPending列表
XRANGE获取消息队列中消息
XREAD消费消息
XREADGROUP分组消费消息
XREVRANGE逆序获取消息队列中消息
XTRIM消息队列容量

12 Stream数据结构,RadixTree,基数树

Stream 是基于 RadixTree 数据结构实现的。另立话题讨论。基数树,http://www.hellokang.net/algorithm/radix-tree.html

13 相关产品

很多成熟的MQ产品:

  • Disque,https://disquedurinterne.net/
  • Kafka,http://kafka.apache.org/
  • ActiveMQ,http://activemq.apache.org/
  • RockMQ,http://rocketmq.apache.org/
  • RabbitMQ,https://www.rabbitmq.com/
  • ZeroMQ,http://zeromq.org/

最后,欢迎关注微信公众号:小韩说课,一起交流一起进步。


本文首发于 GitChat,未经授权不得转载,转载需与 GitChat 联系。

阅读全文: http://gitbook.cn/gitchat/activity/5c9a452004714778e37df815

您还可以下载 CSDN 旗下精品原创内容社区 GitChat App ,阅读更多 GitChat 专享技术内容哦。

FtooAtPSkEJwnW-9xkCLqSTRpBKX

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页