(原文发布于https://redis.io/topics/streams-intro,对应redis6.04,本文已完成大半,忽然发现网上已有翻译文章,但还是按自己的理解完成文章的翻译。原文较长,原想将英文也附上,好对照,但确实太长了,终省去。)
“流”是Redis5.0引入的新的数据类型,它的模型来自于日志数据,以一种更加抽象的方式,但日志的基本特征没有变化:通常像一个以只增加方式打开的文件。Redis流基本上就是一个只添加方式的数据结构。至少在概念上,作为一种在内存里展现的抽象的数据结构,Redis流结构克服了日志文件的一些限制,实现了更加强大的操作。
尽管结构本身相当简单,但redis流是redis中最复杂的数据类型,因为它实现了一些额外的非强制性命令:一系列允许使用者等待新数据的阻塞操作,即等待产生者将新数据添加到流中,另外,还有一个叫“消费者组”概念。
“使用者组”最初是由被人广泛使用的消息系统Kafka所引入的。虽然Redis使用了完全不同的术语来重新实现,但它们的目标是一致的,即允许一组客户协同地使用同一消息流的不同部分。
流的基础知识
为了理解Redis流是什么和如何用它,我们将忽略所有高级特性而仅关注它本身的数据结构、操作它的命令。基本上这一部分对其他Redis数据类型,如Lists,Sets,Sorted Sets等也是适用的。但是,应当注意,lists也有可选的复杂的阻塞API,例如BLPOP等类似命令,所以在这点上,Streams与lists并没有很大差别,就是都有复杂且强大的附加API。
由于streams是一种只添加的数据结构,基本的写命令为XADD,它将一条新数据添加到指定的stream。一条stream数据不仅可以是一个字符串,而且可以由一对或多对键-值对构成,这种方式下,一条stream数据已经是具有结构的数据了,就像CVS格式的只增日志文件一样,每一行存在多个分割的域。
> XADD mystream * sensor-id 1234 temperature 19.8
1518951480106-0
上面这个XADD命令将一条数据sensor-id: 1234, temperature: 19.8添加到mystrame流中,条目ID用自增方式,这个ID被返回,即1518951480106-0。命令的第一个参数是流名称mystream,第二个参数是用来标记每一个数据条目的条目ID,但是,在上面的例子中,我们想让服务器产生一个新ID,所以我们用*做参数。每个新ID都是单调增的,所以简单来说,新增的ID都比以前的ID要大。服务器自增产生的ID几乎总是你期望的,所以需要显式指定一个ID的情况比较少见。对此我们将在后面详细说明。每一条信息数据都有一个ID,这是与日志文件的另一个相似之处,在日志中,行号或文件的字节偏移量被用来标识一个特定的条目。回到我们XADD的例子中,流名称和ID之后,下面的参数就是构成流数据条目的域-值对。
如果想得到一个stream中有多少条目,可以用XLEN命令:
> XLEN mystream
(integer) 1
条目ID
XADD返回的用来明确标识流每一数据条目的ID由两部分组成:
<millisecondsTime>-<sequenceNumber>
毫秒时间数部分实际上是产生这个ID的Redis节点的本地时间,如果当前的毫秒时间数比前一条目的毫秒时间数小,则采用前一个毫秒时间数,所以,即使时钟跳回过去,ID单调增的特性依然保持。序列数用来给那些同一毫秒内产生的条目,由于序列数有64 位宽,所以实际应用中,同一毫秒内的条目数没有限制。
初看起来这种结构的ID有点奇怪,敏感的读者会想为什么时间值会成为ID的一部分。原因在于Redis流支持通过ID进行范围查询,ID与它产生的时间相关,这就能方便地用时间范围来查询。在涉及XRANGE命令时我们将看到这一点。
如果用户因为某些原因不想采用与时间相关的自增ID,而采用与另一个外部系统关联的ID,XADD命令可以不用*来触发自动产生ID,而显式接受一个,如下例:
> XADD somestream 0-1 field value
0-1
> XADD somestream 0-2 foo bar
0-2
注意这个例子中,最小的ID是0-1,命令不接受比上次值小的ID。
> XADD somestream 0-1 foo bar
(error) ERR The ID specified in XADD is equal or smaller than the target stream top item
从流中获得数据
现在我们终于可以通过XADD命令向我们的流中添加数据条目,尽管添加数据比较简明,但是,查询流来获得数据却不是如此。如果我们继续以日志文件作类比,一个显见的办法是用Unix命令tail -f,即我们可以采用侦听,来得到添加到流中的新数据。注意这里与Redis 中阻塞的list操作不同,list操作中采用像BLPOP这样的POP风格的命令,一个list元素只能发给一个正处于阻塞的用户,对于流,我们希望多个用户都能看到新添加的信息,就像许多tail -f进程都能看到加入日志文件的内容一样。用传统的术语来描述,我们要能够将流数据“扇出”,以分发到多个用户。
以上仅是一种获得流数据的方式,我们还可以采用另外不同的方式来获取流:不是作为消息系统,而是作为时间序列库。此时,获取新添加的数据是必要的,从时间范围来查询流,也成为另外一种很自然的方式,或者采用一个cursor来遍历所有的历史信息,这绝对是另一种有用的获取流的方式。
最后,如果以数据使用者的视角,我们可能还想采用另一种方法获取流:流信息能够根据不同的信息使用者分组,使用者组只能看到流中信息的一个子集,在这种方式下,就可以平衡不同使用者的处理过程,每一个使用者都会得到不同的信息进而处理,而不是处理所有信息。这基本上就是Kafka采用使用者组来做的事情。通过使用者组从Redis流中读取信息是另一个令人感兴趣的方式。
通过不同的命令,Redis持上面描述的三种查询流的方法。下面的章节将会描述它们。从最简单但更直接的方式:范围查询开始。
通过范围来查询:XRANGE and XREVRANGE
通过范围查询流,我们仅需指定两个ID:起始点和终止点。返回信息的范围包含起点或终点,因此范围是含边界的。-和+这两个特殊的ID分别指明可能的最小和最大的ID。
> XRANGE mystream - +
1) 1) 1518951480106-0
2) 1) "sensor-id"
2) "1234"
3) "temperature"
4) "19.8"
2) 1) 1518951482479-0
2) 1) "sensor-id"
2) "9999"
3) "temperature"
4) "18.2"
返回的每一条目是一个有两个元素的数组:ID和域-值对列表。我们已经说过条目ID与时间有关,-字符左边的部分是这个条目被创建时刻,那个创建节点的本地的Unix时间的毫秒数(应注意采用特定的XADD命令,流可以产生复制,所以从节点上的ID与主节点的ID相同)。 这意味着我可以用XRANGE命令通过一段时间范围来查询。为此,我可以省略ID的序列数部分,如果省略,范围的起始点的序列数将假设为0,而终止点的序列数将假设为可能的最大序列数。这样,用两个Unix时间毫秒数,我们就能得到所有在这个时间范围内产生的条目。例如,我可以进行一个两毫秒范围内条目的查询:
> XRANGE mystream 1518951480106 1518951480107
1) 1) 1518951480106-0
2) 1) "sensor-id"
2) "1234"
3) "temperature"
4) "19.8"
我仅得到一条信息,但是在实际的应用中,我可以查询数小时范围内的信息,或两毫秒内就有许多条目,甚至数量巨大条目。基于此,XRANGE命令支持在后面加一个COUNT选项,通过指定count,我就能得到N个条目,如果想得到更多,可从返回得到的最后的ID,将序列数部分增加1,再次查询。我们在下面的例子中将会看到这种情况。开始我们用XADD添加10个条目(我没有展示,已经假设mystream流由10个条目构成),现在进行我的遍历,每条命令得到2个条目。我开始整个范围的查询,但用count 2方式。
> XRANGE mystream - + COUNT 2
1) 1) 1519073278252-0
2) 1) "foo"
2) "value_1"
2) 1) 1519073279157-0
2) 1) "foo"
2) "value_2"
为了继续遍历,得到下两条数据,我就要用上次返回的最后的ID,即1519073279157-0,在序列数部分加上1,注意序列数是64位长,所以不用担心溢出,此时得到的结果为519073279157-1,可以用它来作为下一个XRANGE命令的起始点:
> XRANGE mystream 1519073279157-1 + COUNT 2
1) 1) 1519073280281-0
2) 1) "foo"
2) "value_3"
2) 1) 1519073281432-0
2) 1) "foo"
2) "value_4"
XREVRANGE命令与XRANGE命令相同,只是它以相反的顺序返回结果,所以实际使用上,XREVRANGE可用来检查流中最后一条信息。
> XREVRANGE mystream + - COUNT 1
1) 1) 1519073287312-0
2) 1) "foo"
2) "value_10"
需要注意XREVRANGE的起始点和中止点参数也是相反序的。
用XREAD侦听新信息
当我们不想对流进行范围查询时,通常我们是想订阅流中新到的信息。这个概念好像与Redis中的Pub/Sub相关,那里你可以订阅一个频道或Redis阻塞列表,然后等待它将信息发送发送给你,但在这里,使用流的方式有一些本质的不同:
- 一个流可以有多个等待数据客户端(使用者)。缺省情况下,特定流中的一条新信息,将会传输到每一个等待它的使用者。这个行为是不同于阻塞列表的,那里每个使用者得到的是不同的信息,但是,向多个使用者“扇出”这一功能,这里与Pub/Sub类似。
- Pub/Sub中的信息是“触发/遗忘”型,它们不会被保存。当一条信息被用户接收到以后,它就从阻塞列表中被弹出(高效删除)了,流工作的方式与此有本质不同。添加到流中的信息是“新”还是“旧”并不十分明确(除非用户显式删除条目):不同的使用者判断是否是新消息来自于从保留的上次接收到的ID。
- 流的使用者组提供了Pub/Sub或阻塞列表不能完成的一些控制,对同一个流的不同订阅组,可以明确知道已处理的条目,能够检查待发送的条目,获取未处理的信息,为每一用户提供连贯的历史信息,这些信息是属于用户的私人信息。
用来侦听新信息到达流的命令是XREAD,它比XRANGE要复杂一些,所以我们先以简单的形式开始,然后再提供完整的命令形式。
> XREAD COUNT 2 STREAMS mystream 0
1) 1) "mystream"
2) 1) 1) 1519073278252-0
2) 1) "foo"
2) "value_1"
2) 1) 1519073279157-0
2) 1) "foo"
2) "value_2"
上面是非阻塞形式的XREAD命令。注意COUNT选项不是强制的,仅必需的部分是STREAM项,它指定一个流名称关键字列表以及每个流相对应的使用者已经得到的最大的ID,所以这条命令将提供给用户那些ID大于指定ID的信息。
在上面的例子中,我们键入了STREAMS mystream 0,所以我们可以得到mystream流中所有ID大于0-0的信息。如同我们在上面例子中看到的,这条命令返回了流名称,这是由于在实际使用中,可以用这条命令同时提供多个流名,来读取不同的流。例如,我可以采用如下的形式:STREAMS mystream otherstream 0 0。注意在STREAMS后,我们需要提供流名称,然后是ID,基于这种原因,STREAMS项必须是这个命令的最后选项。
XREAD命令除了可以一次性读取多个流外,我们也能够通过指定我们所拥有的最后的ID来得到更新的信息条目,采用简单的形式,这条命令并不与XRANGE有什么不同,但是,让人感兴趣的是,通过指定BLOCK参数,我们能非常简单地将XREAD变为阻塞模式。
> XREAD BLOCK 0 STREAMS mystream $
上面例子中,除了去掉了COUNT选项外,我指定了BLOCK选项,设置超时时间为0毫秒(即不会超时),并且对流mystream,我传入了一个特殊的而不是一个正常ID $,这个特殊ID的意思是采用已存储在mystream流中最大的ID作为ID,所以我们将仅得到从我们开始侦听之后的新信息,这与Unix命令tail -f的方式类似。
注意当采用BLOCK选项时,我们并不是一定要用特殊的ID $,我们可以用任何合法的ID。如果这条命令不需要阻塞就能满足我们的要求,它就会如此,否则就会阻塞。通常如果我们想从新的信息条目开始,我们就用$开始,然后使用上次得到的最后一条的ID继续下次调用,如此等等。
XREAD的阻塞形式也能够侦听多个流,通过指定多个流的名字就可以了。如果至少有一个流中存在ID大于指定ID的信息,这个请求命令就可以同步返回结果,否则,这个命令就产生阻塞,直到第一个得到新数据(根据指定的ID确定)的流返回结果。
与阻塞列表操作类似,由于实现上是FIFO机制,阻塞型读取流操作对等待的用户来说是公平的,对指定流产生阻塞的第一个用户,在流得到新信息时,将第一个被解阻塞。
除了COUNT和BLOCK外,XREAD没有其他选项,所以它是一个相当基本的命令,目的就是将使用者关联到一个或多个流上。更加强大的使用流的方法是通过使用者组API,但是,通过使用者组来读取流是由另一个不同的命令XREADGROUP来实现的,这将在下一节来讲解。
使用者组
如果当前的工作是不同的客户端使用同一个流中的信息,那么XREAD命令已经提供了一种向N个客户端“扇出”的方法,可能也利用slave节点以提供更加具有扩展性的“读”。但是在某些特定的条件下,我们想要做的并不是向多个客户端提供同一个流中的信息,而是向许多客户端提供同一个流中信息的不同子集。采用原方法一个显而易见的问题是信息处理速度慢:需要有N个不同“工作者”,通过分发不同的信息来接收流的不同部分,才能够允许我们扩展处理信息的能力。
以实际情况来描述,如果我们想象有三个使用者C1,C2,C3和一个有信息1,2,3,4,5,6,7的流,我们采用如下的方式来分配这些信息:
1 -> C1
2 -> C2
3 -> C3
4 -> C1
5 -> C2
6 -> C3
7 -> C1
为了达到这种效果,Redis采用了一个叫做“使用者组”的概念。特别需要理解的是,从Kafka实现使用者组的角度来看,Redis的使用者组与它没有关系,但从它们实现的概念上,它们是相似的,这是它们仅有的类似,所以我决定与最初普及这种思想的软件产品一样,不改变这个术语。
一个使用者组就像一个虚拟的使用者,可以从流中得到数据,但实际上它为多个使用者服务,并提供如下保证:
- 每条信息可能发送给某个不同的使用者,但同一信息不可能同时发送给多个使用者。
- 在使用者组内部,采用大小写敏感的名字来标识不同的使用者,客户端采用使用者组方式时,必须采用某一使用者的名称。这意味着即使客户端断线,服务器的使用者组依然会保留所有的状态,客户端重新上线时,可以再次获得同样的使用者身份,但这时需要客户端提供那个唯一的使用者名称标识。
- 每个使用者组有一个首条未发送的信息id,所以,当一个使用者要求新信息时,使用者组就能提供先前从未发送的信息。
- “使用完”一条信息需要显式地用一个特定的命令来确认,即告述服务器,这条信息已被正确处理了,可以从使用者组中去除了。
- 一个使用者组追踪所有未被确认而挂起的信息,即那些已发送给组内使用者、但没得到使用者确认的信息。正是由于有这一特性,在回溯历史信息时,每个使用者仅能看到发给它的信息。
某种意义上,一个使用者组可以被想象流的不同种侧面状态:
+----------------------------------------+
| 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流的一个辅助的数据结构,很明显,一个流可以有多个使用者组,而每个组包含多个不同的使用者。实际上,对同一个流,可以采用XREAD而不通过使用者来读取,也可以在不同的使用者组中采用XREADGROUP来读取。
现在是时候来仔细看看基本的使用者组命令了,如下所示:
- XGROUP用来创建、销毁和管理使用者组
- XREADGROUP用来通过使用者组读取流
- XACK用来让使用者标识挂起的信息已被正确处理了
创建一个使用者组
假设我有一个叫mystream的流,为创建一个使用者组,需要这样做:
> XGROUP CREATE mystream mygroup $
OK
如上所示,当创建一个使用者组时,我们需要指定一个ID,就是上面命令中的$,这是因为当第一个使用者连接上来后,使用者组需要知道该给它服务什么信息,即当前最后一条信息的ID是什么。如果我们指定如上面所示的$,则表示从现在起,流中新到的信息将发给组中的使用者。如果我们指定为0,则表示流中所有的历史信息都会被发给使用者组。当然,你可以指定其他合法的ID,你需要知道的是,使用者组将发送那些ID大于你指定的ID。因为$意味着流中当前最大的ID,指定它就有只发送新信息的效果。
XGROUP CREATE也支持自动创建流,如果流不存在,使用可选的MKSTREAM作为命令最后一个参数:
> XGROUP CREATE newstream mygroup $ MKSTREAM
OK
现在,使用者组已被创建了,我们可以使用XREADGROUP命令,开始通过使用者组方式来读取信息。我们以两个叫Alice和Bob的使用者身份来读取,看看系统是如何将不同的信息返回给Alice和Bob的。
XREADGROUP与XREAD命令非常类似,都提供BLOCK选项,因而它可以是一个同步命令。但是,这里有一个必须指定的强制性选项GROUP,它有两个参数:使用者组的名字和使用者的名字。选项COUNT也被支持,并且与XREAD中的意义一致。
在开始从流中读信息之前,我们放些信息在里面:
> 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
注意:这里message是域名,水果是与之关联的值,记住流信息是小型字典类型。
现在是时候以使用者组方式来读取了。
> 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来读取流,并且我是使用者Alice。当以使用者组来进行操作时,必须指定使用者在组内可以唯一标识他的名字。
上面的命令行还有一个非常重要的细节:必须给定的STREAMS选项后,流mystream的条目ID是一个特殊ID符合 >,这个特殊ID仅在使用者组场景下有效,它的意思是:那些迄今还不曾发给其他使用者的信息。
这几乎就是你想要的,当然,也可以指定一个真的ID,例如0或其他有效ID,此时,XREADGROUP将从那些挂起的历史信息中返回我们,而不是这个组的新信息。根据指定的ID,XREADGROUP基本上有如下特性:
- 如果ID是特殊值>,则命令仅返回迄今还没有发给其他使用者的新信息,作为副作用,此时将更新使用者组的last ID。
- 如果ID是另外的有效数字,则命令允许我们获取挂起的历史信息,即那部分发送给这个特定的用户(由提供的使用者名来确定)迄今并没有用XACK确认的信息。
我们可以立即测试这些特性,指定ID为0,不要COUNT选项:我们会看到只有挂起的信息,即那条苹果的信息:
> 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 mygroup Bob COUNT 2 STREAMS mystream >
1) 1) "mystream"
2) 1) 1) 1526569498055-0
2) 1) "message"
2) "orange"
2) 1) 1526569506935-0
2) 1) "message"
2) "strawberry"
Bob通过同样的使用者组mygroup来读取,要求最大两条信息。事情的结果是Redis返回了新信息。如你所看到的,“苹果”那条信息并没有被发送,因为它已被发送给Alice了,所以Bob得到的是“橙子”和“草莓”,如此这般。
组中的Alice,Bob或其他成员可以从同一流中读取不同的信息,可以读取未处理的历史信息,或标记已处理的信息,这些情况需要创建不同的语义来获取或使用信息。
需要记住的一些事情:
- 在第一次被涉及的时候,使用者是被自动创建出来的,不需要显式创建。
- 采用XREADGROUP,你可以同时读取不同的流,但是,要使它工作,你必须在每一个流中创建一个同名的使用者组。这不是一个常用的需求,但值得提一下,从技术上讲,它是可行的。
- XREADGROUP是一个“写”命令,即便它从流中读取,但作为副作用,它会修改使用者组,所以它只能在主节点被调用。
下面是一个用Ruby编写的,以使用者组方式来使用使用者的例子,Ruby代码可被任何有编程经验但不懂Ruby的人所理解。
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
这里,你能看到如何得到历史信息,即挂起信息的思路,这种方法比较有用,因为使用者在之前可能宕机,重启后我们希望能再次读取那些发送过的但没有确认的信息。这样,我们可能一次或多次处理一条信息(至少在使用者发生故障这种场景下,但是也可能涉及Redis持久化和复制的限制,关于这个问题请参阅相关章节)。
一旦历史信息被处理掉了,我们就得到一个空的信息列表,然后,我们可以转向用“>”这个特殊的ID来获取新信息。