《Redis 8.10.3 深度剖析》系列 ·第 17 章. 一个迟到十六年的类型

1 阅读1小时+

《Redis 8.10.3 深度剖析》系列 ·第 17 章. 一个迟到十六年的类型

Redis 从 1.0 起就有 String、List、Set、Hash、ZSet 五种类型,2026 年的 8.8 才补上第六种:Array。src/server.h:898 一行宏:

#define OBJ_ARRAY 7     /* Array object. */

编号 7 而不是 6,因为 6 已经被 OBJ_STREAM 占了。这个编号顺序本身就是一段历史:类型号按引入时间分配,Module 是 5、Stream 是 6、Array 是 7。OBJ_TYPE_MAX 现在等于 8,ENABLE_GCRA 编译开关打开时是 9。

Object 层的登记簿必须同步更新,src/server.h:901-915 留了一份清单,列出新增一个类型要改的八个地方:rdb.c(存/取)、aof.c(重写)、db.c(类型名与 COPY)、debug.c(digest 与日志)、defrag.c(碎片整理)、module.c(RM_KeyType)、object.c(创建/释放/dismiss/allocSize/Length)、tests 里的模糊测试命令表。这份清单是给未来的贡献者看的,Array 自身按它逐项落地。

1.1 为什么需要它

List 的随机访问是 O(N)。ZSet 按 score 有序,但 member 唯一且每个元素的成本是"skiplist 节点 + sds + dict entry"三层。Stream 的索引是 128 位 ID 且必须单调递增。Hash 的字段是字符串,不能寻址。Redis 缺的是最朴素的一种结构:用整数下标直接访问的序列,而且要允许下标稀疏、允许下标大到 2^64-2。

src/sparsearray.h:21-63 的文件头注释把定位写得很直接:

 * Sparse arrays are random-access sequences indexed by non-negative 64-bit
 * integers. They support O(1) get/set operations and efficient iteration.

三个关键词:随机访问、64 位索引、稀疏。任何一个已有类型都不满足。

1.2 与既有类型的对照

维度ListZSetStreamArray
按整数下标取O(N)不支持按 ID,O(log N)O(1)
下标稀疏不支持不适用不适用支持,洞不占内存
下标空间2^64 元素上限受内存—128 位 ID2^64-2
元素唯一性可重复member 唯一ID 唯一每槽一个
值类型sdssds + doublefield-value 对tag 化的标量或 arString
编码切换quicklist/listpacklistpack/skiplist无无(但 slice 内部有 dense/sparse 两态)
单元素内存~16-40 字节~80 字节~40 字节 + 索引0 字节(内联进指针)

最后一行是本章的核心卖点:Array 的整数、浮点、7 字节以内字符串不占任何额外内存,全部编码进那 8 字节的槽位本身。一个存了 100 万个 12345 的 Array,值的内存开销是零,只有槽位数组本身的 8 MB。

1.3 编码:只有一个

src/object.h:88:

#define OBJ_ENCODING_SLICED_ARRAY 13 /* Encoded as sliced array */

OBJ_ARRAY 类型只有 OBJ_ENCODING_SLICED_ARRAY 一个合法编码,和 Stream 的处境完全一样。区别在于 Stream 的这个"唯一"是历史演化的结果(早期有 OBJ_ENCODING_STREAM 之外的设想),而 Array 从第一天起就没打算做小对象特化。

不做特化的代价是:一个只存了一个元素的 Array,最小开销是

  • redisArray 头 80 字节
  • flat 目录 1 个槽 8 字节
  • 1 个 sparse slice 72 字节(sizeof(arSlice) 32 + 4 个 uint16_t offsets 8 + 4 个指针 32)

合计 160 字节,落到 jemalloc 的三个 class 上(80→80、8→8、72→80),实际约 168 字节。作为对比,一个只存一对 field-value 的 Hash 走 listpack 大约 100 字节。

这个代价比 Stream 的 400 字节好得多,原因是 Array 的头结构只有 80 字节且没有 radix tree。但它仍然是"小对象不友好"的——这是刻意的取舍:Array 的定位是大规模索引容器,不为存三个元素优化。

1.4 代码规模与署名

src/sparsearray.c   2080 行  数据结构本体
src/sparsearray.h    312 行  结构定义 + 全部 inline 访问器
src/t_array.c       2021 行  命令层 + Redis 集成
tests/unit/type/array.tcl  3114 行

src/sparsearray.h:11 和 src/sparsearray.c:12 各有一行署名:

 * This data structure was designed and implemented by Salvatore Sanfilippo.

antirez 的设计风格在这一章里随处可见:结构体挤到最小、位域复用、注释里解释"为什么这样而不是那样"、以及那种"我知道这看起来丑但省了两次函数调用"的坦率。

sparsearray.c/h 的隔离度很高:sparsearray.h 只 include 三个标准头(stdint.h、stddef.h、string.h),不依赖 server.h。sparsearray.c 才 include server.h,为了用 zmalloc/serverAssert/d2string。头文件注释里那句 "mapped to Redis server struct for easy standalone adaptation"(src/sparsearray.c:35)说明它考虑过被抽出来单独使用:

#define ArraySliceSize  server.array_slice_size
#define ArraySparseKMax server.array_sparse_kmax
#define ArraySparseKMin server.array_sparse_kmin

三个宏把配置项直接映射到 server 全局。这意味着同一份代码里不同 Array 对象可以有不同的 slice_size——因为 redisArray 结构里存了自己的 slice_size 副本(src/sparsearray.h:155),创建时从 server.array_slice_size 拷贝。改配置只影响新建的数组,老数组保持原尺寸。这个设计避免了"改配置导致已有数据布局失效"的灾难。


2. 三级结构:slice、目录、超级目录

src/sparsearray.h:119-161 三个结构体定义了整个存储布局。

2.1 arSlice:32 字节的联合体

typedef struct arSlice {
    uint8_t encoding;       /* 0=dense, 1=sparse */
    uint8_t _pad1[3];
    uint32_t count;         /* Non-empty items in this slice */
    union {
        struct {
            uint32_t offset;    /* First logical offset in window */
            uint32_t winsize;   /* Window size (power of two) */
            uint32_t max_idx;   /* Highest offset with a value */
            void **items;       /* Points into payload */
        } dense;
        struct {
            uint32_t cap;       /* Capacity */
            uint16_t *offsets;  /* Points into payload */
            void **values;      /* Points into payload (aligned) */
        } sparse;
    } layout;
} arSlice;

头部 8 字节(1 字节 encoding + 3 字节填充 + 4 字节 count),联合体两个成员都是 24 字节:

  • dense:offset/winsize/max_idx 各 4 字节 = 12,加 4 字节填充,加 items 指针 8 = 24
  • sparse:cap 4 + 4 填充 + offsets 指针 8 + values 指针 8 = 24

所以 sizeof(arSlice) == 32(64 位)。32 字节正好是半个 cache line,一个 slice 头加若干元素能装进两个 cache line。

encoding 用 uint8_t 而不是位域,理由是两种编码要频繁判断(每次 arGet 都要分支),位域要额外一次移位掩码。用 1 字节换掉一次 and+shr,在热路径上值得。

_pad1[3] 是显式的——antirez 没有依赖编译器自动填充,而是把填充写进结构体,这样 sizeof 在任何编译器上都可预测。

2.2 redisArray:80 字节的头

typedef struct redisArray {
    uint64_t count;             /* Total non-empty items */
    uint64_t insert_idx;        /* Last insert index, or UINT64_MAX if none */
    uint64_t dir_alloc;         /* Flat directory length (flat mode) */
    uint64_t dir_highest_used;  /* Highest non-NULL slice index */
    uint64_t num_slices;        /* Number of allocated slices */
    size_t alloc_size;          /* Tracked total allocation (for slot stats) */
    uint32_t slice_size;        /* Slice size (power of two) */
    uint32_t sdir_len;          /* Superdir entries count */
    uint32_t sdir_cap;          /* Superdir capacity */
    uint32_t _pad;
    arSlice **dir;              /* Flat directory or NULL */
    arSDirEntry *superdir;      /* Super-directory or NULL */
} redisArray;

五个 uint64_t(count、insert_idx、dir_alloc、dir_highest_used、num_slices)+ size_t alloc_size = 48 字节,四个 uint32_t = 16 字节(凑到 64),两个指针 = 16 字节,合计 80。

count 与 num_slices 是两个不同的量:前者是"非空槽位数",后者是"已分配的 slice 数"。一个 slice 可以存 4096 个元素,所以 count / num_slices 是平均密度,ARINFO FULL 的 avg-dense-fill 就在算这个。

dir 与 superdir 是互斥的两个字段,靠"哪个非 NULL"区分模式。这比用一个 mode 枚举省 4 字节,也避免了"mode 说是 flat 但 dir 是 NULL"这种不一致状态——但代价是两个字段都要在每条路径上判空。

2.3 arSDirEntry:2048 个槽的块

typedef struct arSDirEntry {
    uint64_t block_id;      /* slice_id / AR_SUPER_BLOCK_SLOTS */
    uint32_t count;         /* Non-NULL slots in this block */
    uint32_t _pad;
    arSlice **slots;        /* AR_SUPER_BLOCK_SLOTS pointers to slices */
} arSDirEntry;

24 字节的头 + 一个独立的 slots 数组(AR_SUPER_BLOCK_SLOTS = 2048 个指针 = 16 KB on 64-bit,8 KB on 32-bit)。slots 单独分配而不是内联进结构体,因为 arSDirEntry 数组本身要能 memmove(插入新 block 时),内联 16 KB 会让每次移动变得昂贵。

2.4 为什么需要 superdir

src/sparsearray.c:510-544 的注释把动机写得极其清楚,值得整段抄下来:

 * The superdir avoids catastrophic metadata growth for sparse/high indices.
 * A flat directory must be sized up to the highest slice_id, even if almost
 * all entries are NULL. With very large index jumps, that would waste huge
 * memory. Superdir keeps metadata proportional to the number of populated
 * blocks/slices instead of the largest slice_id ever seen.

用一个具体例子量化。默认 slice_size = 4096,如果在 index 0 和 index 2^40 各写一个元素:

  • flat 模式需要 2^40 / 4096 = 2^28 个指针 = 2 GB 的目录
  • superdir 模式需要 2 个 block:2^28 / 2048 = 2^17 号 block 之前的所有 block 都不存在,只有 block 0 和 block 131072 被分配,各 16 KB,合计 32 KB

差距是 6 万倍。这就是 64 位索引空间能安全存在的前提。

2.5 提升阈值:8,388,608

src/sparsearray.c:657-669:

void arDirGrow(redisArray *ar, uint64_t slice_id) {
    /* Check if promotion to superdir is needed */
    if (ar->superdir == NULL && slice_id >= AR_SUPER_BLOCK_SLOTS) {
        arPromoteToSuperDir(ar);
    }
    ...
}

判据是 slice_id >= 2048。换算成逻辑下标:

2048 * 4096 = 8,388,608

也就是第一个需要 block_id 1 的下标。8.4M 以下的数组永远待在 flat 模式,目录最大 2048 个指针(16 KB)。这个数字不是随便选的:它等于 AR_SUPER_BLOCK_SLOTS,而 2048 的选择看注释(src/sparsearray.h:75-78):

/* Superdir: fixed-size blocks of slice pointers. Each block holds 2048
 * pointers to actual array slices, which uses about 8 KB on 32-bit builds
 * and 16 KB on 64-bit builds. This keeps very large indices from forcing
 * catastrophic flat-directory growth. */

16 KB 是一个 block 的大小,也是 jemalloc 的一个大对象 class。2048 个指针 = 16 KB 意味着 block 分配不会浪费尾部的碎片。

2.6 这段设计是安全性的,不只是性能的

注意 src/sparsearray.c:539-543 那段 Hint:

 * Hint: here what we gain is not just efficiency. Also there are no security
 * concerns with setting a very high index. No problem with a corrupted RDB
 * file containing a very high index, and no need to configure a maximum index
 * allowable in an array. Thanks to this design the array type of Redis is
 * a more useful and safe type.

三个"不需要":不需要给 Array 配一个 max-index 配置项,不需要在 RDB 加载时校验下标上界,不需要担心"写一个大下标就能 OOM"的攻击。

对比 Stream:XADD 的 ID 也能到 2^64,但 Stream 用 radix tree,本身就按需增长,不需要上界。Array 如果用 flat 目录就必须有上界,那 8.4M 或 2^32 之类的数字就会变成 API 的一部分(像 GETRANGE 的越界截断那样)。superdir 把这个数字从"用户可见的约束"变成了"内部的实现细节"。

这是本章第一个值得记住的判断:superdir 的存在意义不是让大数组更快,而是让"下标没有上界"这件事可以被承诺。

2.7 flat 目录的几何增长与减半收缩

arDirGrow 的 flat 分支(src/sparsearray.c:671-689):

    if (slice_id < ar->dir_alloc) return;

    uint64_t new_alloc = ar->dir_alloc ? ar->dir_alloc : 1;

    while (new_alloc <= slice_id) {
        new_alloc <<= 1;
    }

    arSlice **new_dir = arReallocAndTrack(ar, ar->dir, new_alloc * sizeof(arSlice *));
    memset(new_dir + ar->dir_alloc, 0, (new_alloc - ar->dir_alloc) * sizeof(arSlice *));

注意 while (new_alloc <= slice_id):<= 而不是 <。这意味着 dir_alloc 是严格大于 slice_id 的 2 的幂。若 slice_id = 2047,new_alloc 从 1 开始翻倍到 2048,此时 2048 <= 2047 为假,停止,dir_alloc = 2048。所以 flat 模式的目录长度可以是 2048,正好容纳 slice_id 0..2047。

这也是"flat 模式只覆盖 block 0"的算术依据:dir_alloc 最大 2048,而 AR_SUPER_BLOCK_SLOTS 也是 2048,所以 flat 目录的下标永远落在 [0, 2047],等价于 block 0 的 2048 个槽。

收缩只能减半(src/sparsearray.c:691-707):

void arDirMaybeShrink(redisArray *ar) {
    if (ar->superdir) return;  /* Superdir mode: blocks freed individually */
    if (ar->count == 0) return;  /* Will be deleted anyway */
    if (ar->dir_highest_used >= ar->dir_alloc * 9 / 10) return;

    /* Find smallest power of two > dir_highest_used */
    uint64_t new_alloc = 1;
    while (new_alloc <= ar->dir_highest_used) new_alloc <<= 1;
    if (new_alloc >= ar->dir_alloc) return;

    ar->dir = arReallocAndTrack(ar, ar->dir, new_alloc * sizeof(arSlice *));
    ar->dir_alloc = new_alloc;
}

两个门槛:dir_highest_used >= dir_alloc * 9/10 时直接返回(省掉循环),否则算"刚好容纳 highest_used 的 2 的幂"。第二个判断 new_alloc >= dir_alloc 保证不会"收缩"到相同大小——因为 dir_highest_used < dir_alloc,new_alloc 可能等于 dir_alloc,此时 realloc 到同样大小是浪费。

* 9 / 10 而不是 * 0.9:整数运算,避免浮点。副作用是 dir_alloc = 1 时 9/10 = 0,dir_highest_used >= 0 恒真,于是目录长度为 1 时永不尝试收缩——无害,因为 1 已经是最小。

2.8 dir_highest_used 的三个用途

src/sparsearray.c:709-715 注释列了三条:

 * 1. arLen() is O(1) this way.
 * 2. We can start reverse scans from the rightmost populated directory entry.
 * 3. We can shrink the directory (in flat mode) if needed, since we know
 *    the usage.

第一条最关键。arLen() 定义为"最大下标 + 1":

uint64_t arLen(redisArray *ar) {
    if (ar->count == 0) return 0;
    arSlice *s = arGetSlice(ar, ar->dir_highest_used);
    if (s == NULL) return 0;  /* Defensive: if count>0 but slice missing, corrupted state */
    uint32_t local_max = arSliceMaxIdx(s);
    return arMakeIdx(ar->dir_highest_used, local_max, ar->slice_size) + 1;
}

dir_highest_used 给出"最高非空 slice 的编号",再问这个 slice 内部"最高的非空 offset"。arSliceMaxIdx(src/sparsearray.c:116-122)对 dense 直接读 max_idx 字段,对 sparse 读 offsets[count-1]——后者依赖 sparse 的 offsets 数组有序,这正是二分查找的前提。

没有 dir_highest_used,arLen() 就要从目录尾部倒扫,最坏 O(目录长度) = 2048 次指针判空。有了它,arLen() 是"一次 slice 查找 + 一次字段读",即 O(1)(superdir 模式下严格说是 O(log B),B = block 数)。

第三条用途只在 flat 模式生效,见上一节。

第二条用途体现在 arScanIterInit:反向扫描从 dir_highest_used 开始而不是从 dir_alloc - 1。对于 ARSET k 100 v(只有一个元素、slice_id = 0)这种极端情况,dir_alloc = 1 和 dir_highest_used = 0 相同;但对于"曾经写过很大下标、后来删掉"的数组,dir_alloc 可能是 2048 而 dir_highest_used 只有 3,反向扫描能省掉 2045 次判空。

2.9 num_slices 的维护成本

num_slices 只在 arSetSlice 里增减(src/sparsearray.c:762-798):

    } else {
        if (s == NULL && ar->dir[slice_id] != NULL) ar->num_slices--;
        else if (s != NULL && ar->dir[slice_id] == NULL) ar->num_slices++;
        ar->dir[slice_id] = s;
    }

这个 else if 的写法有个细节:s == NULL && dir[slice_id] == NULL 时两个分支都不走,num_slices 不变——正确,因为本来就没有 slice。s != NULL && dir[slice_id] != NULL 时也不变——但这是替换语义(slice 被 realloc 后地址变了),计数不该变。

superdir 分支更复杂(src/sparsearray.c:783-792):

            arSlice **slot = arSuperDirEnsureSlot(ar, slice_id);
            arSDirEntry *entry = arSuperDirGetEntry(ar, slice_id);
            if (*slot == NULL) {
                entry->count++;
                ar->num_slices++;
            }
            *slot = s;

arSuperDirEnsureSlot 之后又调 arSuperDirGetEntry——同一个 block_id 做了两次二分查找。这是为了拿到 entry 指针来改 count,而 EnsureSlot 只返回 slot 指针。两次二分是 O(log B) 的常数倍,在 arSet 的热路径上,属于可以接受但明显可优化的冗余。


3. 下标算术:一个 uint16_t 决定了 slice 的上限

3.1 三个 inline 函数

src/sparsearray.h:167-186:

static inline int arSliceBits(uint32_t slice_size) {
    if (slice_size == 4096) return 12;  /* Fast path for default */
    int bits = 0;
    uint32_t x = slice_size;
    while (x > 1) { x >>= 1; bits++; }
    return bits;
}

static inline uint64_t arSliceId(uint64_t idx, uint32_t slice_size) {
    return idx >> arSliceBits(slice_size);
}

static inline uint32_t arSliceOff(uint64_t idx, uint32_t slice_size) {
    return (uint32_t)(idx & (slice_size - 1));
}

static inline uint64_t arMakeIdx(uint64_t slice_id, uint32_t off, uint32_t slice_size) {
    return (slice_id << arSliceBits(slice_size)) | off;
}

arSliceBits 的"快路径"针对 4096——默认值,绝大多数部署都跑这条路,直接返回 12,跳过循环。非默认值最多循环 16 次(65536 需要 16 位)。

这个循环本身可以换成 31 - __builtin_clz(slice_size),但 slice_size 保证是 2 的幂且 ≥ 256,循环最多 16 次,而 arSliceBits 的调用频率也不算最高(arGet/arSet 各一次)。antirez 选了可移植的写法。

arSliceOff 用掩码而非取模:idx & (slice_size - 1)。这要求 slice_size 必须是 2 的幂,由 config 校验函数保证(src/config.c:2637-2644):

static int isValidArraySliceSize(long long val, const char **err) {
    if (val <= 0 || (val & (val - 1)) != 0) {
        *err = "array-slice-size must be a power of two";
        return 0;
    }
    return 1;
}

val & (val - 1) 是经典的"判断 2 的幂"技巧:2 的幂只有一位是 1,减一之后低位全变 1,与运算为 0。

范围是 AR_SLICE_SIZE_MIN = 256 到 AR_SLICE_SIZE_MAX = 65536(src/sparsearray.h:70-71),config 注册在 src/config.c:3559,MODIFIABLE_CONFIG。

3.2 65536 这个上限的真实来源

AR_SLICE_SIZE_MAX = 65536 看起来是个"合理的性能取值",实际是类型强制的。

sparse slice 的 offsets 数组是 uint16_t *(src/sparsearray.h:133)。slice 内部偏移的范围是 [0, slice_size - 1]。要让 slice_size - 1 能被 uint16_t 表示,必须 slice_size <= 65536。

代码里到处在把这个偏移往 uint16_t 里塞,最典型的是 arSet(src/sparsearray.c:1332):

        uint32_t pos = arSparseFindPos(s, (uint16_t)rel_idx, &found);

这里 rel_idx 是 uint32_t,强转成 uint16_t 传给二分查找。如果 slice_size 允许 131072,rel_idx 可能是 100000,强转后变成 34464,查找会定位到完全错误的位置——这是个静默的数据损坏,不是崩溃。

同样的强转出现在 arDel(:1439)、ARSCAN 的迭代器准备(src/t_array.c:647、:655)、arDeleteSliceRange(:1550-1551)。

所以 AR_SLICE_SIZE_MAX 不是可调参数,是用 uint16_t 存偏移这个决定的下界约束。反过来说,如果要支持更大的 slice,必须把 offsets 换成 uint32_t,那 sparse slice 的固定开销会从 32 字节涨到 32 字节(头不变,offsets 数组翻倍),kmax=10 时从 72 字节涨到 92 字节。用 16 位换来的是每个元素在稀疏态下省 2 字节。

值得对比:dense slice 的偏移用 uint32_t(offset、winsize、max_idx 都是),因为它存的是窗口边界而不是元素偏移列表,dense 的元素位置是隐式的(数组下标)。只有 sparse 需要显式存偏移,才受 16 位限制。

3.3 一处不一致:迭代器用除法

src/t_array.c:557-558:

    it->lo_slice = it->lo / it->slice_size;
    it->hi_slice = it->hi / it->slice_size;

而 sparsearray.h 提供的是 arSliceId(idx, slice_size),内部是移位。同一件事,两种写法。

迭代器这里用除法的理由是"迭代器持有一份 slice_size 副本,除以它比调函数更直接"——但编译器不会把变量除数优化成移位。对于 4096 这种 2 的幂,/ 在 x86 上走 div 指令,延迟 20-30 周期,而 >> 是 1 周期且可流水。

注释里说这个迭代器是"微优化过的热路径"(src/t_array.c:546-551):

    /* Note that a few things here could be taken
     * from the array itself, as they are immutable,
     * but after introducing this abstraction a small
     * but measurable speed regression suggested to
     * micro-optimize for this hot path and have
     * iterator-side copies of often used stuff. */

既然已经为了省一次内存读取而复制了字段,两处除法却没换成移位,说明这个微优化只做了一半。这是可以观察到的、不影响正确性的不对称。

3.4 arMakeIdx 的移位与 slice_id 的范围

static inline uint64_t arMakeIdx(uint64_t slice_id, uint32_t off, uint32_t slice_size) {
    return (slice_id << arSliceBits(slice_size)) | off;
}

slice_id 是 uint64_t,左移 bits 位。如果 slice_id 已经接近 2^64 / slice_size,左移会溢出并丢失高位。

arSet 的断言(src/sparsearray.c:1286)拦住了最极端的情况:

    serverAssert(idx != UINT64_MAX);

idx = UINT64_MAX 时 slice_id = UINT64_MAX >> 12 = 2^52 - 1,arMakeIdx 回来仍是 UINT64_MAX。因为 UINT64_MAX 被显式禁止,所以最大的合法 idx 是 UINT64_MAX - 1,对应 slice_id = 2^52 - 1、off = 4095,左移 12 位后 (2^52-1) << 12 = 2^64 - 4096,加 4095 = 2^64 - 1,刚好不溢出。

所以"索引上界是 2^64 - 2"不是保守估计,是精确上界。src/sparsearray.c:1283-1286 的注释解释了两个理由:

    /* UINT64_MAX can't be used for a couple of reasons: for once,
     * the array len is the max index set + 1, so we could not represent
     * that; also it is a sentinel for last set index still not being set. */

第一条:arLen = max_idx + 1,如果 max_idx = UINT64_MAX,arLen 回绕成 0。 第二条:insert_idx 用 UINT64_MAX 表示"还没插入过"(AR_INSERT_IDX_NONE),如果合法索引也是 UINT64_MAX,两者无法区分。

第二条理由更本质:一个 64 位量同时要表达"第 2^64-1 个位置"和"没有位置",必须牺牲一个取值。


4. tagged pointer:把值塞进 8 字节

这是整章最精巧的部分,也是 Array 与 Redis 其它所有类型的分水岭。其它类型的元素都是"指针指向 sds"或"指针指向结构体";Array 的元素可能就是值本身。

4.1 四种编码与 2 位 tag

src/sparsearray.h:89-94:

/* Tagged value encoding (low 2 bits). NULL (0) means empty slot. */
#define AR_TAG_PTR       ((uintptr_t)0)   /* arString pointer (low 2 bits = 00) */
#define AR_TAG_INT       ((uintptr_t)1)   /* Immediate signed integer (01) */
#define AR_TAG_FLOAT     ((uintptr_t)2)   /* Immediate float (10) */
#define AR_TAG_STR       ((uintptr_t)3)   /* Inline small string (11) */
#define AR_TAG_MASK      ((uintptr_t)3)
tag二进制含义64 位可用负载
000arString 指针62 位地址(靠分配器对齐)
101立即数整数62 位有符号
210立即数浮点62 位(IEEE-754 尾数截断 2 位)
311内联短字符串7 字节

外加一个隐含的第五态:指针值为 0(NULL)表示空槽。arIsEmpty 就是 v == NULL(src/sparsearray.h:192)。因为 tag 0 的指针必须是真实分配地址,不可能是 0,所以 NULL 不与任何一种编码冲突。

这个设计的前提是所有堆指针的低 2 位都是 0——8 字节对齐。malloc/zmalloc 保证 16 字节对齐(glibc 的 malloc 返回 2*sizeof(size_t) 对齐 = 16 字节),所以低 4 位都是 0,取低 2 位做 tag 绰绰有余。

4.2 为什么不用 SDS

src/sparsearray.c:26-28:

 * SDS strings are not used as values since the final
 * bits of SDS pointers are not guaranteed to be zero.

src/sparsearray.c:130-133 展开:

 * We use arString instead of SDS because SDS pointers are not guaranteed to
 * have the low bits zero (SDS points inside an allocation, after the header).
 * Our tagged pointer scheme needs tag 00 for heap strings, so we need aligned
 * pointers. zmalloc guarantees sufficient alignment.

SDS 的 sdshdr 结构把 len/alloc/flags 放在数据前面,sds 指针指向 header 之后的数据区。所以 sds 指针 = 分配基址 + header_size,header_size 可能是 3、5、8、16 等,不是 8 的倍数,低 2 位可能是 01、10、11。

如果沿用 SDS,tag 00 就无法区分"堆字符串指针"和"碰巧低两位是 00 的整数"——整个编码方案失效。因此 Array 引入了自己的字符串类型 arString,它返回分配基址本身,保证对齐。

这是本章第一个"为了数据结构牺牲复用"的决定。代价是 Redis 生态里所有针对 sds 的优化(sdscatlen 的预分配、sdsAllocSize、共享 sds)都无法用在 Array 值上。

4.3 arString:2 字节或 8 字节的变长头

src/sparsearray.c:135-165 用 ASCII 图画了两种头格式:

 * 1. Short header (2 bytes): lengths up to 32767 bytes.
 *    The top bit of the first byte is set, and the remaining 15 bits store
 *    the length in big-endian form.
 *
 *      +--------+--------+-------------------+
 *      |1LLLLLLL|LLLLLLLL|      payload      |
 *      +--------+--------+-------------------+
 *       byte 0   byte 1
 *
 * 2. Long header (8 bytes): lengths up to 2^63-1 bytes.
 *    The top bit of the first byte is clear, and the remaining 63 bits store
 *    the length in big-endian form.

读长度的代码(src/sparsearray.c:90-99):

size_t arStringLen(const void *ptr) {
    const uint8_t *p = (const uint8_t *)ptr;
    if (p[0] & 0x80) {
        return ((size_t)(p[0] & 0x7F) << 8) | p[1];
    } else {
        size_t len = 0;
        for (int i = 0; i < 8; i++) len = (len << 8) | p[i];
        return len;
    }
}

短头:首字节最高位为 1,剩下 7 位 + 第二字节 = 15 位长度,范围 0..32767。所以阈值是 len <= 32767 用 2 字节头(arStringHeaderSize,src/sparsearray.c:86-88)。

长头:首字节最高位为 0,8 个字节拼成 63 位长度。注意循环是 len = (len << 8) | p[i],从 p[0] 到 p[7],即大端。写的时候(src/sparsearray.c:182-184):

        for (int i = 7; i >= 0; i--) {
            ptr[7 - i] = (len >> (i * 8)) & 0xFF;
        }

i 从 7 到 0,写到 ptr[0] 到 ptr[7],最高字节在前。一致。

选择大端而不是原生序,是为了让"首字节的最高位"这个标志位在任何架构上都落在同一个位置(ptr[0] 的 bit 7)。如果用原生序,小端下 ptr[0] 是长度最低字节,标志位就要放在 ptr[7],读的时候要按指针大小分支。大端把"先读一个字节判断格式"变成无分支操作。

一段值得注意的设计取舍(src/sparsearray.c:160-161):

 * For simplicity we use a 63 bit len even when Redis is compiled with a 32
 * bit target, the overhead for strings > 32k is small.

32 位构建上 size_t 只有 32 位,根本用不到 63 位长度,但代码仍然用 8 字节头而不是 4 字节头。理由是"超过 32KB 的字符串额外 4 字节开销很小"。这避免了在两个架构上维护两套头格式——RDB 是架构中立的(见 §14),但内存布局不必中立,只是"不值得为 4 字节引入条件编译"。

arStringNew 的断言(src/sparsearray.c:170-172):

#if SIZE_MAX > UINT32_MAX
    serverAssert(len < ((size_t)1 << 63));
#endif

用 #if 包起来是因为 32 位构建上 (size_t)1 << 63 本身就是 UB(移位数超过类型宽度),而这个断言在 32 位上是恒真的,编译进去只是浪费。

4.4 整数:62 位有符号

src/sparsearray.h:198-216:

static inline int arIsInt(void *v) {
    return ((uintptr_t)v & AR_TAG_MASK) == AR_TAG_INT;
}

static inline int64_t arToInt(void *v) {
    return (int64_t)(intptr_t)v >> 2;  /* Arithmetic shift preserves sign */
}

static inline void *arFromInt(int64_t ival) {
    return (void *)(((uintptr_t)ival << 2) | AR_TAG_INT);
}

static inline int arIntFits(int64_t ival) {
#if UINTPTR_MAX == UINT64_MAX
    return ival >= -(1LL << 61) && ival <= (1LL << 61) - 1;
#else
    return ival >= -(1LL << 29) && ival <= (1LL << 29) - 1;
#endif
}

arToInt 的关键是 (int64_t)(intptr_t)v >> 2 用算术右移。C 标准里右移有符号负数是实现定义行为,但所有主流编译器都做算术右移(补符号位)。注释显式标注了这一点。

若用逻辑右移(先转 uintptr_t 再移),-1 编码成 0xFFFFFFFFFFFFFFFD,逻辑右移 2 位得 0x3FFFFFFFFFFFFFFF,是个巨大的正数。算术右移得 0xFFFFFFFFFFFFFFFF,即 -1。所以这个"实现定义"是被依赖的,且是唯一可行的做法。

arFromInt 的 (uintptr_t)ival << 2 先转无符号再左移。C 标准里左移负数(或有符号溢出)是 UB,转成无符号后避开。若直接写 ival << 2,UBSan 会报错。

arIntFits 的范围 [-(2^61), 2^61 - 1] 是 62 位有符号范围。左移 2 位后:

  • 最大:(2^61 - 1) << 2 = 2^63 - 4,加 tag 1 = 2^63 - 3
  • 最小:-2^61 << 2 = -2^63,即 0x8000000000000000,加 tag 1 = 0x8000000000000001

都能放进 64 位,且 tag 位正确。若范围放宽到 2^61,2^61 << 2 = 2^63,超出 int64 正数范围,符号位被污染。

所以 62 位不是"留两位给 tag"这么简单,而是"留两位后必须保持有符号语义"。上界取 2^61 - 1 而不是 2^61 是为了让最大值左移后不触到符号位。

32 位同理:30 位有符号,范围 [-(2^29), 2^29 - 1]。

4.5 浮点:清两位尾数,然后验证 round-trip

这是四种编码里唯一可能丢失信息的,因此也是唯一带"验证"的。

arTryEncodeFloat(src/sparsearray.c:829-906)分两段:先做便宜的语法过滤,再做昂贵的 round-trip。

过滤段(src/sparsearray.c:837-854):

    size_t i = 0;
    int dot_seen = 0;

    if (len == 0) return 0;
    if (s[0] == '-') {
        if (len == 1) return 0;
        i = 1;
    }
    for (; i < len; i++) {
        char c = s[i];
        if (c == '.') {
            if (dot_seen) return 0;
            dot_seen = 1;
        } else if (c < '0' || c > '9') {
            return 0;
        }
    }
    if (!dot_seen) return 0;

只接受 -?[0-9]+(\.[0-9]+)? 形态,且必须有小数点。没有小数点的走 arTryEncodeInt(整数优先),所以这里拒绝是正确分工。

这个过滤同时拒绝了:科学计数法(1e10)、inf/nan、十六进制、前导加号、前导空格、多小数点。这些都是 string2d 会接受的,但它们的 d2string 输出形式与输入不同,round-trip 必然失败,所以提前拒绝省下 string2d 的开销。

string2d 本身也接受 1e10,如果不过滤,"1e10" 会走到 round-trip 才发现 d2string(1e10) 返回 "10000000000" 或 "1e+10"(取决于实现),与输入不等,返回 0。过滤只是提前失败。

round-trip 段(src/sparsearray.c:861-903):

    uint64_t bits_trunc;
    double d_trunc;
#if UINTPTR_MAX == UINT64_MAX
    uint64_t bits;
    memcpy(&bits, &d, sizeof(bits));
    bits_trunc = bits & ~AR_TAG_MASK;
    memcpy(&d_trunc, &bits_trunc, sizeof(d_trunc));
#else
    float f = (float)d;
    if (!isfinite(f)) return 0; // May happen after casting.
    ...
#endif

    /* Verify round-trip */
    char buf[AR_INLINE_BUFSIZE];
    int buflen = d2string(buf, sizeof(buf) - 2, d_trunc);
    if ((size_t)buflen == len && memcmp(buf, s, len) == 0) {
        *out = arFromFloatBits(bits_trunc);
        return 1;
    }

    /* Also try the ".0" form. d2string(1.0) returns "1", but when floats are
     * later converted back to strings we restore ".0" for integer-looking
     * values, so inputs like "1.0" can still round-trip exactly. */
    buf[buflen] = '.';
    buf[buflen + 1] = '0';
    buf[buflen + 2] = '\0';
    buflen += 2;
    if ((size_t)buflen == len && memcmp(buf, s, len) == 0) {
        *out = arFromFloatBits(bits_trunc);
        return 1;
    }

三步:

  1. 把 double 的位模式清低 2 位,得到"将被存储的值"d_trunc
  2. 用 d2string 把 d_trunc 转回字符串,与原始输入逐字节比较
  3. 若不等,再试"补 .0"的形式

第二步是唯一的安全网。清 2 位尾数相当于把精度从 52 位降到 50 位,3.14159 这种十进制小数转成 double 后尾数低位有值,清掉后值就变了,d2string 输出 3.1415899999... 之类,与输入不符,返回 0,退化成 arString 存储。

所以 Array 里存的浮点是"能精确 round-trip 的浮点",不是所有浮点。 这个语义很微妙:ARSET k 0 3.14 存成 arString(因为 3.14 的 double 表示清 2 位后变了),而 ARSET k 0 3.5 存成立即数(3.5 = 0x400C000000000000,低 2 位本来就是 0)。用户看到的都是 3.14,但内存布局和后续的 AROP SUM 路径不同(前者走 string2ld 解析,后者直接读 tagged bits)。

第三步处理 1.0:

  • string2d("1.0") → d = 1.0,位模式 0x3FF0000000000000,低 2 位是 0,d_trunc = 1.0
  • d2string(1.0) → "1"(Redis 的 d2string 对整数值的浮点不输出小数点)
  • "1" 与 "1.0" 不等,第一次比较失败
  • 补 .0 后 "1.0" 与输入相等,接受

arFormatFloat(src/sparsearray.c:913-924)在反向转换时做对称的事:

int arFormatFloat(double d, char *buf, size_t bufsize) {
    serverAssert(bufsize >= AR_INLINE_BUFSIZE);
    int len = d2string(buf, bufsize - 2, d);
    if (isfinite(d) && !memchr(buf, '.', len) && !memchr(buf, 'e', len) &&
        !memchr(buf, 'E', len)) {
        serverAssert((size_t)len + 2 < bufsize);
        buf[len++] = '.';
        buf[len++] = '0';
        buf[len] = '\0';
    }
    return len;
}

如果 d2string 的输出里既没有 . 也没有 e/E(即"看起来像整数"),补 .0。这样 arDecode 把 1.0 还原成 "1.0" 而不是 "1",用户读到的与写入的一致。

这个 .0 的游戏之所以必要,是因为 Redis 的 d2string 遵循"最短可 round-trip 表示"原则,而 Array 需要"与我存进去的字节一致"。 两者目标不同,只能在边界上打补丁。

arFormatFloat 的 bufsize - 2 也是为补 .0 预留空间。断言 bufsize >= AR_INLINE_BUFSIZE(64)保证 2 字节够用。

4.6 短字符串:7 字节,第一个字节只放 tag 和长度

src/sparsearray.h:245-269:

static inline int arIsSmallStr(void *v) {
    return ((uintptr_t)v & AR_TAG_MASK) == AR_TAG_STR;
}

static inline int arSmallStrLen(void *v) {
    return (int)(((uintptr_t)v >> 2) & AR_SMALLSTR_LEN_MASK);
}

static inline int arToSmallStr(void *v, char *buf) {
    int len = arSmallStrLen(v);
    uintptr_t val = (uintptr_t)v;
    for (int i = 0; i < len; i++) {
        buf[i] = (char)((val >> (8 * (i + 1))) & 0xFF);
    }
    buf[len] = '\0';
    return len;
}

static inline void *arFromSmallStr(const char *s, int len) {
    uintptr_t v = AR_TAG_STR | ((uintptr_t)len << 2);
    for (int i = 0; i < len; i++) {
        v |= ((uintptr_t)(uint8_t)s[i]) << (8 * (i + 1));
    }
    return (void *)v;
}

位布局(64 位):

 bit  63..24      23..16    15..8     7..5    4..2   1..0
     [ byte6 ]   [ byte5 ] [ byte4 ] [byte3] [byte2] [byte1]
     其实是:byte1 在 bit 8..15,byte2 在 bit 16..23 ... byte6 在 bit 48..55
     字节 0(bit 0..7)= tag(2 位) + len(3 位) + 3 位浪费

arFromSmallStr 把 s[i] 放在 bit 8*(i+1),即 s[0] 在 bit 8,s[6] 在 bit 56。bit 0..7 只用了 5 位(2 位 tag + 3 位长度),剩下 3 位(bit 5..7)永远为 0。

为什么不把这 3 位也用上(比如多存半个字符)?因为字节对齐让 arToSmallStr 的循环是纯粹的移位+掩码,若跨字节打包就要处理边界。省 3 位换来的复杂度不值得——7 字节已经覆盖了绝大多数小值(数字、短标识、"true"/"false" 的一部分)。

长度用 3 位,范围 0..7。AR_SMALLSTR_MAXLEN = 7、AR_SMALLSTR_LEN_MASK = 0x7(src/sparsearray.h:97-98)。长度 0 的字符串——ARSET k 0 ""——会编码成 v = AR_TAG_STR | 0 = 3,非 NULL,所以是"有值的空字符串"而非空槽。测试 ARGREP handles missing keys and syntax errors、ARSET/ARGET with empty string 验证了这一点。

32 位构建:AR_SMALLSTR_MAXLEN = 3、掩码 0x3,s[0] 在 bit 8,s[2] 在 bit 24,bit 0..7 放 tag(2) + len(2) + 浪费 4 位。

4.7 编码决策顺序

arEncode(src/sparsearray.c:927-947):

void *arEncode(const char *s, size_t len) {
    void *v;

    /* Try integer first */
    if (arTryEncodeInt(s, len, &v)) {
        return v;
    }

    /* Try float */
    if (arTryEncodeFloat(s, len, &v)) {
        return v;
    }

    /* Try small string (architecture-dependent inline limit). */
    if (len <= AR_SMALLSTR_MAXLEN) {
        return arFromSmallStr(s, (int)len);
    }

    /* Fall back to arString (8+ bytes) */
    return arStringNew(s, len);
}

四层,顺序不可换:

  1. 整数优先,因为 "1" 既是合法整数也是 1 字节字符串,整型编码更紧凑(tagged 无负载)且 AROP 的数值运算更快
  2. 浮点第二,因为 "1.0" 是浮点不是整数(string2ll 拒绝),但也可能被误判为短字符串
  3. 短字符串第三,只对 ≤ 7 字节
  4. arString 兜底

arTryEncodeInt 用 string2ll(src/sparsearray.c:805-812):

int arTryEncodeInt(const char *s, size_t len, void **out) {
    long long ll;
    if (string2ll(s, len, &ll) && arIntFits(ll)) {
        *out = arFromInt(ll);
        return 1;
    }
    return 0;
}

string2ll 是 Redis 自己的严格整数解析:只接受可选负号 + 数字,不接受空格、加号、前导零(除了 "0" 本身)。这比 strtoll 严格得多,也拒绝了 "007" 这种(会退回字符串存储,读取时仍是 "007")。

arIntFits 拦住超出 62 位的整数。ARSET k 0 99999999999999999999(20 个 9,超出 int64)→ string2ll 就失败;ARSET k 0 4611686018427387904(2^62,超出 2^61-1 但在 int64 内)→ string2ll 成功但 arIntFits 失败 → 落到短字符串(长度 19 > 7)→ arString。读取时 arStringData 返回原字节,ARGET 看到的是 "4611686018427387904"。正确但不紧凑。

4.8 解码:一个函数四种分支

arDecode(src/sparsearray.c:1016-1048):

const char *arDecode(void *v, char *buf, size_t bufsize, size_t *outlen) {
    serverAssert(bufsize >= AR_INLINE_BUFSIZE);
    if (arIsEmpty(v)) {
        if (outlen) *outlen = 0;
        return NULL;
    }

    if (arIsInt(v)) {
        int64_t ival = arToInt(v);
        int len = ll2string(buf, 32, ival);
        if (outlen) *outlen = len;
        return buf;
    }

    if (arIsFloat(v)) {
        double d = arToDouble(v);
        int len = arFormatFloat(d, buf, bufsize);
        if (outlen) *outlen = len;
        return buf;
    }

    if (arIsSmallStr(v)) {
        int len = arSmallStrLen(v);
        if (outlen) *outlen = len;
        arToSmallStr(v, buf);
        return buf;
    }

    /* arString pointer */
    size_t len = arStringLen(v);
    if (outlen) *outlen = len;
    return arStringData(v);
}

返回值有两种来源:前三种返回调用方的 buf(因为立即数的字节表示不在内存里,必须现场格式化),第四种返回 arString 内部的指针(别名,不拷贝)。

所以调用约定是"返回值可能在 buf 里,也可能指向对象内部,调用方不得修改、且在对象被修改前有效"。注释(src/sparsearray.c:1010-1015)说:

 * For inline values, buf must point to at least AR_INLINE_BUFSIZE bytes and
 * the returned pointer will be buf. For arString values, the returned pointer
 * aliases the string payload directly. Returns NULL if value is empty.
 *
 * This is a helper function used for AOF rewriting, AROP string "MATCH"
 * and DEBUG DIGEST.

bufsize >= AR_INLINE_BUFSIZE(64)是硬要求,因为 arFormatFloat 内部有 serverAssert(bufsize >= AR_INLINE_BUFSIZE)。整数分支只用了 32 字节(ll2string(buf, 32, ival)),但断言要求 64。这是"任何分支都安全"的保守设计。

ll2string 的 32 字节上限足够:int64 最小 -9223372036854775808 是 20 字符 + NUL = 21。


5. 内存记账:alloc_size 的四条写入路径

src/sparsearray.c:42-80 定义了整章的内存记账机制。

5.1 四个分配包装

static inline void *arAllocAndTrack(redisArray *ar, size_t size) {
    size_t usable;
    void *ptr = zmalloc_usable(size, &usable);
    if (ar) ar->alloc_size += usable;
    return ptr;
}
static inline void *arCallocAndTrack(redisArray *ar, size_t size) {
    size_t usable;
    void *ptr = zcalloc_usable(size, &usable);
    if (ar) ar->alloc_size += usable;
    return ptr;
}
static inline void arFreeAndTrack(redisArray *ar, void *ptr) {
    size_t usable;
    zfree_usable(ptr, &usable);
    if (ar) ar->alloc_size -= usable;
}
static inline void *arReallocAndTrack(redisArray *ar, void *ptr, size_t size) {
    size_t usable, old_usable;
    void *newptr = zrealloc_usable(ptr, size, &usable, &old_usable);
    if (ar) ar->alloc_size += usable - old_usable;
    return newptr;
}

核心是"记的是分配器报告的 usable size,不是请求的 size"。jemalloc 会把 72 字节的请求装进 80 字节的 class,zmalloc_usable 返回 80,alloc_size 加 80。

这解释了一个反常现象:arSparseAllocSize(4) 算出 72,但实际记账 80。MEMORY USAGE 看到的是 80,与 sizeof 推导的结果差 8。

ar 参数可以为 NULL,此时跳过记账(if (ar))。这个 NULL 用在两处:arFree(整个数组要销毁,记账无意义)和 arSliceFree(NULL, s)。

5.2 arReallocAndTrack 的旧尺寸依赖

    void *newptr = zrealloc_usable(ptr, size, &usable, &old_usable);
    if (ar) ar->alloc_size += usable - old_usable;

usable - old_usable 可能是负数(收缩时),用 size_t 无符号运算会回绕。但 alloc_size 本身是 size_t,ar->alloc_size += (负数的无符号表示) 等价于减法回绕,结果正确。这是"无符号减法当有符号用"的经典技巧,只要不中间溢出就安全。

zrealloc_usable 同时返回新旧 usable,避免"先查旧尺寸再 realloc"的两次调用。

5.3 值的记账是分开的

/* Track a tagged value entering/leaving the array (arString bookkeeping). */
static inline void arTrackValueIn(redisArray *ar, void *v) {
    if (ar && arIsPtr(v)) ar->alloc_size += zmalloc_size(v);
}
static inline void arTrackValueOut(redisArray *ar, void *v) {
    if (ar && arIsPtr(v)) ar->alloc_size -= zmalloc_size(v);
}

只有 arIsPtr(v) 为真(tag 0,堆字符串)才记账。整数/浮点/短字符串的内存就在那 8 字节槽位里,已经算在 slice 的分配里,不能重复计。

arStringNew 内部只调 zmalloc,不记账(src/sparsearray.c:174):

    uint8_t *ptr = zmalloc(hdr_size + len);

记账的责任落在调用方:arSet 里 arTrackValueIn(ar, v)(src/sparsearray.c:1323、:1340、:1392),arSliceFree 里 arTrackValueOut(ar, ...)(:274、:280)。

这种"分配与记账分离"的设计有风险:任何一条忘了调 arTrackValueIn 的路径都会让 alloc_size 偏小。检查所有 arStringNew 的调用点:arEncode(返回值由调用方记账)、arValueFromRdbInt/arValueFromRdbFloat/arValueFromRdbSmallStr(RDB 加载,交给 arSet 记账)、arSliceDup(显式 arTrackValueIn)。覆盖完整,但依赖纪律。

5.4 为什么 arDup 必须重新记账

src/sparsearray.c:1147-1157 的注释回答了一个自然的疑问:

 * The dear reader of this code may wonder why we don't just duplicate the
 * array and its slices without tracking memory, and then copy the memory
 * field of the array at the end. The problem is that the array does not
 * track the logical allocated memory, but the actual memory usage reported
 * by the allocator: there is no guarantee that the allocations of the copy
 * perfectly match the ones of the original array.

因为记的是"分配器实际给的",不是"逻辑大小"。复制出来的 slice 可能落在不同的 jemalloc class 上(尤其是当源分配经历过增长、当前 size 与 class 不完全匹配时),所以不能直接抄 alloc_size。

arSliceDup(src/sparsearray.c:1158-1195)因此每个 slice 都走 arAllocAndTrack,每个 arString 都走 arStringDup + arTrackValueIn。

5.5 dismiss:把页还给 OS

arDismiss(src/sparsearray.c:1123-1145)是 fork 前 dismissMemory 的入口:

void arDismiss(redisArray *ar, size_t size_hint) {
    if (!ar) return;
    uint64_t count = ar->count;
    int dismiss_values = (count != 0 && size_hint / count >= server.page_size);
    ...
}

size_hint 是整个键的内存占用(kvobjAllocSize 的结果)。size_hint / count 是平均每元素字节数。只有平均每元素 ≥ 一个页(通常 4096 字节)时才 dismiss 值本身——因为 dismissMemory 内部有 size_hint <= server.page_size/2 的门槛(src/server.c:7592),小于半页的调用是空操作。

arSliceDismiss(src/sparsearray.c:1099-1119)有个小瑕疵:

        if (dismiss_values) {
            void **items = s->layout.dense.items;
            for (uint32_t i = 0; i < s->layout.dense.winsize; i++) {
                if (arIsPtr(items[i]))
                    dismissMemory(items[i], arStringLen(items[i]));
            }
        }

传给 dismissMemory 的长度是 arStringLen(payload 长度),而实际分配是 hdr_size + len(多 2 或 8 字节)。少给的这 2-8 字节不影响正确性(madvise(MADV_DONTNEED) 作用于页,长度只是"是否值得"的门槛),但如果一个字符串恰好跨页边界,最后一页可能不会被释放。

arDismiss 不清 alloc_size,因为 dismiss 不改变所有权,只是提示内核"这些页可以丢弃"。

5.6 一个空数组的精确开销

用 sizeof 和 arSparseAllocSize 推算,ARSET k 0 v 之后的内存:

项逻辑大小jemalloc class记账
redisArray808080
flat 目录(1 槽)888
sparse slice(cap=4)728080
值(整数)0—0
值(arString,10 字节)243210 或 32
合计(整数)160—168

arString 的记账是 zmalloc_size(v) = 32,而 arStringLen 返回 10。所以 MEMORY USAGE 报的是"分配器给的",不是"用户数据"。

对比一下同规模的其它类型(单个元素):

  • String:robj 16 + sds(embstr 时合一)约 32-48
  • Hash(listpack,1 对):约 100
  • Set(listpack,1 个):约 80
  • ZSet(listpack,1 个):约 90
  • Stream(1 条消息):约 400
  • Array(1 个整数):约 168

Array 比 String 贵、比 Stream 便宜。这个位置符合它的定位:为中等规模以上的索引容器设计,单元素场景不是目标。


6. sparse 与 dense 的双向切换

这是 Array 内部唯一的"编码切换",虽然它发生在 slice 级别而不是整个对象级别。

6.1 两态的取舍

sparse:offsets[] + values[] 两个平行数组,按 offset 有序。查找二分 O(log k),插入要 memmove。内存 = 32 头 + 2k + 8k(k = cap)。

dense:一个 items[] 窗口,下标直接映射。查找 O(1),插入 O(1)。内存 = 32 头 + 8w(w = winsize)。

关键数字:默认 kmax = 10,所以 sparse 最多 10 个元素 = 32 + 20 + 80 = 132 字节(cap=10)。dense 的最小窗口 8 = 32 + 64 = 96 字节。

k = 10 时 sparse 反而比 dense 大。 那为什么还要 sparse?

因为 dense 的窗口是"从最小 offset 到最大 offset 的连续区间"。10 个元素如果散布在 offset 0、100、200、...、900,dense 需要窗口 1024(下一个 2 的幂),内存 32 + 8192 = 8224 字节。sparse 只要 132。

所以真实判据不是"元素个数"而是"元素密度":

  • sparse 成本 ∝ 元素数(线性)
  • dense 成本 ∝ 跨度(可能远大于元素数)

kmax = 10 的意思是:只要元素数超过 10,就假定密度足够高,dense 更划算。这个假定在"顺序写入"场景下几乎总成立(10 个连续元素 → dense 窗口 8→16,成本 32+128 = 160,sparse 132,其实还是 sparse 小一点,但 dense 的查找是 O(1))。

所以 kmax 是一个"性能阈"而不是"内存阈"。到 11 个元素时,二分 + memmove 的成本开始超过 dense 的窗口浪费。注释里没这么说,但从数字能推出来。

6.2 promote:sparse 到 dense

arSparsePromote(src/sparsearray.c:407-451):

arSlice *arSparsePromote(redisArray *ar, arSlice *s, uint32_t slice_size) {
    if (s->count == 0) {
        arFreeAndTrack(ar, s);
        return arSliceDenseNew(ar, 0, slice_size);
    }

    uint16_t *offsets = s->layout.sparse.offsets;
    void **values = s->layout.sparse.values;

    uint32_t min_off = offsets[0];
    uint32_t max_off = offsets[s->count - 1];
    uint32_t need = max_off - min_off + 1;

    uint32_t winsize = nearestNextPowerOf2(need);
    if (winsize < AR_SLICE_MIN_ALLOC) winsize = AR_SLICE_MIN_ALLOC;

    uint32_t offset = min_off;
    if (winsize >= slice_size) {
        winsize = slice_size;
        offset = 0;
    } else if (offset + winsize > slice_size) {
        /* Window would exceed slice boundary, adjust offset */
        offset = slice_size - winsize;
    }
    ...

窗口大小 = nearestNextPowerOf2(max_off - min_off + 1),即"刚好容纳现有跨度"的 2 的幂。这是精确的:不按 count 算,按跨度算。

三个钳位:

  1. winsize < 8 → 提到 8(AR_SLICE_MIN_ALLOC)
  2. winsize >= slice_size → 设为 slice_size 且 offset = 0(整个 slice 一个窗口)
  3. offset + winsize > slice_size → 左移 offset 到 slice_size - winsize

第 3 条的例子:slice_size = 4096,min_off = 4000,max_off = 4095,need = 96,winsize = 128。offset = 4000,4000 + 128 = 4128 > 4096,所以 offset = 4096 - 128 = 3968。窗口是 [3968, 4095],包含了 [4000, 4095],满足。

第 2 条的必要性:need 可能等于 slice_size(元素占满整个 slice),此时 nearestNextPowerOf2(4096) = 4096,winsize = 4096 >= slice_size,offset = 0。若不设 offset = 0,min_off 可能非 0,窗口会越界。

搬移时逐个填(src/sparsearray.c:442-447):

    memset(d->layout.dense.items, 0, winsize * sizeof(void *));
    for (uint32_t i = 0; i < s->count; i++) {
        serverAssert(offsets[i] >= offset);
        serverAssert(offsets[i] - offset < winsize);
        d->layout.dense.items[offsets[i] - offset] = values[i];
    }

两个断言验证上面的钳位逻辑。若钳位算错,这里会 panic 而不是静默越界写——这是好的。

注意 max_idx = max_off(:437):dense 的 max_idx 记录"最高有值的 offset",promote 时直接抄 sparse 的 offsets[count-1],不需要重扫。

6.3 demote:dense 到 sparse

arDenseMaybeDemote(src/sparsearray.c:458-492)有六个前置判断,逐个都必要:

arSlice *arDenseMaybeDemote(redisArray *ar, arSlice *d) {
    if (ArraySparseKMax == 0) return d; // Sparse is disabled by config.
    if (d->count > ArraySparseKMin) return d; // Yet not at demotion level.
    if (d->count > ArraySparseKMax) return d; // Just config sanity check.
    if (d->layout.dense.winsize == AR_SLICE_MIN_ALLOC) return d; // Already small.

    /* Only demote if it actually saves memory. We require the dense slice
     * to be significantly larger than sparse would be (at least 25% bigger),
     * and large enough in absolute terms (4x kmin) to be worth the trouble. */
    size_t dense_bytes = arDenseAllocSize(d->layout.dense.winsize);
    size_t sparse_bytes = arSparseAllocSize(ArraySparseKMin);
    if (d->layout.dense.winsize < 4 * ArraySparseKMin) return d;
    if (dense_bytes < sparse_bytes * 5 / 4) return d;
  1. kmax == 0:配置禁用了 sparse,永不 demote
  2. count > kmin:元素还太多(默认 kmin=5,即 count ≤ 5 才考虑)
  3. count > kmax:防御性检查,当前面两个通过时不可能成立(kmin < kmax)
  4. winsize == 8:窗口已经最小,demote 后可能更大
  5. winsize < 4 * kmin:默认即 winsize < 20,太小的窗口不值得搬
  6. dense_bytes < sparse_bytes * 5/4:省不到 25% 就不搬

第 5 条的注释说 "large enough in absolute terms (4x kmin) to be worth the trouble"。默认 kmin=5,所以 winsize >= 20,实际最小是 32(2 的幂)。

第 6 条的算术(默认值):sparse_bytes = arSparseAllocSize(5) = 32 + 10 + padding(6) + 40 = 88,88 * 5/4 = 110。dense_bytes = 32 + 8*w。要 32 + 8w >= 110,即 w >= 9.75,w >= 16(2 的幂)。

结合第 5 条(w >= 20),实际门槛是 w >= 32(因为 32 是 ≥ 20 的最小 2 的幂)。此时 dense_bytes = 32 + 256 = 288,sparse_bytes = 88,比例 3.3 倍,确实值得搬。

所以真实的 demote 条件是:一个 dense slice 的窗口 ≥ 32 且元素数 ≤ 5。 这在"写入 100 个元素后删掉 95 个"的场景下触发。

arDenseMaybeDemote 用 arSparseAllocSize(ArraySparseKMin) 估算目标大小,而 demote 后确实分配 ArraySparseKMin 的容量(:473):

    arSlice *s = arAllocAndTrack(ar, arSparseAllocSize(ArraySparseKMin));
    s->encoding = AR_SLICE_SPARSE;
    s->count = 0;
    s->layout.sparse.cap = ArraySparseKMin;

注意这里把 cap 设为 kmin 而不是 kmax。若之后又插入元素,cap 会按 2 倍增长到 kmax(src/sparsearray.c:1360-1384)。这是一个"先给最小值,按需增长"的策略。

搬移的最后一步也值得注意(src/sparsearray.c:482-488):

    for (uint32_t i = 0; i < d->layout.dense.winsize && s->count < d->count; i++) {
        if (!arIsEmpty(d->layout.dense.items[i])) {
            offsets[s->count] = d->layout.dense.offset + i;
            values[s->count] = d->layout.dense.items[i];
            s->count++;
        }
    }

循环条件是 i < winsize && s->count < d->count。第二个条件让循环提前结束——当已经搬够 count 个元素时停。这是为了 defensive:如果 dense 的 items 里有多于 count 个非空(不可能,但不信任),不会越界写 offsets。

搬完后 s->count 应该等于 d->count。代码没有断言这一点,是个小疏漏。

6.4 kmin 与 kmax 的配置约束

src/sparsearray.h:72-73:

#define AR_SPARSE_KMAX_DEFAULT    10
#define AR_SPARSE_KMIN_DEFAULT    5

config 范围(src/config.c:3560-3561):kmax 0..256,kmin 0..256,都是 MODIFIABLE_CONFIG。

两个交叉校验函数(src/config.c:2646-2660):

static int isValidArraySparseKmax(long long val, const char **err) {
    if (val > 0 && (unsigned int)val <= server.array_sparse_kmin) {
        *err = "array-sparse-kmax must be greater than array-sparse-kmin when non-zero";
        return 0;
    }
    return 1;
}

static int isValidArraySparseKmin(long long val, const char **err) {
    if (server.array_sparse_kmax > 0 && (unsigned int)val >= server.array_sparse_kmax) {
        *err = "array-sparse-kmin must be less than array-sparse-kmax";
        return 0;
    }
    return 1;
}

必须 kmin < kmax(当 kmax > 0)。两个函数互相读对方的当前值,所以校验结果依赖设置顺序:先设 kmax=3 会失败(因为 kmin 默认 5),必须先设 kmin=2 再设 kmax=3。

这是 Redis config 校验的常见缺陷——交叉约束没有"先应用再校验"的原子性。用户必须知道顺序。错误信息会提示"must be greater than array-sparse-kmin",但不会建议"先降 kmin"。

两个边界值的语义:

  • kmax = 0:完全禁用 sparse。arSet 里 if (ArraySparseKMax > 0) s = arSliceSparseNew(ar); else s = arSliceDenseNew(ar, rel_idx, ar->slice_size);(src/sparsearray.c:1297-1302)。所有 slice 从 dense 开始,arDenseMaybeDemote 第一行就返回。
  • kmin = 0:永远不 demote。因为 if (d->count > ArraySparseKMin) 变成 if (d->count > 0),只要 slice 非空就返回。只有 count = 0 才通过,但 count = 0 时 slice 会被整个释放(arDel 里 if (s->count == 0) { arSliceFree... }),走不到 demote。

kmin = 0 这个"看起来像配置错"的取值其实有明确语义:禁用 demote,但保留 promote。数组会单调地"只升不降",适合写入为主的场景。

6.5 dense 窗口的双向增长

dense 的窗口是一个滑动区间 [offset, offset + winsize)。写入超出右边界要向右扩,超出左边界要向左扩。两条路径的实现差异极大。

向右扩(arSliceDenseGrowRight,src/sparsearray.c:288-337):

    uint32_t new_winsize = s->layout.dense.winsize;

    /* Double until rel_idx fits */
    while (rel_idx >= s->layout.dense.offset + new_winsize && new_winsize < slice_size) {
        new_winsize <<= 1;
    }

    uint32_t new_offset = s->layout.dense.offset;
    if (new_winsize >= slice_size) {
        new_winsize = slice_size;
        new_offset = 0;
    } else if (new_offset + new_winsize > slice_size) {
        new_offset = slice_size - new_winsize;
    }

    /* Fast path: when offset does not move, we can use realloc() to grow
     * the dense allocation without relocating existing items ourselves. */
    if (new_offset == s->layout.dense.offset) {
        uint32_t old_winsize = s->layout.dense.winsize;
        arSlice *ns = arReallocAndTrack(ar, s, arDenseAllocSize(new_winsize));
        ns->layout.dense.winsize = new_winsize;
        ns->layout.dense.items = (void **)(ns + 1);

        /* New tail must be explicitly zeroed for arIsEmpty() semantics. */
        memset(ns->layout.dense.items + old_winsize, 0,
               (new_winsize - old_winsize) * sizeof(void *));
        return ns;
    }

realloc 快路径成立的条件是 offset 没动。因为窗口向左不扩时 offset 保持不变,数据区的前缀位置不变,realloc 只是把尾部拉长,items[] 的相对下标全部有效。

但 items 指针必须重新赋值:realloc 可能把整块搬到新地址,ns->layout.dense.items = (void **)(ns + 1)。若忘了这行,items 会指向已释放的旧内存。

新增的尾部必须 memset 成 0:arIsEmpty 判定依赖 NULL。realloc 不保证新内存清零(jemalloc 的 rallocx 可能返回脏页)。

向左扩(arSliceDenseGrowLeft,src/sparsearray.c:343-374)没有 realloc 路径:

arSlice *arSliceDenseGrowLeft(redisArray *ar, arSlice *s, uint32_t rel_idx, uint32_t slice_size) {
    uint32_t old_end = s->layout.dense.offset + s->layout.dense.winsize;
    uint32_t need = old_end - rel_idx;

    /* Find next power of two that fits */
    uint32_t new_winsize = nearestNextPowerOf2(need);
    if (new_winsize < AR_SLICE_MIN_ALLOC) new_winsize = AR_SLICE_MIN_ALLOC;
    if (new_winsize > slice_size) new_winsize = slice_size;

    /* Position the window so that the old data is right-aligned (leaving
     * slack on left) */
    int32_t new_offset = (int32_t)old_end - (int32_t)new_winsize;
    if (new_offset < 0) new_offset = 0;
    if (new_winsize == slice_size) new_offset = 0;

注释(:339-342)解释了为什么不能 realloc:"in this case no realloc() optimization is possible, still we can grow on the left more than needed (next power of two) so if there is a right-to-left access pattern we can cope."

不能 realloc 的原因是数据要从"靠左"变成"靠右"——offset 变小了,同一个元素在新窗口里的相对位置变大。这必须搬内存。而 realloc 只会在尾部追加,无法在头部插入空间。

"多长一点"的策略:need 是精确需求,nearestNextPowerOf2 把它放大到 2 的幂,然后窗口"右对齐"(new_offset = old_end - new_winsize),左侧留出 slack。这样连续向左写入时,前几次不需要再扩。

int32_t new_offset 用有符号中间变量,因为 old_end - new_winsize 可能为负(窗口比已有数据还小的情况,在 need 计算正确时不会发生,但 int32_t 转换后的 < 0 检查是保险)。

这个左扩的 slack 有一个副作用:arDenseMaybeDemote 的判据是 winsize,左扩把 winsize 放大到 2 的幂可能让 demote 更容易触发(winsize 增大 → 更可能满足 winsize >= 32)。这在"反向扫描写入"的场景下会导致频繁 demote,然后稀疏插入,然后 promote——一种来回震荡。

没有代码阻止这种震荡。arDenseMaybeDemote 只看当前状态,不看历史。

6.6 新建 dense 的 offset 左移

arSliceDenseNew(src/sparsearray.c:219-239):

    uint32_t winsize = AR_SLICE_MIN_ALLOC;
    uint32_t offset = rel_idx;

    /* Adjust offset if the initial window would extend past the slice
     * boundary. For example, with slice size 4096 (the default), creating
     * the slice around relative index 4093 needs the window shifted left. */
    if (offset + winsize > slice_size) {
        offset = slice_size - winsize;
    }

ARSET k 4093 v 时 rel_idx = 4093,winsize = 8,4093 + 8 = 4101 > 4096,所以 offset = 4088,窗口 [4088, 4095],包含 4093。

若不左移,offset = 4093,窗口 [4093, 4100] 越出 slice 边界,写 items[4093 - 4093] 会在分配外的位置。

这个修正只在新建时做一次。后续 growRight 里有同样的逻辑(:300-303)。

6.7 promote 与 demote 的触发点汇总

触发位置条件
sparse → densearSets->count >= ArraySparseKMax 且要插入新元素
sparse → dense(预判)arMayPromoteToDenseForRangeSet范围写入的最终元素数会超过 kmax
dense → sparsearDel删除后 count <= kmin 且窗口够大
dense → sparsearDeleteSliceRange批量删除后同上
dense 新建arSet 建新 slice 且 kmax == 0配置禁用 sparse
dense 新建arMayPromoteToDenseForRangeSet 建新 slice 且范围 > kmax批量写入预判

注意 arSetSlice 的返回值处理:arSet 里 promote 之后要 arSetSlice(ar, slice_id, d)(src/sparsearray.c:1350),因为 slice 的地址变了,目录里的指针必须更新。demote 同理(:1434)。

这两处更新如果漏掉,目录会指向已释放的旧 slice。 这是整个结构里最容易出错的地方:slice 的指针存在于目录中,但 slice 会自我替换。凡是"返回新 slice"的函数,调用方都必须回写目录。

arSliceDenseGrowIfNeeded 的调用点也遵循这个规则(src/sparsearray.c:1308-1309):

        s = arSliceDenseGrowIfNeeded(ar, s, rel_idx, ar->slice_size);
        arSetSlice(ar, slice_id, s); // In case it changed.

注释 "In case it changed" 就是这个意思。而这行 arSetSlice 会在 slice 未变时也执行一次——arSetSlice 会判 s != NULL && dir[slice_id] == NULL 才增计数,所以幂等,但多了一次函数调用和两次比较。这是"为了正确性牺牲微效率"的典型。

6.8 arMayPromoteToDenseForRangeSet:一个安全但脆弱的优化

src/sparsearray.c:1780-1832 的动机(注释 :1769-1779):

 * When ARSET writes many elements to a sparse slice, each insertion
 * requires a sorted insert with memmove. If the slice eventually exceeds
 * kmax elements, it gets promoted to dense anyway - wasting all that work.

批量写入 100 个连续值时,如果不预判,会经历:sparse 插入 10 次(每次 memmove)→ promote 到 dense → 再插 90 次。预判则直接建 dense。

实现逐 slice 检查(:1789-1831):

    for (uint64_t sid = slice_lo; sid <= slice_hi; sid++) {
        uint64_t range_start = (sid == slice_lo) ? lo : (sid << arSliceBits(ar->slice_size));
        uint64_t range_end = (sid == slice_hi) ? hi : ((sid + 1) << arSliceBits(ar->slice_size)) - 1;
        ...
        if (s == NULL) {
            if (range_size > ArraySparseKMax) {
                arSetSlice(ar, sid, arSliceDenseNew(ar, start_off, ar->slice_size));
            }
            continue;
        }

        if (s->encoding == AR_SLICE_DENSE) continue;

        if (range_size > ArraySparseKMax) {
            arSetSlice(ar, sid, arSparsePromote(ar, s, ar->slice_size));
            continue;
        }

        /* Count existing elements in [start_off, end_off] via linear scan. */
        uint16_t *offsets = s->layout.sparse.offsets;
        uint32_t existing = 0;
        for (uint32_t i = 0; i < s->count; i++) {
            if (offsets[i] >= start_off && offsets[i] <= end_off) {
                existing++;
            }
        }

        uint32_t new_elements = range_size - existing;
        if (s->count + new_elements > ArraySparseKMax) {
            arSetSlice(ar, sid, arSparsePromote(ar, s, ar->slice_size));
        }
    }

这个函数是 O(slice 数) 的,而 slice 数 = 跨度 / slice_size。 如果 lo 和 hi 相距 10^12,循环会跑 2.4 亿次。

它之所以安全,是因为唯一的两个调用点都保证了跨度等于要写入的元素数:

  • arsetCommand(src/t_array.c:189-190):last_idx = start_idx + num_values - 1,跨度就是 num_values
  • arinsertCommand(src/t_array.c:1516-1517):同理

所以 slice_hi - slice_lo <= num_values / 256,循环次数上界是 num_values / 256。写 100 万个值最多循环 3906 次,可接受。

这是一个隐式契约:函数名说的是"for range set",没有说明"range 必须紧凑"。 若将来有人在 ARDELRANGE 或某个新命令里复用它处理任意范围,会造成长时间阻塞。sparsearray.h:303 把它列为公开 API,注释只有一行 "Bulk operations"。

更稳的写法是在函数开头断言 hi - lo < 某个上界,或者把"逐 slice 检查"改成"只检查 lo_slice 和 hi_slice 两个边界 slice"(中间整片 slice 要么不存在要么已 dense)。当前的实现没有做这个优化——它对中间每一个 slice 都调 arGetSlice,在 superdir 模式下每次是两次二分查找(arSetSlice 内部还有一次)。


7. 单点读写:三条路径的分支密度

7.1 arGet:O(1) 的边界在哪

void *arGet(redisArray *ar, uint64_t idx) {
    uint64_t slice_id = arSliceId(idx, ar->slice_size);
    uint32_t rel_idx = arSliceOff(idx, ar->slice_size);

    arSlice *s = arGetSlice(ar, slice_id);
    if (s == NULL) return NULL; // No slice at all for this index.

    if (s->encoding == AR_SLICE_DENSE) {
        if (rel_idx < s->layout.dense.offset ||
            rel_idx >= s->layout.dense.offset + s->layout.dense.winsize)
        {
            // The slice window does not include this index.
            return NULL;
        }
        return s->layout.dense.items[rel_idx - s->layout.dense.offset];
    } else {
        int found;
        uint32_t pos = arSparseFindPos(s, (uint16_t)rel_idx, &found);
        if (found) {
            void **values = s->layout.sparse.values;
            return values[pos];
        }
        return NULL;
    }
}

三段:算 slice_id、取 slice、按编码分支。dense 分支是两次比较加一次数组索引,sparse 分支是一次二分查找。

arGetSlice 的复杂度决定了 arGet 的真实复杂度:

  • flat 模式:if (slice_id >= dir_alloc) return NULL; return dir[slice_id];,纯 O(1)
  • superdir 模式:arSuperDirGetSlot → arSuperDirFind 二分 superdir[] → slots + block_off,O(log B),B 是已分配的 block 数

所以 ARGET 命令文档写的 "O(1)"(src/commands/arget.json)只对 flat 模式严格成立。superdir 模式下 B 的上界是 2^64 / (2048 * 4096) = 2^39,log2 是 39 次比较——实际上 B 远小于这个值(受实际分配的元素数限制),但理论上不是常数。

这个近似在所有"O(1)"的宣称里都存在(ARLEN、ARNEXT、ARSEEK 同样)。Redis 的命令复杂度注释一向是"忽略对数的常数级",XADD 也写 O(1) 而实际是 rax 插入 O(键长)。

7.2 dense 分支的空洞判定

if (rel_idx < offset || rel_idx >= offset + winsize) return NULL;

窗口是 [offset, offset + winsize),rel_idx 不在其中就是"这个 slice 有数据但不覆盖这个位置"。这是 dense 的一个特性:slice 存在 ≠ 这个具体 offset 有值。

窗口通常在 slice 的某个连续区间内。ARSET k 0 v 建了窗口 [0, 8),之后 ARGET k 4095:rel_idx = 4095,4095 >= 0 + 8,返回 NULL。正确。

相比之下 sparse 分支不需要这个检查:arSparseFindPos 会在 offsets[] 里二分,找不到就是没有。

两条分支的空洞表示不同:

  • dense:空洞是 items[pos] == NULL,位置存在但值为空
  • sparse:空洞是"offset 不在 offsets[] 里",位置不存在

这个不对称导致 arScanIter 要写两套逻辑(§9)。

7.3 arSet:五个阶段

src/sparsearray.c:1281-1405,从断言到收尾约 120 行,是本章最长的单函数。

阶段 0:前置断言

    serverAssert(v != NULL);  /* Use arDel for deletion, not arSet(v=NULL) */
    serverAssert(idx != UINT64_MAX);

第一条:删除必须走 arDel,不能用 arSet(v=NULL)。原因是 arSet 的计数逻辑假设"写入一个非空值",若允许 NULL 就要区分"填空洞"和"清空已有",逻辑复杂化。

第二条在 §3.4 讨论过。

阶段 1:确保目录容量

    arDirGrow(ar, slice_id);

这一步可能触发 superdir 提升。注意 arDirGrow 在 superdir 模式下直接返回(src/sparsearray.c:665-669):

    if (ar->superdir) {
        /* Superdir allocates blocks on-demand in arSetSlice(), so we don't
         * allocate a 2048-pointer block for ranges that end up empty. */
        return;
    }

注释说的是"按需分配 block",实际在 arSetSlice 里通过 arSuperDirEnsureSlot 分配。所以 arDirGrow 之后 arGetSlice 仍可能返回 NULL——这是正常的。

阶段 2:无 slice 则创建

    arSlice *s = arGetSlice(ar, slice_id);

    if (s == NULL) {
        if (ArraySparseKMax > 0) {
            s = arSliceSparseNew(ar);
        } else {
            s = arSliceDenseNew(ar, rel_idx, ar->slice_size);
        }
        arSetSlice(ar, slice_id, s);
    }

默认从 sparse 开始,因为新 slice 通常只写一个元素,sparse 的 72 字节远小于 dense 的 96 字节(winsize=8 时 32+64)。

只有配置 array-sparse-kmax 0 时才直接建 dense。

arSliceSparseNew 的初始容量(src/sparsearray.c:256-264):

arSlice *arSliceSparseNew(redisArray *ar) {
    uint32_t cap = (ArraySparseKMax < 4) ? ArraySparseKMax : 4;
    arSlice *s = arAllocAndTrack(ar, arSparseAllocSize(cap));

min(kmax, 4)。默认 kmax=10 时 cap=4。所以空 sparse slice 是 32 + 8 + 0 + 32 = 72 字节。

阶段 3 dense 路径

    if (s->encoding == AR_SLICE_DENSE) {
        /* Grow the slice window if needed */
        s = arSliceDenseGrowIfNeeded(ar, s, rel_idx, ar->slice_size);
        arSetSlice(ar, slice_id, s); // In case it changed.

        uint32_t pos = rel_idx - s->layout.dense.offset;
        void *old = s->layout.dense.items[pos];

        if (arIsEmpty(old)) {
            s->count++;
            ar->count++;
        } else {
            /* Replace existing value. */
            arTrackValueOut(ar, old);
            arFreePtr(old);
        }

        arTrackValueIn(ar, v);
        s->layout.dense.items[pos] = v;

        /* Update max_idx */
        if (rel_idx > s->layout.dense.max_idx) {
            s->layout.dense.max_idx = rel_idx;
        }
    }

顺序很重要:先 arTrackValueOut + arFreePtr(old),再 arTrackValueIn(v)。如果反过来,且 v == old(同一指针,正常不会发生),会先记账再释放已记账的指针。

arFreePtr 只在 arIsPtr(old) 时真正 free(src/sparsearray.c:208-212):

void arFreePtr(void *v) {
    if (arIsPtr(v)) {
        arStringFree(v);
    }
}

立即数(int/float/smallstr)是"值即指针",不需要释放。

max_idx 只在变大时更新。删除时由 arDenseUpdateMaxIdx 负责缩小。

阶段 4 sparse 路径:替换

    } else {
        int found;
        uint32_t pos = arSparseFindPos(s, (uint16_t)rel_idx, &found);
        uint16_t *offsets = s->layout.sparse.offsets;
        void **values = s->layout.sparse.values;

        if (found) {
            /* Replace existing */
            arTrackValueOut(ar, values[pos]);
            arFreePtr(values[pos]);
            arTrackValueIn(ar, v);
            values[pos] = v;
        } else {

和 dense 的替换逻辑一致,但位置来自二分。

阶段 5 sparse 路径:插入或 promote

            if (s->count >= ArraySparseKMax) {
                /* Promote to dense */
                arSlice *d = arSparsePromote(ar, s, ar->slice_size);

                /* Grow window if needed */
                d = arSliceDenseGrowIfNeeded(ar, d, rel_idx, ar->slice_size);
                arSetSlice(ar, slice_id, d);

                uint32_t dpos = rel_idx - d->layout.dense.offset;
                arTrackValueIn(ar, v);
                d->layout.dense.items[dpos] = v;
                d->count++;
                ar->count++;
                if (rel_idx > d->layout.dense.max_idx) d->layout.dense.max_idx = rel_idx;
            } else {

promote 之后立刻 arSliceDenseGrowIfNeeded,因为 arSparsePromote 算出的窗口只覆盖已有元素的范围。如果要写入的 rel_idx 在范围之外(比如已有元素在 [0, 5],现在写 100),窗口 [0, 8) 不含 100,必须再扩。

这个"两次调整窗口"是 promote 的必要成本:arSparsePromote 不知道调用方要写到哪。

阶段 5b:sparse 容量增长

                /* Insert in sparse */
                if (s->count >= s->layout.sparse.cap) {
                    /* Grow capacity, we grow 2x but note that there is no
                     * point in growing more than kmax, so we clamp to kmax. */
                    uint32_t new_cap = s->layout.sparse.cap * 2;
                    if (new_cap > ArraySparseKMax) new_cap = ArraySparseKMax;
                    arSlice *ns = arAllocAndTrack(ar, arSparseAllocSize(new_cap));
                    ns->encoding = AR_SLICE_SPARSE;
                    ns->count = s->count;
                    ns->layout.sparse.cap = new_cap;
                    arSparseSetupPointers(ns);

                    /* Copy old data to new slice */
                    uint16_t *old_offsets = s->layout.sparse.offsets;
                    void **old_values = s->layout.sparse.values;
                    uint16_t *new_offsets = ns->layout.sparse.offsets;
                    void **new_values = ns->layout.sparse.values;
                    memcpy(new_offsets,old_offsets,s->count * sizeof(uint16_t));
                    memcpy(new_values,old_values,s->count * sizeof(void *));

                    arFreeAndTrack(ar, s);
                    s = ns;
                    arSetSlice(ar, slice_id, s);
                    offsets = new_offsets;
                    values = new_values;
                }

容量序列是 4 → 8 → 10(clamp 到 kmax)。不是 4 → 8 → 16,因为 count >= kmax 会走 promote,永远不会需要 16。

注意 arFreeAndTrack(ar, s) 而不是 arSliceFree(ar, s):后者会遍历并释放所有 arString(src/sparsearray.c:269-285),而这里元素的所有权已经搬到 ns 上了。用 arSliceFree 会造成双重释放。

这个"搬移后裸释放"的模式在代码里出现两次(arSet 的这里、arSliceDenseGrowRight 的 arFreeAndTrack(ar, s) 在 :335)。两处的共同点是"值已搬走,只释放壳"。

阶段 5c:保序插入

                /* Shift and insert in place */
                memmove(offsets + pos + 1, offsets + pos,
                        (s->count - pos) * sizeof(uint16_t));
                memmove(values + pos + 1, values + pos,
                        (s->count - pos) * sizeof(void *));
                offsets[pos] = (uint16_t)rel_idx;
                arTrackValueIn(ar, v);
                values[pos] = v;
                s->count++;
                ar->count++;

两次 memmove,因为 offsets 和 values 是两个独立数组。若用 struct {uint16_t off; void *val;} 的数组,插入只需一次 memmove,但每个元素会因对齐占用 16 字节(2 字节 offset + 6 填充 + 8 指针)而不是分开存的 2+8=10 字节。kmax=10 时,分开存省 60 字节,但插入多一次 memmove。

antirez 选了分开存。10 个元素的 memmove 是 20 字节和 80 字节各一次,缓存在 L1 里,成本可忽略;而省下的 60 字节是持续的内存。

阶段 6:更新 dir_highest_used

    /* Update dir_highest_used. The count==1 check handles when we just added
     * the first element to an empty array. */
    if (slice_id > ar->dir_highest_used || ar->count == 1) {
        ar->dir_highest_used = slice_id;
    }

|| ar->count == 1 这个条件是必要的补丁:数组从空变非空时,dir_highest_used 可能已经是 0,而 slice_id 也是 0,第一个条件不成立。如果不写这个补丁,ar->count == 0 时空数组的 dir_highest_used 是 0(初始值),写第一个元素到 slice 0 后仍是 0——看似没问题。

真正的问题在别处:arDel 删空后 arDirUpdateHighest 会把 dir_highest_used 设为 0,然后 arDirMaybeShrink。若数组被清空(但键还在,实际上清空会删键),再写入 slice 5:

  • ar->count 从 0 变 1
  • slice_id = 5 > dir_highest_used = 0 → 第一个条件成立

所以补丁针对的是"写入 slice 0 到空数组"。此时两个条件都不成立?0 > 0 假,count == 1 真 → 成立,赋 0。赋的还是 0,什么也没变。

结论:这个 || ar->count == 1 是死代码。它对 dir_highest_used 的赋值与现状相同,不产生任何效果。注释说它"handles when we just added the first element to an empty array",但那个场景下 dir_highest_used 已经是 0,赋 0 是幂等的。

唯一可能的意义是"防御性":如果 dir_highest_used 因为某个 bug 变成了非 0(比如 100),而数组是空的,那么写 slice 0 时第一个条件 0 > 100 为假,第二个条件 count == 1 为真,会把它修正为 0。这是从异常状态恢复的保险,正常路径上不触发。

7.4 arDel:三种收尾

int arDel(redisArray *ar, uint64_t idx) {
    uint64_t slice_id = arSliceId(idx, ar->slice_size);
    uint32_t rel_idx = arSliceOff(idx, ar->slice_size);

    arSlice *s = arGetSlice(ar, slice_id);
    if (s == NULL) return 0;

    if (s->encoding == AR_SLICE_DENSE) {
        if (rel_idx < s->layout.dense.offset || rel_idx >= s->layout.dense.offset + s->layout.dense.winsize) {
            return 0;
        }

        uint32_t pos = rel_idx - s->layout.dense.offset;
        void *old = s->layout.dense.items[pos];
        if (arIsEmpty(old)) return 0;

        arTrackValueOut(ar, old);
        arFreePtr(old);
        s->layout.dense.items[pos] = NULL;
        s->count--;
        ar->count--;

        /* Update max_idx if we deleted the max */
        arDenseUpdateMaxIdx(s, rel_idx);
        if (s->count != 0) {
            /* Maybe demote to sparse. */
            arSetSlice(ar, slice_id, arDenseMaybeDemote(ar, s));
            return 1;
        }
    } else {

dense 路径的 if (s->count != 0) { ...; return 1; } 是一个提前返回。它跳过了函数末尾的 arDirUpdateHighest 和 arDirMaybeShrink。

这是正确的:slice 还在(count > 0),dir_highest_used 指向的 slice 没被删除,不需要重算。但 demote 可能把这个 slice 从 dense 变成 sparse,dir_highest_used 的"最高 slice"语义不变。

不过有个细节:如果被删的 rel_idx 是这个 slice 的最高元素,且这个 slice 是 dir_highest_used 指向的 slice,那么 arLen 会变小——但 arLen 是每次现算的(读 arSliceMaxIdx),不缓存,所以不需要更新任何状态。dir_highest_used 只表示"最高的非空 slice",不表示"最高元素的位置"。

这个分离是对的:slice 级的索引和元素级的索引分开维护,各自更新。

arDenseUpdateMaxIdx(src/sparsearray.c:496-508):

void arDenseUpdateMaxIdx(arSlice *d, uint32_t deleted_off) {
    /* Note that if the slice is left without elements, it will get
     * deallocated so there is nothing to set. */
    if (d->count == 0 || deleted_off < d->layout.dense.max_idx) return;

    /* Scan backward from old max to find new max. */
    for (int pos = d->layout.dense.max_idx - d->layout.dense.offset; pos >= 0; pos--) {
        if (!arIsEmpty(d->layout.dense.items[pos])) {
            d->layout.dense.max_idx = d->layout.dense.offset + pos;
            return;
        }
    }
}

两个早退:

  1. d->count == 0:slice 马上要被删除,max_idx 无意义
  2. deleted_off < max_idx:删的不是最高元素,max_idx 不变

第二个条件说明这个函数只在"删掉了最高元素"时做 O(winsize) 的重扫。注释说 "we only scan when we deleted the current max, which is rare"。

pos 用 int 而有符号的理由:循环条件是 pos >= 0,如果 max_idx == offset(最高元素就是窗口第一个),pos = 0,扫一次 items[0],若为空则 pos-- 成 -1,退出。用无符号会死循环。

(int) 强转也有边界:max_idx - offset 最大是 winsize - 1(4096 时 65535),远小于 INT_MAX,安全。

sparse 路径的删除(src/sparsearray.c:1437-1453):

    } else {
        int found;
        uint32_t pos = arSparseFindPos(s, (uint16_t)rel_idx, &found);
        if (!found) return 0;

        uint16_t *offsets = s->layout.sparse.offsets;
        void **values = s->layout.sparse.values;

        arTrackValueOut(ar, values[pos]);
        arFreePtr(values[pos]);
        memmove(offsets + pos, offsets + pos + 1,
                (s->count - pos - 1) * sizeof(uint16_t));
        memmove(values + pos, values + pos + 1,
                (s->count - pos - 1) * sizeof(void *));
        s->count--;
        ar->count--;
    }

同样两次 memmove,同样要 - pos - 1(搬移的是 pos 之后的元素数)。

if (!found) return 0; 直接返回,跳过收尾。这是另一个提前返回,正确的原因同 dense 的 if (arIsEmpty(old)) return 0;:什么都没改,不需要收缩目录。

统一的收尾(src/sparsearray.c:1455-1464):

    /* Delete the slice if now it is empty. */
    if (s->count == 0) {
        arSliceFree(ar, s);
        /* Note that in superdir mode arSetSlice() will also free
         * empty blocks. */
        arSetSlice(ar, slice_id, NULL);
        arDirUpdateHighest(ar, slice_id);
        arDirMaybeShrink(ar);
    }
    return 1;

四步:释放 slice、目录置空、重算最高、可能收缩。

路径覆盖检查:只有两种情况下会走到这里——sparse 分支删除后(不 return),以及 dense 分支删除后 count == 0。dense 分支 count > 0 时已经 return。所以这里的 s->count == 0 是必然成立的。判断是冗余的,但从"防御性编程"角度保留。

arSliceFree(ar, s) 在这里用带 ar 参数的版本,因为它要 arTrackValueOut 每个值并释放。而此时值还没被释放(只有 count == 0 才走到这,说明所有值都被删过了,items[] 全 NULL)。

等等——dense 分支 count == 0 时,items[] 里的值都已经在之前的删除中被释放了。arSliceFree 会再遍历一遍 winsize 个槽,全是 NULL,arFreePtr(NULL) 的 arIsPtr(NULL) 返回假(v != NULL && ...),所以是空操作。安全但多余 O(winsize)。

sparse 分支 count == 0 时,offsets[] 里的有效元素数是 0,arSliceFree 的循环 for (i = 0; i < s->count; i++) 一次都不执行。安全。

7.5 arSetSlice 的两种模式

void arSetSlice(redisArray *ar, uint64_t slice_id, arSlice *s) {
    if (ar->superdir) {
        uint64_t block_id = slice_id / AR_SUPER_BLOCK_SLOTS;
        uint32_t block_off = slice_id % AR_SUPER_BLOCK_SLOTS;

        int found;
        uint32_t pos = arSuperDirFind(ar, block_id, &found);

        if (s == NULL) {
            /* Setting to NULL: decrement block count, maybe remove block */
            if (!found) return;  /* Block doesn't exist, nothing to do */
            arSDirEntry *entry = ar->superdir + pos;
            if (entry->slots[block_off] != NULL) {
                entry->slots[block_off] = NULL;
                entry->count--;
                ar->num_slices--;
                /* Remove empty block */
                if (entry->count == 0) {
                    arSuperDirRemoveBlock(ar, pos);
                }
            }
        } else {
            /* Setting to non-NULL: ensure block exists */
            arSlice **slot = arSuperDirEnsureSlot(ar, slice_id);
            arSDirEntry *entry = arSuperDirGetEntry(ar, slice_id);
            if (*slot == NULL) {
                entry->count++;
                ar->num_slices++;
            }
            *slot = s;
        }
    } else {
        if (s == NULL && ar->dir[slice_id] != NULL) ar->num_slices--;
        else if (s != NULL && ar->dir[slice_id] == NULL) ar->num_slices++;
        ar->dir[slice_id] = s;
    }
}

三个值得注意的点。

其一:置 NULL 时如果 block 不存在就直接返回(if (!found) return;)。这一行避免了"置空一个从未存在的 block"时误建 block。

其二:block 空了就被删掉(arSuperDirRemoveBlock),保持"superdir 里没有空 block"的不变式。这个不变式让 arScanIter 和 arDeleteRange 可以假定"block 存在 ⇒ 至少有一个非空 slice"。

其三:arSuperDirEnsureSlot + arSuperDirGetEntry 是两次二分。前面提过。如果改成 EnsureSlot 返回 arSDirEntry * 加 slot 指针就能省一次,但当前签名是历史遗留。

arSuperDirRemoveBlock(src/sparsearray.c:621-628):

void arSuperDirRemoveBlock(redisArray *ar, uint32_t pos) {
    arFreeAndTrack(ar, ar->superdir[pos].slots);
    if (pos < ar->sdir_len - 1) {
        memmove(ar->superdir + pos, ar->superdir + pos + 1,
                (ar->sdir_len - pos - 1) * sizeof(arSDirEntry));
    }
    ar->sdir_len--;
}

memmove 把后面的 entry 前移,保持 superdir[] 按 block_id 有序——这是二分查找的前提。代价是删除一个 block 是 O(已分配 block 数)。

ar->sdir_len 是 uint32_t,ar->sdir_len - 1 在 sdir_len == 0 时下溢成 UINT32_MAX。但 arSuperDirRemoveBlock 只在 entry->count == 0 时调用,而 entry 存在意味着 sdir_len >= 1,所以安全。

7.6 arGetSlice 与 arSuperDirGetSlot 的 NULL 语义

arSlice **arSuperDirGetSlot(redisArray *ar, uint64_t slice_id) {
    uint64_t block_id = slice_id / AR_SUPER_BLOCK_SLOTS;
    uint32_t block_off = slice_id % AR_SUPER_BLOCK_SLOTS;

    int found;
    uint32_t pos = arSuperDirFind(ar, block_id, &found);
    if (!found) return NULL;

    return ar->superdir[pos].slots + block_off;
}

返回的是槽位的地址而非槽位的值。所以:

  • 返回 NULL:整个 block 不存在
  • 返回非 NULL 但 *slot == NULL:block 存在但这个槽空

arGetSlice 把两种情况都映射成 NULL(src/sparsearray.c:749-757):

arSlice *arGetSlice(redisArray *ar, uint64_t slice_id) {
    if (ar->superdir) {
        arSlice **slot = arSuperDirGetSlot(ar, slice_id);
        return slot ? *slot : NULL;
    } else {
        if (slice_id >= ar->dir_alloc) return NULL;
        return ar->dir[slice_id];
    }
}

这个"两态归一"抹掉了信息,arSetSlice 需要区分时只能重新二分。若 arGetSlice 返回 arSlice **,调用方能同时知道"block 是否存在"和"槽是否为空",arSetSlice 就能省掉那次 arSuperDirFind。


8. 范围删除:三层算法

ARDELRANGE key start end [start end ...] 的复杂度声明值得单独看(src/commands/ardelrange.json):

"Proportional to the number of existing elements / slices touched, not to the numeric span of the requested ranges"

不是 O(范围长度)。删 [0, 10^12] 和一个只含 3 个元素的数组,成本是 3 个元素,不是 10^12 次循环。

这个保证靠三层分解实现。

8.1 第一层:边界 slice 的元素级删除

arDeleteRange(src/sparsearray.c:1597-1730)先算边界:

    uint32_t slice_size = ar->slice_size;
    uint64_t lo_slice = arSliceId(lo, slice_size);
    uint64_t hi_slice = arSliceId(hi, slice_size);
    uint32_t lo_off = arSliceOff(lo, slice_size);
    uint32_t hi_off = arSliceOff(hi, slice_size);

    uint64_t deleted = 0;
    int touched_highest = 0;  /* Did we touch dir_highest_used? */

    if (lo_slice == hi_slice) {
        /* Range is within a single slice: element-level delete only */
        deleted = arDeleteSliceRange(ar, lo_slice, lo_off, hi_off);
        if (lo_slice >= ar->dir_highest_used) touched_highest = 1;
    } else {
        /* 1. Delete within lo_slice: [lo_off .. slice_size-1] */
        deleted += arDeleteSliceRange(ar, lo_slice, lo_off, slice_size - 1);

        /* 2. Delete within hi_slice: [0 .. hi_off] */
        deleted += arDeleteSliceRange(ar, hi_slice, 0, hi_off);
        if (hi_slice >= ar->dir_highest_used) touched_highest = 1;
        ...

单 slice 的情况直接走元素级。跨 slice 时,lo_slice 删 [lo_off, slice_size-1],hi_slice 删 [0, hi_off],中间整片另行处理。

arDeleteSliceRange(src/sparsearray.c:1491-1582)按编码分两条路。dense:

        /* Dense: intersect deletion range with allocated window */
        uint32_t win_lo = s->layout.dense.offset;
        uint32_t win_hi = s->layout.dense.offset + s->layout.dense.winsize - 1;

        /* Clamp to window */
        uint32_t eff_lo = (del_lo > win_lo) ? del_lo : win_lo;
        uint32_t eff_hi = (del_hi < win_hi) ? del_hi : win_hi;

        if (eff_lo <= eff_hi) {
            /* Clear every populated slot in the effective dense range. */
            for (uint32_t off = eff_lo; off <= eff_hi; off++) {
                uint32_t pos = off - s->layout.dense.offset;
                if (!arIsEmpty(s->layout.dense.items[pos])) {
                    arTrackValueOut(ar, s->layout.dense.items[pos]);
                    arFreePtr(s->layout.dense.items[pos]);
                    s->layout.dense.items[pos] = NULL;
                    s->count--;
                    ar->count--;
                    deleted++;
                }
            }

clamp 到窗口范围,然后逐槽清空。注意 arIsEmpty 判断:空洞跳过但不计 deleted。

dense 的 max_idx 更新(src/sparsearray.c:1521-1531):

            /* Update max_idx if affected */
            if (s->count > 0 && s->layout.dense.max_idx >= eff_lo) {
                /* Scan backwards to find new max */
                s->layout.dense.max_idx = s->layout.dense.offset;
                for (int32_t i = (int32_t)win_hi; i >= (int32_t)win_lo; i--) {
                    if (!arIsEmpty(s->layout.dense.items[i - s->layout.dense.offset])) {
                        s->layout.dense.max_idx = i;
                        break;
                    }
                }
            }

判据是 max_idx >= eff_lo(删除范围覆盖了当前最高元素)。重扫从 win_hi 往下,找第一个非空。

注意初值 max_idx = offset:如果整个窗口都空了(但 s->count > 0 保证不可能),max_idx 会停在 offset,这是错误的值。实际不可达,但没有断言。

sparse 的删除(src/sparsearray.c:1542-1578):

        /* Sparse: deleted elements form a contiguous span in the sorted
         * offsets/values arrays. Find that span, free the values in it,
         * then compact the tail once. */
        uint16_t *offsets = s->layout.sparse.offsets;
        void **values = s->layout.sparse.values;

        int found;
        uint32_t first = arSparseFindPos(s, (uint16_t)del_lo, &found);
        uint32_t last = arSparseFindPos(s, (uint16_t)del_hi, &found);
        if (found) last++;

        /* Free all values in the contiguous sparse span to delete. */
        for (uint32_t i = first; i < last; i++) {
            arTrackValueOut(ar, values[i]);
            arFreePtr(values[i]);
        }

        /* Shift the surviving tail left to close the deleted gap. */
        if (first < last) {
            uint32_t tail = s->count - last;
            if (tail > 0) {
                memmove(offsets + first, offsets + last,
                        tail * sizeof(uint16_t));
                memmove(values + first, values + last,
                        tail * sizeof(void *));
            }

            deleted = last - first;
            s->count -= deleted;
            ar->count -= deleted;
        }

两个 arSparseFindPos 的语义:

  • first = 第一个 ≥ del_lo 的位置(左边界)
  • last = 第一个 ≥ del_hi 的位置,若 del_hi 恰好存在则 +1

所以 [first, last) 就是落在 [del_lo, del_hi] 内的所有元素。这一段在 offsets 里是连续的(因为 offsets 有序),所以可以一次 memmove 收尾。

对比 dense 的逐槽"清空但保留位置",sparse 是"找出连续段,整体前移"。两种策略的差异根源还是 §7.2 说的空洞表示方式。

if (found) last++; 的 found 变量复用:arSparseFindPos 的输出参数 found 在两次调用中复用了同一个变量。第一次的 found 被丢弃,第二次的才用。如果第一次 found 为真而第二次为假,last 不加 1,last 指向第一个大于 del_hi 的元素,[first, last) 正好是不含 del_hi 的元素。正确。

若 del_hi 存在,last 指向它,+1 后包含它。正确。

found 变量这种"复用作临时"的写法在 C 里常见但有可读性风险。

8.2 第二层:中间整片 slice

        /* 3. Delete full slices in between [lo_slice+1 .. hi_slice-1] */
        if (lo_slice + 1 <= hi_slice - 1) {
            if (ar->superdir) {

superdir 模式的关键优化(src/sparsearray.c:1625-1679):

                uint64_t mid_lo = lo_slice + 1;
                uint64_t mid_hi = hi_slice - 1;
                uint64_t lo_block = mid_lo / AR_SUPER_BLOCK_SLOTS;
                uint64_t hi_block = mid_hi / AR_SUPER_BLOCK_SLOTS;

                /* arSuperDirFind() is a lower-bound search on block_id.
                 * start is the first entry whose block_id is >= lo_block.
                 * end is the first entry whose block_id is > hi_block, so the
                 * blocks to visit are exactly [start, end). */
                int found;
                uint32_t start = arSuperDirFind(ar, lo_block, &found);
                uint32_t end = arSuperDirFind(ar, hi_block, &found);
                if (found) end++; /* Convert matching index to past-the-end. */

                /* Iterate backward because deleting the last slice in a block
                 * removes that block entry and compacts the superdir array. */
                for (int32_t bi = (int32_t)end - 1; bi >= (int32_t)start; bi--) {

这里展示了 superdir 的真正价值:删 [0, 10^12] 时,flat 模式要遍历 2.4 亿个目录槽(虽然大多是 NULL),superdir 只访问"实际存在的 block"。若只有 3 个 block,循环 3 次。

两个 arSuperDirFind 构成区间 [start, end):

  • start:第一个 block_id >= lo_block 的 entry
  • end:第一个 block_id > hi_block 的 entry(若 hi_block 存在则 +1)

倒序遍历的理由(注释 :1645-1646):删空一个 block 会调 arSuperDirRemoveBlock,它会 memmove 后面的 entry 前移。如果正序遍历,删除当前 entry 后下一个 entry 移到了当前位置,但循环变量已经 ++,会跳过它。

倒序时删除第 bi 个不影响 [start, bi-1] 区间,安全。

这是"边遍历边删除有序数组"的经典陷阱,注释明确写了。

块内删除(src/sparsearray.c:1649-1673):

                    arSDirEntry *e = ar->superdir + bi;
                    uint64_t block_base = e->block_id * AR_SUPER_BLOCK_SLOTS;
                    uint64_t block_end = block_base + AR_SUPER_BLOCK_SLOTS - 1;

                    /* Convert the global middle slice range to the local slot
                     * interval covered inside this block. */
                    uint32_t first_si = (mid_lo > block_base) ?
                        (uint32_t)(mid_lo - block_base) : 0;
                    uint32_t last_si = (mid_hi < block_end) ?
                        (uint32_t)(mid_hi - block_base) : AR_SUPER_BLOCK_SLOTS - 1;

                    for (uint32_t si = first_si; si <= last_si; si++) {
                        if (e->slots[si]) {
                            uint64_t slice_id = block_base + si;
                            deleted += e->slots[si]->count;
                            ar->count -= e->slots[si]->count;
                            arSliceFree(ar, e->slots[si]);
                            e->slots[si] = NULL;
                            e->count--;
                            ar->num_slices--;
                            if (slice_id >= ar->dir_highest_used)
                                touched_highest = 1;
                        }
                    }

                    /* Remove the superdir block if empty. */
                    if (e->count == 0) {
                        arSuperDirRemoveBlock(ar, bi);
                    }

注意块内是正序遍历(si++),因为删一个 slot 不影响其他 slot 的下标(slots[] 是定长数组,删一个只是置 NULL,不压缩)。只有 block 级别的 superdir[] 会压缩。

first_si/last_si 的 clamp 把全局 [mid_lo, mid_hi] 映射到块内局部 [0, 2047]。

e->count-- 与 ar->num_slices-- 手工维护,因为这里绕过了 arSetSlice(性能考虑:arSetSlice 会做二分)。

这里直接把 arSliceFree 后置 NULL,不走 arSetSlice。所以如果有 bug 导致 e->count 与 slots[] 实际非空数不一致,arSetSlice 的自动修正不会发生。这是手工维护计数的代价。

flat 模式的对应代码(src/sparsearray.c:1680-1695):

                /* Flat mode: delete full slices in middle range */
                uint64_t end = hi_slice - 1;
                if (end >= ar->dir_alloc) end = ar->dir_alloc - 1;

                for (uint64_t sid = lo_slice + 1; sid <= end; sid++) {
                    if (ar->dir[sid]) {

end 必须 clamp 到 dir_alloc - 1,因为 hi_slice 可能超出目录范围(用户请求删 [0, 10^12] 但目录只有 2048 个槽)。如果不 clamp 会越界读。

这里有个隐含的循环上界:flat 模式下 dir_alloc <= 2048,所以最多循环 2048 次。这是"flat 模式不会因为大范围而慢"的原因——它的目录本身就是有界的。

8.3 第三层:重算 dir_highest_used

    /* Update dir_highest_used if we touched or deleted high slices */
    if (touched_highest && ar->count > 0) {
        ar->dir_highest_used = 0;
        if (ar->superdir) {
            for (int32_t bi = ar->sdir_len - 1; bi >= 0; bi--) {
                arSDirEntry *e = ar->superdir + bi;
                if (e->count == 0) continue;
                for (int32_t si = AR_SUPER_BLOCK_SLOTS - 1; si >= 0; si--) {
                    if (e->slots[si]) {
                        ar->dir_highest_used = e->block_id * AR_SUPER_BLOCK_SLOTS + si;
                        goto found_highest;
                    }
                }
            }
        } else {
            for (int64_t i = (int64_t)ar->dir_alloc - 1; i >= 0; i--) {
                if (ar->dir[i]) {
                    ar->dir_highest_used = i;
                    goto found_highest;
                }
            }
        }
    }
found_highest:

    if (ar->count == 0) {
        ar->dir_highest_used = 0;
    }

    arDirMaybeShrink(ar);
    return deleted;

touched_highest 是"删除范围是否碰到了当前最高 slice"的标志。如果是,必须倒扫找新的最高。

goto found_highest 落在 for 循环外,所以正常结束(没找到)也会 fall through 到这个标签——此时 dir_highest_used 保持 0(上面已经赋过)。这个写法让"找到"和"没找到"都汇合到同一处,避免重复代码。

if (ar->count == 0) ar->dir_highest_used = 0; 是兜底:清空后最高位归零。

touched_highest 的赋值有三处(单 slice 的 :1612、hi_slice 的 :1621、中间整片的 :1670-1671 和 :1692-1693),但漏了 lo_slice 在跨 slice 情况下的检查:

        /* 1. Delete within lo_slice: [lo_off .. slice_size-1] */
        deleted += arDeleteSliceRange(ar, lo_slice, lo_off, slice_size - 1);
        /* ← 这里没有 if (lo_slice >= ar->dir_highest_used) touched_highest = 1; */

只有单 slice 分支和 hi_slice 分支设了标志。跨 slice 时 lo_slice 不设。

这是 bug 吗? 分析:lo_slice < hi_slice(跨 slice 分支)。如果 lo_slice >= dir_highest_used,那么 hi_slice > lo_slice >= dir_highest_used,所以 hi_slice >= dir_highest_used 必然成立,hi_slice 分支会设标志。

所以漏检是安全的:只要 lo_slice >= dir_highest_used,则 hi_slice 也满足,标志已经设了。这个推理依赖 hi_slice > lo_slice,成立。

结论:不是 bug,但是依赖传递性的隐式正确性。如果将来有人改了这个结构(比如允许 lo_slice == hi_slice 走跨 slice 分支),就会出问题。

8.4 arTruncate:一个特例

void arTruncate(redisArray *ar, uint64_t limit) {
    if (ar->count == 0) return;

    uint64_t len = arLen(ar);
    if (limit >= len) return;  /* Nothing to delete */

    arDeleteRange(ar, limit, len - 1);
}

删 [limit, len-1]。limit == 0 时删全部。

注释(:1732-1736):

 * Truncate array: delete all elements with index >= limit.
 * Used by ARRING to implement ring buffer wrap-around.
 *
 * This is implemented as a special case of arDeleteRange. limit==0 means
 * "delete everything". */

但 ARRING 从来没有调用它。grep 全仓库,arTruncate 的调用点只有 sparsearray.h 的声明和这里的定义。它是为"环形缓冲区收缩"预留的接口,而实际的 ARRING 用 arRingRework 的整体重建来实现(§13)。

一个未使用的公开 API。若 ARRING 曾计划用 arTruncate,那个方案会在 winsize 层面留下大量半空 slice,不如重建干净。


8.5 一句话概括这一章

Array 的"读写内核"由三块咬合而成:三级结构(slice 是最小分配单元、目录把下标映射到 slice、超级目录用 block 把海量下标压缩成两层)、下标算术(一个 uint16_t 决定 slice 上限 65536,arMakeIdx/arSliceLocalIdx 的移位与掩码)、tagged pointer(2 位 tag 把整数/浮点/小串/长串四种值塞进 8 字节,零二次分配)。在这之上,alloc_size 用四个分配包装做精确记账,sparse/dense 双向切换在"连续窗口"与"稀疏散点"之间自动取舍。单点读写(arGet/arSet/arDel)的分支密度极高——arSet 五个阶段、arDel 三种收尾、dense 窗口的双向增长与 offset 左移,每一个都是为了在不牺牲 O(1) 的前提下把内存压到最低。范围删除用"边界元素级 + 中间整片 + 重算 dir_highest_used"三层算法,把"删一半"从 O(n) 的逐元素操作变成少量整 slice 释放。

理解了这一章,你就拿到了 Array 的全部"为什么这么设计":slice 上限为什么是 65536(不是 65535、不是 2 的更大幂)、tagged pointer 为什么不用 SDS、dense 窗口为什么宁可左移 offset 也不越界。下一章进入"怎么用"——迭代器如何支撑四个命令、ARGETRANGE/ARGREP 的边界与正则实现、以及 RDB/AOF/defrag 这套外围系统如何与内部结构博弈。

8.6 本章涉及文件清单

  • src/sparsearray.h:类型与常量定义(slice 上限、dense/sparse 编码、AR_SUPER_BLOCK_SLOTS)。
  • src/sparsearray.c:三级结构、下标算术、arGet/arSet/arDel/arTruncate、sparse/dense 切换、arDefrag/arDefragIncremental。
  • src/t_array.c:18 个命令实现、arScanIter 迭代器、ARGETRANGE、ARGREP。

8.7 验证方式

  • ./runtest --single unit/type/array 覆盖全部读写/切换/删除路径。
  • 用 ARSET key 99999999 v 触发超级目录,再用 ARINFO key FULL 观察 sparse-slices / avg-dense-size。
  • CONFIG SET array-sparse-kmax 3 后连续 ARSET 同 slice 内多元素,观察 promote 时机(§6.4)。