bigywater.tech
Redis · 39

Stream 的 radix tree

Stream 不是把每条消息单独存成一个对象。它把很多条消息打包进一段 listpack,再用一棵 radix tree 按消息 ID 把这些 listpack 组织起来。这篇不靠推测:直接用 DUMP 拿到 Stream 的序列化数据,把里面的 key 和 listpack 拆开看,再用几组实验量一量这个结构的内存和查找代价。

1000 个
10 万条消息在 radix 树里的 key 数(每个 key 对应一个 listpack)
18.5 B
默认配置下每条消息(4 个字段)的 MEMORY USAGE 均值
4.3 → 4.5 µs
按 ID 点查:1000 条 → 100 万条

先看真实结构:DUMP 拆开 Stream

把 stream-node-max-entries 调成 4,写入 10 条消息,再解析 DUMP 的字节
实测 · 本机执行输出
写入的 10 个 ID: ['1000-0', '1007-1', '1014-2', '1021-0', '1028-1', '1035-2', '1042-0', '1049-1', '1056-2', '1063-0']
DUMP 长度 249 字节;第 1 字节(RDB 类型号)= 27
RDB 里 listpack 节点数 = 3 (与 XINFO radix-tree-keys = 3 )
节点 0: key 长 16 字节 = 大端(ms=1000, seq=0);listpack 61 字节,头部记录 61 字节 / 25 个元素
节点 1: key 长 16 字节 = 大端(ms=1028, seq=1);listpack 62 字节,头部记录 62 字节 / 25 个元素
节点 2: key 长 16 字节 = 大端(ms=1056, seq=2);listpack 42 字节,头部记录 42 字节 / 15 个元素
对照第 1、5、9 条的 ID: 1000-0 1028-1 1056-2

RDB 序列化 Stream 时,直接写出的就是「若干个(radix key, listpack)对」。解出来:key 恒为 16 字节,前 8 字节是毫秒、后 8 字节是序号(大端),值就是该节点第一条消息的 ID:第 1、5、9 条的 ID 正好是三个 key。listpack 头部记录的总字节数与实际长度一致;4 条消息的节点有 25 个元素,2 条的有 15 个元素。

这些元素数字与「每条消息固定占 5 个元素、节点另有 5 个元素做头部」的算式吻合(4×5+5=25,2×5+5=15),但那是我对这组数字的解读,没有另外拆解每个元素去验证。

节点里能装多少条:默认每个 100 条

stream-node-max-entries=100,stream-node-max-bytes=4096
实测 · 本机执行输出
 entries  radix-keys  radix-nodes  entries/key  MEMORY USAGE  B/entry
       1           1            1          1.0           306    306.0
      50           1            1         50.0          1189     23.8
     100           1            1        100.0          2090     20.9
     101           2            2         50.5          2167     21.5
    1000          10            9        100.0         18828     18.8
   10000         100           69        100.0        185870     18.6
  100000        1000          671        100.0       1854662     18.5

10 万条消息只有 1000 个 radix key,平均每个 key 装 100 条;101 条时就变成 2 个。radix-tree-nodes(树内部节点数)比 key 数小,671 对 1000:key 之间共享前缀,树把它们压缩了。每条消息的内存从 1 条时的 306 字节掉到大批量时的 18.5 字节,因为固定开销被节点里的几十上百条消息摊薄。

两个上限:条数和字节数

哪个先到,就先开新节点
实测 · 本机执行输出
 max-entries  radix-keys  radix-nodes  MEMORY USAGE  B/entry
           1       10000           77        552053     55.2
          10        1000           77        218553     21.9
         100         100           68        185675     18.6
        1000          10           14        189088     18.9
       10000           1            1        188958     18.9

把 stream-node-max-entries 从 1 调到 10000(同时关掉字节上限),同样 10000 条:max-entries=1 时 10000 个 key、每条 55.2 字节;10 时降到 21.9 字节;100 时 18.6 字节;再往大调收益消失(18.9 和 18.9 字节,反而略高于 100)。

实测 · 本机执行输出
 value bytes  radix-keys  entries/key
          10          10        100.0
         100          28         35.7
         500         143          7.0
        2000         500          2.0

默认还有 4096 字节的上限:消息值 10 字节时 1000 条只要 10 个节点,值 100 字节时 28 个,500 字节时 143 个,2000 字节时 500 个(每个节点只装 2 条)。所以「每节点 100 条」只是上限,消息大了先撞到的是字节上限。

字段名相同,才省内存

同一节点里的消息共用第一条消息的字段名
实测 · 本机执行输出
相同字段名: 161519 B (16.2 B/条)   每条不同字段名: 317583 B (31.8 B/条)   比值 1.97

同样是 10000 条、每条 2 个字段:字段名都相同时 161519 字节(16.2 字节/条),每条字段名都不一样时 317583 字节(31.8 字节/条),约 1.97 倍。字段名相同的消息不用重复存字段名,这是 Stream 比「每条一个 Hash」省内存的原因之一(这里只对比了 Stream 自身两种写法,没有对比 Hash)。

按 ID 查找:耗时不随长度增长

20000 次随机点查,管道批量发送
实测 · 本机执行输出
N=    1000  radix-keys=    10  20000 次点查    85.3 ms  (  4.3 µs/次)
N=  100000  radix-keys=  1000  20000 次点查    86.2 ms  (  4.3 µs/次)
N= 1000000  radix-keys= 10000  20000 次点查    89.5 ms  (  4.5 µs/次)

流从 1000 条涨到 100 万条(radix key 从 10 涨到 10000),点查耗时从 4.3 µs 到 4.5 µs,几乎不变。注意:这里含一次管道里的网络往返摊销,只能说明「差别很小」,不能拆出树查找本身的耗时;100 万条那一组,被查的 ID 取自流的头部和尾部各 1 万条,不是全范围均匀随机。

分步演示:消息如何落进 radix 树的节点

数据取自上面的 DUMP 解析和点查实验
Stream 的 radix tree
当前写入/查找的对象

    本文用到的工具与边界

    • 本机 macOS 27 / Redis 8.10.2 独立实例(本机回环端口,save 关闭),redis-cli 风格输出由自写的 RESP 客户端打印;计时用 time.perf_counter,数字每次运行会有小幅抖动
    • 没有验证:radix 树内部节点的具体形状(只有 XINFO 给出的 radix-tree-nodes 计数)、listpack 内每个元素的含义(只对元素总数做了算术对照)、查找算法是否用的是「找 ≤ ID 的最大 key」(只从耗时不随长度变化间接推断)、Stream 与 Hash 的内存对比
    京ICP备2026067502号-1京公网安备11010502063487号