Redis stream

Redis Streams

Redis Stream 表现的像append-only的log, 但实现了一些操作来解决通常append-only log的一些限制.这包含复杂度O(1)的随机访问和复杂消费策略, 如消费者分组. 你可以使用stream来记录并同时进行发布. Redis stream是use case包含:

  • 事件追踪 (如 追踪用户的动作, 点击等等)
  • 传感器监控
  • 通知 (如 将每个用户的通知存储在相分离的stream中)

Redis对于每个stream的entry都会生成唯一的ID. 你可以用id去获取相关的entry, 或者用它来读取并处理所有后续的entries. 需要注意的是, 这里的id与时间相关, 可能与你在自己的redis实例中看到的id并不一样.

Redis stream支持多种修剪策略(防止stream数据无边际扩张)和 多个消费策略(XREAD, XREADGROUP,和XRANGE)

Basic commands

  • XADD添加新的entry到stream
  • XREAD读取一或多个entry,从给定的位置开始并按照时间向前移动
  • XRANGE返回所提供的entry ID之前的entries
  • XLEN返回stream的length

Examples

当赛车手经过检查点时, 我们就为每个赛车手添加一个entry, 包含赛车手的姓名,速度,位置和location ID:

bash 复制代码
> XADD race:france * rider Castilla speed 30.2 position 1 location_id 1
"1692632086370-0"
> XADD race:france * rider Norem speed 28.8 position 3 location_id 1
"1692632094485-0"
> XADD race:france * rider Prickett speed 29.7 position 2 location_id 1
"1692632102976-0"

从ID **1692632086370-0**开始读取两个stream entry

bash 复制代码
> XRANGE race:france 1692632086370-0 + COUNT 2
1) 1) "1692632086370-0"
   2) 1) "rider"
      2) "Castilla"
      3) "speed"
      4) "30.2"
      5) "position"
      6) "1"
      7) "location_id"
      8) "1"
2) 1) "1692632094485-0"
   2) 1) "rider"
      2) "Norem"
      3) "speed"
      4) "28.8"
      5) "position"
      6) "3"
      7) "location_id"
      8) "1"

从stream的尾部开始, 读取至多100个entry. 如果没有entry被写入, 阻塞超时时间为300ms

bash 复制代码
> XREAD COUNT 100 BLOCK 300 STREAMS race:france $
(nil)

Performance

添加一个entry到stream的复杂度是O(1). 获取单个entry的复杂度是O(n), 这里的n是ID的长度. 由于stream的ID通常比较短并且有固定的长度, 所以获取单个entry的时间通常也是固定的, 至于为什么, 因为stream是由**radix trees实现的.**

简单的说, Redis stream提供了高效的插入和读取, 查看每个command的时间复杂度获取更多详细信息

Streams basics

Streams是append-only的数据结构, 最基础的写command: XADD, 添加一个新的entry到指定的stream.

每个stream entry由一个或多个field-value对组成, 有点像字典或redis hash:

bash 复制代码
> XADD race:france * rider Castilla speed 29.9 position 1 location_id 2
"1692632147973-0"

上面示例调用XADDcommand向stream key race:france添加了一个entry: rider: Castilla, speed: 29.9, position: 1, location_id: 2 , 使用的是自动生成的entry Id, 该id会被command返回, 为"1692632147973-0". 该命令第一个参数是key的名称 race:france, 第二个参数是代表stream中每个entry的id. 但是在该例子中, 我们想让server为我们生成ID 所以传入了 *****, 生成的id是单调递增的, 即每个新的entry都会比老的entry拥有更高的id. 自动生成的id在大多数情况下都是可以满足你的需求的, 显式地去指定id的需求相当较少. 我们后面还会再讨论这个问题. 每个stream entry都有id这个现象类似于log文件的line number, 或byte 偏移量. 回到我们的XADD示例, 在key名称和id参数之后, 后面的参数都是用来组成我们stream entry的field-value对.

可以使用XLENcommand 来获取stream的item的数量

bash 复制代码
> XLEN race:france
(integer) 4

Entry IDs

XADDcommand返回的id 由两部分组成

bash 复制代码
<millisecondsTime>-<sequenceNumber>

Milliseconds部分实际是生成id的redis节点的本地时间, 然而, 如果当前的millisecond 比之前的entry time要小, 就会使用先前的entry time, 所以即使将时钟调早了, id也是会单调递增的. 当entry在同一millisecond中创建多个entry是, 就会使用到sequenceNumber. 由于sequenceNumber是64bit, 因此在同一millisecond中生成的entry的数量没有限制.

这种id的格式可能一开始看起来陌生, 你可能纳闷为什么时间是id的一部分. 原因是redis stream支持通过id进行范围查询. 因为id是和entry生成的时间相关的, 这也就提供了根据时间范围查询的能力. 我们很快会介绍这一点, 当介绍XRANGEcommand的时候.

如果有其他什么原因, 你需要与时间不相关 而是与其他外部系统id相关的递增id. XADDcommand可以接受指定ID而不是*

bash 复制代码
> XADD race:usa 0-1 racer Castilla
0-1
> XADD race:usa 0-2 racer Norem
0-2

请注意, 最小的id是 0-1, command不会接受小于等于之前entry的id:

bash 复制代码
> XADD race:usa 0-1 racer Prickett
(error) ERR The ID specified in XADD is equal or smaller than the target stream top item

如果你运行的是 redis 7及以后, 你可以只提供millisecond部分, 这时, id的sequence就会自动生成.

bash 复制代码
> XADD race:usa 0-* racer Prickett
0-3

Getting data from Streams

现在我们知道如何通过XADD向stream中添加entry. 下面介绍如何从stream中取出数据. 如果继续以log作为比喻, 获取数据最常见的就是command tail -f, 也就是我们想监听添加到stream中的新消息. 请注意与redis的阻塞list操作不同, 在阻塞list操作, 一个新的数据只会传递到执行BLPOP类似阻塞command的一个客户端上, 而stream会让多个消费者都看到这条最新的消息,与tail -f类似, 多个进程都可以看到什么被添加到log中. 用传统的术语说的话, 我们想让stream 能够fan out 消息到多个客户端.

然而, 我们还可以换个视角看待stream, 不是以消息系统的视角去看, 而是以时间序列存储的视角去看. 获取新添加的消息是非常有用的, 但是通过时间单位去查询或者使用游标去扫描消息的方式查询是更普遍的. 这无疑也是非常实用的查询模式.

最后, 如果我们从消费者的视角看stream, 还有一种使用stream的方式, 消息流可以分片给多个消费者,这样消费者组就只能看见消息流的一个子集. 使用这种方式, 就可以把消息处理扩展到不同的消费者中, 不需要单个消费者处理所有消息: 每个消费者都得到不同的消息区处理. 这就是kafka使用消费者组做的事情. 通过消费者组来获取消息 是另一种读取redis stream的有趣方式.

Redis stream支持上面三种查询方式, 下面的章节会一一展示, 从 最简单和最直接的使用方式: range query开始

Querying by range: XRANGE and XREVRANGE

要根据range对stream进行查询, 我们需要指定两个id, start和end. range查询是闭区间的, 也就是会包含start id和 end id. 两个特殊的id: - 和 + 分别代表最小和最大的id

bash 复制代码
> XRANGE race:france - +
1) 1) "1692632086370-0"
   2) 1) "rider"
      2) "Castilla"
      3) "speed"
      4) "30.2"
      5) "position"
      6) "1"
      7) "location_id"
      8) "1"
2) 1) "1692632094485-0"
   2) 1) "rider"
      2) "Norem"
      3) "speed"
      4) "28.8"
      5) "position"
      6) "3"
      7) "location_id"
      8) "1"
3) 1) "1692632102976-0"
   2) 1) "rider"
      2) "Prickett"
      3) "speed"
      4) "29.7"
      5) "position"
      6) "2"
      7) "location_id"
      8) "1"
4) 1) "1692632147973-0"
   2) 1) "rider"
      2) "Castilla"
      3) "speed"
      4) "29.9"
      5) "position"
      6) "1"
      7) "location_id"
      8) "2"

每个返回的entry都有两个item:id和field-value对, 我们已经知道entry 的id与时间有关, 这意味着我可以使用XRANGE进行时间范围上的查询. 为了这样做, 我想要忽略id的sequence部分, 如果忽略, range的start的sequence就会假定为0, end的sequence就会家定位sequence number的最大值. 这样, 只通过两个millisecond Unix 时间戳, 就可以查到这段时间内所有的entry, 如下例

bash 复制代码
> XRANGE race:france 1692632086369 1692632086371
1) 1) "1692632086370-0"
   2) 1) "rider"
      2) "Castilla"
      3) "speed"
      4) "30.2"
      5) "position"
      6) "1"
      7) "location_id"
      8) "1"

在这个范围内只有一个entry. 然而在实际应用中, 可能查询的是几小时内的数据, 或者在两个millisecond中的结果数量可能很大. 基于这个原因, XRANGE支持可选的COUNT 选项, 通过指定count, 你可以获取指定的数量. 如果想要更多, 可以根据最后返回的id, 进行递增查询. 如下例所示, 我们假设stream race:france 有4个数据, 每次只得到两个数据

bash 复制代码
> XRANGE race:france - + COUNT 2
1) 1) "1692632086370-0"
   2) 1) "rider"
      2) "Castilla"
      3) "speed"
      4) "30.2"
      5) "position"
      6) "1"
      7) "location_id"
      8) "1"
2) 1) "1692632094485-0"
   2) 1) "rider"
      2) "Norem"
      3) "speed"
      4) "28.8"
      5) "position"
      6) "3"
      7) "location_id"
      8) "1"

为了继续查询后两个数据, 取最后一条的id, 即**1692632094485-0**, 并在前面添加( ,代表左边开区间

bash 复制代码
> XRANGE race:france (1692632094485-0 + COUNT 2
1) 1) "1692632102976-0"
   2) 1) "rider"
      2) "Prickett"
      3) "speed"
      4) "29.7"
      5) "position"
      6) "2"
      7) "location_id"
      8) "1"
2) 1) "1692632147973-0"
   2) 1) "rider"
      2) "Castilla"
      3) "speed"
      4) "29.9"
      5) "position"
      6) "1"
      7) "location_id"
      8) "2"

现在我们查询出了4条数据, 如果继续查询, 我们会得到空数组

bash 复制代码
> XRANGE race:france (1692632147973-0 + COUNT 2
(empty array)

由于XRANGE查询复杂度是O(log(N)), 返回M个数据的复杂度是O(M), 所以当count比较小的时候, 该命令的复杂度就是对数, 这意味着每一步的查询都是很快的, 所以这也就不需要XSCAN这样的命令了.

command XREVRANGE等价于XRANGE, 但是以相反的顺序返回数据, 所以可以用它查看stream的最后数据是什么

bash 复制代码
> XREVRANGE race:france + - COUNT 1
1) 1) "1692632147973-0"
   2) 1) "rider"
      2) "Castilla"
      3) "speed"
      4) "29.9"
      5) "position"
      6) "1"
      7) "location_id"
      8) "2"

请注意XREVGRANGEcommand的start和stop参数 顺序是相反的

Listening for new items with XREAD

我们还可以对stream的新数据进行订阅. 这个概念可能看起来和redis的pub/sub相关, 订阅到一个channel. 但是有一些基础性质上的不同:

  1. 一个stream 可以有多个客户端(消费者)等待数据. 每一个新的数据, 默认情况下, 会被发给每一个消费者. 这个行为与blocking lists是不同的, blocking lists的每个消费者都会得到不同的数据. 然而, 这个fan out多个消费者的能力与Pub/Sub相似.
  2. 在Pub/Sub中, 消息不会被存储, 当使用blocking lists时, message会别pop掉, stream工作的方式很不相同, 所有的消息都会永远存储在stream中(除非用户显式地说删除entry): consumer格努最后收到消息的id就知道新消息是从哪个点开始的
  3. Stream的消费者分组能力 是pub/sub或blocking lists达不到的. 同一个stream有不同的分组, 就可以显式的确认已处理过的消息, 检查待处理的消息, 声明未处理的消息, 并且每个客户端只能看到它私有的历史消息.

提供这种监听新消息到达stream的能力被称为XREAD,相对XRANGE会有些复杂, 刚开始 我们以加单的形式开始, 后面提供该命令的全貌

bash 复制代码
> XREAD COUNT 2 STREAMS race:france 0
1) 1) "race:france"
   2) 1) 1) "1692632086370-0"
         2) 1) "rider"
            2) "Castilla"
            3) "speed"
            4) "30.2"
            5) "position"
            6) "1"
            7) "location_id"
            8) "1"
      2) 1) "1692632094485-0"
         2) 1) "rider"
            2) "Norem"
            3) "speed"
            4) "28.8"
            5) "position"
            6) "3"
            7) "location_id"
            8) "1"

上面的是非阻塞形式的XREAD. 请注意Count选项并不是强制的. 唯一强制性的选项是STREAMS 选项, 它指定了一系列的key以及相应的最大id, 这样command就能提供给客户端大于所定义的最大id的消息.

在上面的command中, 我们写了**STREAMS race:france 0**,所以我们想要stream race:france上所有id大于0-0的消息. 就像你在上面例子看到的一样, 这个command但会key的名称, 因为可能同一时间从多个不同的stream中读取数据, 例如我可以协程下面这种形式:STREAM race:france race:italy 0 0. 在STREAM 选项后, 我们需要提供key名称 和后面的IDs. 基于这个原因, STREAMS 选项必须是最后一个选项. 其他的所有选项都必须出现在STRAMS选项之前.

除了XREAD可以同时访问多个streams, 以及能够指定最后id以便我们可以得到最新的消息. 在这个简单的形式中, 这个command做的与XRANGE没有什么不同. 然而,有趣的是, 我们可以将XREAD很容易转为阻塞命令,通过指定BLOCK参数

bash 复制代码
> XREAD BLOCK 0 STREAMS race:france $

在上面的例子中, 除了移除了COUNT, 还指定了BLOCK选项 和超时时间 0 millisecond(意味着永不超时). 并且, 没有传递一个正常id而是传递了一个特殊的id $ , 代表XREAD使用stream中最后的即最大的id, 这样我们就只能接收从我们开始监听开始后的新消息. 这与tail -f这个unix command很相似.

当使用BLOCK这个选项时, 不是非得使用这个特殊的ID <math xmlns="http://www.w3.org/1998/Math/MathML"> , 我们可以使用任何有效的 i d . 如果这个 c o m m a n d 能够马上满足我们的需要 , 它就不会阻塞 , 否则会阻塞 . 正常情况下 , 如果我们想从新的 e n t r y 开始消费 s t r e a m , 就用 i d ,我们可以使用任何有效的id. 如果这个command能够马上满足我们的需要, 它就不会阻塞, 否则会阻塞. 正常情况下, 如果我们想从新的entry开始消费stream, 就用id </math>,我们可以使用任何有效的id.如果这个command能够马上满足我们的需要,它就不会阻塞,否则会阻塞.正常情况下,如果我们想从新的entry开始消费stream,就用id, 然后我们使用接收到的最后的消息的id进行下一次调用, 以此类推

XREAD的阻塞形式也是能够监听多个stream的, 通过指定多个key的名称

与blocking list操作类似, blocking stream 也是先进先出的模式, 对于一个stream, 第一个被阻塞的客户端会率先被释放 当新数据到来时.

XREAD除了count和block, 没有其他的选项. 所以他是一个它是一个很基本的command, 用来将消费者连接到一个或多个stream上. Redis还有消费stream的更强大的功能, 如使用消费者分组API, 然后, 通过消费者分组去读取数据是由另一个叫做XREADGROUPcommand实现的

Consumer groups

XREAD已经为我们提供了一种将消息fan-out 多个客户端的能力。但是,在一些场景下,我们不是想多个客户端消费一个stream,而是同一stream可以提供不同子集的消息给客户端。比如说,消息处理的速度很慢,我们想要N个不同的客户端接收stream的不同部分的消息来扩展消息处理,这样就能扩展消息的处理的能力

如果我们有三个消费者C1,C2,C3,和一个包含消息1,2,3,4,5,6,7的stream,我们想按照下表进行路由

rust 复制代码
1 -> C1
2 -> C2
3 -> C3
4 -> C1
5 -> C2
6 -> C3
7 -> C1

为了达到这个效果,redis使用了一个叫做consumer groups的概念。但redis的consumer group和kafka的consumer group从实现角度来讲 没有一点相干性,理解这一点非常重要。虽然他们在功能上有些相似性,所以我使用了kafka的这个术语,因为consumer group是kafka率先提出的。

Consumer group就像一个伪消费者,它从stream中获取数据,并服务给多个消费者,并提供下列的保证:

  1. 每一个消息被发送到一个不同的消费者,不会发生一个消息发送给多个消费者的情况
  2. 消费者通过名称来标记属于哪个consumer group,名称是大小写敏感的,实现消费者的客户端必须有一个名称。即使客户端断开了链接,consumer group也会保持所有状态,因为客户端还会再次声明成为同一个消费者,这意味客户端必须提供唯一的标识符
  3. 每一个consumer group都有first ID的概念,这样当消费者请求新消息时,consumer group就能提供先前未发送的消息
  4. 消息一条消息,需要使用指定的command进行显式的确认。确实之后,redis就认为这条消息已经被正确消费,并可以从consumer group中删除了
  5. Consumer group会追踪当前处于待处理状态的所有消息,即消息被发送到consumer group中的consumer,但还没有确认。多谢有这个feature,我们才获取stream的消息历史时,每个consumer才能只看到发送给他的消息列表。

在某种程度上,consumer group可以想象为下面的状态

sql 复制代码
+----------------------------------------+
| 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)               |
+----------------------------------------+

这样就很容易理解一个consumer group能做那些时间,它是如何提供给consumer他们的待处理的消息,consumer是如何请求大于last_delivered_id的消息的。如果你把consumer group看做redis stream的一种数据结构,很显然,一个stream可以有多个consumer group。

接下来我们学习基础consumer group command,它们是

  • XGROUP用于创建、销毁、管理 consumer group
  • XREADGROUP用于通过consumer group从stream中读取数据
  • XACK用于将待处理的消息标记为处理完成

Creating a consumer group

如果已经有一个stream类型的key:race:france,为了创建一个consumer group

ruby 复制代码
> XGROUP CREATE race:france france_riders $
OK

正如你看大的,当创建consumer group时,我们需要指定id,本例中为 <math xmlns="http://www.w3.org/1998/Math/MathML"> ,因为需要知道当第一个 c o n s u m e r 连接时,需要发送给它什么消息。也就是,当 g r o u p 创建时, ∗ l a s t m e s s a g e i d ∗ 是什么。如果 i d 写为 ,因为需要知道当第一个consumer连接时,需要发送给它什么消息。也就是,当group创建时,*last message id*是什么。如果id写为 </math>,因为需要知道当第一个consumer连接时,需要发送给它什么消息。也就是,当group创建时,∗lastmessageid∗是什么。如果id写为,意思就是从现在开始到达的新消息才会提供给group中的consumer。如果我们指定id为0,consumer group为消费stream中所有的消息。你需要知道的就是consumer group开始发送的消息是大于你指定的id的。

XGROUP CREATE 还支持自动创建stream,如果其不存在,使用选项MKSTREAM 作为最后的参数

objectivec 复制代码
> XGROUP CREATE race:italy italy_riders $ MKSTREAM
OK

Consumer group创建之后,我们就可以使用XREADGROUP command读取消息。

XREADGROUPXREAD很相似,并提供BLOCK 选项。其GROUP选项必须指定,该选项有两个参数:consumer group的名称以及consumer的名称。也支持选项COUNT ,这与XREAD选项的COUNT相同。

我们会向race:italy stream中添加赛车手,并使用consumer group读取消息。

sql 复制代码
> XADD race:italy * rider Castilla
"1692632639151-0"
> XADD race:italy * rider Royce
"1692632647899-0"
> XADD race:italy * rider Sam-Bodden
"1692632662819-0"
> XADD race:italy * rider Prickett
"1692632670501-0"
> XADD race:italy * rider Norem
"1692632678249-0"
> XREADGROUP GROUP italy_riders Alice COUNT 1 STREAMS race:italy >
1) 1) "race:italy"
   2) 1) 1) "1692632639151-0"
         2) 1) "rider"
            2) "Castilla"

XREADGROUP响应就像XREAD的响应。请注意上面的 GROUP <group-name> <consumer-name>。意思是我要使用mygroup从stream中读取消息,并且我是消费者Alice。对于每次consumer想要对consumer group执行操作,都必须指定其名称,唯一标记group中的consumer。

还有一个重要细节,在强制的STREAMS 选项后面,我们指定id为特殊id >。这个特殊的id只有在consumer group的上下文中才有效,意思是,目前为止未发送给其他consumer的消息。

通常情况下,这就是你想要的,但如果你想指定一个id,如0或者其他有效的id,在这种情况下,consumer group只是提供给我们待处理消息的历史,然后 我们永远不会在这个group中看到新消息。XREADGROUP根据我们指定的id会有以下的行为:

  • 如果id是特殊id >,command回只返回从未发送给其他consumer的新消息。这也会更新consumer group的last id
  • 如果id是其他有效的id,command就会让我们获取我们待处理消息的历史。这就是,被发送给指定的consumer的未被确认的消息。

我们可以通过指定id为0来验证这个行为,

sql 复制代码
> XREADGROUP GROUP italy_riders Alice STREAMS race:italy 0
1) 1) "race:italy"
   2) 1) 1) "1692632639151-0"
         2) 1) "rider"
            2) "Castilla"

然而,当我们对该消息进行确认,就不会再返回任何东西

sql 复制代码
> XACK race:italy italy_riders 1692632639151-0
(integer) 1
> XREADGROUP GROUP italy_riders Alice STREAMS race:italy 0
1) 1) "race:italy"
   2) (empty array)

现在我们让consumer Bob来读取

sql 复制代码
> XREADGROUP GROUP italy_riders Bob COUNT 2 STREAMS race:italy >
1) 1) "race:italy"
   2) 1) 1) "1692632647899-0"
         2) 1) "rider"
            2) "Royce"
      2) 1) "1692632662819-0"
         2) 1) "rider"
            2) "Sam-Bodden"

Bob请求了最大两条数据。当redis发送消息的时候,会发什么什么呢,可以看到消息Castilla并没有出现,它被发送到consumer Alice,Bob只得到了Royce 和Sam-Bodden。

Alice,Bob以及其他在这个group的consumer,都能够从同一个stream中读取不同的消息,读取他们还没确认的历史消息,或标记消息为已处理。这样就可以创建不同的拓扑和语义为stream中消费消息。

  • Consumer 是自动创建的,不需要显式的创建
  • 使用XREADGROUP 你可以同时对多个key进行读取。要这样做,你需要在每个stream上创建相同名字的consumer group。通常没这种需求,但因为该feature技术上是支持的,所以还是值得一提
  • XREADGROUP是一个写命令,尽管他是从stream中读取消息,但是consumer group会被修改打那个进行读取时,所以它只能在master节点上调用

下面是一个使用consumer group 使用Ruby写的一个例子。

ini 复制代码
require 'redis'

if ARGV.length == 0
    puts "Please specify a consumer name"
    exit 1
end

ConsumerName = ARGV[0]
GroupName = "mygroup"
r = Redis.new

def process_message(id,msg)
    puts "[#{ConsumerName}] #{id} = #{msg.inspect}"
end

$lastid = '0-0'

puts "Consumer #{ConsumerName} starting..."
check_backlog = true
while true
    # Pick the ID based on the iteration: the first time we want to
    # read our pending messages, in case we crashed and are recovering.
    # Once we consumed our history, we can start getting new messages.
    if check_backlog
        myid = $lastid
    else
        myid = '>'
    end

    items = r.xreadgroup('GROUP',GroupName,ConsumerName,'BLOCK','2000','COUNT','10','STREAMS',:my_stream_key,myid)

    if items == nil
        puts "Timeout!"
        next
    end

    # If we receive an empty reply, it means we were consuming our history
    # and that the history is now empty. Let's start to consume new messages.
    check_backlog = false if items[0][1].length == 0

    items[0][1].each{|i|
        id,fields = i

        # Process the message
        process_message(id,fields)

        # Acknowledge the message as processed
        r.xack(:my_stream_key,GroupName,id)

        $lastid = id
    }
end

正如你看到的,我们从消费历史即待处理的消息开始。这是非常有用的,因为consumer可能之前崩溃过,所以当重启的时候,我们想重新读取发送给我们但没确认的消息。请注意 我们可能会处理一个消息多次,有可能因为consumer失败,也有可能因为redis持久化或复制的限制,可以关于这方面具体的章节。

一旦历史消息被处理,我们就得到了一个空的消息列表。我们此时就可以使用id > 这个特殊id来消费新的消息。

Recovering from permanent failures

在实际情况中,消费者有可能永久的失败并且不可恢复。那么这些consumer中的害处pending状态的消息怎么办呢?

Redisconsumer group 提供了一种feature,可用在这种情形,去认领指定consumer中的pending 消息。这些消息就会改变他们的所有者 重新分配给另一个consumer。一个consumer 必须检查pending状态的消息,并使用特殊的command认领指定的消息

首先,就是XPENDING这个命令,用来查找consumer group中的pending状态的entry。这是一个只读command,它的调用不会改变任何消息的所有者。在这个命令的最简单的形式中,有两个参数,stream的名称和consumer group的名称。

bash 复制代码
> XPENDING race:italy italy_riders
1) (integer) 2
2) "1692632647899-0"
3) "1692632662819-0"
4) 1) 1) "Bob"
      2) "2"

当以这种方式调用的时候,command输出了pending状态消息的总数量,然后就是这些pending消息的最小消息id和最大消息id,后边就是消费者和它们有的pending消息。我们的Bob consumer有两条pending的消息。

我们也可以向XPENDING中添加更多的参数来获取更多信息,完整的command的定义如下

xml 复制代码
XPENDING <key> <groupname> [[IDLE <min-idle-time>] <start-id> <end-id> <count> [<consumer-name>]]

通过提供start id和 end id(可以为- +),和count 来控制command返回的数量,最后是可选的consumer name,用来只展示指定consumer中的pending 消息。

bash 复制代码
> XPENDING race:italy italy_riders - + 10
1) 1) "1692632647899-0"
   2) "Bob"
   3) (integer) 74642
   4) (integer) 1
2) 1) "1692632662819-0"
   2) "Bob"
   3) (integer) 74642
   4) (integer) 1

现在我们就有了每个消息的详情:id,consumer 名称,idle time 毫秒形式 即这个消息发送给consumer后过去了多长时间, 最后是这个消息被发送了多少次。我们有两个来自Bob的消息,它们已经idle了60000+毫秒,大约一分钟

你可以使用XRANGE来查看第一条消息的内容

arduino 复制代码
> XRANGE race:italy 1692632647899-0 1692632647899-0
1) 1) "1692632647899-0"
   2) 1) "rider"
      2) "Royce"

现在Bob可能不能很快恢复,现在我们用Alice去认领这些消息。

这个command是非常复杂的并且在它的完整形式中有很多选项,我们现在只使用我们正常所需要的,这个情况下,可简化为

xml 复制代码
XCLAIM <key> <group> <consumer> <min-idle-time> <ID-1> <ID-2> ... <ID-N>

对于指定的stream key和group,我想要所指定的消息id将其所有者改为所指定名称为<consumer>的consumer上。然而,我们还提供了一个最小idletime,这样,只有所提供的消息的idle time大于指定的idle time时 命令才会生效。这是非常有效的当两个客户端同时对消息进行认领时。

arduino 复制代码
Client 1: XCLAIM race:italy italy_riders Alice 60000 1692632647899-0
Client 2: XCLAIM race:italy italy_riders Lora 60000 1692632647899-0

认领一个消息会重置其idle time 并且增加其delivery counter,所以第二个客户端会认领失败。这样我们避免了消息的重复处理(即使在通常情况下,你也无法获取精确一次处理)

下面是命令执行的结果

objectivec 复制代码
> XCLAIM race:italy italy_riders Alice 60000 1692632647899-0
1) 1) "1692632647899-0"
   2) 1) "rider"
      2) "Royce"

这个消息被Alice成功认领,Alice现在可以处理这条消息并确认

从上面的示例可以看出,XCLAIM命令成功认领一个消息后 会把这条消息返回。但这并不是强制的。可以使用选项JUSTID只返回成功认领的id。当你想减少带宽时并且你不关心消息内容时,这就非常有用了。

认领操作还可以通过单独的进程实现:检查pending状态的消息,将大于指定idle time的消息分配给活跃的消费者。可以通过redis stream的可观测性的feature来获取当前活跃的消费者。这是下一章节的内容

Automatic claiming

在redis 6.2版本中添加的XAUTOCLAIM命令,实现了上面我们说的认领过程。XPENDINGXCLAIM是不同recovery机制类型的基石。

XAUTOCLAIM筛选出pending状态idle time大于指定时间的消息,并改变其所有者。这个command的签名如下

xml 复制代码
XAUTOCLAIM <key> <group> <consumer> <min-idle-time> <start> [COUNT count] [JUSTID]

所以,在上面的示例中,我可以使用自动认领的方式去认领一条消息,像下面一样:

arduino 复制代码
> XAUTOCLAIM race:italy italy_riders Alice 60000 0-0 COUNT 1
1) "0-0"
2) 1) 1) "1692632662819-0"
      2) 1) "rider"
         2) "Sam-Bodden"

XCLAIM一样,该命令也返回了所认领消息的数组,也返回了stream id 可以让我们对pending状态的entry进行迭代。这个stream id是个游标,我们在下次调用中使用它继续认领idle的pending 消息。

arduino 复制代码
> XAUTOCLAIM race:italy italy_riders Lora 60000 (1692632662819-0 COUNT 1
1) "1692632662819-0"
2) 1) 1) "1692632647899-0"
      2) 1) "rider"
         2) "Royce"

XAUTOCLAIM返回0-0时 ,这意味着它已经到达了consumer group pending entries list的终点。即没有更多的idle pending 消息。

Claiming and the delivery counter

你在XPENDING输出中看到的counter是每个消息被delivery的次数。有两种方式会增加counter:一种是通过XCLAIM认领消息时,或者调用XREADGROUP来获取pending 消息的history时。

当存在失败时,消息会被发送多次是正常的,但是最终都会得到处理并被确认。然而,当处理一些消息时,可能还有别的问题,如代码存在bug,再这种情况下,consumer处理这样的消息永远都会失败。但我们有delivery尝试的次数counter,所以我们可以使用counter来检测由于某些原因不能被处理的消息。所以一旦counter达到指定次数,最好把这样的消息放在另一个stream中,并想系统管理员发送一个提示。这就是redis stream实现死信这个概念的方式。

Streams observability

消息系统如果确实可观测性 将很难工作。因为不知道谁在消费消息,那些消息是pending状态,哪些consumer group处于活动状态,这让一切变得不透明。基于这个原因,redis stream和consumer group 都有不同的方式去观察运行状态。我们已经介绍了XPENDING,让我们可以查看当前处于处理状态的消息,以及他们的idle time和delivery count。

然而我们想做的不止这些。XNIFO命令可与子命令结合来查询关于stream或consumer group的信息。

这个命令使用子命令来对stream以及consumer group的状态信息进行查询。例如XINFO STREAM查询了stream自己的相关信息。

bash 复制代码
> XINFO STREAM race:italy
 1) "length"
 2) (integer) 5
 3) "radix-tree-keys"
 4) (integer) 1
 5) "radix-tree-nodes"
 6) (integer) 2
 7) "last-generated-id"
 8) "1692632678249-0"
 9) "groups"
10) (integer) 1
11) "first-entry"
12) 1) "1692632639151-0"
    2) 1) "rider"
       2) "Castilla"
13) "last-entry"
14) 1) "1692632678249-0"
    2) 1) "rider"
       2) "Norem"

输出的信息展示了stream内部的构成,展示了stream中的第一条和最后一条消息,还可以获取到与该stream关联的有几个consumer group。可以用XINFO GROUPS来获取更多consumer group的信息。

sql 复制代码
> XINFO GROUPS race:italy
1) 1) "name"
   2) "italy_riders"
   3) "consumers"
   4) (integer) 3
   5) "pending"
   6) (integer) 2
   7) "last-delivered-id"
   8) "1692632662819-0"

可以看到,XINFO命令输出了一系列的field-value,可以让你很快理解输出的信息是什么,也允许该命令能输出更多的信息 并与旧版本兼容。其他一些需要带宽传输高效的命令,如XPENDING就只输出了信息而不需要field。

在上面使用GROUPS子命令的示例中,我们还可以获取指定consumer的详细信息

bash 复制代码
> XINFO CONSUMERS race:italy italy_riders
1) 1) "name"
   2) "Alice"
   3) "pending"
   4) (integer) 1
   5) "idle"
   6) (integer) 177546
2) 1) "name"
   2) "Bob"
   3) "pending"
   4) (integer) 0
   5) "idle"
   6) (integer) 424686
3) 1) "name"
   2) "Lora"
   3) "pending"
   4) (integer) 1
   5) "idle"
   6) (integer) 72241

如果你不记得这个命令的语法,可以使用HELP命令

xml 复制代码
> XINFO HELP
1) XINFO <subcommand> [<arg> [value] [opt] ...]. Subcommands are:
2) CONSUMERS <key> <groupname>
3)     Show consumers of <groupname>.
4) GROUPS <key>
5)     Show the stream consumer groups.
6) STREAM <key> [FULL [COUNT <count>]
7)     Show information about the stream.
8) HELP
9)     Prints this help.

Differences with Kafka partitions

Redis stream的consumer group在某种程度上与kafka基于分片的consumer group很相似。然而在实际应用中,redis stream和kafka是非常不同的。redis的分区只是逻辑上的,所有的消息都只是放在一个redis key中,只要哪个客户端能够处理数据,redis stream就会把消息发送给他,而不是看客户端从哪个分区读取。例如,如果consumer C3在某个时间点出现永久性故障,redis 会继续服务C1和C2,就像只有两个逻辑分区一样。

然而,如果你真的想要把同一个stream中的消息分给多个redis实例。你不得不使用多个key和分片系统如redis cluster或其他application-specific 分片系统。Redis stream是不会自动分片给多个实例的。

  • 如果你想使用 1 stream -> 1 consumer,你会按照顺序处理消息
  • If you use N streams with N consumers, so that only a given consumer hits a subset of the N streams, you can scale the above model of 1 stream -> 1 consumer.
  • 如果你使用 1 stream -> N consumer,相当于在N个consumer上做了负载均衡。然而,消息处理的顺序可能不是有序的,因为可能一个处理 message 3的consumer处理的比处理message 4的consumer 处理的要快。

Kafka 的分区 更像使用N个不同的redis key,redis consumer group更像服务端的复杂均衡系统,

Capped streams

许多应用都不想把数据永远存在stream中,所以有一个stream中最大容纳数量是非常有用的,一旦设定的容量被达到后,就可以将数据将redis转移到其他存储,可能不如redis这么快,但是很适合用于存储历史数据。Redis stream对这提供了相应的支持,一个就是XADD 命令的MAXLEN选项。

bash 复制代码
> XADD race:italy MAXLEN 2 * rider Jones
"1692633189161-0"
> XADD race:italy MAXLEN 2 * rider Wood
"1692633198206-0"
> XADD race:italy MAXLEN 2 * rider Henshaw
"1692633208557-0"
> XLEN race:italy
(integer) 2
> XRANGE race:italy - +
1) 1) "1692633198206-0"
   2) 1) "rider"
      2) "Wood"
2) 1) "1692633208557-0"
   2) 1) "rider"
      2) "Henshaw"

使用MAXLEN时,当达到指定的length时,老的entry会自动删除,来让stream保持固定的大小。现在还没有选项让stream只保留不超过指定时间的选项,是为了redis stream能持续运行,如果有这样的command并执行,会阻塞很长一段时间为了删除数据,想象一下如果有一个插入峰值,然后就是很长时间的停顿,然后有是一次插入,所有的都是一样的最大保存时间。stream删除数据的时候就会阻塞。所有这取决于用户提前做些计划并指导所需的最大stream长度。此外,虽然stream的长度与内存使用成正比,但按时间修剪并不那么容易:他取决于随时间插入的速率。

然而,使用MAXLEN的代价也可能比较大:stream为了让内存更高效所以以树形结构存储。修改一个由几十个数据组成的节点并不是最佳选择,所以可以使用这种命令的特殊形式:

yaml 复制代码
XADD race:italy MAXLEN ~ 1000 * ... entry fields here ...

介于MAXLEN选项和实际count之前的~参数,意味着我并不需要stream的数据数量精确在1000个,可以是1000或1010或1030,只需要确保至少1000个数据。使用这个参数,只有我们能够删除一整个node的时候才会删除数据。这就变得非常高效,而且这通常情况就是我们想要的。你需要注意的是客户端库对此有多少不同的实现。例如,python客户端默认为近似值 并且需要显式设置为真实长度。

这里还有XTRIM命令,其表现与MAXLEN选项很相似,除了他可以自己执行

bash 复制代码
> XTRIM race:italy MAXLEN 10
(integer) 0

或者说

bash 复制代码
> XTRIM mystream MAXLEN ~ 10
(integer) 0

然而,XTRIM命令设计为可接受不同修剪策略。另一种修剪策略是MINID,它删除id比它小的entry。

由于XTRIM是一个显式命令,用户需要知道不同修剪策略的缺点。

另一个可能会添加到XTRIM的修剪策略,是根据IDs的范围去删除,使用XRANGEXTRIM来将数据从redis移动到其他的存储系统,如果需要。

Special IDs in the streams API

你应该注意到在redis api中有很多可以使用的特殊id,这里做一些回顾和总结。

首先就是-+,被用在XRANGE命令进行的范围查询中。分别代表最小的id(0-1)以及最大的id (18446744073709551615-18446744073709551615)。正如你看到的,写-或者+比写这些数字干净多了。

还有$ 代表stream中id最大的entry。例如,如果我们想使用XREADGROUP只得到最新的entry,我可以使用这个id代表我已经有所有存在的entry,但是没有未来会插入的数据。类似的,当我们创建或设置一个consumer group的id时,我可以设置最后发送的数据id为$,来让consumer group中的consumer只接受新数据。

另一个特殊的id 是>,这个id只有在使用XREADGROUP的时候用到,代表我们想要从未发送给其他消费者的entry。即>代表一个consumer group的 last delivered ID

最后,*只在XADDcommand用,代表为新entry自动生成id

Persistence, replication and message safety

就像其他的redis数据结构一样,stream也是异步地复制到副本中 且持久化到AOF或RDB文件中,然而,不那么明显的是 consumer group 的完整状态也会存储到AOF,RDB和副本中,所以如果一个消息在master中处于pending状态,副本也一样。在重启后,AOF会恢复consumer group的状态

请注意redis stream和consumer group持久化和复制使用的是redis默认的复制,所以

  • 如果消息的持久化对于你的应用很重要,那么AOF需要使用强fsync策略

  • 默认情况下,异步的复制并不保证XADD命令或consumer group状态的改变都被复制:在故障转移之后,一些东西可能会丢失,这取决与副本从master接收数据的能力

  • WAIT命令可用于强制将更改传播到一组副本。但请注意,尽管这样做可以极大地减少数据丢失的可能性,但由Sentinel或Redis Cluster操作的Redis故障转移过程只进行了最大努力检查以故障转移到最新更新的副本,并且在某些特定故障条件下可能会提升缺少某些数据的副本。

所以当使用redis stream和consumer group设计应用时,请确保在经历故障时能够理解你的应用应该有的语义属性。并相应地进行配置,评估是否对你的用例足够安全。

Removing single items from a stream

Streams还提供了一种将数据根据其id从其中删除的命令,对于一个append only的数据结构来说,这可能看起来非常奇怪,但如果你的应用牵扯例如隐私,法规这样的问题的时,就会变得非常有用。这个命令是XDEL,接收stream的名称和要删除的id

bash 复制代码
> XRANGE race:italy - + COUNT 2
1) 1) "1692633198206-0"
   2) 1) "rider"
      2) "Wood"
2) 1) "1692633208557-0"
   2) 1) "rider"
      2) "Henshaw"
> XDEL race:italy 1692633208557-0
(integer) 1
> XRANGE race:italy - + COUNT 2
1) 1) "1692633198206-0"
   2) 1) "rider"
      2) "Wood"

不过需要注意的是,在当前版本的实现中,内存并没有完全释放,知道一个macro node完全是空的。

Zero length streams

Stream和其他redis 数据结构的一个区别就是,当其他数据结构不再拥有任何元素时,这个key就会被删掉。例如,当ZREM删除最后一个元素时,sorted set会被完全地删除。而streams允许其内部没有数据。

至于stream为什么这么特殊,是因为streams可能有一些相关的consumer group,我们不想只是因为stream中没有数据了 就失去所定义的consumer group。当前,即使stream没有相关联的consumer group,stream也不会被删除。

Total latency of consuming a message

非阻塞的stream 命令,例如,XRANGE XREADXREADGROUP 不携带BLOCK 选项时,和其他redis command一样是同步执行的,所以讨论这种命令的耗时没什么意义,还不如去redis文档中去查看这些命令的复杂度。当按范围查询数据时,可以说 stream的和sorted set一样快,并且XADD也是非常快,当使用pipeline时,可以轻松地在一秒内插入50到100万个项目。

但是,如果我们想搞懂:对于一个consumer group中的consumer来说,从通过XADD添加一个消息开始,到consumer通过XREADGROUP获取到这条消息的耗时还是很有意义的。

How serving blocked consumers works

我们首先需要了解redis使用了什么模型来路由stream 消息,以及通常 等待数据的阻塞操作是如何被管理的。

  • 阻塞的客户端在一个hash table中被引用,将至少有一个阻塞中consumer的key 与 等待该key的consumer列表相关联。通过这种方式,如果该key接收到数据,我们就可以对所有等待该key的客户端进行处理
  • 当写操作发生时,即当XADD命令被调用时,它会调用signalKeyAsReady()方法。这个方法会把该key放到一个需要被处理的key的列表中,因为这些key有可能有些新的数据。但这种ready的key并不会马上被处理,所以在同一个event loop cycle中,这个key是有可能收到其他的写操作的。
  • 最后,再回到event loop之前,这些redis keys终被处理。对于每个key,等待数据的客户端列表都会被扫描,如果适用,这样的客户端就会接收到新的数据。

正如你看到的,基本上,在回到event loop之前,调用XADD的客户端和阻塞等待消费消息的客户端都会output buffer中产生他们的reply。所以XADD的调用者收到相应和consumer收到新消息的时间基本是同时的。

这个模型是push-based的,由于添加数据到consumer buffer是调用XADD执行的,所以这个延时还是可预测的。

Latency tests results

为了检查延时指标,我们使用多个Ruby程序推送消息,消息携带一个field记录发送时间,还有一些Ruby程序用来从consumer group中读取并处理消息。消息的处理过程由比较当前电脑时间和消息的时间戳组成,来计算总的耗

rust 复制代码
Processed between 0 and 1 ms -> 74.11%
Processed between 1 and 2 ms -> 25.80%
Processed between 2 and 3 ms -> 0.06%
Processed between 3 and 4 ms -> 0.01%
Processed between 4 and 5 ms -> 0.02%

所以99.9%的请求耗时 <= 2 毫秒。异常值也与平均值非常接近。

添加几百万未确认的消息到stream 也不会改变这个benchmark,大多数的query的延迟都非常小。

这里还有几点说明:

  • 我们每次循环处理至多10k条消息,这意味着参数XREADGROUPCOUNT被设置为10000。这会添加很多延迟,但是这个必要的 为了允许慢的consumer能够跟上消息流。所以在真实应用场景中,延迟会比这更小。
  • 用来进行benchmark的系统相比于当前是比较落后的
相关推荐
九圣残炎28 分钟前
【springboot】简易模块化开发项目整合Redis
spring boot·redis·后端
.生产的驴1 小时前
Electron Vue框架环境搭建 Vue3环境搭建
java·前端·vue.js·spring boot·后端·electron·ecmascript
爱学的小涛1 小时前
【NIO基础】基于 NIO 中的组件实现对文件的操作(文件编程),FileChannel 详解
java·开发语言·笔记·后端·nio
爱学的小涛1 小时前
【NIO基础】NIO(非阻塞 I/O)和 IO(传统 I/O)的区别,以及 NIO 的三大组件详解
java·开发语言·笔记·后端·nio
北极无雪1 小时前
Spring源码学习:SpringMVC(4)DispatcherServlet请求入口分析
java·开发语言·后端·学习·spring
爱码少年1 小时前
springboot工程中使用tcp协议
spring boot·后端·tcp/ip
2401_857622669 小时前
SpringBoot框架下校园资料库的构建与优化
spring boot·后端·php
2402_857589369 小时前
“衣依”服装销售平台:Spring Boot框架的设计与实现
java·spring boot·后端
哎呦没10 小时前
大学生就业招聘:Spring Boot系统的架构分析
java·spring boot·后端
_.Switch11 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j