- Published on
Redis 8.8 的 Array 类型 — 自 Stream 之后首次新增的核心数据结构,以及 Valkey 没有的东西
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 相隔六天发布的两个版本
- 自 Stream 之后首次新增的核心类型
- Array 到底是什么
- 内部表示 — 切片、指针标记、超级目录
- 18 个命令
- ARLEN 与 ARCOUNT 统计的是不同的东西
- 厂商基准测试说了什么,没说什么
- 文档依然写着 preview
- Valkey 没有
- 8.8 的其余部分 — INCREX 可能更实用
- 一个由 AI 打造的数据结构
- 那么,你该不该用
- 结语
- 参考资料
引言 — 相隔六天发布的两个版本
2026 年 5 月,两个从同一个源头分叉出去的项目几乎同时发布了新版本。Valkey 9.1.0 于 5 月 19 日发布,Redis 8.8.0 于 5 月 25 日发布。相隔六天。
把两份发布说明并排读一遍,温度差异一目了然。Valkey 9.1.0 是第一个稳定的 9.1 版本,它自己写着「Upgrade urgency LOW」,新功能只有集群总线流量指标和缓解 rehash 期间延迟尖峰这两项,其余是三个 CVE 和一堆 bug 修复。Redis 8.8.0 的列表则是另一种性质 — 排在最上面的一行写着:"New data structure: Array (@antirez)"。
这里不是要聊 fork 的戏剧性,而是要看源码树里能确认到的具体差异。而这个差异确确实实以一行代码的形式存在于 src/server.h 中。
自 Stream 之后首次新增的核心类型
Redis 的核心对象类型是以常量的形式写死在 server.h 里的。Redis 8.8.0 标签下对应的部分是这样的:
#define OBJ_STRING 0 /* String object. */
#define OBJ_LIST 1 /* List object. */
#define OBJ_SET 2 /* Set object. */
#define OBJ_ZSET 3 /* Sorted set object. */
#define OBJ_HASH 4 /* Hash object. */
#define OBJ_MODULE 5 /* Module object. */
#define OBJ_STREAM 6 /* Stream object. */
#define OBJ_ARRAY 7 /* Array object. */
OBJ_ARRAY 7 是新加入的一行。它前面的 OBJ_STREAM 6 是 Stream,看 XADD 的命令定义,它的 since 是 5.0.0。也就是说,核心对象类型数量的增加,是自 2018 年 Redis 5.0 引入 Stream 以来的第一次。
这里要纠正一个常见的误解。「Vector Set 不是在 8.0 就加进去了吗?」 — 没错,但 Vector Set 不是核心类型。VADD 的定义在 modules/vector-sets/commands.json 里,分组是 vector_set,实现是 modules/vector-sets/hnsw.c。也就是说它是随 Redis 代码树一起发布的一个模块,而不是 t_*.c 那样的核心类型。相比之下,Array 是直接以 src/t_array.c(约 73KB)和 src/sparsearray.c(约 80KB)的形式进入核心的。
Array 到底是什么
官方文档对它的定义是这样的 — Array 是一种稀疏(sparse)的索引寻址数据结构,把 0 到 2⁶⁴−1 范围内的整数索引映射到字符串值。它不像 List 那样按序列位置访问,而是直接按索引访问,可以设置任意索引而不必分配中间那些空缺的槽位。
至于为什么需要它,antirez 在 PR #15162 的正文里亲自写明了。照他原话的意思,只要「位置本身带有业务含义」,现有的类型就都有点不趁手 — Hash 给你随机查找,但索引得单独存成一个 key,且没有区间可见性;List 给你追加和裁剪,但难以访问中间的元素;Stream 则完全是另一种东西,只能追加写入的事件流。第 37 号槽位、第 4 步、第 18552 行、文件第 11 行 — 这些都是他举的例子。
汇总起来,Array 瞄准的场景是这些:
- 索引本身就是领域主键的数据(时间槽位、座位号、行号、步骤号)
- 值只是稀疏存在的数据(传感器的缺测值、只附加在部分行上的注释)
- 只保留最近 N 个元素的环形缓冲区
内部表示 — 切片、指针标记、超级目录
antirez 在 PR 正文里写的编码方案分为四层。
- 密集(dense)的时候,Array 实质上就是一个更花哨一点的 C 数组,存储索引本身不需要额外付出成本。
- 但它并不是完全铺平,而是按每 4096 个元素切成一个切片,每个切片在元素很少时会用一种特殊的稀疏编码。切片一旦为空,目录里就只剩一个
NULL。 - 小整数、浮点数、短字符串会被打上指针标记(pointer tagging),不会占用超出指针槽本身的额外内存。
- 变得非常稀疏时,会启用由多个带窗口的目录组成的超级目录。这种表示只有在元素数超过 800 万,或者设置了非常高的索引时才会触发。
第 4 层为什么存在,antirez 的开发笔记里有交代。他写道,他希望即使用户执行 ARSET myarray 293842948324 foo,也不会触发一次巨大的内存分配,而他在实现过程中才意识到,最初设计的两层(目录+切片)结构并不够用。于是他重新设计,让这个数据结构在特定条件下在内部改变形态,变成由多个切片化的密集目录组成的超级目录,目的是让 ARSCAN 的运行时间与实际存在的元素数成正比,而不是与区间宽度成正比。
这里有一点需要诚实地指出。那个目标针对的是「典型情况」,而不是一个保证。ARSCAN 和 AROP 官方给出的复杂度表述是 — "O(P) where P is visited positions in touched slices (dense scanned slots + sparse entries), with worst-case O(|end-start|+1) and typical case close to O(N)"。最坏情况是与区间宽度成正比的。文档自己把这条但书写出来,这件事本身反倒是一个可以信任的信号。
18 个命令
在 Redis 8.8.0 标签下,src/commands/ 里以 AR 开头的命令正好有 18 个。
ARSET ARGET ARMSET ARMGET ARGETRANGE ARSCAN
ARDEL ARDELRANGE ARLEN ARCOUNT ARINFO
ARINSERT ARNEXT ARSEEK ARRING ARLASTITEMS
AROP ARGREP
(antirez 的开发笔记里提到过 ARPOP,但它没有出现在 8.8.0 最终的命令列表里。不要照单全收博客里提到的名字,看打了 tag 的源码才靠得住。)
按性质分组,大致是这样的。
基础读写。 ARSET 从给定索引开始写入若干个连续的值。ARGET 读取某一个索引,未设置的索引返回 nil。ARMSET 和 ARMGET 一次处理多个索引-值对。到这里为止都是 @fast。
游标类。 Array 大体上是无状态的,但它记住一件事:最后一次追加的元素的索引。ARINSERT 在这个游标位置写入并推进游标,ARNEXT 会告诉你下一个将被使用的索引,ARSEEK 则把游标移动到指定位置。
环形缓冲区。 ARRING 指定大小后,一旦溢出会自动回绕并裁剪。ARLASTITEMS 返回最近写入的 N 个元素,加上 REV 可以反转顺序。
服务端聚合。 AROP 对一个区间执行 SUM、MIN、MAX、AND、OR、XOR,以及 MATCH 和 USED。
搜索。 ARGREP 用一个文本谓词遍历区间内的元素。谓词有 EXACT、MATCH、GLOB、RE 四种,多个谓词可以用 AND 或 OR 组合,还带有 LIMIT、WITHVALUES、NOCASE 选项。
文档里给出的实际操作示例如下。
ARSET events:1 0 "login" "click" "purchase" -> 3
ARGET events:1 0 -> "login"
ARGET events:1 999 -> nil
ARRING readings 3 "v0" -> 0
ARRING readings 3 "v1" -> 1
ARRING readings 3 "v2" -> 2
ARRING readings 3 "v3" -> 0 # 超过大小 3,回绕到 0 号
ARGET readings 0 -> "v3" # 覆盖了最早的 v0
ARLASTITEMS readings 3 -> ["v1", "v2", "v3"]
ARLASTITEMS readings 3 REV -> ["v3", "v2", "v1"]
ARLEN 与 ARCOUNT 统计的是不同的东西
实践中第一个会踩到的地雷就是这个。文档的例子原样照搬如下。
ARSET sparse 0 "a" -> 1
ARSET sparse 1000000 "b" -> 1
ARLEN sparse -> 1000001
ARCOUNT sparse -> 2
ARLEN 是「最大索引 + 1」,ARCOUNT 是「非空元素的个数」。两者都是 O(1),但含义完全不同。按 List 的 LLEN 那种直觉去用 ARLEN,会从一个只有两个元素的 Array 里读出一百万。对于一个稀疏数据结构来说,这是一个理所当然的定义,但正是这种理所当然的定义,才会在凌晨三点让人栽跟头。
ARGETRANGE 和 ARSCAN 的差异是同一个道理 — ARGETRANGE seq 0 3 会把空缺用 nil 填上,返回 ['a', 'b', None, 'd'],而 ARSCAN seq 0 3 只返回实际存在的元素,并带上各自的索引。
厂商基准测试说了什么,没说什么
以下是 Redis 在 8.8 发布文章里给出的数字。全部都是厂商自测,条件是单个 Redis 8.8 实例、Intel Sapphire Rapids m7i.metal-24xl、10 万个元素。
| 操作(10 万元素,1KB 值) | Array | List | Hash |
|---|---|---|---|
| 随机元素读取 | 675K ops/sec | 133K ops/sec | 626K ops/sec |
| 随机元素写入 | 757K ops/sec | 137K ops/sec | 689K ops/sec |
| 随机元素删除 | 841K ops/sec | — | 730K ops/sec |
Redis 自己的结论是「在随机元素操作上,Array 的吞吐量比 Hash 高 8~15%,比 List 至少快 5 倍」。环形缓冲区的对比是这样的:
| 环大小 / 元素大小 | Array (ARRING) | List (RPUSH+LTRIM) |
|---|---|---|
| 1K / 100B | 1.11M inserts/sec | 512K inserts/sec |
| 100K / 100B | 1.12M inserts/sec | 528K inserts/sec |
| 1K / 1KB | 840K inserts/sec | 424K inserts/sec |
| 100K / 1KB | 837K inserts/sec | 413K inserts/sec |
老老实实地解读这张表,应该是这样的。
相对 Hash 的收益并不大。 8~15% 是厂商在自己的硬件上、为了推广自家新功能而测出来的数字。如果你已经在好好地用 Hash,光凭这个数字并不构成迁移的理由。
相对 List 的 5 倍是个不公平的比较对象。 List 上的「随机元素读取」本来就是 O(N) 的链表遍历。5 倍是靠让 List 去做它本就不该做的事情跑出来的,并不代表 List 慢。ARRING 的 2 倍才是更公平的比较 — 它把 RPUSH+LTRIM 两次往返压缩成了一条原子命令,这确实是实际会用到的模式。
内存上 Array 花得更多。 按同一篇文章里的表格,在 100 字节元素的情况下,每元素 Array 是 122 字节、List 是 104 字节、Hash 是 151 字节;在 1KB 元素的情况下分别是 1290 / 1035 / 1337 字节。用 Redis 自己的话说,Array 比 List 每元素多花约 18% 的内存。把环形缓冲区从 LPUSH+LTRIM 换成 ARRING,换来吞吐量翻倍的同时,也要多付出这么多内存。
而这张表里没有的东西还有 — 稀疏情况下的内存数字、集群环境、复制/AOF 负载、ARM 上的数字。发布文章只展示了 x86 单实例的情况。
文档依然写着 preview
这是最重要的一条但书,发布文章里没有,只出现在文档里。官方 Array 文档的第一句话是这样的:
Array is a new data type that is currently in preview and may be subject to change.
它虽然已经进入了 GA 发行版,文档却依然明确写着「处于预览阶段,可能发生变化」。所有命令定义的 since 都写着 8.8.0,但这并不等于对 API 稳定性的承诺。在把生产环境的数据模型架在它上面之前,一定要把这一句话算进决策里。
还有一条但书。为了给 ARGREP 提供正则支持,TRE 库被以 deps/tre 的形式打包了进来(可以在 8.8.0 的 deps/ 目录列表里确认)。antirez 说他选择 TRE 是因为它能保证在病态模式下时间和空间不会爆炸,他还写道自己亲自优化了 foo|bar|zap 这类交替(alternation)模式低效的问题,并修复了几个潜在的安全问题。即便如此,一个朴素的事实仍然成立 — 服务器里进来了一个全新的原生 C 正则引擎,而用户输入的模式就跑在它上面。PR 上挂着的自动审查机器人也把这一点标记为「新增的原生代码攻击面」。
Valkey 没有
回到本文的出发点。看 Valkey 9.1.0 标签下的 src/server.h,对象类型到 OBJ_STREAM 6 就结束了,没有 OBJ_ARRAY。src/ 目录下只有 t_hash.c、t_list.c、t_set.c、t_stream.c、t_string.c、t_zset.c 六个文件,没有 t_array.c。src/commands/ 里也没有以 AR 开头的命令。
也就是说,截至 2026 年 5 月,Redis 有 7 个核心类型,Valkey 有 6 个。从同一份代码库分叉出去的两个服务器,如今拥有不同的数据结构集合。这不是性能调优或配置上的差异,而是数据模型层面的差异,而且方向是单向的 — 用 Array 建模的数据没法迁移到 Valkey。
license 也和这一点重叠。Redis 8.8 的 LICENSE.txt 是 RSALv2 / SSPLv1 / AGPLv3 三重授权,而 Valkey 的 COPYING 写着 SPDX-License-Identifier: BSD-3-Clause。这段背景在开源许可证格局变迁一文中另有专门讨论。简单说,如果一个组织当初选 Valkey 就是为了留在 BSD 阵营,那 Array 从一开始就不在选项之内;而如果 Array 对你有吸引力,你同时也在接受那份许可证。这不是功能上的选择题,而是打包在一起的选择题。
8.8 的其余部分 — INCREX 可能更实用
Array 是标题党,但对很多团队来说,8.8 里马上能用得上的可能是 INCREX。限速器过去大多是用 Lua 脚本实现的,8.8 把它做成了一个命令(实现者是 raffertyyu 和 Redis 团队)。
INCREX key
[<BYFLOAT|BYINT> increment]
[LBOUND lowerbound] [UBOUND upperbound] [SATURATE]
[EX sec | PX msec | EXAT unix-time-sec | PXAT unix-time-msec | PERSIST]
[ENX]
发布文章说明的要点有三个。第一,它会同时返回新的计数器值和实际生效的增量,调用方可以立刻判断放行还是拒绝。第二,给出 ENX 时只有在 key 还没有过期时间的情况下才会设置过期,因此窗口的 TTL 只在窗口创建时被钉住,不会被后续请求不断推迟延长 — 这正是不用 Lua 手写时大家几乎都会踩一次的坑。第三,超过边界就拒绝,给出 SATURATE 则会贴着边界部分接受。限速算法本身在令牌桶与滑动窗口一文里已经整理过了。
除此之外,8.8 还加入了让流消费者显式归还消息的 XNACK(SILENT/FAIL/FATAL 模式)、按哈希字段粒度的通知、ZUNION/ZINTER 的 COUNT 聚合器,以及 JSON.SET 的 FPHA(指定浮点数组以 BF16/FP16/FP32/FP64 中的哪一种存储)。
一个由 AI 打造的数据结构
antirez 在开发笔记里毫不避讳地写下了这个类型是怎么做出来的。他从一月的第一周开始,第一个月只手写规格文档。一开始和 Opus 搭档,GPT-5.3 发布之后,就把设计和开发都转移到了 Codex 上。从第二个月起,边用自动编码实现边持续做代码审查,前面提到的间接层重设计就是这个阶段做出来的。第三个月是压力测试。
他自己的结论是,高质量的系统编程仍然需要他本人完全投入,但借助 AI,他能够走到平时会直接跳过的复杂度水平。而 ARGREP 原本根本不在计划之内 — 是在把玩这个数据结构的过程中,尝试把 Markdown 文件塞进 Array,正好和他自己需要一个 agent 技能知识库的处境对上了,于是就做了出来。
这个 PR 本身的规模是可以核实的数字 — 18 个提交、86 个文件、+22,258 / −39 行。5 月 4 日打开,5 月 13 日合并,12 天后就随 8.8.0 GA 一起发布了。这件事该怎么评价见仁见智,但至少值得记录下来的是,这是一个「AI 写的代码进入了生产数据库的核心数据结构」的具体且有据可查的案例。看文档上的 preview 标签时,把这个背景放在一起考量也是合理的。
那么,你该不该用
用了值回成本的情况
- 索引真的就是领域主键 — 时间槽位、座位/隔间号、行号、工作流步骤。如果你现在是把索引当字符串字段塞进 Hash 里用,这正是它的位置。
- 你在用
RPUSH+LTRIM跑环形缓冲区,那两次往返和非原子性确实让人头疼。ARRING能把它压缩成一条命令。 - 你把区间聚合全部拉到客户端去做。
AROP能在服务端就把它做完。
属于过度或有风险的情况
- 需要 push/pop,或者需要在元素中间插入 → Redis 自己会告诉你用 List。
- 是按字段名而不是数字索引访问 → 它会告诉你用 Hash。
- 内存紧张 → 每元素比 List 多花约 18%。
- 你在用 Valkey,或者有可能会用 → 那边不存在这个类型。
- 需要 API 稳定性 → 文档写着 preview。
- 已经有一套运作良好的基于 Hash 的设计 → 厂商基准测试里那 8~15% 的数字,不足以为重写正名。
结语
归纳一下。Redis 8.8 自 Stream 之后第一次新增了一个核心对象类型,这个类型是面向「索引本身带有意义」的数据的稀疏容器。它靠 4096 个元素一组的切片和指针标记同时应付密集和稀疏两种情况,用 18 个命令把聚合和 grep 都下放到服务端完成。同一个月发布的 Valkey 9.1 的源码里没有这个类型,往后两个服务器在数据模型这个层面上会继续保持分裂。
与此同时,这也是一个虽然进了 GA、文档却依然写着 preview 的类型;厂商基准测试里的大部分收益,来自让 List 去做它不擅长的事情;相对 Hash 的实质收益,是个位数到十几个百分点,换来的代价是更多内存。
所以结论不是「Redis 出了个好东西,那就用吧」。如果你现在正把索引当字符串塞进 Hash 的 key 里,或者正在忍受 LPUSH+LTRIM 的往返开销,Array 精准地瞄准了这种具体的不便。如果你没有这种不便,那么 8.8 里对你重要的东西可能不是 Array,而是 INCREX,也可能什么都不是。新的数据结构是一把新锤子,先确认有没有钉子,才是正确的顺序。
参考资料
- Redis 8.8.0 发布说明 (GitHub)
- Valkey 9.1.0 发布说明 (GitHub)
- Implement the new Redis Array type — PR #15162 (antirez)
- Redis array type: short story of a long development — antirez 开发笔记
- Announcing Redis 8.8 — 厂商发布文章,基准数据的出处
- Redis arrays 官方文档 — preview 标注与 18 个命令
- TRE — ARGREP 背后的正则引擎
- 2026 年内存缓存与 KV 存储对比 (相关文章)
- 2026 开源许可证格局变迁 (相关文章)
- 限速算法 — 令牌桶与滑动窗口 (相关文章)