Redis streams as a pure data structure

Salvatore Sanfilippo

Redis Streams 作为一种纯粹的数据结构

Redis 5 中以“Streams”之名引入的新数据结构在社区中引起了不小的关注。我迟早想做一次社区调查,与拥有生产环境用例的用户交流,并撰写相关博客。今天我想谈另一个问题:我开始怀疑,许多用户只把 Streams 当作解决 Kafka(TM) 类似用例的一种手段。实际上,这个数据结构在设计时确实*也*考虑了生产者与消费者的消息传递场景,但认为 Redis Streams 只适合这一点是极其狭隘的。流式处理是一种非常出色的模式和“心智模型”,在设计系统时加以运用可以取得巨大成功,但 Redis Streams 和大多数 Redis 数据结构一样,更为通用,可以用来建模数十种互不相关的问题。因此在这篇博文中,我将把 Streams 当作一种纯粹的数据结构来讨论,完全忽略其阻塞操作、消费者组以及所有与消息传递相关的部分。

Streams 是打了兴奋剂的 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 有序集合(Sorted Set)非常相似的混合数据结构:它们*感觉上像*一种基础数据结构,但要达到这种效果,内部使用了多种表示方式。

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

Redis Streams 被表示为经过增量压缩(delta-compressed)的宏节点(macro node),这些节点通过一棵基数树(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 命令会自动生成并返回条目 ID,该 ID 单调递增,由两部分组成:<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 建模领域堪称一场革命。

内存占用

上述用例不仅仅是一个更可靠的方案的问题。Stream 方案的内存开销与旧的“每个对象配一个有序集合 + Hash”的做法相比差异巨大,以至于某些过去不可行的做法如今变得完全可行。

以下是按前述配置存储一百万场比赛的内存数字:

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 在这方面几乎无能为力。Streams 的内存特性和灵活性,再加上创建有上限的流的能力(参见 XADD 的选项),是开发者手中一件非常重要的工具。

结论

Streams 非常灵活,用途众多,不过我想让这篇博文保持简短,以确保读者能从上面的示例和内存分析中获得清晰的核心信息。也许这对许多读者来说早已显而易见,但在过去几个月与人交流的过程中,我感觉 Streams 与流式处理用例之间存在很强的绑定印象,仿佛这个数据结构只擅长那一件事。事实并非如此 :-)