【译】Redis喜提新数据结构:Redis Streams

3,767 阅读8分钟

本文是Redis作者antirez的一篇博客

原文地址:antirez.com/news/128

我们在Redis5版本迎来了一个新的数据结构,它的名字叫做"Streams"。(撒花)Streams一经推出,就引起了社区中各位大佬的关注。所以我决定过一段时间做一个社区调查,讨论一下它的使用场景,并会在博客中将结果记录下来(是Redis作者的博客)。今天我想聊的是另一个问题:我怀疑有很多用户认为Streams的使用场景是和Kafka一样的。实际上,这个数据结构的设计背景也是消息的生产和消费,但你应该认为Redis Streams只是更擅长做这样的事情。流是一种很好的模型和"心理模型",它能帮助我们更好的设计系统,但是Redis Streams像其他Redis数据结构一样,它更加通用,可以用来处理更多不同的问题。所以这篇博客我们会重点关注Redis Streams作为一种数据结构有哪些特性,而完全忽略它的阻塞操作、消费群和所有消息相关的内容。

Streams是steroids上的CSV文件

如果你想记录一系列的结构化数据,并且确定数据库是足够大的,你可能会说:我们以追加写入的方式打开一个文件,每一行记录是一个CSV数据项:

time=1553096724033,cpu_temp=23.4,load=2.3 time=1553096725029,cpu_temp=23.2,load=2.1

这看起来很简单,然后人们一直这样做了好多年,并且一直持续着:如果你知道你在做什么,那么这将成为一种固定的模式。如果同样的事情发生在内存中会怎样呢?内存的顺序写入能力更强,并且会自动移除掉CSV文件的一些限制:

  1. 很难批量查询
  2. 太多的冗余信息:每个条目的时间几乎相同,字段也相同。但是移除字段会降低灵活性,就不能再增加别的字段了
  3. 每个条目的偏移量都是它在文件中的字节偏移量,而如果我们修改了文件结构,那么这些偏移量就会失效。所以这里缺少一个唯一标识的ID。
  4. 不能删除条目,只能标记无效。如果不重写日志的话,又没有垃圾回收,重写日志经常会因为各种原因出错,所以最好不要重写。

不过使用这样的CSV条目也有一些好处:没有固定格式,字段可以改变,生成比较容易,而且存储格式比较紧凑。我们保留了其优点,去掉了限制,于是设计出了像Redis Sorted Set这样的混合数据结构——Redis Streams。他们看起来像基本数据结构一样,但是为了得到这样的效果,内部是有多种表现形式的。

Streams 101(就是Streams基础部分)

Redis Streams是一种通过基数树连接的增量压缩的宏节点。(好难理解的概念,把原话贴出来:Redis Streams are represented as delta-compressed macro nodes that are linked together by a radix tree)。它的作用是,快速查找一个随机项,获取范围值,删除旧值来创建一个有大小上限的流。对程序员来说,我们的接口和CSV文件很相似:

> XADD mystream * cpu-temp 23.4 load 2.3
"1553097561402-0"
> XADD mystream * cpu-temp 23.2 load 2.1
"1553097568315-0"

通过这个例子可以看到,XADD命令自动生成并返回了一个entry ID。它是单调递增的,并且有两部分组成,<时间>-<数量>,时间是毫秒级,而数量则是同一毫秒生成的entry数量递增。

所以第一个从上面所说的"追加写入CSV"文件抽象出来概念就是,如果用星号作为XADD命令的ID参数,就从服务器获取了一个entry ID。这个ID不仅仅是entry的唯一标识,也和entry加入流的时间有关。XRANGE命令可以批量获取或获取单个数据项。

> XRANGE mystream 1553097561402-0 1553097561402-0
1) 1) "1553097561402-0"
   2) 1) "cpu-temp"
      2) "23.4"
      3) "load"
      4) "2.3"

在这个例子中,为了得到单个数据项,我用了相同的ID作为起始和结束值。然而我可以获取任意范围的数据项,并且用COUNT参数限制结果的数量。我也可以将起止参数都设置为时间戳,获取一段时间内的数据项。

> XRANGE mystream 1553097560000 1553097570000
1) 1) "1553097561402-0"
   2) 1) "cpu-temp"
      2) "23.4"
      3) "load"
      4) "2.3"
2) 1) "1553097568315-0"
   2) 1) "cpu-temp"
      2) "23.2"
      3) "load"
      4) "2.1"

篇幅原因,我们不再展示更多的Streams API了。我们有相关的文档,感兴趣的同学可以去阅读。目前为止,我们只需要关注基本使用方法:XADD用来增加数据,XRANGE(或XREAD)用来读取数据。我们来看一下我为什么说Streams是一个强大的数据结构。

网球运动员

前几天我和一个最近在学习Redis的朋友一起建模一个应用程序:这是一个用来追踪当地网球场、球员和比赛的app。很明显,球员是一个小的模型,在Redis中只需要用一个hash就足够了,key的形式可以是player:<id>。当你进一步使用Redis建模时,就会意识到你需要去追踪指定网球俱乐部的一场比赛。如果球员1和球员2打了一场比赛,球员1获胜。那么我们可以这样来记录:

> XADD club:1234.matches * player-a 1 player-b 2 winner 1
"1553254144387-0"

通过这样简单的操作,我们就可以获得如下的信息:

  1. 一场比赛的唯一标识:流里的ID
  2. 不需要创建一个表示比赛的对象
  3. 分页查询比赛情况,或者查看某场比赛是否在指定时间就进行

在Streams出现之前,我们需要创建一个Sorted Set,分数是时间。Sorted Set的元素是比赛的ID(存在一个Hash里)。这不仅是增加了工作量,而且还造成了更多的内存浪费,比你想象的要多得多。

现在看起来Streams像是一个追加模式的,以时间为分数,元素是小型Hash的Sorted Set。简而言之,这是Rediscover建模环境中的一次革命。

内存使用情况

上面的例子不仅仅是固化模式的问题,相比旧有的Sorted Set+ Hash的模式,Streams对内存的节省做了很好的优化,然而这一点是不容易被发现的。

假设我们要记录100万场比赛,

Sorted Set + Hash的内存使用量是220MB(242RSS)

Stream的内存使用量是16.8MB(18.11RSS)

这不仅仅是一个数量级的差异(实际上是13倍的差异),这意味着我们旧有的模式实在是太浪费了,而新的模式是完美可行的。Redis Streams还有其他神奇的地方:宏节点可以包括多个元素,它们使用叫做listpack的数据进行编码。listpacks会对二进制形式的整数进行编码,即时它是语义字符串。最重要的是,我们使用了增量压缩和相同字段压缩。我们可以通过ID或时间进行查询,因为宏节点是用基数树连接的。基数树叶被设计为使用很少的内存。所有的事情都使用极少的内存,但有趣的是,用户并不能从语义上看到使Streams更加高效的实现细节。

现在我们来做一个简单的计算,如果我保存了100万个entry,使用了18MB内存,那么1000万个就是180MB,1亿个使用1.8GB,保存10亿数据也只使用18GB内存。

时间序列

有一个比较重要的事情需要注意,在我看来,上面我们用来记录网球比赛的例子与把Redis Streams作为一个时间序列来使用非常不同。没错,逻辑上我们仍然是记录一类事件,但本质上的区别是记录日志和创建一个entry并存入对象的不同。在使用时间序列时,我们只是记录一个外部事件,而不需要真的展示一个对象。你可能认为这个区别不重要,但事实不是这样。对Redis用户来说很重要的是,如果需要保存一系列有序的对象,并且给每个对象赋一个ID,那么就需要使用Redis Streams。

然而即时是一个简单的时间序列,也是一个很大的用例,因为在Streams出现之前,Redis在面对这种用例时令人有些绝望。一个节省内存,并且灵活的流,对开发者来说是一个重要的工具。

结论

Streams非常灵活并且有很多使用场景,我想尽量用简短的语言,以确保上面的例子和内存分析更加通俗易懂。也许大多数读者已经搞懂了。不过在上个月我和别人交流时感觉到Streams和流式处理还是有着很强的关联。就像这个数据结构只能用来处理流一样,事实并非如此。