Redis streams as a pure data structure

Salvatore Sanfilippo

作为纯数据结构的 Redis Streams

原文由 Salvatore Sanfilippo 发布,订阅该博客

Redis 5 中以“Streams”之名引入的新数据结构在社区中引起了不小的关注。迟早我想做一次社区调研,和那些在生产环境中使用它的用户聊聊,并把结果写成博客。但今天我想谈另一个问题:我开始怀疑,许多用户只把 Streams 当成解决类似 Kafka™ 这类场景的工具。实际上,这个数据结构的设计目标*也*包括在生产者-消费者消息传递的场景下工作,但如果认为 Redis Streams 仅擅长于此,那就过于狭隘了。流式处理是一种非常棒的模式和“心智模型”,在系统设计中运用得当会带来巨大收益,但和大多数 Redis 数据结构一样,Redis Streams 更为通用,可以用来建模数十种互不相关的不同问题。因此在这篇博文中,我将完全抛开阻塞操作、消费者组以及所有与消息相关的部分,只把 Streams 当作纯数据结构来讨论。

Stream 是加强版的 CSV 文件

如果你想记录一系列结构化数据,又觉得数据库终究是小题大做,你可能会说:干脆以只追加模式打开一个文件,把每一行都记成一条 CSV(逗号分隔值)记录:

(open data.csv in append only)
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 Streams 的设计思路就是保留这些优点,同时突破上述局限。最终得到的是一种混合数据结构,和 Redis 有序集合非常相似:用起来*就像*一种基础数据结构,但为了达到这种效果,内部其实采用了多种表示方式。

Streams 入门(如果你已经了解 Redis Stream 基础,可以跳过本节)

Redis Streams 在底层被表示为经过增量压缩的宏节点,这些宏节点通过基数树链接在一起。这样做的好处是可以非常快速地随机定位到任意条目,按需获取范围数据,删除旧条目以创建有容量限制的流,等等。然而面向程序员的接口却和 CSV 文件非常相似:

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

从上面的例子可以看到,XADD 命令会自动生成并返回条目 ID,该 ID 单调递增,由两部分组成:<time>-<counter>。time 部分是毫秒级时间戳,counter 则是在同一毫秒内生成的条目所对应的递增计数。

因此,在“只追加 CSV 文件”这个想法之上,第一个新的抽象就是:由于我们在 XADD 中用星号作为 ID 参数,服务端会免费为我们生成条目 ID。这个 ID 不仅可以用来指向流中的某条具体记录,还与该条目被添加到流中的时间相关联。事实上,通过 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 参数限制返回的结果数量。同样,指定范围时也不必写出完整的 ID,只需使用 ID 中的毫秒级 Unix 时间戳部分,就能获取给定时间区间内的元素:

> 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,相关内容可以查阅 Redis 文档。现在我们只需关注这种使用模式:用 XADD 添加数据,用 XRANGE(以及 XREAD)按范围取回数据(具体用哪个取决于你的需求),然后来看看我为什么说 Streams 作为数据结构如此强大。

不过,如果你想更深入地了解 Redis Streams 及其 API,请务必查看这里的教程:https://redis.io/topics/streams-intro

网球选手

几天前,我和一位正在学习 Redis 的朋友一起设计一个应用:用来跟踪本地网球场、选手和比赛。在 Redis 中对选手建模的方式显而易见,选手是一个小对象,用一个 Hash 就够了,键名类似 player:<id>。随着进一步用 Redis 作为主存储来建模应用数据,你很快会意识到,需要一种方式来记录某个网球俱乐部中进行的比赛。如果 player:1 和 player:2 打了一场,player 1 获胜,我们就可以在流中写入这样一条记录:

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

通过这一简单的操作,我们就获得了:

  1. 比赛的唯一标识:即流中的 ID。
  2. 无需为了标识一场比赛而额外创建一个对象。
  3. 免费获得范围查询能力,可以对比赛进行分页,或查看过去某个时间点进行的比赛。

在 Streams 出现之前,我们需要创建一个以时间为分值的有序集合:有序集合中的元素是比赛的 ID,而比赛本身则作为 Hash 保存在另一个键中。这不仅更麻烦,还会浪费惊人的内存。比你想象的要多得多(见后文)。

目前想说明的一点是,Redis Streams 有点像一个只追加模式、按键以时间为序的有序集合,其中每个元素都是一个小 Hash。而正是这种简单性,在 Redis 建模的语境下堪称一场革命。

内存占用

上面的用例不仅仅是模式更稳固的问题。与过去那种为每个对象都创建一个有序集合加 Hash 的做法相比,Stream 方案的内存开销截然不同,以至于过去不可行的一些事情,现在变得完全可行。

按照前面所述的配置存储一百万场比赛,数据如下:

Sorted Set + Hash memory usage = 220 MB (242 RSS)
Stream memory usage                  = 16.8 MB (18.11 RSS)

这相差了一个数量级以上(准确说是 13 倍),这意味着那些昨天对内存来说成本过高的用例,如今已完全可行。奥秘全在于 Redis Streams 的内部表示:宏节点可以包含多个元素,这些元素以一种名为 listpack 的数据结构进行极其紧凑的编码。举例来说,listpack 会负责把整数以二进制形式编码,即使它们在语义上是字符串。在此之上,我们还会进行增量压缩和同字段压缩。但我们依然能够按 ID 或时间进行查找,因为这些宏节点通过基数树链接在一起,而基数树本身也被设计得非常节省内存。所有这些共同造就了极低的内存占用,但有趣的是,从语义层面看,用户完全感知不到让 Streams 变得高效的这些实现细节。

现在来做个简单的算术。如果用大约 18 MB 内存就能存储 100 万条记录,那么用 180 MB 就能存 1000 万条,1.8 GB 就能存 1 亿条。仅用 18 GB 内存,就能拥有 10 亿条记录。

时间序列

在我看来,有一点很重要:上面用 Stream 来表示一场网球比赛的用法,在语义上与把 Redis Stream 用于时间序列*截然不同*。是的,从逻辑上讲我们仍然是在记录某种事件,但一个根本区别在于,一种情况下我们通过记录日志、创建条目来呈现对象;而在时间序列的场景下,我们只是在度量外部发生的某件事,它本身并不真正代表一个对象。你可能会觉得这种区别微不足道,但事实并非如此。对于 Redis 用户来说,建立这样一种观念很重要:Redis Streams 可以用来创建具有全序关系的小对象,并为这些对象分配 ID。

当然,即便是最基本的时间序列用例,在这里也同样意义重大,因为在 Streams 出现之前,Redis 在这方面的表现多少有些力不从心。流的内存特性和灵活性,再加上创建有容量限制的流的能力(参见 XADD 选项),对开发者而言是非常重要的工具。

结论

Streams 非常灵活,用例众多,不过我想把这篇博文写得简短一些,以确保通过上面的示例和内存分析,能传达出一个清晰的核心信息。也许这对许多读者来说已经显而易见,但在过去几个月与人们的交流中,我感觉大家把 Streams 与流式处理用例强行关联在了一起,好像这个数据结构只擅长做这件事。事实并非如此 :-)

本文章由 muse-spark-1.2-contributor 进行翻译

评论