2026年9月9日
我做了一个用 2.10 GiB 容纳 8080 万个 ID 的有序集合
在维基百科重放中,Goblin Core 的紧凑 INT32/FLOAT32 有序集合比 Redis、Valkey 和 Dragonfly 少占 53.6–72.2% 的 RSS。更快完成是额外收获。
我为 Goblin Core 做了一个节省内存的新有序集合,然后让它处理将近十五亿次维基百科编辑递增。
它的内存占用还不到最省内存的竞品的一半。它也先于 Redis、Valkey 和 Dragonfly 完成,但减少内存占用才是目标。
这个新类型叫作紧凑有序集合。它保留有序集合的操作——查找成员、修改分数、查询排名、返回范围——但用紧凑的二进制布局存储有明确类型的 ID 和分数。
在 80,798,328 个成员时,INT32/FLOAT32 版本的进程 RSS 为 2.10 GiB。最省内存的竞品 Dragonfly 用了 4.53 GiB,紧凑版的占用少了 53.6%。
先看结果,再看背后的实现。
先看内存
英文维基百科历史数据集 提供了原生整数页面 ID。每次编辑转换成一条命令:
ZINCRBY key 1 <page_id>
这样建立的是页面编辑次数排行榜,存储 ID 和计数器,不存文章正文或修订内容。冻结后的输入共有 1,483,700,913 次递增。
结果按最终进程 RSS 从小到大排列,重放时间作为次要指标列出。
| 引擎 | 最终进程 RSS | 重放时间 |
|---|---|---|
| Goblin 紧凑 INT32/FLOAT32 | 2.10 GiB | 7 小时 08 分 02 秒 |
| Goblin 标准版 | 3.46 GiB | 7 小时 34 分 35 秒 |
| Dragonfly,单 proactor | 4.53 GiB | 7 小时 22 分 34 秒 |
| Valkey 9.1.0 | 5.21 GiB | 8 小时 19 分 20 秒 |
| Redis 8.8.0 | 5.47 GiB | 8 小时 25 分 41 秒 |
| Redis 7.2.4 | 7.55 GiB | 8 小时 52 分 13 秒 |
在最终成员数相同的情况下,紧凑版比竞品少占 53.6–72.2% 的 RSS,比我们自己的标准有序集合少占 39.2%。
它的重放时间也比竞品减少了 3.3–19.6%,比标准 Goblin 减少了 5.8%。这是一个有价值的次要结果:在这次运行中,更小的内存占用没有带来重放性能代价。
所有引擎都通过了完整状态映射验证:同样的 80,798,328 个成员,同样的总编辑次数,没有命令错误。
这是在 AMD Threadripper PRO 5995WX 主机 naamah 上进行的一次并行运行。每个引擎使用一个普通的 redis-cli 客户端,通过 Unix 域套接字逐条等待回复,没有使用 --pipe,也没有显式绑定 CPU。这里测量的是最终 RSS,而不是峰值 RSS,同时记录端到端重放时间;较小的时间差还需要重复试验,才能视为稳定的排名。
接下来看看,这个布局怎样节省内存,同时保持高效的更新。
ID 不必是字符串
通用有序集合接受任意字符串成员。当成员真的是用户名或一段文本时,这很有用。
但很多应用排序的是页面 ID、账户 ID、设备 ID 或 UUID。应用已经知道成员的类型。
我希望存储引擎利用这个信息。
紧凑有序集合支持有符号 INT32、有符号 INT64 和 UUID 成员,每种都可以搭配 FLOAT32 或 FLOAT64 分数,共六种布局。整数成员占四或八个二进制字节,UUID 占十六个,分数占四或八个。
没有一张字符串到 ID 的字典藏在别处。维基百科适合这个测试,正是因为它已经有整数 ID。把任意名称转换成整数仍然需要一张映射表,那部分内存必须计入应用预算。
在本次配置中,分数与成员组成的元组有八字节有效载荷。整个有序集合当然不止这个成本:它需要两个索引,两者都有管理信息和预留容量。
接下来的工作,就是把这些索引也做紧凑。
两个视图,一个紧凑布局
有序集合要回答两个不同的问题:
- 给定一个成员,它的分数是多少?
- 给定一个分数或排名,这里应该有哪些成员?
成员到分数的视图使用 Swiss 哈希表。键和分数保持所选的二进制宽度。
排序视图使用以内存区索引连接的 B+ 树。叶节点连续存放分数与成员组成的元组。分支和叶节点之间使用 32 位内存区索引引用,而不是每个成员各自携带堆内存指针。子树计数支持排名查询,相连的叶节点支持顺序范围输出。
完整重放结束时,这棵树有 288,451 个叶节点、12,419 个分支节点,共五层。
这样既缩小了每条记录,也把相邻记录放在一起。但还有一个昂贵的问题:成员的分数变化时怎么办?
计数器负载会反复提出这个问题。这次重放中约 94.6% 的命令都在更新已有成员。
合并一个叶节点,不是整个世界
每个叶节点都有一个已排序区和一个小型待更新区。
更新先进入这个局部区域。在同一叶节点中再次更新同一成员,会覆盖它的待更新记录。旧的基础条目会被标记为失效,下次合并时不必重新找出哪些值已经被替代。
待更新区达到阈值后,叶节点进行整理:丢弃过时条目,对有效的待更新记录排序,再从后向前合并到叶节点已预留的存储中。合并会成块搬移仍然有效的连续区段,并保留没有变化的前缀。
它不会分配一个与整个有序集合同样大的临时向量。
对 INT32/FLOAT32 而言,一个叶节点的排序容量是 512 条记录。默认合并指数为 0.5,局部阈值就是 ceil(sqrt(512)) = 23 条待更新记录。是一个叶节点中的二十三条记录,不是每积累约八千万个成员的平方根那么多次更新,就进行一次全局合并。
指数可以在 0 到 1 之间配置。读取只协调所访问叶节点中的记录,不会触发维护性合并。稀疏的相邻叶节点可以重新分配记录或合并,所以分数迁移不会留下一串空块。
更新路径还会复用已经在 Swiss 表中找到的分数位置。先修改树,成功后再写入新分数。只有真正插入新成员时才预留容量,普通改分不需要。
这些选择让存储保持紧凑,也让维护工作留在局部。
把整个对象算进去
完整重放结束后,紧凑版内部统计的分配量为每个有效成员 27.03 字节,标准 Goblin 为 44.74 字节,也就是对象内存减少 39.6%。
这与进程 RSS 减少 39.2% 不是同一种测量。对象计数统计数据结构的分配量,RSS 衡量整个服务器的常驻内存页。两者都重要,不能拿一个引擎的对象计数与另一个引擎的进程 RSS 比较。
标准版当时已经针对这个负载使用了自适应整数分数存储,也没有通过强制收尾合并来改善紧凑版的内存数字。
测得的节省包含实际索引和已分配容量,不只是那八字节有效载荷。
我针对什么做了专门设计
这是一个自定义类型,它的约定比字符串成员有序集合更窄。
同分整数成员按数值排序。Redis 在字符串同分排序中把 10 放在 2 前面,紧凑整数集合则把 2 放在 10 前面。UUID 同分时按十六字节值的字典序排列。成员以规范形式返回,不保留原始文本拼写。
分数精度也有明确选择。FLOAT32 舍入到二进制 32 位浮点格式,FLOAT64 保留二进制 64 位浮点精度。这次重放中的最高计数为 2,162,914,低于 FLOAT32 连续整数精确表示的上限 16,777,216,因此递增一直保持精确。我们分别验证各表示法要求的排序,再比较完整的成员到分数映射。
这里只测试了 INT32/FLOAT32。任意字符串、更大的计数器、其他分数分布和读密集负载,都需要合适的表示法和各自的测量。应用需要更通用的约定时,标准有序集合仍然可用。
怎么使用
如果负载中的成员全是整数 ID,可以在启动时选择紧凑有序集合:
goblin-core --zset-implementation packed-int32-float32 \
--packed-zset-merge-exponent 0.5
此后普通的 ZADD 和 ZINCRBY 命令就会创建紧凑集合。已有键保留自己的表示法;这个选项不会转换它们。
也可以通过命令前缀显式指定类型:
GOBLIN.PACKED_INT32_FLOAT32.ZINCRBY page-edits 1 1001
GOBLIN.PACKED_INT32_FLOAT32.ZRANGE page-edits 0 -1 WITHSCORES
客户端仍使用普通 RESP。page-edits 是正常的数据库键名,它的成员才是以二进制整数存储的值。
紧凑有序集合文档 介绍六种布局和完整命令。基准报告 和原始证据 提供精确配置、测量结果和验证记录。
我做这个类型,是为了让应用已经知道类型的数据少占一些内存。在这个规模上,即使相对最省内存的竞品,紧凑布局也将进程 RSS 减少了一半以上。
更快完成是额外收获。主要成果是:经过验证的 8080 万个页面计数器,只占 2.10 GiB 进程内存。