Redis Streams (消息队列)

什么是 Redis Stream?

Redis Stream 是 Redis 5.0 版本新增加的数据结构。

Redis Stream 主要用于消息队列(MQ,Message Queue),Redis 本身是有一个 Redis 发布订阅 (pub/sub) 来实现消息队列的功能,但它有个缺点就是消息无法持久化,如果出现网络断开、Redis 宕机等,消息就会被丢弃。

简单来说发布订阅 (pub/sub) 可以分发消息,但无法记录历史消息。

而 Redis Stream 提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失。

Redis Stream 的结构如下所示,它有一个消息链表,将所有加入的消息都串起来,每个消息都有一个唯一的 ID 和对应的内容:

在这里插入图片描述

每个 Stream 都有唯一的名称,它就是 Redis 的 key,在我们首次使用 xadd 指令追加消息时自动创建。

上图解析:

  • Consumer Group :消费组,使用 XGROUP CREATE 命令创建,一个消费组有多个消费者(Consumer)。
  • last_delivered_id :游标,每个消费组会有个游标 last_delivered_id,组内任意一个消费者读取了消息都会使游标 last_delivered_id 往前移动。
  • pending_ids :消费者(Consumer)的状态变量,作用是维护消费者的未确认的 id。 pending_ids 记录了当前已经被客户端读取的消息,但是还没有 ack (Acknowledge character:确认字符)。

Redis Stream 的特点

  • 消息ID的序列化生成
  • 消息遍历
  • 消息的阻塞和非阻塞读取
  • 消息的分组消费
  • 未完成消息的处理
  • 消息队列监控

Redis Stream 的相关命令

消息队列相关命令

  • XADD - 添加消息到末尾
  • XTRIM - 对流进行修剪,限制长度
  • XDEL - 删除消息
  • XLEN - 获取流包含的元素数量,即消息长度
  • XRANGE - 获取消息列表,会自动过滤已经删除的消息
  • XREVRANGE - 反向获取消息列表,ID 从大到小
  • XREAD - 以阻塞或非阻塞方式获取消息列表

消费者组相关命令:

  • XGROUP CREATE - 创建消费者组
  • XREADGROUP GROUP - 读取消费者组中的消息
  • XACK - 将消息标记为"已处理"
  • XGROUP SETID - 为消费者组设置新的最后递送消息ID
  • XGROUP DELCONSUMER - 删除消费者
  • XGROUP DESTROY - 删除消费者组
  • XPENDING - 显示待处理消息的相关信息
  • XCLAIM - 转移消息的归属权
  • XINFO - 查看流和消费者组的相关信息;
  • XINFO GROUPS - 打印消费者组的信息;
  • XINFO STREAM - 打印流信息

XADD

使用 XADD 向队列添加消息,如果指定的队列不存在,则创建一个队列,XADD 语法格式:

XADD key ID field value [field value ...]
  • key :队列名称,如果不存在就创建
  • ID :消息 id,我们使用 * 表示由 redis 生成,可以自定义,但是要自己保证递增性。
  • field value : 记录。
redis> XADD mystream * name Sara surname OConnor
"1601372323627-0"
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"
redis>	

上面的例子中,调用了 XADD 命令往名为 mystream 的 Stream 中添加了一个条目 name: Sara, surname: OConnor,使用了自动生成的条目 ID,也就是命令返回的值,具体在这里是1601372323627-0;即插入时的 Redis 服务器本地 Unix 毫秒时间戳加上序号 0(数字 0),如果处于高并发场景下,同一秒生产了很多消息入队,那么时间戳不变,序号累加,从 0 ~ N;由于序号是64位的,所以实际上对于在同一毫秒内生成的条目数量是没有限制的。

如果由于某些原因,用户需要与时间无关但实际上与另一个外部系统ID关联的增量ID,XADD命令可以带上一个显式的ID,而不是使用通配符*来自动生成。由于服务器自动生成ID几乎总是我们所想要的,需要显式指定ID的情况非常少见。

> XADD somestream 0-1 field value
0-1
> XADD somestream 0-2 foo bar
0-2

请注意,在这种情况下,最小ID为0-1,并且命令不接受等于或小于前一个ID的ID:

> XADD somestream 0-1 foo bar
(error) ERR The ID specified in XADD is equal or smaller than the target stream top item

XTRIM

使用 XTRIM 对流进行修剪,限制长度(如果长度消息队列长度大于限制长度,则从后取指定限制长度个消息), 语法格式:

XTRIM key MAXLEN [~] count
  • key :队列名称
  • MAXLEN :长度
  • count :数量
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

使用 XDEL 删除消息,语法格式:

XDEL key ID [ID ...]
  • key:队列名称
  • ID :消息 ID
redis> XADD mystream * a 1
"1538561698944-0"
redis> XADD mystream * b 2
"1538561700640-0"
redis> XADD mystream * c 3
"1538561701744-0"
redis> 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

使用 XLEN 获取流包含的元素数量,即消息长度,语法格式:

XLEN key
  • key:队列名称
redis> XADD mystream * item 1
"1601372563177-0"
redis> XADD mystream * item 2
"1601372563178-0"
redis> XADD mystream * item 3
"1601372563178-1"
redis> XLEN mystream
(integer) 3
redis>

XRANGE

使用 XRANGE 获取消息列表,会自动过滤已经删除的消息 ,语法格式:

XRANGE key start end [COUNT count]
  • key :队列名
  • start :开始值, - 表示最小值
  • end :结束值, + 表示最大值
  • count :数量
redis> XADD mystream * name Virginia surname Woolf
"1601372577811-0"
redis> XADD mystream * name Jane surname Austen
"1601372577811-1"
redis> XADD mystream * name Toni surname Morrison
"1601372577811-2"
redis> XADD mystream * name Agatha surname Christie
"1601372577812-0"
redis> XADD mystream * name Ngozi surname Adichie
"1601372577812-1"
redis> XLEN mystream
(integer) 5
redis> XRANGE mystream - + 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"

返回的每个条目都是有两个元素的数组:ID和键值对列表。我们已经说过条目ID与时间有关系,因为在字符-左边的部分是创建Stream条目的本地节点上的Unix毫秒时间,即条目创建的那一刻(请注意:Streams的复制使用的是完全详尽的XADD命令,因此从节点将具有与主节点相同的ID)。这意味着我可以使用XRANGE查询一个时间范围。然而为了做到这一点,我可能想要省略ID的序列号部分:如果省略,区间范围的开始序列号将默认为0,结束部分的序列号默认是有效的最大序列号。这样一来,仅使用两个Unix毫秒时间去查询,我们就可以得到在那段时间内产生的所有条目(包含开始和结束)。例如,我可能想要查询两毫秒时间,可以这样使用:

redis> XRANGE mystream 1601372577811 1601372577812 2
1) 1) "1601372577811-0"
   2) 1) "name"
      2) "Virginia"
      3) "surname"
      4) "Woolf"
2) 1) "1601372577812-1"
   2) 1) "name"
      2) "Jane"
      3) "surname"
      4) "Austen"

我在这个范围内只有一个条目,然而在实际数据集中,我可以查询数小时的范围,或者两毫秒之间包含了许多的项目,返回的结果集很大。因此,XRANGE命令支持在最后放一个可选的COUNT选项。通过指定一个count,我可以只获取前面N个项目。如果我想要更多,我可以拿返回的最后一个ID,在序列号部分加1,然后再次查询。我们在下面的例子中看到这一点。我们开始使用XADD添加10个项目(我这里不具体展示,假设流mystream已经填充了10个项目)。要开始我的迭代,每个命令只获取2个项目,我从全范围开始,但count是2。

redis> XRANGE mystream - + 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"

为了继续下两个项目的迭代,我必须选择返回的最后一个ID,即1601372577811-0,并且在ID序列号部分加1。请注意,序列号是64位的,因此无需检查溢出。在这个例子中,我们得到的结果ID是1601372577811-1,现在可以用作下一次XRANGE调用的新的start参数:

> XRANGE mystream 1601372577811-1 + COUNT 2
1) 1) 1601372577823-0
   2) 1) "foo"
      2) "value_3"
2) 1) 1601372577845-0
   2) 1) "foo"
      2) "value_4"

依此类推。由于XRANGE的查找复杂度是O(log(N)),因此O(M)返回M个元素,这个命令在count较小时,具有对数时间复杂度,这意味着每一步迭代速度都很快。所以XRANGE也是事实上的流迭代器并且不需要XSCAN命令。

XREVRANGE命令与XRANGE相同,但是以相反的顺序返回元素,因此XREVRANGE的实际用途是检查一个Stream中的最后一项是什么。

XREVRANGE

使用 XREVRANGE 获取消息列表,会自动过滤已经删除的消息 ,语法格式:

XREVRANGE key end start [COUNT count]
  • key :队列名
  • end :结束值, + 表示最大值
  • start :开始值, - 表示最小值
  • count :数量
redis> XADD mystream * name Virginia surname Woolf
"1601372731458-0"
redis> XADD mystream * name Jane surname Austen
"1601372731459-0"
redis> XADD mystream * name Toni surname Morrison
"1601372731459-1"
redis> XADD mystream * name Agatha surname Christie
"1601372731459-2"
redis> XADD mystream * name Ngozi surname Adichie
"1601372731459-3"
redis> XLEN mystream
(integer) 5
redis> XREVRANGE mystream + - COUNT 1
1) 1) "1601372731459-3"
   2) 1) "name"
      2) "Ngozi"
      3) "surname"
      4) "Adichie"
redis>

XREAD

当我们不想按照Stream中的某个范围访问项目时,我们通常想要的是订阅到达Stream的新项目。这个概念可能与Redis中你订阅频道的Pub/Sub或者Redis的阻塞列表有关,在这里等待某一个key去获取新的元素,但是这跟你消费Stream有着根本的不同:

  1. 一个Stream可以拥有多个客户端(消费者)在等待数据。默认情况下,对于每一个新项目,都会被分发到等待给定Stream的数据的每一个消费者。这个行为与阻塞列表不同,每个消费者都会获取到不同的元素。但是,扇形分发到多个消费者的能力与Pub/Sub相似。
  2. 虽然在Pub/Sub中的消息是fire and forget并且从不存储,以及使用阻塞列表时,当一个客户端收到消息时,它会从列表中弹出(有效删除),Stream从跟本上以一种不同的方式工作。所有的消息都被无限期地附加到Stream中(除非用户明确地要求删除这些条目):不同的消费者通过记住收到的最后一条消息的ID,从其角度知道什么是新消息。
  3. Streams 消费者组提供了一种Pub/Sub或者阻塞列表都不能实现的控制级别,同一个Stream不同的群组,显式地确认已经处理的项目,检查待处理的项目的能力,申明未处理的消息,以及每个消费者拥有连贯历史可见性,单个客户端只能查看自己过去的消息历史记录。

我们可以在不定义消费组的情况下进行Stream消息的独立消费,当Stream没有新消息时,甚至可以阻塞等待。Redis设计了一个单独的消费指令 xread,可以将 Stream 当成普通的消息队列(list)来使用。使用 xread 时, 我们可以完全忽略消费组(Consumer Group)的存在,就好比 Stream 就是一个普通的列表(list)。

在这里插入图片描述

使用 XREAD 以阻塞或非阻塞方式获取消息列表 ,语法格式:

XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] id [id ...]
  • count :数量
  • milliseconds :可选,阻塞毫秒数,没有设置就是非阻塞模式
  • key :队列名
  • id :消息 ID
redis> XADD writers * name Virginia surname Woolf
"1601372731458-0"
redis> XADD writers * name Jane surname Austen
"1601372731459-0"
redis> XADD writers * name Toni surname Morrison
"1601372731459-1"
redis> XADD writers * name Agatha surname Christie
"1601372731459-2"
redis> XADD writers * name Ngozi surname Adichie
"1601372731459-3"

# 从 Stream 头部读取两条消息
> 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"

以上是XREAD的非阻塞形式。注意COUNT选项并不是必需的。可以通过传入多个key来同时从不同的Stream中读取数据。

客户端如果想要使用 xread 进行 顺序消费,一定要 记住当前消费 到哪里了,也就是返回的消息 ID。下次继续调用 xread 时,将上次返回的最后一个消息 ID 作为参数传递进去,就可以继续消费后续的消息。

除了XREAD可以同时访问多个Stream这一事实,以及我们能够指定我们拥有的最后一个ID来获取之后的新消息,在个简单的形式中,这个命令并没有做什么跟XRANGE有太大区别的事情。然而,有趣的部分是我们可以通过指定BLOCK参数,轻松地将XREAD 变成一个 阻塞命令

> XREAD BLOCK 0 STREAMS mystream $

请注意,在上面的例子中,除了移除COUNT以外,我指定了新的BLOCK选项,超时时间为0毫秒(意味着永不超时),block 1000 表示阻塞 1s,如果 1s 内没有任何消息到来,就返回 nil

此外,我并没有给流 mystream传入一个常规的ID,而是传入了一个特殊的ID$。这个特殊的ID意思是XREAD应该使用流 mystream已经存储的最大ID作为最后一个ID。以便我们仅接收从我们开始监听时间以后的消息。这在某种程度上相似于Unix命令tail -f

请注意当使用BLOCK选项时,我们不必使用特殊ID$。我们可以使用任意有效的ID。如果命令能够立即处理我们的请求而不会阻塞,它将执行此操作,否则它将阻止。通常如果我们想要从新的条目开始消费Stream,我们以$开始,接着继续使用接收到的最后一条消息的ID来发起下一次请求,依此类推。

XREAD的阻塞形式同样可以监听多个Stream,只需要指定多个键名即可。如果请求可以同步提供,因为至少有一个流的元素大于我们指定的相应ID,则返回结果。否则,该命令将阻塞并将返回获取新数据的第一个流的项目(根据提供的ID)。

跟阻塞列表的操作类似,从等待数据的客户端角度来看,阻塞流读取公正的,由于语义是FIFO样式。阻塞给定Stream的第一个客户端是第一个在新项目可用时将被解除阻塞的客户端。

XREAD命令没有除了COUNTBLOCK以外的其他选项,因此它是一个非常基本的命令,具有特定目的来攻击消费者一个或多个流。使用消费者组API可以用更强大的功能来消费Stream,但是通过消费者组读取是通过另外一个不同的命令来实现的,称为XREADGROUP

消费者组

当手头的任务是从不同的客户端消费同一个Stream,那么XREAD已经提供了一种方式可以扇形分发到N个客户端,还可以使用从节点来提供更多的读取可伸缩性。然而,在某些问题中,我们想要做的不是向许多客户端提供相同的消息流,而是 ‘从同一个消息流’ 向许多客户端提供不同的消息子集。这很有用的一个明显的例子是处理消息的速度很慢:能够让N个不同的客户端接收流的不同部分,通过将不同的消息路由到准备做更多工作的不同客户端来扩展消息处理工作。
在这里插入图片描述

实际上,假如我们想象有三个消费者C1,C2,C3,以及一个包含了消息1, 2, 3, 4, 5, 6, 7的Stream,我们想要按如下图表的方式处理消息:

1 -> C1
2 -> C2
3 -> C3
4 -> C1
5 -> C2
6 -> C3
7 -> C1

为了获得这个效果,Redis使用了一个名为消费者组的概念。非常重要的一点是,从实现的角度来看,Redis的消费者组与Kafka ™ 消费者组没有任何关系,它们只是从实施的概念上来看比较相似。

消费者组就像一个伪消费者,从流中获取数据,实际上为多个消费者提供服务,提供某些保证:

  1. 每条消息都提供给不同的消费者,因此不可能将相同的消息传递给多个消费者。
  2. 消费者在消费者组中通过名称来识别,该名称是实施消费者的客户必须选择的区分大小写的字符串。这意味着即便断开连接过后,消费者组仍然保留了所有的状态,因为客户端会重新申请成为相同的消费者。 然而,这也意味着由客户端提供唯一的标识符。
  3. 每一个消费者组都有一个第一个ID永远不会被消费的概念,这样一来,当消费者请求新消息时,它能提供以前从未传递过的消息。
  4. 消费消息需要使用特定的命令进行显式确认,表示:这条消息已经被正确处理了,所以可以从消费者组中逐出。
  5. 消费者组跟踪所有当前所有待处理的消息,也就是,消息被传递到消费者组的一些消费者,但是还没有被确认为已处理。由于这个特性,当访问一个Stream的历史消息的时候,每个消费者将只能看到传递给它的消息。

在某种程度上,消费者组可以被想象为关于Stream的一些状态

| consumer_group_name: mygroup           |
| consumer_group_stream: somekey         |
| last_delivered_id: 1292309234234-92    |
|                                        |
| consumers:                             |
|    "consumer-1" with pending messages  |
|       1292309234234-4                  |
|       1292309234232-8                  |
|    "consumer-42" with pending messages |
|       ... (and so forth) 

如果你从这个视角来看,很容易理解一个消费者组能做什么,如何做到向给消费者提供他们的历史待处理消息,以及当消费者请求新消息的时候,是如何做到只发送ID大于last_delivered_id的消息的。同时,如果你把消费者组看成Redis Stream的辅助数据结构,很明显单个Stream可以拥有多个消费者组,每个消费者组都有一组消费者。实际上,同一个Stream甚至可以通过XREAD让客户端在没有消费者组的情况下读取,同时有客户端通过XREADGROUP在不同的消费者组中读取。

现在是时候放大来查看基本的消费者组命令了,具体如下:

  • XGROUP 用于创建,摧毁或者管理消费者组。
  • XREADGROUP 用于通过消费者组从一个Stream中读取。
  • XACK 是允许消费者将待处理消息标记为已正确处理的命令。

XGROUP CREATE

使用 XGROUP CREATE 创建消费者组,语法格式:

XGROUP [CREATE key groupname id-or-$] [SETID key groupname id-or-$] [DESTROY key groupname] [DELCONSUMER key groupname consumername]
  • key :队列名称,如果不存在就创建
  • groupname :组名。
  • $ : 表示从尾部开始消费,只接受新消息,当前 Stream 消息会全部忽略。

从头开始消费:

XGROUP CREATE mystream mygroup 0-0

如果我们指定的消息ID是0,那么消费者组将会开始消费这个Stream中的所有历史消息。当然,你也可以指定任意其他有效的ID。你所知道的是,消费者组将开始传递ID大于你所指定的ID的消息。因为$表示Stream中当前最大ID的意思,指定$会有只消费新消息的效果。如果是刚创建的 stream 建议从0开始消费

从尾部开始消费:

XGROUP CREATE mystream mygroup $

如你所看到的上面这个命令,当创建一个消费者组的时候,我们必须指定一个ID,在这个例子中ID是$。这是必要的,因为消费者组在其他状态中必须知道在第一个消费者连接时接下来要服务的消息,即消费者组创建完成时的最后消息ID是什么?如果我们就像上面例子一样,提供一个$,那么只有从现在开始到达Stream的新消息才会被传递到消费者组中的消费者。

创建一个消费者组

假设我已经存在类型流的 mystream,为了创建消费者组,我只需要做:

# 方便下面消费组命令特性测试,多创建一个消费组
> XGROUP CREATE mystream mygroup 0
OK
> XGROUP CREATE mystream mygroup2 0
OK

请注意:目前还不能为不存在的Stream创建消费者组,但有可能在不久的将来我们会给XGROUP命令增加一个选项,以便在这种场景下可以创建一个空的Stream。

现在消费者组创建好了,我们可以使用XREADGROUP命令立即开始尝试通过消费者组读取消息。我们会从消费者那里读到,假设指定消费者分别是Alice和Bob,来看看系统会怎样返回不同消息给Alice和Bob。

XREADGROUPXREAD非常相似,并且提供了相同的BLOCK选项,除此以外还是一个同步命令。但是有一个强制的选项必须始终指定,那就是GROUP,并且有两个参数:消费者组的名字,以及尝试读取的消费者的名字。选项COUNT仍然是支持的,并且与XREAD命令中的用法相同。

在开始从Stream中读取之前,让我们往里面放一些消息:

> XADD mystream * message apple
1526569495631-0
> XADD mystream * message orange
1526569498055-0
> XADD mystream * message strawberry
1526569506935-0
> XADD mystream * message apricot
1526569535168-0
> XADD mystream * message banana
1526569544280-0

XREADGROUP GROUP

使用 XREADGROUP GROUP 读取消费组中的消息,语法格式:

XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]
  • group :消费组名
  • consumer :消费者名。
  • count : 读取数量。
  • milliseconds : 阻塞毫秒数。
  • key : 队列名。
  • ID : 消息 ID。

现在是时候尝试使用消费者组读取了:

# 行末的 > 号表示从当前消费组的 last_delivered_id 后面开始读取消息,每当消费者读取一条消息,last_delivered_id 就会递增
> XREADGROUP GROUP mygroup Alice COUNT 1 STREAMS mystream >
1) 1) "mystream"
   2) 1) 1) 1526569495631-0
         2) 1) "message"
            2) "apple"

XREADGROUP的响应内容就像XREAD一样。但是请注意上面提供的GROUP <group-name> <consumer-name>,这表示我想要使用消费者组 mygroup 从 Stream 中读取,消费者是 Alice。每次消费者执行操作时,都必须要指定在该消费者组中能够唯一标识它的名字。

在以上命令行中还有另外一个非常重要的细节,在强制选项STREAMS之后,键mystream请求的ID是特殊的ID >。这个特殊的ID只在消费者组的上下文中有效,其意思是:消息到目前为止从未传递给其他消费者

这几乎总是你想要的,但是也可以指定一个真实的ID,比如0或者任何其他有效的ID,在这个例子中,我们请求XREADGROUP只提供给我们历史待处理的消息,在这种情况下,将永远不会在组中看到新消息。所以基本上XREADGROUP可以根据我们提供的ID有以下行为:

如果ID是特殊ID>,那么命令将会返回到目前为止从未传递给其他消费者的新消息,这有一个副作用,就是会更新消费者组的最后ID

如果ID是任意其他有效的数字ID,那么命令将会让我们访问我们的历史待处理消息。即传递给这个指定消费者(由提供的名称标识)的消息集,并且到目前为止从未使用XACK进行确认。

我们可以立即测试此行为,指定ID为0,不带任何COUNT选项:我们只会看到唯一的待处理消息,即关于apples的消息:

> XREADGROUP GROUP mygroup Alice STREAMS mystream 0
1) 1) "mystream"
   2) 1) 1) 1526569495631-0
         2) 1) "message"
            2) "apple"

但是,如果我们确认这个消息已经处理,它将不再是历史待处理消息的一部分,因此系统将不再报告任何消息:

> XACK mystream mygroup 1526569495631-0
(integer) 1
> XREADGROUP GROUP mygroup Alice STREAMS mystream 0
1) 1) "mystream"
   2) (empty list or set)

如果你还不清楚XACK是如何工作的,请不用担心,这个概念只是已处理的消息不再是我们可以访问的历史记录的一部分。

现在轮到Bob来读取一些东西了:

> XREADGROUP GROUP mygroup2 Bob COUNT 2 STREAMS mystream >
1) 1) "mystream"
   2) 1) 1) 1526569495631-0
         2) 1) "message"
            2) "apple"
      2) 1) 1526569498055-0
         2) 1) "message"
            2) "orange"

可以看到,当使用第二个消费组进行消费时,依然可以从头到尾读取 stream。

所以消费组具有以下特点:

  • 每创建一个消费组,该消费组所处游标(last_delivered_id)都是从0开始的
  • 每个消费组消费的时候,各个消费组之间互不干扰
  • 读到新消息后,对应的消息 ID 就会进入消费者的 PEL (正在处理的消息) 结构里,客户端处理完毕后使用 xack 指令 通知服务器,本条消息已经处理完毕,该消息 ID 就会从 PEL 中移除

Streams 消息太多了怎么办?设置 Stream 的上限

许多应用并不希望将数据永久收集到一个Stream。有时在Stream中指定一个最大项目数很有用,之后一旦达到给定的大小,将数据从Redis中移到不那么快的非内存存储是有用的,适合用来记录未来几十年的历史数据。

Redis Stream对此有一定的支持。这就是XADD命令的MAXLEN选项,这个选项用起来很简单:

> XADD mystream MAXLEN 2 * value 1
1526654998691-0
> XADD mystream MAXLEN 2 * value 2
1526654999635-0
> XADD mystream MAXLEN 2 * value 3
1526655000369-0
> XLEN mystream
(integer) 2
> XRANGE mystream - +
1) 1) 1526654999635-0
   2) 1) "value"
      2) "2"
2) 1) 1526655000369-0
   2) 1) "value"
      2) "3"

如果使用 MAXLEN 选项,当 Stream 的达到指定长度后,老的消息会自动被淘汰掉,因此 Stream 的大小是恒定的。目前还没有选项让 Stream 只保留给定数量的条目,因为为了一致地运行,这样的命令必须在很长一段时间内阻塞以淘汰消息。(例如在添加数据的高峰期间,你不得不长暂停来淘汰旧消息和添加新的消息)

另外使用 MAXLEN 选项的性能消耗是很大的,Stream 为了节省内存空间,采用了一种特殊的结构表示,而这种结构的调整是需要额外的花销的。所以我们可以使用一种带有 ~ 的特殊命令:

XADD mystream MAXLEN ~ 1000 * ... entry fields here ...

它会基于当前的结构合理地对节点执行裁剪,来保证至少会有 1000 条数据,可能是 1010 也可能是 1030

除了XADD 之外还有XTRIM命令可用,它做的事情与上面讲到的MAXLEN选项非常相似,但是这个命令不需要添加任何其他参数,可以以独立的方式与Stream一起使用。

> XTRIM mystream MAXLEN 10

或者,对于XADD选项:

> XTRIM mystream MAXLEN ~ 10

但是,XTRIM旨在接受不同的修整策略,虽然现在只实现了MAXLEN。鉴于这是一个明确的命令,将来有可能允许按时间来进行修整,因为以独立的方式调用这个命令的用户应该知道她或者他正在做什么。

一个有用的驱逐策略是,XTRIM应该具有通过一系列ID删除的能力。目前这是不可能的,但在将来可能会实现,以便更方便地使用XRANGEXTRIM来将Redis中的数据移到其他存储系统中(如果需要)。

怎么避免消息丢失?

在客户端消费者读取 Stream 消息时,Redis 服务器将消息回复给客户端的过程中,客户端突然断开了连接或者某个消费者,消费了某条消息后,但是并没有处理成功(例如消费者进程宕机),这条消息可能会丢失,因为组内其他消费者不能再次消费到该消息了。

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

但是 PEL 里已经保存了发出去的消息 ID,待客户端重新连上之后,可以再次收到 PEL 中的消息 ID 列表。不过此时 xreadgroup 的起始消息 ID 不能为参数 > ,而必须是任意有效的消息 ID,一般将参数设为 0-0,表示读取所有的 PEL 消息以及自 last_delivered_id 之后的新消息。

127.0.0.1:6379> XPENDING mq1 group1
1) (integer) 3                        # 3个已经读取但是没处理
2) "1610522197116-0"                    # 未被消费起始id
3) "1610522197116-2"                    # 未被消费结束id
4) 1) 1) "cusA"                        # greoup1组中的消费者A有2个已读未处理
      2) "2"
   2) 1) "cusB"                        # greoup1组中的消费者B有1个已读未处理
      2) "1"
      
# 利用 start end count 获取消息的详细信息
127.0.0.1:6379> XPENDING mq1 group1 - + 10
1) 1) "1610522197116-0"     #消息ID
   2) "cusA"                #消费者
   3) (integer) 1488851     #从第一次读取到现在过了1488851ms
   4) (integer) 1            # 消息被读取了1次
2) 1) "1610522197116-1"
   2) "cusA"
   3) (integer) 1234483
   4) (integer) 1
3) 1) "1610522197116-2"
   2) "cusB"
   3) (integer) 1082148
   4) (integer) 1
 
# 获取某个消费者的 pending 列表
127.0.0.1:6379> XPENDING mq1 group1 - + 10 cusA
1) 1) "1610522197116-0"
   2) "cusA"
   3) (integer) 2889119
   4) (integer) 1
2) 1) "1610522197116-1"
   2) "cusA"
   3) (integer) 2634751
   4) (integer) 1

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

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

如何标识消息处理完毕?

# 标识总消息列表的第二条消息处理完毕
127.0.0.1:6379> XACK mqq group1 1610522197116-1  #通知消息处理结束,用消息ID标识
(integer) 0
 
# 然后我查一下 group1 的 penging 信息
127.0.0.1:6379> XPENDING mq1 group1
1) (integer) 3
2) "1610522197116-0"
3) "1610522197116-2"
4) 1) 1) "cusA"
      2) "2"
   2) 1) "cusB"
      2) "1"
 
*可以看到,1610522197116-1 这条信息已经没有了*

如何做消息转移?

# group1 中的 cusA 这条信息(1610522197116-1)有 3176717ms没有被处理
127.0.0.1:6379> XPENDING mq1 group1 - + 10
1) 1) "1610522197116-0"
   2) "cusA"
   3) (integer) 3431085
   4) (integer) 1
2) 1) "1610522197116-1"
   2) "cusA"
   3) (integer) 3176717
   4) (integer) 1
3) 1) "1610522197116-2"
   2) "cusB"
   3) (integer) 3024382
   4) (integer) 1
 
# 接下来我把消息 1610522197116-1 转移给 group1 中的 cusB
# 转移超过 35 秒的消息 1610522197116-1 到  group1 中的 cusB
127.0.0.1:6379> XCLAIM mq1 group1 cusB 35000 1610522197116-1
1) 1) "1610522197116-1"
   2) 1) "meessage"
      2) "2"
 
# 查看消费者 group1 看到 1610522197116-1 已经跑到消费者 cusB 中了
127.0.0.1:6379> XPENDING mq1 group1 - + 10
1) 1) "1610522197116-0"
   2) "cusA"
   3) (integer) 3653453
   4) (integer) 1
2) 1) "1610522197116-1"
   2) "cusB"
   3) (integer) 9656
   4) (integer) 2
3) 1) "1610522197116-2"
   2) "cusB"
   3) (integer) 3246750
   4) (integer) 1

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

当出现故障时,消息被多次传递是很正常的,但最终它们通常会得到处理。但有时候处理特定的消息会出现问题,因为消息会以触发处理代码中的bug的方式被损坏或修改。在这种情况下,不能被消费者处理,也就是不能被XACK,长时间处于Pending列表中, 消费者处理这条特殊的消息会一直失败。因为我们有传递尝试的计数器,所以我们可以使用这个计数器来检测由于某些原因根本无法处理的消息。所以一旦消息的传递计数器达到你给定的值,比较明智的做法是将这些消息放入另外一个Stream,并给系统管理员发送一条通知。这基本上是Redis Stream实现的dead letter概念的方式。或者将坏消息删除。

信息监控,XINFO

# 查看队列信息
127.0.0.1:6379>  Xinfo stream mq1
 1) "length"
 2) (integer) 4
 3) "radix-tree-keys"
 4) (integer) 1
 5) "radix-tree-nodes"
 6) (integer) 2
 7) "groups"
 8) (integer) 2
 9) "last-generated-id"
10) "1610522197116-4"
11) "first-entry"
12) 1) "1610522197116-0"
    2) 1) "meessage"
       2) "1"
13) "last-entry"
14) 1) "1610522197116-4"
    2) 1) "meessage"
       2) "5"

# 观察消费组信息
xinfo groups mygroup

# 如果同一个消费组有多个消费者,我们可以通过xinfo consumers指令观察每个消费者的状态
xinfo consumers mygroup Alice

与Kafka(TM)分区的差异

Redis Stream的消费者组可能类似于基于Kafka(TM)分区的消费者组,但是要注意Redis Stream实际上非常不同。分区仅仅是逻辑的,并且消息只是放在一个Redis键中,因此不同客户端的服务方式取决于谁准备处理新消息,而不是从哪个分区客户端读取。例如,如果消费者C3在某一点永久故障,Redis会继续服务C1和C2,将新消息送达,就像现在只有两个逻辑分区一样。

类似地,如果一个给定的消费者在处理消息方面比其他消费者快很多,那么这个消费者在相同单位时间内按比例会接收更多的消息。这是有可能的,因为Redis显式地追踪所有未确认的消息,并且记住了谁接收了哪些消息,以及第一条消息的ID从未传递给任何消费者。

但是,这也意味着在Redis中,如果你真的想把同一个Stream的消息分区到不同的Redis实例中,你必须使用多个key和一些分区系统,比如Redis集群或者特定应用程序的分区系统。单个Redis Stream不会自动分区到多个实例上。

我们可以说,以下是正确的:

  • 如果你使用一个Stream对应一个消费者,则消息是按顺序处理的。
  • 如果你使用N个Stream对应N个消费者,那么只有给定的消费者hits N个Stream的子集,你可以扩展上面的模型来实现。
  • 如果你使用一个Stream对应多个消费者,则对N个消费者进行负载平衡,但是在那种情况下,有关同一逻辑项的消息可能会无序消耗,因为给定的消费者处理消息3可能比另一个消费者处理消息4要快。

所以基本上Kafka分区更像是使用了N个不同的Redis键。而Redis消费者组是一个将给定Stream的消息负载均衡到N个不同消费者的服务端负载均衡系统。

持久化,复制和消息安全性

与任何其他Redis数据结构一样,Stream会异步复制到从节点,并持久化到AOF和RDB文件中。但可能不那么明显的是,消费者组的完整状态也会传输到AOF,RDB和从节点,因此如果消息在主节点是待处理的状态,在从节点也会是相同的信息。同样,节点重启后,AOF文件会恢复消费者组的状态。

但是请注意,Redis Stream和消费者组使用Redis默认复制来进行持久化和复制,所以:

  • 如果消息的持久性在您的应用程序中很重要,则AOF必须与强大的fsync策略一起使用。
  • 默认情况下,异步复制不能保证复制XADD命令或者消费者组的状态更改:在故障转移后,可能会丢失某些内容,具体取决于从节点从主节点接收数据的能力。
  • WAIT命令可以用于强制将更改传输到一组从节点上。但请注意,虽然这使得数据不太可能丢失,但由Sentinel或Redis群集运行的Redis故障转移过程仅执行尽力检查以故障转移到最新的从节点,并且在某些特定故障下可能会选举出缺少一些数据的从节点。 因此,在使用Redis Stream和消费者组设计应用程序时,确保了解你的应用程序在故障期间应具有的语义属性,并进行相应地配置,评估它是否足够安全地用于您的用例。

从Stream中删除单个项目

Stream还有一个特殊的命令可以通过ID从中间移除项目。一般来讲,对于一个只附加的数据结构来说,这也许看起来是一个奇怪的特征,但实际上它对于涉及例如隐私法规的应用程序是有用的。这个命令称为XDEL,调用的时候只需要传递Stream的名称,在后面跟着需要删除的ID即可:

> XRANGE mystream - + COUNT 2
1) 1) 1526654999635-0
   2) 1) "value"
      2) "2"
2) 1) 1526655000369-0
   2) 1) "value"
      2) "3"
> XDEL mystream 1526654999635-0
(integer) 1
> XRANGE mystream - + COUNT 2
1) 1) 1526655000369-0
   2) 1) "value"
      2) "3"

但是在当前的实现中,在宏节点完全为空之前,内存并没有真正回收,所以你不应该滥用这个特性。

零长度Stream

Stream与其他Redis数据结构有一个不同的地方在于,当其他数据结构没有元素的时候,调用删除元素的命令会把key本身删掉。举例来说就是,当调用ZREM命令将有序集合中的最后一个元素删除时,这个有序集合会被彻底删除。但Stream允许在没有元素的时候仍然存在,不管是因为使用MAXLEN选项的时候指定了count为零(在XADDXTRIM命令中),或者因为调用了XDEL命令。

存在这种不对称性的原因是因为,Stream可能具有相关联的消费者组,以及我们不希望因为Stream中没有项目而丢失消费者组定义的状态。当前,即使没有相关联的消费者组,Stream也不会被删除,但这在将来有可能会发生变化。

同类型产品

很多成熟的MQ产品:

RabbitMQ,Messaging that just works

Kafka,Apache Kafka

ActiveMQ,Apache ActiveMQ ™ – Index

RockMQ,Apache RocketMQ

Disque,https://disquedurinterne.net/

ZeroMQ,Distributed Messaging - zeromq

参考文章

Redis Stream

Redis Streams 介绍

Redis(8)——发布/订阅与Stream

Redis 数据类型 Stream

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值