《Redis 8.10.3 深度剖析》系列 ·第 14 章 ZSet:`skiplist`、并行索引与集合运算

8 阅读1小时+

《Redis 8.10.3 深度剖析》系列 ·第 14 章 ZSet:skiplist、并行索引与集合运算

ZSet 是 Redis 唯一一个用两套数据结构同时持有同一批元素的类型。Hash 只有一个容器,List 只有一个容器,Set 在任一时刻也只有一个容器——编码切换是"销毁旧的、建立新的"。ZSet 不是:skiplist 编码的 ZSet 同时有一张 dict 和一条 skiplist,两者装的逻辑数据完全相同,只是一份按 member 哈希、一份按 (score, member) 排序。

这不是冗余,而是两个索引服务两类操作的必然结果。ZSCORE 要 O(1),只有哈希能给;ZRANGE 要 O(log n) 的有序定位,只有有序索引能给;ZRANK 要 O(log n) 的排名,需要有序索引上额外的 span 字段。任何一个单一结构都无法同时满足,所以只能并存。

并存的代价是所有权的模糊。经典实现里,skiplist 节点持有 sds ele,dict 的 key 也指向同一个 sds——两个容器共享一块内存,谁都不能单独 free 它。注释把这条规则写在文件开头(src/t_zset.c:28-33):

Note that the SDS string representing the element is the same in both the hash table and skiplist in order to save memory. What we do in order to manage the shared SDS string more easily is to free the SDS string only in zslFreeNode(). The dictionary has no value free method set. So we should always remove an element from the dictionary, and later from the skiplist.

8.10 把这条规则推得更远:sds 不再是"节点持有的指针",而是被焊进节点内存块的尾巴。dict 里存的也不再是 sds,而是节点指针本身,靠一个新的回调 keyFromStoredKey 在比较/哈希时把 sds 反解出来。这一章的主线就是这次重构——它把 8.10 之前"两次分配 + 一个共享指针"变成了"一次分配 + 一个偏移量"。

要回答的问题:level[0].span 为什么可以被抢来存元信息、抢了之后 span 的语义如何分层、ZRANK 为什么从"O(log n) 次字符串比较"变成"最多 32 次指针跳"、ZRANGESTORE ... LIMIT 的 offset 为什么能在 skiplist 上做到 O(log n) 而在 listpack 上只能 O(offset)、ZUNIONSTORE 为什么先建 dict 再建 skiplist、以及 ZINCRBY 到底是不是"先删后插"。


1. 两个索引,一份数据

1.1 zset 结构本身只有两个指针

typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

src/server.h:1816-1819,16 字节。它没有任何"元素数"字段——长度从 zsl->length 取(zsetLength,src/t_zset.c:1377-1387),因为 skiplist 反正要维护它,dict 的 ht_used 虽然也有,但跨两个 ht_table 求和还要考虑 rehash,不如直接用 zsl->length。

dict 的 dictType 定义在文件顶部(src/t_zset.c:54-64):

dictType zsetDictType = {
    dictSdsHash,        /* hash function */
    NULL,               /* key dup */
    NULL,               /* val dup */
    dictSdsKeyCompare,  /* compares embedded sds by keyFromStoredKey */
    NULL,               /* key destructor - skiplist owns the node memory */
    NULL,               /* val destructor */
    NULL,               /* allow to expand */
    .no_value = 1,      /* no values stored (only nodes) */
    .keyFromStoredKey = zslGetNodeElementForDict,
};

七个字段里有四个是 NULL,每一个 NULL 都对应一条所有权约定:

  • key dup = NULL:dictAdd 不会复制 key,直接把传进来的指针存下。这里传进来的是 zskiplistNode *,节点由 skiplist 分配,dict 只是"借"。
  • val dup = NULL 且 no_value = 1:dictEntry 里没有独立的 v 字段,整个 dictEntryNoValue 只有 16 字节(第 13 章 §10 已算过:24 → 16,省三分之一)。
  • key destructor = NULL:dictRelease 时不去 free key。因为 key 是节点指针,节点由 zslFree 释放。如果这里也 free 一次,就是 double free。
  • val destructor = NULL:本来就没有 val。

no_value = 1 与 keys_are_odd = 1 是两件不同的事。第 13 章的 setDictType 两个都开了:keys_are_odd 让 key 指针直接存进桶数组(用最低位标记 XX1),根本不分配 dictEntry。zsetDictType 只开了 no_value,因为 zset 的 key 是"节点指针",虽然也是指针,但节点的低两位是对齐的、可以标记——理论上也能开 keys_are_odd。不开的原因值得推一下:keys_are_odd 要求"这个指针永远不变、也不被别处引用成 dictEntry",而 zset 的 dictEntry 需要被 dictUnlink 摘下(zsetRemoveFromSkiplist)、被 defrag 重写(activeDefragZsetNode 要拿到 plink)。桶里直接存指针就没有 link 可操作了。所以这里保留了真实的 dictEntry,只是把它压到 16 字节。

1.2 keyFromStoredKey:dict 里存的不是 key

这是 8.10 给 dict 加的能力。src/dict.h:56-66 的注释说得很清楚:

If dictType.keyFromStoredKey is non-NULL, then dict distinguishes between the lookup key and the actual stored-key object. In this case, "key" is used to locate entries, while "storedKey" is the actual element stored in the dict.

也就是说 dict 内部有两个不同的 "key" 概念:

概念类型用途
lookup keysds调用方传进来要查的东西
stored keyzskiplistNode *桶里实际存的指针

哈希与比较都必须作用在逻辑 key(也就是 sds)上,所以 dict 在每个需要的地方把 stored key 反解一次(src/dict.c:98-100):

static const void *dictStoredKey2Key(dict *d, const void *key __stored_key) {
    return (d->type->keyFromStoredKey) ? d->type->keyFromStoredKey(key) : key;
}

dict.c 里有 8 处调用它:查桶(dictFindLinkInternal)、插入(dictFindLinkForInsert)、rehash 迁移(dictRehash)、dictScan、dictUnlink、dictGetFairRandomKey 等。每一处都是"我手上是个 stored key,先转成逻辑 key 再比"。

这个设计的收益:dictGetKey(de) 返回的就是节点指针。所以 ZSCORE 的路径是:

dictEntry *de = dictFind(zs->dict, member);
zskiplistNode *znode = dictGetKey(de);
*score = znode->score;

src/t_zset.c:1546-1549。一次哈希拿到节点,score 直接从节点读——不需要再跳一次 skiplist,也不需要 sds 与节点之间的映射表。ZRANK 更狠:拿到节点后用 zslGetRankByNode 从节点往右数(§7.3),完全不碰 dict 之外的东西。

代价是 dict 的哈希/比较路径多了一次函数指针调用。dictSdsHash 收到的是逻辑 key,所以反解必须发生在调用它之前——不是"每次都反解",而是"在 dict 内部按需反解"。对 ZSCORE 这种一次查找来说,多一次 inline 调用可以忽略。

1.3 删除顺序不能换

zsetRemoveFromSkiplist 的注释(src/t_zset.c:1756-1760)是全文件最该背下来的一段:

de = dictUnlink(zs->dict,ele);
if (de != NULL) {
    zskiplistNode *znode = dictGetKey(de);
    /* Delete from the hash table and later from the skiplist.
     * Note that the order is important: deleting from the skiplist
     * actually releases the SDS string representing the element,
     * which is shared between the skiplist and the hash table, so
     * we need to delete from the skiplist as the final step. */
    dictFreeUnlinkedEntry(zs->dict,de);
    zslDelete(zs->zsl, znode);
    return 1;
}

三步:dictUnlink(从桶链上摘下,但不释放 entry)→ dictFreeUnlinkedEntry(释放 entry 本身,此时 znode 已经被存进局部变量)→ zslDelete(释放节点,节点里的 sds 随之消失)。

为什么必须 Unlink 而不是 dictDelete?因为 zsetRemoveFromSkiplist 的入口参数是 sds ele(逻辑 key),而 zslDelete 需要的是 zskiplistNode *。dictDelete 会连 entry 一起释放,znode 就再也取不到了——必须先把节点指针抢出来。

第二个理由更隐蔽:dictDelete 内部的 dictFreeUnlinkedEntry 会调用 keyDestructor。zsetDictType 的 keyDestructor 是 NULL,所以不会误 free 节点的 sds。但如果哪天有人给它填上析构函数(比如为了支持别的用途),dictDelete 就会在 zslDelete 之前把 sds free 掉,zslDelete 再拿它做比较就是 use-after-free。注释里"we need to delete from the skiplist as the final step"防的就是这个。

zslDeleteRangeByScore/ByLex/ByRank 三个批量删除函数里的顺序也是一样的(src/t_zset.c:571-578):zslUnlinkNode → dictDelete(dict, zslGetNodeElement(x)) → zslFreeNode。注意这里 dictDelete 用 zslGetNodeElement(x) 现算逻辑 key,而不是"先把 key 存下来",因为 zslUnlinkNode 只是摘链、节点还在,sds 依然有效。


2. 8.10 的节点:把 sds 焊进节点,把 span 抢来存元信息

2.1 两个改动,一个动机

src/server.h:1788-1807:

/* Node info placed in level[0].span since it's unused at level 0 (static assert verified) */
typedef struct zskiplistNodeInfo {
    uint16_t sdsoffset;  /* Offset from node start to sds data (after sds header) */
    uint8_t levels;      /* Number of levels in this node (1-32) */
    uint8_t reserved;
} zskiplistNodeInfo;

typedef struct zskiplistNode {
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        /* Span is the number of elements between this node and the next node at this level.
         * At level 0, span is repurposed to store zskiplistNodeInfo for regular nodes, */
        unsigned long span;
    } level[];
    /* sds ele is embedded after level[] array (assist zslGetNodeElement(node) to access it) */
} zskiplistNode;

对照经典实现,zskiplistNode 少了 sds ele 字段、多了两条注释:小 8 字节(24 → 16),ele 从"独立分配的 sds 指针"变成"跟在 level[] 后面的连续字节"。

zslCreateNode 是这次重构的现场(src/t_zset.c:169-205):

static zskiplistNode *zslCreateNode(zskiplist *zsl, int level, double score, sds ele) {
    size_t usable;
    size_t ele_len = sdslen(ele);
    char sds_type = sdsReqType(ele_len);
    size_t sds_hdr_len = sdsHdrSize(sds_type);

    size_t node_size = sizeof(zskiplistNode) + level * sizeof(struct zskiplistLevel);
    size_t sds_buf_size = sds_hdr_len + ele_len + 1;
    size_t total_size = node_size + sds_buf_size;

    zskiplistNode *zn = zmalloc_usable(total_size, &usable);

    zn->score = score;
    zn->backward = NULL;

    size_t sds_offset = node_size + sds_hdr_len;
    debugServerAssert(sds_offset < ZSL_OFFSET_MAX_ELE);

    char *sds_buf = (char*)zn + node_size;
    sds embedded_sds = sdsnewplacement(sds_buf, sds_buf_size, sds_type, ele, ele_len);

    zslSetNodeInfo(zn, level, sds_offset);

    serverAssert(embedded_sds == (sds)((char*)zn + sds_offset));
    zsl->alloc_size += usable;
    return zn;
}

这是一次从两次 malloc 到一次的合并,和第 10 章 Entry 的 aux bit 打包、第 13 章 intset 的紧凑数组同类:减少分配次数与指针、提高缓存局部性。

2.2 单块分配换来了什么

其一,省一次分配。 经典实现是 zmalloc(node) + sdsnewlen(ele),两次 malloc。jemalloc 下每次 malloc 有约 20-30ns 的固定开销,加上两次元数据(每个 8 字节)。改成一次后,插入一个元素少一次分配、少 16 字节元数据。

其二,sds 与节点在同一缓存行附近。 ZRANGE 遍历时先读 ln->score(节点头部),再读 zslGetNodeElement(ln)(节点尾部),两者相距 node_size = 32~544 字节。经典实现里 ele 指向堆上另一处,遍历时是"节点数组 + 字符串数组"两路指针追逐。现在一次预取就能覆盖。

其三,alloc_size 记账变简单。 每个节点一次 zmalloc_usable 就拿到 usable size,直接累加(src/t_zset.c:202);释放时 zfree_usable 再减回去(src/t_zset.c:224-229)。zslFree 结尾用 debugServerAssert(zsl->alloc_size == zmalloc_usable_size(zsl)) 交叉验证这套记账(src/t_zset.c:243)——和第 12 章 quicklist 的 alloc_size 是同一套纪律。

其四,sds 的类型可以按元素长度精确决定。 sdsReqType(ele_len) 对 len < 32 返回 SDS_TYPE_5(头 1 字节)、对 len ≤ 251 返回 SDS_TYPE_8(头 3 字节),以此类推。整个缓冲区按 total_size 一次分配,不需要 sdsMakeRoomFor 那种"先分配再扩容"的逻辑,类型一次定死、不会出现"sdshdr8 长大成 sdshdr16 导致指针移动"。这一点很关键:节点一旦入链,sds 地址就绝不能变(否则 sdsoffset 与 dict 里存的指针全失效),单块分配天然满足它。

2.3 level[0].span 为什么可以抢

span 的定义是"这一层上,本节点到下一个节点之间隔了多少个节点"。经典实现里 span 在所有层都有意义,zslGetRank 靠它累加。

但在 level 0,forward 指向的是"有序链表上的下一个节点",中间不可能再夹任何节点——level 0 是一条完整的链,每个节点都在上面。所以 level 0 的 span 恒等于 1(有后继)或 0(是尾节点)。零信息量。

这是整个重构的支点。src/t_zset.c:75-99 四个 inline 函数把这件事封装起来:

static inline unsigned long zslGetNodeSpanAtLevel(zskiplistNode *x, int level) {
    if (level > 0) return x->level[level].span;
    /* For level 0, if regular node, span is 1. If tail node, span is 0. */
    return x->level[0].forward ? 1 : 0;
}

读 level 0 的 span 时,不读内存,而是"看有没有后继"现算。写 level 0 的 span 时(zslSetNodeSpanAtLevel / zslIncrNodeSpanAtLevel / zslDecrNodeSpanAtLevel),直接跳过——因为 level 0 的 span 不是可写字段,是 zskiplistNodeInfo。

这套封装的安全性取决于有没有人绕过它。答案是没有:直写 level[0].span 的只有 zslSetNodeInfo,以及 defrag 的整块 memcpy 搬迁。

2.4 那 4 个字节够不够

zskiplistNodeInfo 是 uint16_t + uint8_t + uint8_t = 4 字节,按对齐规则 sizeof 也是 4(uint16_t 对齐 2,总大小 4 是 2 的倍数)。span 是 unsigned long,LP64 下 8 字节。所以装得下,且有一半余量。这条不变式用 static_assert 固化(src/t_zset.c:102):

static_assert(sizeof(zskiplistNodeInfo) <= sizeof(((zskiplistNode *)0)->level[0].span), "Must fit in level[0].span");

static_assert 是编译期检查,比运行时 serverAssert 更早发现问题——如果有人往 zskiplistNodeInfo 里加字段加到 9 字节,编译就失败,不需要等测试跑到某条路径。

写入的时候用了一个 union(src/t_zset.c:108-114):

static inline void zslSetNodeInfo(zskiplistNode *node, uint8_t levels, uint16_t sdsoffset) {
    union {
        zskiplistNodeInfo info;
        unsigned long span;
    } u = { .info = { .levels = levels, .sdsoffset = sdsoffset } };
    node->level[0].span = u.span;
}

不直接 *(zskiplistNodeInfo*)&node->level[0].span = ... 是因为严格别名(strict aliasing):编译器有权假设 unsigned long* 和 zskiplistNodeInfo* 不指向同一块内存,直接转换是 UB。走 union 是 C 标准明确允许的类型双关(C11 6.5.2.3),u.info 写、u.span 读,结果由实现定义但所有主流编译器都按位拷贝处理。

顺带一个细节:u 用 = { .info = {...} } 初始化,意味着 reserved 被零初始化。这很重要——如果 reserved 是垃圾值,那么"同一个节点两次 zslSetNodeInfo 写同样的 levels/sdsoffset"会产生不同的 span 位模式,任何按 span 比较的逻辑都会误判。零初始化把"位模式"固定下来了。

2.5 sdsoffset 的 16 位与那个"同一个常量两个用途"

sds_offset 是节点起始到 sds 数据区的偏移,等于 node_size + sds_hdr_len。上界:

  • node_size = 16 + level × 16,level ≤ ZSKIPLIST_MAXLEVEL = 32 → 最大 16 + 512 = 528
  • sds_hdr_len 最大是 SDS_TYPE_64 的 17 字节
  • 合计 ≤ 545

远小于 65535,所以 uint16_t 绰绰有余。debugServerAssert(sds_offset < ZSL_OFFSET_MAX_ELE) 把这个事实写成断言(src/t_zset.c:189)。

但这里有个值得警惕的写法:ZSL_OFFSET_MAX_ELE 和 ZSL_OFFSET_NO_ELE 都是 UINT16_MAX(src/t_zset.c:48-49):

#define ZSL_OFFSET_MAX_ELE  UINT16_MAX
#define ZSL_OFFSET_NO_ELE   UINT16_MAX

同一个数值承担两个语义:"偏移上界(不含)"与"无元素哨兵"。它们能共存,靠的是"偏移严格小于 65535"这条被 debugServerAssert 守着的不变式——一旦偏移真到了 65535,就会被 zslGetNodeElement 当成"没有元素":

sds zslGetNodeElement(const zskiplistNode *node) {
    zskiplistNodeInfo *info = zslGetNodeInfo(node);
    debugServerAssert(info->sdsoffset != ZSL_OFFSET_NO_ELE);
    return (char*)node + info->sdsoffset;
}

src/t_zset.c:129-133。而且这个断言是 debugServerAssert——只在 DEBUG_ASSERTIONS 编译时生效,发布版里没有。所以"偏移不会到 65535"这条属性,在发布版里只靠"545 < 65535"这个结构性事实保证,没有运行时兜底。

从工程角度评价:风险可接受(545 与 65535 差两个数量级),但把两个语义拆成两个常量、或让哨兵用 0(0 不可能是合法偏移——节点头至少 16 字节)会更清晰。

哨兵只在一个地方用到:header 节点。zslCreateHeaderNode 传 ZSL_OFFSET_NO_ELE(src/t_zset.c:157),因为 header 没有元素。zslDebugVerifyStruct 会检查这一点(src/t_zset.c:4846)。

2.6 header 节点还是独立的 32 层

static zskiplistNode *zslCreateHeaderNode(zskiplist *zsl) {
    size_t usable;
    zskiplistNode *zn = zmalloc_usable(sizeof(*zn) + ZSKIPLIST_MAXLEVEL * sizeof(struct zskiplistLevel), &usable);
    zn->score = 0;
    zn->backward = NULL;
    for (int j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
        zn->level[j].forward = NULL;
        zn->level[j].span = 0;  /* Will be overwritten for level[0] below */
    }
    zslSetNodeInfo(zn, ZSKIPLIST_MAXLEVEL, ZSL_OFFSET_NO_ELE);
    zsl->alloc_size += usable;
    return zn;
}

src/t_zset.c:142-163。header 不内联 sds,所以它就是 16 + 32×16 = 528 字节,jemalloc 落在 640 的 size class(528 超过 512,下一档是 640),浪费 112 字节。

header 的 levels 写成 ZSKIPLIST_MAXLEVEL = 32,表示"header 参与 32 层",与普通节点语义一致。真正用它的是 zslInsertNode 的提层分支:

if (level > zsl->level) {
    for (i = zsl->level; i < level; i++) {
        rank[i] = 0;
        update[i] = zsl->header;
        zslSetNodeSpanAtLevel(update[i], i, zsl->length);
    }
    zsl->level = level;
    zslGetNodeInfo(zsl->header)->levels = level;
}

src/t_zset.c:288-296。注意最后一行:zsl->level 和 header->level[0].span 里的 levels 是两份冗余状态,必须同步更新。zsl->level 用于遍历(for (i = zsl->level-1; i >= 0; i--)),header.levels 用于 zslDebugVerifyStruct 的一致性检查。少写一行,功能还能跑,但 DEBUG 下会被抓出来。

这是"抢 span"的第一处隐性成本:原本单一的 zsl->level 变成了两处,而代码没有封装出 zslSetLevel()。给 header 的 span 写 zsl->length 同理——新层的 header span 必须等于全表节点数,因为它跨过了所有节点。

2.7 一次分配的反面:节点不能原地长大

单块分配有一个硬约束:节点入链后,sds 地址不能变。因为 sdsoffset 是相对偏移、dict 里存的是节点指针、skiplist 的 forward/backward 也指向节点。所以:

  • member 永不修改。 没有 ZSETMEMBER 之类的命令,要"改名"只能删了重加,语义上就是不同元素——约束与命令集天然吻合。
  • score 可以原地改。 它是节点头部的独立字段,改它不影响任何指针,zslUpdateScore 的快路径正是利用这一点(§6)。
  • 不能给 sds 追加内容。 经典实现里对 node->ele 调 sdsMakeRoomFor 会把 sds realloc 到新地址、dict 里的指针立刻失效;8.10 之后连"能不能"都不存在了——节点里没有独立的 sds 指针可传。

单块分配把一条隐含约定(不许动 ele)变成了结构上的不可能。


3. 层数分布:p = 1/4 的账

3.1 zslRandomLevel 的三行

static int zslRandomLevel(void) {
    static const int threshold = ZSKIPLIST_P*RAND_MAX;
    int level = 1;
    while (random() < threshold)
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

src/t_zset.c:254-260。ZSKIPLIST_P = 0.25(src/server.h:657),ZSKIPLIST_MAXLEVEL = 32(src/server.h:656)。

threshold 是 0.25 × RAND_MAX。random() 返回 [0, 2^31-1],所以每次判定的成功率是 0.25(差一两个最低位,可忽略)。level 从 1 开始,每成功一次加 1,所以:

  • P(level = 1) = 1 - 0.25 = 0.75
  • P(level ≥ k) = 0.25^(k-1)
  • E[level] = 1 / (1 - 0.25) = 4/3 ≈ 1.333

level < MAXLEVEL ? level : MAXLEVEL 是截断而非拒绝——如果 32 次判定全成功(概率 0.25^31 ≈ 2^-62),仍然返回 32。这比"重抽直到小于 32"便宜,且对分布的影响在 2^-62 量级上完全不可测。

static const int threshold 是刻意的:它让编译器把乘法提到函数外只算一次,且 threshold 是 int,与 random() 的返回值同为 32 位有符号比较,不会隐式提升到 double。写成 while (random() < ZSKIPLIST_P*RAND_MAX) 则可能每轮重算 double 乘法并做 int→double 转换。

3.2 32 层为什么够

跳表的查找复杂度是 O(log_{1/p} n)。p = 0.25 意味着底数是 4:

  • 4^32 = 2^64,所以 32 层理论上能索引 2^64 个元素
  • Redis 的 dict 在有生之年装不到 2^64 个元素(内存上限是 2^48 字节量级)

更实际的约束是概率:一个节点有 32 层的概率是 0.25^31 ≈ 4.6e-19。装 10 亿(1e9)个元素,期望出现 4.6e-10 个 32 层节点——基本不可能。所以实际上限远低于 32。

注释写 "Should be enough for 2^64 elements"(src/server.h:656),正是这个 4^32 = 2^64 的算法。

3.3 每个节点平均多少字节

单块分配后,一个节点的实际占用:

部分字节
score + backward16
level[],平均 1.333 层 × 1621.3
sds 头(短 member 用 SDS_TYPE_5)1
sds 数据 + \0len + 1

合计约 38.3 + len 字节,再向上取整到 jemalloc 的 size class。

举三个例子(x86-64,jemalloc class 序列 8/16/32/48/64/80/96/112/128/160/192/224/256/320/384/448/512/640/…):

  • 6 字节 member、1 层:16 + 16 + 1 + 7 = 40 → class 48
  • 6 字节 member、2 层:16 + 32 + 1 + 7 = 56 → class 64

对比 listpack 编码:6 字节 member + 整数 score 在 listpack 里是"字符串 entry(1 编码 + 6 数据 + 1 backlen)+ 2 字节整数 entry"= 10 字节,跳表每节点约 48 字节,差 4.8 倍;再算上 dict 的 16 字节 entry 与桶数组,zset-max-listpack-entries = 128 这个阈值就落在"内存差 8 倍"与"查找慢 10 倍"的交叉点附近。

而 zsl->alloc_size 就是按这套口径精确记账的(zsetAllocSize,src/t_zset.c:1389-1403):

size = sizeof(zset) + zslAllocSize(zsl) + sizeof(dict) + dictMemUsage(d);

注意这里把 dictMemUsage(d) 也算进去了——dictMemUsage 假设每个元素有一个 dictEntry,而 zsetDictType 的 no_value = 1 恰好让每个元素真的只有一个 16 字节的 dictEntryNoValue。所以 zset 的 hashtable 侧记账是准确的,不像第 13 章 Set 的 setTypeAllocSize 那样高估(那里 dictMemUsage 假设有 val,而 keys_are_odd 让 key 直接存桶里、连 entry 都没有)。


4. 插入:rank[]、update[] 与 span 三分

4.1 找位置的循环

x = zsl->header;
for (i = zsl->level-1; i >= 0; i--) {
    /* store rank that is crossed to reach the insert position */
    rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
    while (zslCompareWithNode(score, ele, x->level[i].forward) > 0) {
        rank[i] += zslGetNodeSpanAtLevel(x, i);
        x = x->level[i].forward;
    }
    update[i] = x;
}

src/t_zset.c:276-285。

从最高层往下。每层做两件事:走 forward 直到"下一个节点比目标大",把走过的 span 累加进 rank[i]。

rank[i] 的语义是:在第 i 层从 header 走到插入位置,跨过了多少个 level-0 节点。因为 span 本来就是"这一层跳过的节点数",逐层累加就得到全表意义上的位置。

rank[i] = i == (zsl->level-1) ? 0 : rank[i+1] 这一行的意思是"每层从上一层的结果开始累加"——因为下降一层不会改变已经跨过的节点数,只是换了个更细的粒度继续走。

4.2 > 0 而不是 >= 0

zslCompareWithNode 返回 1/0/-1(src/t_zset.c:120-126):

int zslCompareWithNode(double score, sds ele, const zskiplistNode *n) {
    if (n == NULL) return -1; /* NULL is +infinity, comes after any real node */
    if (score < n->score) return -1;
    if (score > n->score) return 1;
    return sdscmp(ele, zslGetNodeElement(n));
}

三件事:

  1. n == NULL 返回 -1,也就是"目标比 NULL 大"——把 NULL 当成 +∞,这样 while 条件自然为假、循环停下,不需要额外判空。
  2. 先比 score,浮点比较,两条 if 处理了大/小。
  3. 分数相等才比 member,sdscmp。

while (... > 0) 意味着"只有目标严格大于当前才继续走"。所以插入点定位在"第一个 ≥ 目标的位置之前"。对同分元素,sdscmp > 0 才继续走,所以新元素会插在"第一个字典序更大的同分元素"前面——有序性由 (score, ele) 联合保证。

ZADD 保证 ele 不在表内(调用方先查 dict),所以 == 0 不会发生。但 zslUpdateScore 的慢路径会先用旧 (score, ele) 定位到自己,那里如果写成 >= 0 就会从自己身上跨过去、定位到下一个节点,update[] 全错。所以 > 0 这个符号是两条路径共用的正确写法。

4.3 span 的三分公式

for (i = 0; i < level; i++) {
    node->level[i].forward = update[i]->level[i].forward;
    update[i]->level[i].forward = node;

    /* update span covered by update[i] as node is inserted here */
    zslSetNodeSpanAtLevel(node, i, zslGetNodeSpanAtLevel(update[i], i) - (rank[0] - rank[i]));
    zslSetNodeSpanAtLevel(update[i], i, (rank[0] - rank[i]) + 1);
}

src/t_zset.c:299-306。

设 update[i] 是第 i 层的插入前驱,它原来的 span 是 S(跨过的 level-0 节点数)。插入位置在 update[i] 之后的第 d = rank[0] - rank[i] 个节点处。这个 d 的含义是"从 update[i] 到插入点之间、只在 level 0 上可见的节点数":rank[0] 是全表意义上插入点之前的节点数,rank[i] 是第 i 层走到 update[i] 时跨过的节点数,相减即得。

于是:

  • update[i] 的新 span = d + 1(跨过 d 个旧节点 + 新节点自己)
  • node 的新 span = S - d(原来跨 S 个,被切掉前 d 个,剩下的是新节点到原后继的距离)

两个公式加起来守恒:(d+1) + (S-d) = S+1。

4.4 未触及的层要 +1

for (i = level; i < zsl->level; i++) {
    zslIncrNodeSpanAtLevel(update[i], i, 1);
}

src/t_zset.c:309-311。新节点只有 level 层,但对于"比它高的层",新节点依然存在于 level 0,只是不参与那些层。所以那些层上 update[i] 的 span 要多算 1(新节点从它下面过去了)。

不写这段会怎样?ZRANK 会算错。因为 zslGetRank 是逐层累加 span 的,高层少 1 会一路传导到最终 rank。测试里查 ZRANK 一个排在后面的元素,会得到比真实值小的数。

4.5 提层时的两个副作用

if (level > zsl->level) {
    for (i = zsl->level; i < level; i++) {
        rank[i] = 0;
        update[i] = zsl->header;
        zslSetNodeSpanAtLevel(update[i], i, zsl->length);
    }
    zsl->level = level;
    zslGetNodeInfo(zsl->header)->levels = level;
}

src/t_zset.c:288-296。新节点比当前最高层还高时,多出来的层要补 update[] 和 rank[]:update[i] = header(新层上前面什么都没有),rank[i] = 0,header 在该层的 span = zsl->length(跨过全表所有节点)。

zslSetNodeSpanAtLevel(node, i, ...) 在随后的第 4.3 节那个循环里会算成 zsl->length - (rank[0] - 0) = zsl->length - rank[0],也就是"新节点后面还有多少节点"。这个值恰好是 zslGetRankByNode 需要的(§7.3)。

4.6 backward 与 tail

node->backward = (update[0] == zsl->header) ? NULL : update[0];
if (node->level[0].forward)
    node->level[0].forward->backward = node;
else
    zsl->tail = node;
zsl->length++;

src/t_zset.c:314-320。backward 只在 level 0 维护(zskiplistLevel 里没有 backward 字段),所以跳表是"多层单向 + 底层双向"。这就是注释里的第三条修改(src/t_zset.c:40-42):

c) there is a back pointer, so it's a doubly linked list with the back pointers being only at "level 1". This allows to traverse the list from tail to head, useful for ZREVRANGE.

update[0] == header 要判空,因为 header->backward 是 NULL、不能当普通前驱用。

4.7 zslInsert 只是两句话

zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    serverAssert(!isnan(score));
    level = zslRandomLevel();
    zskiplistNode *node = zslCreateNode(zsl, level, score, ele);
    zslInsertNode(zsl, node);
    return node;
}

src/t_zset.c:326-339。拆成 create + insertNode 两步不是为了复用,而是因为 ZUNIONSTORE 需要"先建节点、不入链"(§17)。zslCreateNode 是 static 但在同文件内被 zdiffAlgorithm1、zunionInterDiffGenericCommand 直接调用,绕过 zslRandomLevel 之外的所有逻辑。

注意"元素必须已复制进节点"这一点:zslInsert 的注释说 "The element 'ele' is COPIED into the new node, so the caller retains ownership and can free it."(src/t_zset.c:323-325)。这改变了经典实现的语义(经典实现里 zslInsert 接管 ele 的所有权)。所以所有调用点都跟着变成:

node = zslInsert(zs->zsl,score,ele);
sdsfree(ele); /* zslInsert copied it, we can free our copy */

src/t_zset.c:1482-1484。这是一个接口语义的翻转,代价是每个调用点多一次 sdsfree,收益是调用方可以复用同一个 sds 建多个节点(ZUNIONSTORE 的两阶段就需要这个)。


5. 删除与降层

5.1 摘链:span 的两种处理

static void zslUnlinkNode(zskiplist *zsl, zskiplistNode *x, zskiplistNode **update) {
    int i;
    for (i = 0; i < zsl->level; i++) {
        if (update[i]->level[i].forward == x) {
            zslIncrNodeSpanAtLevel(update[i], i, zslGetNodeSpanAtLevel(x, i) - 1);
            update[i]->level[i].forward = x->level[i].forward;
        } else {
            zslDecrNodeSpanAtLevel(update[i], i, 1);
        }
    }
    ...
}

src/t_zset.c:345-366。两种情况:

  • update[i] 的 forward 就是 x:x 在这一层参与,前驱要跳过 x,所以 update[i].span += x.span - 1(把 x 跨过的部分接过来,减掉 x 自己)。
  • update[i] 的 forward 不是 x:x 在这一层没被前驱直接指向(x 的层数低于 i),但它仍然占了一个 level-0 位置,所以 update[i].span -= 1。

第二种情况的 -1 和第 4.4 节的 +1 严格对称。少写一个,ZRANK 就会漂移。

5.2 降层必须清零 span

while(zsl->level > 1 && zsl->header->level[zsl->level-1].forward == NULL) {
    zsl->header->level[zsl->level-1].span = 0;
    zsl->level--;
}

src/t_zset.c:361-364。当最高层已经没有节点时降层。关键在 span = 0 这一句——如果不写,header 在该层残留的 span 是个陈旧值。

为什么必须清?因为下次提层时(§4.5),zslSetNodeSpanAtLevel(update[i], i, zsl->length) 会重新赋值,看起来清不清都无所谓。但 zslDebugVerifyStruct 有一条检查(src/t_zset.c:4853-4856):

for (i = zsl->level; i < ZSKIPLIST_MAXLEVEL; i++) {
    serverAssert(zsl->header->level[i].forward == NULL);
    serverAssert(zsl->header->level[i].span == 0);
}

"所有 ≥ zsl->level 的层,header 的 span 必须是 0"。这条不变式不参与功能,但它保证了"span 只在有效层上有意义"这个读代码时的假设成立。如果不维护它,读代码的人就得多想一层"这个 span 是有效的吗"。

注意降层只降 header 的 span,没有同步 zslGetNodeInfo(zsl->header)->levels。这是一个不对称:提层时更新了 header 的 levels(§4.5),降层时没更新。所以 header->levels 会一直停留在历史最大值。这不影响功能(没人读 header 的 levels 做判断),但意味着 zslDebugVerifyStruct 也没法检查它——它只能检查 zsl->level 的范围(src/t_zset.c:4850)。

5.3 zslDelete 必须先定位再断言

static void zslDelete(zskiplist *zsl, zskiplistNode *node) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    int i;
    double score = node->score;
    sds ele = zslGetNodeElement(node);

    x = zsl->header;
    for (i = zsl->level-1; i >= 0; i--) {
        while (zslCompareWithNode(score, ele, x->level[i].forward) > 0) {
            x = x->level[i].forward;
        }
        update[i] = x;
    }

    /* Verify we truly found the node */
    serverAssert(x->level[0].forward == node);
    zslUnlinkNode(zsl, node, update);
    zslFreeNode(zsl, node);
}

src/t_zset.c:371-390。明明已经有 node 指针了,为什么还要从头搜一遍?因为 zslUnlinkNode 需要 update[]——每一层的前驱指针。而跳表没有"父指针",只能重新走一遍。

serverAssert(x->level[0].forward == node) 是一致性校验:搜索停下的节点必须是目标的前驱。若 node->score 与 dict 记录的不一致(某处漏了同步),这里会 abort 而不是静默删错节点——发布版也生效,成本只是一次指针比较。

zslDelete 是 static 且只有一个调用点(zsetRemoveFromSkiplist)。批量删除走 zslUnlinkNode + zslFreeNode 的组合,因为它们遍历时已经维护了 update[],不必重复搜索。

5.4 三个批量删除的差异

函数定位方式循环终止
zslDeleteRangeByScore!zslValueGteMin(forward->score, range)zslValueLteMax(x->score, range)
zslDeleteRangeByLex!zslLexValueGteMin(ele(forward), range)zslLexValueLteMax(ele(x), range)
zslDeleteRangeByRanktraversed + span < starttraversed <= end

前两个是"先走到区间外,再从 level[0].forward 开始删"。第三个用 traversed 累加,因为 rank 语义要数节点:定位用严格的 < start,走完后 traversed 是"最后一个 rank < start 的节点",再 traversed++ 让 x 变成 rank = start 的那个——这个 ++ 与对外的 1-based 约定对齐(src/t_zset.c:613:"Start and end are inclusive. Note that start and end need to be 1-based")。三个函数都返回 removed,调用方用它填 ZREMRANGEBY* 的回复和 server.dirty。


6. 改分:快路径与慢路径

6.1 一个常见说法的修正

很多资料说"ZINCRBY 必须先删后插,因为分数变了位置就变了"。8.10 的实现不是这样(src/t_zset.c:396-430):

static void zslUpdateScore(zskiplist *zsl, zskiplistNode *node, double newscore) {
    /* Fast path: if the node, after the score update, would be still exactly
     * at the same position, we can just update the score without
     * actually removing and re-inserting the element in the skiplist. */
    if ((node->backward == NULL || node->backward->score < newscore) &&
        (node->level[0].forward == NULL || node->level[0].forward->score > newscore))
    {
        node->score = newscore;
        return;
    }
    ...
    zslUnlinkNode(zsl, node, update);
    node->score = newscore;
    zslInsertNode(zsl, node);
}

快路径:如果新分数严格落在前后邻居的分数之间,位置不变,直接改 node->score。慢路径:否则摘链、改分、重新入链——但复用同一个节点,不重建。

6.2 快路径的两个 < 和 >

判据是"backward->score < newscore 且 forward->score > newscore",都是严格不等。

为什么严格?假设 backward->score == newscore。那么新位置应该在 backward 之后(因为 backward 的字典序可能更小),但当前节点在 backward 之后是成立的——位置其实没变。可是"是不是所有同分元素里字典序正确的位置"需要比较 member,而 member 没变、backward 也没变,所以相对次序确实是对的。

那为什么不用 <= / >= 放宽?放宽之后,"位置不变"的判断就要依赖 member 的比较,而快路径的目的正是完全不做比较:严格不等号让判据只依赖 score(两次浮点比较),O(1) 且零字符串比较,而这个判据每 ZADD 都跑一次。代价是判据偏保守——真正"位置不变"的同分更新会被判成慢路径,多做一次 O(log n) 摘链入链,这个代价值得付。

6.3 慢路径为什么复用节点

zslUnlinkNode(zsl, node, update);
node->score = newscore;
zslInsertNode(zsl, node);

src/t_zset.c:427-429。复用节点而不是"删了新建",原因在 dict 里:

if (score != curscore) {
    zslUpdateScore(zs->zsl, znode, score);
    /* Note that we did not remove the original element from
     * the hash table representing the sorted set, so we don't
     * need to update the dict - the node pointer stays the same. */
    *out_flags |= ZADD_OUT_UPDATED;
}

src/t_zset.c:1716-1722。dict 里存的 key 是 znode。如果删了重建,dict 的 key 就要换——那是一个 dictUnlink + dictSetKeyAtLink 的完整往返,而且 dict 的桶位置不变(member 没变)但 entry 要重写。复用节点让这条路径完全不碰 dict。

这也是"节点内联 sds"的另一个好处:dict 里的 key 如果是 sds 指针,那改分时 sds 不变、dict 也不需要动;但如果是"节点指针",就必须保证节点地址不变——单块分配 + 复用节点一起保证了这一点。

6.4 zslUpdateScore 的返回类型是 void

注释说 "If the new score would keep the node in its current position, updates in-place and returns NULL. Otherwise, unlinks the node, updates score, reinserts at correct position, and returns node."(src/t_zset.c:392-395)——这是过时的注释,函数签名是 static void,没有返回值。这条注释是从"返回 zskiplistNode *"的旧版本留下的,重构时改了签名没改注释。

这类"注释与代码不符"必须逐个核对。第 12 章遇到过三处(quicklistNode 的 32/40、quicklist 的 40/48、_quicklistNodeAllowMerge 的参数顺序),本章这是第一处。

顺手看 ZADD 的 updated 计数:zslUpdateScore 是 void,所以"是否更新"由调用方用 score != curscore 判断(src/t_zset.c:1716)。如果分数相等,什么都不做,也不计入 updated——这符合 ZADD 的语义(返回"新增的元素数",CH 时返回"新增 + 变更")。

6.5 listpack 编码下的改分

listpack 没有 zslUpdateScore 的余地——元素是交错存储的,改分等于改字节序列:

if (score != curscore) {
    zobj->ptr = zzlDelete(zobj->ptr,eptr);
    zobj->ptr = zzlInsert(zobj->ptr,ele,score);
    *out_flags |= ZADD_OUT_UPDATED;
}

src/t_zset.c:1646-1650。这是真正的"先删后插",两次 O(n) 遍历:zzlDelete 是 lpDeleteRangeWithEntry(一次 memmove),zzlInsert 要从头扫到新位置。所以 listpack 编码下 ZINCRBY 的复杂度是 O(n),而跳表下是 O(log n)(快路径 O(1))。

注意 zzlDelete 之后 zobj->ptr 可能变了(lpDeleteRangeWithEntry 可能 realloc),所以必须重新赋值。这也是为什么 zzlDelete 和 zzlInsert 都返回新的 zl 指针而不是就地修改。


7. rank 的三种算法

ZSet 有三个"求排名/按下标取"的需求,对应三套算法,容易混淆:

函数输入输出复杂度
zslGetRank(score, ele)1-based rank,0 表示未找到O(log n) 次 sdscmp
zslGetRankByNodenode *1-based rank最多 32 次指针跳,零比较
zslGetElementByRankFromNode(node*, level, rank)节点指针O(log n)

7.1 zslGetRank:经典算法,8.10 里已经没人调用

经典实现从 header 最高层往下逐层走位,zslCompareWithNode(...) >= 0(同分也要继续走,因为目标可能在后头)时累加 span,每层结束时顺手判断"x 是不是目标",是就立刻返回 rank,全部走完还没命中则返回 0(所以对外 rank 是 1-based)。代码在 src/t_zset.c:645-662。

代价是每次走位一次 sdscmp:member 512 字节时,32 次走位就是 16KB 的内存比较。这正是 zslGetRankByNode 存在的动机。

但在 8.10 里 zslGetRank 没有任何调用点(grep 可验):zsetRank 走的是 dictFind + zslGetRankByNode(src/t_zset.c:1847)。它保留下来是因为 src/server.h:3789 仍然是公开声明的 API——重构中"保留但不用"是常见取舍,删掉会破坏头文件层面的兼容。

7.2 zslGetElementByRankFromNode:按下标取

static zskiplistNode *zslGetElementByRankFromNode(zskiplistNode *start_node, int start_level, unsigned long rank) {
    zskiplistNode *x;
    unsigned long traversed = 0;
    int i;

    x = start_node;
    for (i = start_level; i >= 0; i--) {
        while (x->level[i].forward && (traversed + zslGetNodeSpanAtLevel(x, i)) <= rank) {
            traversed += zslGetNodeSpanAtLevel(x, i);
            x = x->level[i].forward;
        }
        if (traversed == rank) {
            return x;
        }
    }
    return NULL;
}

src/t_zset.c:688-705。<= rank 而不是 < rank,所以会走到"恰好 rank"的位置。traversed == rank 的提前返回让内层循环不必等到最底层。

zslGetElementByRank 是它的特例(从 header 最高层出发,src/t_zset.c:708-710)。真正有价值的是"从任意节点、任意层出发"——这让 zslNthInRange 可以先 O(log n) 定位到区间起点,再 O(log n) 跳到第 n 个,总复杂度 O(log n) 而不是 O(n)。

7.3 zslGetRankByNode:从节点往右数

unsigned long zslGetRankByNode(zskiplist *zsl, zskiplistNode *x) {
    unsigned long distance_to_end = 0;
    int level;

    while (x) {
        level = zslGetNodeInfo(x)->levels - 1;
        distance_to_end += zslGetNodeSpanAtLevel(x, level);
        x = x->level[level].forward;
    }

    return zsl->length - distance_to_end;
}

src/t_zset.c:672-685。思路是"算它到末尾的距离,再用总长减去"。

关键在于 level = zslGetNodeInfo(x)->levels - 1——从节点自己的最高层跳。注释说 "Time complexity: O(log N) on average, same as traditional approach but faster due to avoiding string comparisons"(src/t_zset.c:670),但实际上比 O(log n) 更强:

每一跳之后,落点的层数严格大于当前节点。 因为 y = x->level[ℓ].forward 说明 y 参与了第 ℓ 层,即 y->levels ≥ ℓ+1 > ℓ+1-1 = ℓ = x->levels - 1。取整后 y->levels > x->levels。层数严格递增,最大 32,所以循环最多执行 32 次,与 n 无关。严格上界是 O(ZSKIPLIST_MAXLEVEL),即常数。

注释写"O(log N) on average"是保守说法。真实情况是"最多 32 次,每次一次加法 + 一次指针解引用,零字符串比较"。对一个 100 万元素的 zset,ZRANK 从"32 次 500 字节 memcmp(16KB)"变成"32 次指针跳"。

循环能收敛还依赖一个不变式:当节点在其最高层没有后继(forward == NULL)时,该层的 span 等于"这个节点之后还有多少个节点"。这样最后一次加法正好补上"后续那些层数不够、跳不到"的节点,然后 x = NULL 结束循环。这个不变式在第 4.5 节的提层分支里自然成立(新节点的顶层 span = zsl->length - rank[0]),在 zslUnlinkNode 里由 span 守恒维护。

zslDebugVerifyStruct 专门检查了这条不变式(src/t_zset.c:4894-4909):

for (i = 1; i < (int)node_level; i++) {
    if (x->level[i].forward) {
        serverAssert(x->level[i].span > 0);
    } else {
        unsigned long nodes_after = 0;
        zskiplistNode *temp = x->level[0].forward;
        while (temp) { nodes_after++; temp = temp->level[0].forward; }
        serverAssert(x->level[i].span == nodes_after);
    }
}

注意这个验证本身是 O(n²) 的(每个节点都可能数一遍后面的节点),所以只在 DEBUG 或测试里跑。zsetTest 就是干这个的(src/t_zset.c:4949 起,N = 1000,每步操作后跑一遍 zslDebugVerifyStruct)。

7.4 三者的分工总结

  • ZRANK:dictFind → zslGetRankByNode。O(1) 哈希 + 32 次指针跳。
  • ZRANGE start stop:zslGetElementByRank(start+1),O(log n)。
  • ZRANGEBYSCORE ... LIMIT offset count:zslNthInRange(range, offset, NULL),O(log n)(§8)。
  • ZSCORE:dictFind → node->score。O(1)。
  • ZCOUNT:两次 zslNthInRange 拿 rank 相减。O(log n)。

整套设计的支点是"dict 里存节点指针",而这条又依赖"节点地址稳定",最终依赖"单块分配 + 复用节点"。


8. 范围查找:zslNthInRange 与 LIMIT 的 O(log n)

8.1 问题

ZRANGEBYSCORE key min max LIMIT 100000 10 要从"落在 [min,max] 区间内的第 100000 个元素"开始取 10 个。

朴素做法是从区间起点 forward 走 100000 步——O(offset),这也正是 listpack 编码下唯一能做的事(src/t_zset.c:3561-3567):

while (eptr && offset--) {
    if (reverse) zzlPrev(zl,&eptr,&sptr);
    else zzlNext(zl,&eptr,&sptr);
}

跳表上则可以 O(log n):先用区间定位到"最后一个小于 min 的节点"(这一步和普通查找一样,O(log n)),再从那一点按下标跳 n 步(zslGetElementByRankFromNode,也是 O(log n))。两次 O(log n) 取代 O(n)。

zslNthInRange 就是这两步的组合(src/t_zset.c:469-547),是整个 8.10 ZRANGE 家族性能的基础。

8.2 三段结构

函数开头先做区间是否为空的三次短路(zslIsInRange,src/t_zset.c:441-455):

if (range->min > range->max ||
        (range->min == range->max && (range->minex || range->maxex)))
    return 0;
x = zsl->tail;
if (x == NULL || !zslValueGteMin(x->score,range))
    return 0;
x = zsl->header->level[0].forward;
if (x == NULL || !zslValueLteMax(x->score,range))
    return 0;
return 1;
  1. 区间本身矛盾(min > max,或 min == max 且任一开区间)→ 空。
  2. 最大元素都 < min → 空。
  3. 最小元素都 > max → 空。

三个判断都是 O(1),避免"区间完全落在数据范围外"时还走一遍 O(log n) 定位——ZRANGEBYSCORE k 1000000 2000000 而所有分数都在 [1,100] 时很常见。

然后是主体(src/t_zset.c:480-544),n >= 0(正向)和 n < 0(反向)两支,逻辑对称但细节不同。

8.3 正向:三个阶段

x = zsl->header;
i = zsl->level - 1;
while (x->level[i].forward && !zslValueGteMin(x->level[i].forward->score, range)) {
    edge_rank += zslGetNodeSpanAtLevel(x, i);
    x = x->level[i].forward;
}
last_highest_level_node = x;
last_highest_level_rank = edge_rank;

阶段一:只在最高层走,走到"下一个节点已经落在区间内(或没有下一个)"为止。此时 edge_rank 是"最后一个小于 min 的节点的 0-based rank + 1",也就是"区间之前有多少个节点"。

注意这里记下了 last_highest_level_node 和 last_highest_level_rank。为什么要记这个?因为后面的 rank 跳跃要从最高层出发才能 O(log n),而从最高层出发的相对 rank 需要减去这个起点偏移。这是一个"从半路出发"的 bookkeeping。

for (i = zsl->level - 2; i >= 0; i--) {
    while (x->level[i].forward && !zslValueGteMin(x->level[i].forward->score, range)) {
        edge_rank += zslGetNodeSpanAtLevel(x, i);
        x = x->level[i].forward;
    }
}
if ((unsigned long)(edge_rank + n) >= zsl->length) return NULL;

阶段二:从次高层往下,继续逼近区间起点,累加 edge_rank。循环结束后 x 是"最后一个小于 min 的节点",edge_rank 是它之后的位置 = 区间起点的 0-based rank。所以区间内第 0 个元素的 rank(1-based)是 edge_rank + 1。

(edge_rank + n) >= zsl->length 是越界检查:如果"区间起点 rank + n"超出了全表长度,说明 n 太大了。

阶段三:两种情况(src/t_zset.c:502-512):

if (n < ZSKIPLIST_MAX_SEARCH) {
    /* If offset is small, we can just jump node by node */
    for (i = 0; i < n + 1; i++) {
        x = x->level[0].forward;
    }
} else {
    /* If offset is big, we can jump from the last zsl->level-1 node. */
    rank_diff = edge_rank + 1 + n - last_highest_level_rank;
    x = zslGetElementByRankFromNode(last_highest_level_node, zsl->level - 1, rank_diff);
}

ZSKIPLIST_MAX_SEARCH = 10(src/server.h:658)是分界点。

  • n < 10:从 x 沿 level[0].forward 走 n+1 步。为什么是 n+1?因为 x 是"区间外最后一个",走一步才到区间内第 0 个,再走 n 步才到第 n 个。
  • n >= 10:算出目标在"从最高层起点算起的相对 rank",交给 zslGetElementByRankFromNode 跳。rank_diff = (区间起点 rank) + n - (最高层起点 rank),都是 1-based,相减得到相对 rank。

为什么分界点是 10?因为 zslGetElementByRankFromNode 的固定开销是"下降 zsl->level 层"(约 10-15 次循环 + 每次一次 forward 判空),而逐节点走是"每步一次指针解引用 + 一次判空"。10 步是这个交叉点。

这个 10 是经验值(src/server.h:658 只有一句 #define,没有注释)。真实交叉点依赖 zsl->level:表里只有 5 层时 zslGetElementByRankFromNode 只要下降 6 层、交叉点应更低,20 层时更高。固定 10 是"不知道层数"前提下的折中。

阶段三之后还有两个检查:

if (x && !zslValueLteMax(x->score,range)) return NULL;
if (x && out_rank) *out_rank = edge_rank + n + 1;

第一个检查"取到的节点是否超出区间上界"——因为 n 可能落在区间内但区间太小。第二个输出 1-based rank。

8.4 反向:n 是负的

n < 0 时,-1 表示"区间内最后一个"。这一支有三个不同:

其一,阶段一之后从最高层重新往下。 正向的第二阶段是 i = zsl->level - 2,反向的是 i = zsl->level - 1(src/t_zset.c:517-525):

for (i = zsl->level - 1; i >= 0; i--) {
    while (x->level[i].forward && zslValueLteMax(x->level[i].forward->score, range)) {
        edge_rank += zslGetNodeSpanAtLevel(x, i);
        x = x->level[i].forward;
    }
}

这次条件是 zslValueLteMax——不是"跳出区间下界",而是"走进区间上界",一直走到"最后一个仍在区间内的节点"。循环结束后 x 是区间内最后一个节点,edge_rank 是它的 0-based rank。所以它的 1-based rank = edge_rank + 1。

其二,越界判据是 edge_rank < -n,而这里的 edge_rank 语义与正向差 1。

理解这一点的关键在于 edge_rank 是累加的 span 总和——从 header 出发跨过的节点数。走到某个节点 x 时,edge_rank 数值上等于 x 的 1-based rank。所以反向分支结束后 edge_rank 就是"区间内最后一个节点的 1-based rank"。

于是 edge_rank < -n 的意思是"区间内元素数 < |n|"。n = -1 时 -n = 1:区间内有 1 个元素时 edge_rank = 1,不小于 1,通过;n = -2 时 -n = 2,区间内有 1 个元素则 edge_rank = 1 < 2,返回 NULL——倒数第二个确实不存在。

而正向分支里 edge_rank 是"区间之前有多少个节点",也就是区间起点的 0-based rank,比反向分支的语义小 1。同一个变量在两个分支里含义差 1,是这段代码最容易读错的地方;(edge_rank + n) >= zsl->length(正向)与 edge_rank < -n(反向)两个判据都建立在这个差 1 之上。

其三,小偏移走 backward。 src/t_zset.c:528-533:

if (n + 1 > -ZSKIPLIST_MAX_SEARCH) {
    for (i = 0; i < -n - 1; i++) {
        x = x->backward;
    }
}

n = -1 时 -n-1 = 0,不走(x 已经是最后一个)。n = -10 时走 9 步。为什么是 9 而不是 10?因为分界点要跟正向对齐:正向 n < 10 时走 n+1 步,n=9 走 10 步;反向 n = -10 时 -n-1 = 9,走 9 步。所以反向量的是"向后走几步",正向量的是"向前走几步到第 n 个",两者都用 ZSKIPLIST_MAX_SEARCH = 10 作阈值但换算方式差了 1。

这种差 1 是"0-based 正向"与"-1-based 反向"两种索引约定的必然产物,写错的话症状是"LIMIT 0 10 与 REV LIMIT 0 10 边界差一个元素"。

8.5 out_rank 在反向分支的含义

两个分支共用同一条公式 *out_rank = edge_rank + n + 1。反向时 edge_rank 是"区间内最后一个的 1-based rank"、n 为负,于是 edge_rank + n + 1 就是从最后一个往前数 |n|-1 个的 rank:区间内 5 个元素 rank 为 10..14、edge_rank = 14 时,n = -1 给 14、n = -5 给 10。

8.6 谁在用 zslNthInRange

三处,全部是"带 offset 的范围查询":

  1. genericZrangebyscoreCommand(src/t_zset.c:3601-3604):

    if (reverse) ln = zslNthInRange(zsl, range, -offset-1, NULL);
    else ln = zslNthInRange(zsl, range, offset, NULL);
    

    注意 -offset-1:LIMIT offset count 的 offset 是"跳过几个",反向时要跳过 offset 个 = 从最后一个开始往前 offset 步 = n = -(offset+1)。

  2. genericZrangebylexCommand(src/t_zset.c:3875-3878):同上,只是用 zslNthInLexRange。

  3. zcountCommand / zlexcountCommand(src/t_zset.c:3700-3712、3776-3789):用 n=0 和 n=-1 拿区间首尾的 rank,相减得计数。

第 3 种用法是本节最实用的推论:ZCOUNT 是两次 O(log n) 而不是 O(k)。ZCOUNT k -inf +inf 在 100 万元素的 zset 上是"两次定位 + 一次减法",不是"遍历 100 万个"。

zslNthInLexRange(src/t_zset.c:866-943)是 zslNthInRange 的逐行翻版,把 zslValueGteMin/LteMax 换成 zslLexValueGteMin/LteMax、把 x->score 换成 zslGetNodeElement(x)。两份代码 78 行,几乎完全相同——这是 C 没有泛型/闭包的直接代价。第 13 章的 lpFindCmp 与 lpCompare 是同一类重复(§8 已经讨论过)。

8.7 listpack 上为什么不能

genericZrangebyscoreCommand 的 listpack 分支(src/t_zset.c:3541-3593)先 zzlFirstInRange(O(n) 扫描)定位起点,然后 while (eptr && offset--) 逐个走。

即使不算"定位起点"的 O(n),LIMIT 的 offset 本身也只能线性走——因为 listpack 没有 span,没有任何"跳过 k 个元素"的快捷方式。lpSeek(zl, 2*idx) 是 O(min(idx, n-idx)),但要先知道"区间起点是第几个元素",而这个信息也没有(zzlFirstInRange 只返回指针,不返回下标)。

所以 ZRANGEBYSCORE ... LIMIT 100000 10 在 listpack 上是 O(n),在 skiplist 上是 O(log n)。这是编码选择对某个具体命令复杂度的直接影响——zset-max-listpack-entries 只有 128,所以 listpack 上 O(n) 最多是 128 次迭代,用户感受不到差别;但如果运维把它调到 10000,LIMIT 的代价就从 128 变成 10000。


9. 两种 range spec:分数区间与字典序区间

9.1 zrangespec 与 ( 前缀

static int zslParseRange(robj *min, robj *max, zrangespec *spec) {
    char *eptr;
    spec->minex = spec->maxex = 0;

    if (min->encoding == OBJ_ENCODING_INT) {
        spec->min = (long)min->ptr;
    } else {
        size_t len = sdslen(min->ptr);
        if (((char*)min->ptr)[0] == '(') {
            spec->min = fast_float_strtod((char*)min->ptr+1,len-1,&eptr);
            if (eptr[0] != '\0' || isnan(spec->min)) return C_ERR;
            spec->minex = 1;
        } else {
            spec->min = fast_float_strtod((char*)min->ptr,len,&eptr);
            if (eptr[0] != '\0' || isnan(spec->min)) return C_ERR;
        }
    }
    ...
}

src/t_zset.c:713-749。四个要点:

其一,OBJ_ENCODING_INT 的 robj 直接取值。 ZRANGEBYSCORE k 1 10 的 "1" 和 "10" 在协议解析阶段会被 tryObjectEncoding 转成 OBJ_ENCODING_INT(第 5 章)。此时 min->ptr 不是字符串,而是整数直接存在指针位里((long)min->ptr)。所以这里必须分两条路:整数编码走 (long)min->ptr,字符串编码才 strtod。

这个分派不只是一次优化——如果对 OBJ_ENCODING_INT 的 robj 调 sdslen(min->ptr),会把一个整数当指针解引用,直接崩溃。

其二,( 前缀剥掉后再解析。 (1.5 表示开区间,spec->min = 1.5、spec->minex = 1。解析时传 len-1 跳过 (。

其三,fast_float_strtod 而不是 strtod。 8.10 引入的快速浮点解析(src/fast_float_strtod.h,基于 fast_float 库):strtod 要处理 locale、十六进制浮点、inf/nan 等形态,约 50-100ns;fast_float 纯十进制解析,约 10-20ns。ZRANGEBYSCORE 每次解析 2 个边界、ZADD 解析每个 score,都在热路径上。

其四,eptr[0] != '\0' 检查整串被消费。 "拒绝部分解析"的标准写法:strtod("1.5abc") 返回 1.5 并把 eptr 指向 "abc",不检查就会把 1.5abc 当合法边界。

isnan 检查是防 ZRANGEBYSCORE k nan 1 这种输入。NaN 参与任何比较都返回 false,会让 zslValueGteMin 的语义全部失效。

9.2 zrangespec 的四个字段

typedef struct zrangespec {
    double min;
    double max;
    int minex;
    int maxex;
} zrangespec;

src/server.h 里定义(约 1830 行附近)。三个判断函数(src/t_zset.c:432-438):

int zslValueGteMin(double value, zrangespec *spec) {
    return spec->minex ? (value > spec->min) : (value >= spec->min);
}
int zslValueLteMax(double value, zrangespec *spec) {
    return spec->maxex ? (value < spec->max) : (value <= spec->max);
}

minex/maxex 为 1 表示"排他",所以用严格不等号。跳表侧与 listpack 侧共用这两个函数(zzlIsInRange、zzlFirstInRange 都调它们),保证两种编码的边界语义完全一致。

9.3 zlexrangespec 与共享哨兵

static int zslParseLexRangeItem(robj *item, sds *dest, int *ex) {
    char *c = item->ptr;
    switch(c[0]) {
    case '+':
        if (c[1] != '\0') return C_ERR;
        *ex = 1;
        *dest = shared.maxstring;
        return C_OK;
    case '-':
        if (c[1] != '\0') return C_ERR;
        *ex = 1;
        *dest = shared.minstring;
        return C_OK;
    case '(':
        *ex = 1;
        *dest = sdsnewlen(c+1,sdslen(c)-1);
        return C_OK;
    case '[':
        *ex = 0;
        *dest = sdsnewlen(c+1,sdslen(c)-1);
        return C_OK;
    default:
        return C_ERR;
    }
}

src/t_zset.c:766-791。四种前缀:

前缀含义dest
-比任何字符串都小shared.minstring
+比任何字符串都大shared.maxstring
[闭区间新分配的 sds
(开区间新分配的 sds

- 和 + 只接受"单字符",c[1] != '\0' 检查保证 -abc 被拒绝(它不是"负无穷",而是一个语法错误)。

shared.minstring / shared.maxstring 是全局共享的 sds,永不释放。用它们当 ±∞ 哨兵,而不是用 NULL,是为了让后续的 sdscmp 调用不需要额外判空——比较逻辑只需要在 sdscmplex 里识别这两个指针就行:

static int sdscmplex(sds a, sds b) {
    if (a == b) return 0;
    if (a == shared.minstring || b == shared.maxstring) return -1;
    if (a == shared.maxstring || b == shared.minstring) return 1;
    return sdscmp(a,b);
}

src/t_zset.c:826-831。四条 if 覆盖了 ±∞ 的所有组合:

  • a == b:同一个指针(包括两个都是 minstring)→ 0
  • a 是 -∞,或 b 是 +∞ → a < b
  • a 是 +∞,或 b 是 -∞ → a > b
  • 否则真实 sdscmp

用指针相等而不是字符串内容比较是关键:如果 shared.minstring 的内容是空串 "",那它和"用户传的 [ 空串"内容相同。用指针区分,- 与 [] 才是不同的东西。这是"用对象身份承载语义"的典型手法,和第 9 章 keymeta 的 metabits 寻址、第 13 章 str == tmpbuf 的来源标记是同一类技巧。

代价:sdscmplex 不能对任意 sds 调用。它靠指针区分语义,所以调用点必须保证值来自 zslParseLexRangeItem 或 zslGetNodeElement,不能是临时拼出来的 sds。

9.4 zslFreeLexRange 的释放判据

void zslFreeLexRange(zlexrangespec *spec) {
    if (spec->min != shared.minstring &&
        spec->min != shared.maxstring) sdsfree(spec->min);
    if (spec->max != shared.minstring &&
        spec->max != shared.maxstring) sdsfree(spec->max);
}

src/t_zset.c:795-800。只释放"不是共享哨兵"的那两个。如果漏了这个判断,ZRANGEBYLEX k - + 每执行一次就会 sdsfree(shared.minstring)——共享对象的引用计数被破坏,下一次 - 比较读到已释放内存。

zslParseLexRange 的失败路径也要小心(src/t_zset.c:813-820):

spec->min = spec->max = NULL;
if (zslParseLexRangeItem(min, &spec->min, &spec->minex) == C_ERR ||
    zslParseLexRangeItem(max, &spec->max, &spec->maxex) == C_ERR) {
    zslFreeLexRange(spec);
    return C_ERR;
}

先把 min/max 置 NULL,再逐个解析。如果第一个成功、第二个失败,spec->min 已分配、spec->max 是 NULL,zslFreeLexRange 里 NULL != shared.minstring 成立,sdsfree(NULL) —— 这是安全的(sdsfree 对 NULL 是 no-op)。如果反过来先不置 NULL,spec 是栈上未初始化值,zslFreeLexRange 会 free 随机指针。

这种"先置空再逐步填充、失败时统一清理"是 C 里处理部分初始化的标准模式,Redis 里反复出现(第 13 章 sunionDiffGenericCommand 的 sets[] 也是)。

9.5 zslParseLexRange 为什么拒绝 INT 编码

if (min->encoding == OBJ_ENCODING_INT ||
    max->encoding == OBJ_ENCODING_INT) return C_ERR;

src/t_zset.c:810-811。BYLEX 的边界必须以 [/(/-/+ 开头,而 "-" 不是合法整数、"[1" 也不是,所以这两个参数正常不会被 tryObjectEncoding 转成 INT。这条检查是防御性的:如果某个客户端传 ZRANGEBYLEX k 1 2,"1" 和 "2" 会被编码成 INT,此时 item->ptr 是整数不是字符串,c[0] 读的是整数的低位字节——完全无意义。所以必须在最前面拦掉。

这条检查导致 ZRANGEBYLEX k 1 2 报 "min or max not valid string range item",而不是崩溃。这是"类型检查放在解引用之前"的正面例子。


10. listpack 编码的 zset:交错的 element/score 对

10.1 布局

listpack 编码的 zset 是一个扁平的 [ele0, score0, ele1, score1, ...] 序列,没有嵌套结构。所以:

  • zzlLength(zl) = lpLength(zl) / 2(src/t_zset.c:1008-1010)
  • 第 i 个元素的 ele 在 lpSeek(zl, 2*i),score 在 lpSeek(zl, 2*i+1)
  • 反向第 i 个(0-based)的 ele 在 lpSeek(zl, -2-2*i)(src/t_zset.c:3449)

lpLength/2 能成立的前提是"元素数为偶数",这由所有插入/删除操作成对进行保证。zzlInsertAt 一次写两个 entry(src/t_zset.c:1258-1272):

listpackEntry entries[2];
entries[0].sval = (unsigned char*)ele;
entries[0].slen = sdslen(ele);
if (score_is_long) {
    entries[1].sval = NULL;
    entries[1].lval = lscore;
} else {
    entries[1].sval = (unsigned char*)scorebuf;
    entries[1].slen = scorelen;
}
if (eptr == NULL)
    zl = lpBatchAppend(zl, entries, 2);
else
    zl = lpBatchInsert(zl, eptr, LP_BEFORE, entries, 2, NULL);

lpBatchAppend / lpBatchInsert 是"一次写多个 entry"的接口,比调两次 lpAppend 少一次 realloc 判断、少一次 lpGetTotalBytes 更新。第 13 章 §17 的 lpBatchDelete 是同一个家族的删除版。

10.2 score 的存储形态

char scorebuf[MAX_D2STRING_CHARS];
long long lscore;
int score_is_long = double2ll(score, &lscore);
if (!score_is_long)
    scorelen = d2string(scorebuf,sizeof(scorebuf),score);

src/t_zset.c:1251-1256。double2ll 判断"这个 double 是否精确等于某个 int64":

  • 是 → entries[1].lval = lscore,走 listpack 的整数编码(2/3/4/5/6/10 字节)
  • 否 → d2string 转成十进制字符串,走字符串编码

这个判断让 ZADD k 1 a、ZINCRBY k 1 a 这类常见用法得到最紧凑的表示:score = 1 存成 2 字节整数 entry,而不是 "1" 的 3 字节字符串 entry。

float2ll/double2ll 在 util.c 里,判据是"(long long)value 反向转回 double 后相等"。所以 -0.0 会被存成整数 0(读回来是 0.0 而非 -0.0)——不影响排序,但严格说改变了值。

MAX_D2STRING_CHARS = 32 是 d2string 的缓冲上界。d2string 用 fpconv_dtoa(grisu2)生成最短往返表示,0.1 存成 "0.1" 而非 "0.10000000000000001";最长是 -1.7976931348623157e+308(24 字符),32 有余量。

10.3 zzlInsert:线性扫描定位

unsigned char *zzlInsert(unsigned char *zl, sds ele, double score) {
    unsigned char *eptr = lpSeek(zl,0), *sptr;
    double s;

    while (eptr != NULL) {
        sptr = lpNext(zl,eptr);
        s = zzlGetScore(sptr);

        if (s > score) {
            zl = zzlInsertAt(zl,eptr,ele,score);
            break;
        } else if (s == score) {
            if (zzlCompareElements(eptr,(unsigned char*)ele,sdslen(ele)) > 0) {
                zl = zzlInsertAt(zl,eptr,ele,score);
                break;
            }
        }

        eptr = lpNext(zl,sptr);
    }

    if (eptr == NULL)
        zl = zzlInsertAt(zl,NULL,ele,score);
    return zl;
}

src/t_zset.c:1279-1310。三个分支:

  • s > score:找到"第一个分数更大"的元素,插在它前面。
  • s == score 且 ele 的字典序小于 eptr:同分时按字典序定位。
  • 扫到结尾没找到:追加到尾部。

这是 O(n) 的顺序扫描,没有二分——listpack 的 entry 变长,无法 O(1) 定位第 k 个,lpSeek 本身是 O(k)。所以 ZADD 在 listpack 上 O(n)、跳表上 O(log n)。

zzlCompareElements 处理"listpack 里存的是整数、要比较的 ele 是字符串"的情况(src/t_zset.c:988-1006):

unsigned char vbuf[32];
vstr = lpGetValue(eptr,&vlen,&vlong);
if (vstr == NULL) {
    vlen = ll2string((char*)vbuf,sizeof(vbuf),vlong);
    vstr = vbuf;
}
minlen = (vlen < clen) ? vlen : clen;
cmp = memcmp(vstr,cstr,minlen);
if (cmp == 0) return vlen-clen;
return cmp;

把整数就地转成十进制字符串(vbuf 是 32 字节栈缓冲,够放 -9223372036854775808 的 21 字符),然后 memcmp。

vlen - clen 这一行是"前缀相同时比长度"——"12" vs "123":memcmp 前 2 字节相等,cmp = 0,然后 2 - 3 = -1,所以 "12" < "123"。这是字典序的标准定义,和 sdscmp(sds.c 里也是 memcmp + 长度差)一致。

10.4 zzlDelete:一次 memmove

static unsigned char *zzlDelete(unsigned char *zl, unsigned char *eptr) {
    return lpDeleteRangeWithEntry(zl,&eptr,2);
}

src/t_zset.c:1246-1248。删两个 entry(ele + score),lpDeleteRangeWithEntry 内部是一次 memmove 把后面的字节前移。eptr 传引用是因为函数可能 realloc,需要把新指针写回去。

zzlDeleteRangeByRank 更狠(src/t_zset.c:1366-1371):

unsigned int num = (end-start)+1;
if (deleted) *deleted = num;
zl = lpDeleteRange(zl,2*(start-1),2*num);
return zl;

一次调用删掉整个区间。lpDeleteRange 内部只做一次 memmove,所以 ZREMRANGEBYRANK k 0 -1(清空)是 O(1) 次 memmove 而不是 O(n) 次。对比逐个 zzlDelete:n 次 memmove,每次搬 n-i 个元素,总 O(n²)。

这与第 13 章 §17 的 lpBatchDelete(SPOP 用)是同一个思路:把"多次单点删除"合并成"一次区间搬移"。区别是 lpBatchDelete 处理"离散的多个删除点"(要分段搬),lpDeleteRange 处理"连续的一段"(一次搬完)。

10.5 zzlFirstInRange 与 zzlLastInRange

unsigned char *zzlFirstInRange(unsigned char *zl, zrangespec *range) {
    unsigned char *eptr = lpSeek(zl,0), *sptr;
    double score;

    if (!zzlIsInRange(zl,range)) return NULL;

    while (eptr != NULL) {
        sptr = lpNext(zl,eptr);
        score = zzlGetScore(sptr);
        if (zslValueGteMin(score,range)) {
            if (zslValueLteMax(score,range)) return eptr;
            return NULL;
        }
        eptr = lpNext(zl,sptr);
    }
    return NULL;
}

src/t_zset.c:1078-1102。zzlIsInRange 先做三次短路(src/t_zset.c:1052-1074):"最后一个 score"用 lpSeek(zl,-1)、"第一个 score"用 lpSeek(zl,1)——lpSeek 对负索引从尾部走,-1 是最后一个 entry(即最后一个 score)、1 是第二个 entry(第一个 score)。

反向的 zzlLastInRange 从 lpSeek(zl,-2)(最后一个 ele)开始,靠 lpPrev 往回退两步:先退到自己的 score,再退到前一个 ele(src/t_zset.c:1125-1131),中间用 serverAssert 保证成对性——若 sptr != NULL 而 lpPrev(zl,sptr) == NULL,说明结构不成对、数据已损坏。

10.6 zzlFind 与混用 lpFind

static unsigned char *zzlFind(unsigned char *lp, sds ele, double *score) {
    unsigned char *eptr, *sptr;
    if ((eptr = lpFirst(lp)) == NULL) return NULL;
    eptr = lpFind(lp, eptr, (unsigned char*)ele, sdslen(ele), 1);
    if (eptr) {
        sptr = lpNext(lp,eptr);
        if (score != NULL) *score = zzlGetScore(sptr);
        return eptr;
    }
    return NULL;
}

src/t_zset.c:1227-1242。lpFind 的第五个参数 skip = 1 让它在交错布局里只在 ele 位置上匹配(命中后跳过 ele 和自己的 score),和第 13 章 §8.1 里 hash 的 [field, value] 布局用法相同。

关键:lpFind 内部用 lpCompare/lpFindCmp 比较,会处理"entry 是整数编码"的情形。所以 zzlFind 能找到"用户传字符串 "123"、listpack 里存的是整数 123"——ZADD k 1 123 之后 ZSCORE k 123 必须能查到,尽管一个存成整数、一个传的是字符串。


11. 编码切换:三条阈值与一张回程票

11.1 三个入口

zset 的编码决定分散在三个地方:

其一,创建时的 zsetTypeCreate(size_hint, val_len_hint)(src/t_zset.c:1413-1424):

robj *zsetTypeCreate(size_t size_hint, size_t val_len_hint) {
    if (size_hint <= server.zset_max_listpack_entries &&
        val_len_hint <= server.zset_max_listpack_value)
    {
        return createZsetListpackObject();
    }
    robj *zobj = createZsetObject();
    zset *zs = zobj->ptr;
    dictExpand(zs->dict, size_hint);
    return zobj;
}

两个 hint:要加的元素数、单个元素的长度。任一超阈值就建跳表,并顺手 dictExpand 到 size_hint 避免边加边 rehash。

zaddGenericCommand 调用它时传的是 (elements, sdslen(c->argv[scoreidx + 1]->ptr))(src/t_zset.c:2044)——只看第一个元素的长度。ZADD k 1 a 2 <1000 字节的长串> 会先建 listpack(因为 a 短),加第二个元素时才触发转换。这是"hint 是下界不是上界"的取舍:宁可先建小的再转,也不必为一个长元素就建跳表。

其二,写入前的 zsetTypeMaybeConvert(zobj, size_hint)(src/t_zset.c:1428-1434):

void zsetTypeMaybeConvert(robj *zobj, size_t size_hint) {
    if (zobj->encoding == OBJ_ENCODING_LISTPACK &&
        size_hint > server.zset_max_listpack_entries)
    {
        zsetConvertAndExpand(zobj, OBJ_ENCODING_SKIPLIST, size_hint);
    }
}

只在 listpack 时判断,只比 zset_max_listpack_entries。注意没有 value 长度判据——单元素长度在写入时才知道(zsetAdd 里查)。

zaddGenericCommand 在 zobj != NULL 时调用它(src/t_zset.c:2049)。这是批量写入的优化:往 126 元素的 listpack 上一次 ZADD 200 个元素,判据 200 > 128 直接成立,省掉"边加边转"。

其三,写入中的判定(src/t_zset.c:1655-1665):

if (zzlLength(zobj->ptr)+1 > server.zset_max_listpack_entries ||
    sdslen(ele) > server.zset_max_listpack_value ||
    !lpSafeToAdd(zobj->ptr, sdslen(ele)))
{
    zsetConvertAndExpand(zobj, OBJ_ENCODING_SKIPLIST, zsetLength(zobj) + 1);
} else {
    zobj->ptr = zzlInsert(zobj->ptr,ele,score);
    ...
}

三道判据,全部在"元素不存在、要新增"的分支里:

  1. 加完之后超 zset_max_listpack_entries
  2. 新元素长度超 zset_max_listpack_value
  3. lpSafeToAdd 失败(第 13 章 §7.4 讨论过,listpack 总大小接近 1<<30 时拒绝增长)

第 3 条与前两条性质不同:前两条是配置语义,第 3 条是安全阀——防止 listpack 的 32 位内部偏移溢出。

11.2 与 Set 的三处差异

第 13 章 §6.2 指出 Set 的三处阈值口径不一致(两处用裸配置、一处用带 1<<30 上限的 intsetMaxEntries())。zset 没有这个问题:

  • zset_max_listpack_entries 是 size_t 配置,默认 128,上限 LONG_MAX,但没有"内部硬上限"需要额外夹取
  • zsetTypeCreate 和 zsetTypeMaybeConvert 用同一个配置字段,判据都是 > server.zset_max_listpack_entries

但有一处语义不对称值得注意:

  • zsetTypeCreate 用 size_hint <= max_entries(允许等于 128)
  • zsetTypeMaybeConvert 用 size_hint > max_entries(严格大于才转)
  • zsetAdd 用 zzlLength + 1 > max_entries(加完之后严格大于才转)

三个判据都是"最终大小 ≤ 128 时用 listpack",一致。区别只在 zsetTypeCreate 的 size_hint 是"预计要加的元素数"(可能偏低),另外两处是"实际元素数"(精确)。

11.3 回程票:zsetConvertToListpackIfNeeded

这是与 Set 最大的区别。Set 的 hashtable 永不主动降级(第 13 章 §3.3),zset 的跳表在特定情况下会降回 listpack(src/t_zset.c:1523-1533):

void zsetConvertToListpackIfNeeded(robj *zobj, size_t maxelelen, size_t totelelen) {
    if (zobj->encoding == OBJ_ENCODING_LISTPACK) return;
    zset *zset = zobj->ptr;

    if (zset->zsl->length <= server.zset_max_listpack_entries &&
        maxelelen <= server.zset_max_listpack_value &&
        lpSafeToAdd(NULL, totelelen))
    {
        zsetConvert(zobj,OBJ_ENCODING_LISTPACK);
    }
}

调用点只有两个,都是"结果落盘"路径:

  • zunionInterDiffGenericCommand 的 dstkey 分支(src/t_zset.c:3148),ZUNIONSTORE/ZINTERSTORE/ZDIFFSTORE 共用
  • georadiusGeneric 的 store 分支(src/geo.c:832),GEOSEARCHSTORE/GEORADIUS...STORE 用

两处的形态一模一样:手工建 zset、逐元素 zslInsert、顺手累加 maxelelen/totelelen、最后调一次 zsetConvertToListpackIfNeeded 再 setKey。geo 那条更直接——createZsetObject() 硬编码建跳表,连 zsetTypeCreate 都不走,因为地理查询的中间结果用跳表插入才便宜(gp->score 是无序的),收尾才知道能不能降。

ZRANGESTORE 不在这两个调用点里:zrangeResultEmitCBufferForStore 走的是 zsetAdd,编码由 zsetAdd 自己的三道判据管(§11.1 其三),所以 ZRANGESTORE 的结果要么一开始就是 listpack,要么一直是跳表,不会"先建跳表再降"。RDB 加载同理,走的是 rdbLoadObject 里的 zsetTypeCreate。

三个判据:

  1. zsl->length <= zset_max_listpack_entries
  2. maxelelen <= zset_max_listpack_value
  3. lpSafeToAdd(NULL, totelelen) —— 估算的总字节数

第 3 条用 NULL 作为 lp 参数,表示"还没有 listpack,只估算容量"。这与第 13 章 §7.6 路径六的 lpSafeToAdd(NULL, totsize + len) 是同一个用法。

maxelelen 和 totelelen 是在构建过程中顺便收集的,不需要额外遍历:zdiffAlgorithm1 每插一个元素就比较/累加(src/t_zset.c:2753-2754),zdiffAlgorithm2 在最后扫一遍 dict(因为它的删除路径拿不到"最大值")。ZINTER 分支同样逐元素累加(src/t_zset.c:3065-3066)。

11.4 为什么 zset 有回程票而 set 没有

三个理由:

其一,两种编码承载的是同一批元素。 zset 的 listpack 和 skiplist 装的都是 (score, member) 对,语义完全等价,转换不丢信息。Set 的 intset 与 listpack 也等价,但 intset → listpack 之后 encoding 字段变了,"全是整数"这个性质需要重新扫描才能确认。

其二,降级的判据在构建过程中免费得到。 ZUNIONSTORE 本来就要逐元素插,顺手记 maxelelen/totelelen 零成本;Set 的 hashtable → intset 却要重新遍历所有元素试 string2ll,是 O(n) 的额外扫描(第 13 章 §13.5 的 maybeConvertToIntset),所以只在 SINTERSTORE 一条路径上开了口子。

其三,降级是"结果落盘"的一次性决策,不是"每次写入都判"。 它只在集合运算结束时调一次,之后该 key 按 listpack 的规则走(写入超阈值再升回去);Set 若支持降级就得考虑"每次 SREM 后判一次",而删除后降级会让 MEMORY USAGE 抖动。

11.5 zsetConvertAndExpand 的两个方向

void zsetConvertAndExpand(robj *zobj, int encoding, unsigned long cap) {
    if (zobj->encoding == encoding) return;
    if (zobj->encoding == OBJ_ENCODING_LISTPACK) {
        ...
        zs = zmalloc(sizeof(*zs));
        zs->dict = dictCreate(&zsetDictType);
        zs->zsl = zslCreate();
        dictExpand(zs->dict, cap);

        eptr = lpSeek(zl,0);
        while (eptr != NULL) {
            score = zzlGetScore(sptr);
            vstr = lpGetValue(eptr,&vlen,&vlong);
            if (vstr == NULL) ele = sdsfromlonglong(vlong);
            else ele = sdsnewlen((char*)vstr,vlen);

            node = zslInsert(zs->zsl,score,ele);
            serverAssert(dictAdd(zs->dict, node, NULL) == DICT_OK);
            sdsfree(ele);
            zzlNext(zl,&eptr,&sptr);
        }
        zfree(zobj->ptr);
        zobj->ptr = zs;
        zobj->encoding = OBJ_ENCODING_SKIPLIST;
    } else if (zobj->encoding == OBJ_ENCODING_SKIPLIST) {
        ...
    }
}

src/t_zset.c:1444-1518。

升向跳表:建 zset/dict/zsl、dictExpand(cap),然后按 listpack 的升序逐个插入。

为什么升序插入不慢?因为 zslInsertNode 每次都是"插到尾部",update[] 全是前一层的尾节点,查找循环的 while 一次都不走,只有 zsl->level 次下降。所以是 O(n × level) 而不是 O(n × log n)。这和 zsetDup 的注释说的"从大到小插入让每次插到头"是同一个道理,只是方向相反(升序插到尾,降序插到头,都是 O(1) 定位)。

降向 listpack:

zs = zobj->ptr;
dictRelease(zs->dict);
node = zs->zsl->header->level[0].forward;
zfree(zs->zsl->header);

while (node) {
    zl = zzlInsertAt(zl,NULL,zslGetNodeElement(node),node->score);
    next = node->level[0].forward;
    zslFreeNode(zs->zsl, node);
    node = next;
}

zfree(zs->zsl);
zfree(zs);

注释说 "Approach similar to zslFree(), since we want to free the skiplist at the same time as creating the listpack."(src/t_zset.c:1497-1498)。

关键在 zzlInsertAt(zl, NULL, ...) —— 传 NULL 表示追加到尾部。因为跳表是升序遍历的,追加正好保持有序。如果传 eptr,每次都要从头 lpSeek(zl,0) 再扫一遍找位置,就退化成 O(n²)。

zfree(zs->zsl->header) 单独释放 header,循环里 zslFreeNode 逐个释放节点(传 zs->zsl 让 alloc_size 递减),最后 zfree(zs->zsl) 释放结构本身——这三步不能合成 zslFree,因为 zslFree 会把 header 与 zsl 一起 zfree 掉,而这里还要先读到 zsl->tail 以外的字段。

dictRelease(zs->dict) 必须排在最前:此时 dict 里的 node 指针还有效,而 keyDestructor 是 NULL,释放 entry 不会碰节点。顺序反了(先释放节点再 dictRelease)会让 dict 的 rehash/清理逻辑访问已释放的 key。


12. zsetAdd:四条路与 GT/LT 的真值表

12.1 四条路

zsetAdd 是 ZSet 的写入核心(src/t_zset.c:1601-1742),结构是"按编码分两支,每支再按元素是否存在分两岔":

编码元素存在元素不存在
listpack改分(可能删了重插)检查阈值,可能转跳表
skiplistzslUpdateScorezslInsert + dictSetKeyAtLink

listpack 分支的"不存在"情况是个穿透:如果阈值超了,它调 zsetConvertAndExpand 转成跳表,然后不返回,落到下面的 if (zobj->encoding == OBJ_ENCODING_SKIPLIST) 分支继续执行。注释明说了这一点(src/t_zset.c:1672-1673):

Note that the above block handling listpack would have either returned or converted the key to skiplist.

这种"转换后继续走下一个分支"依赖 zobj->encoding 已被 zsetConvertAndExpand 改写;它没有失败路径,否则会 serverPanic。

12.2 NX 先判、XX 后判

两条路径的顺序一致:

if (link != NULL) {          /* 元素存在 */
    if (nx) { *out_flags |= ZADD_OUT_NOP; return 1; }
    ...
} else if (!xx) {            /* 元素不存在且不是 XX */
    ... 插入 ...
} else {                     /* 元素不存在但是 XX */
    *out_flags |= ZADD_OUT_NOP;
    return 1;
}

NX 在"存在"分支里判,XX 在"不存在"分支里判。两者永远不会同时为真(zaddGenericCommand 已经在参数校验阶段拒绝了 nx && xx,src/t_zset.c:2011-2015)。

return 1 而不是 return 0——return 0 表示"NaN 错误",return 1 表示"执行成功(可能是 NOP)"。调用方靠 out_flags 里的 ZADD_OUT_NOP 区分。

12.3 GT/LT 的真值表

if ((lt && score >= curscore) || (gt && score <= curscore)) {
    *out_flags |= ZADD_OUT_NOP;
    return 1;
}

src/t_zset.c:1638-1641 与 1708-1711,两条路径完全相同。

这个条件的读法:LT 时"新分 ≥ 旧分"就放弃,GT 时"新分 ≤ 旧分"就放弃。所以:

GTLT新分 < 旧分新分 == 旧分新分 > 旧分
00更新不更新(score == curscore,后面判等)更新
10NOPNOP更新
01更新NOPNOP
11——— (参数校验拒绝)

GT 与 LT 同时给会被拒(src/t_zset.c:2017-2021),GT/LT 与 NX 也不兼容——语义矛盾,NX 只管"不存在",而"变大/变小"只在"存在"时有意义。但 XX 可以与 GT/LT 组合(src/t_zset.c:2022 的注释 "Note that XX is compatible with either GT or LT"):XX + GT 就是"只更新已存在的元素,且只在分数变大时"。

GT/LT 在"元素不存在"时不生效——新元素直接插入,不管分数多少。ZADD 的文档语义如此:GT 只约束更新、不约束新增。

12.4 ZADD_OUT_* 四个标志

*out_flags = 0;
...
*out_flags |= ZADD_OUT_NAN;      /* 结果不是数 */
*out_flags |= ZADD_OUT_NOP;      /* 被 NX/XX/GT/LT 拦下 */
*out_flags |= ZADD_OUT_ADDED;    /* 新增了元素 */
*out_flags |= ZADD_OUT_UPDATED;  /* 更新了分数 */

ADDED 与 UPDATED 互斥,NOP 与前两者互斥,NAN 是错误。zaddGenericCommand 用它们累计 added/updated/processed(src/t_zset.c:2070-2072),其中 processed = !NOP 专供 INCR 模式:被 NX/XX 拦下时 ZINCRBY 要回 nil 而不是分数(src/t_zset.c:2081-2085)。

回复口径有两个:默认回 added、CH 时回 added + updated;而 server.dirty += (added+updated) 恒按后者计(src/t_zset.c:2075)——传播与持久化关心的是"改了几个元素",与 CH 无关。

12.5 先解析全部 score 再动手

scores = zmalloc(sizeof(double)*elements);
for (j = 0; j < elements; j++) {
    if (getDoubleFromObjectOrReply(c,c->argv[scoreidx+j*2],&scores[j],NULL) != C_OK)
        goto cleanup;
}

src/t_zset.c:2033-2037。注释说明了理由(src/t_zset.c:2030-2032):

Start parsing all the scores, we need to emit any syntax error before executing additions to the sorted set, as the command should either execute fully or nothing at all.

ZADD k 1 a 2 b bad c 如果边解析边插入,会在写入 a、b 之后才发现 bad 不是数字,此时 key 已经被改了——但命令返回错误,客户端会认为"什么都没做"。Redis 的单命令原子性要求"要么全做、要么全不做",所以必须先解析完再执行。

elements 由 (argc - scoreidx) / 2 得出、上限是 argc/2,所以 100 万元素的 ZADD 会一次 zmalloc 8MB。这是 Redis 里少见的"按参数规模分配",但 argv 本身已经占了 100 万个 robj,8MB 不算突出。

goto cleanup 在 retval == 0(NaN)时也会走(src/t_zset.c:2064-2069),此时前面的元素已经写进去了。但 NaN 只可能在 INCR 模式下产生(score += curscore 溢出成 inf 或 nan),而 INCR 只允许 1 个元素对(src/t_zset.c:2024-2028),所以"部分写入"最多影响 1 个元素。这个漏洞在语义上是可接受的:ZINCRBY k inf a 在 a 的分数是 -inf 时会返回 NaN 错误,但 a 的分数保持不变(因为 NaN 检查在 zslUpdateScore 之前)。

12.6 skiplist 分支的"一次哈希"

dictEntryLink bucket, link;
link = dictFindLink(zs->dict, ele, &bucket);

if (link != NULL) { ... 存在 ... }
else if (!xx) {
    znode = zslInsert(zs->zsl, score, ele);
    dictSetKeyAtLink(zs->dict, znode, &bucket, 1);
    ...
}

src/t_zset.c:1682-1729。dictFindLink 一次遍历同时给出"找到的 link"与"没找到时应插入的 bucket"(第 13 章 §7.3 讨论过这个协议),于是查找与插入共用一次哈希。

朴素写法 dictFind + dictAdd 会重新算哈希、重新走桶链;对长 member 来说一次哈希就是几十纳秒。

dictSetKeyAtLink(d, key, link, newItem) 的第四参数 newItem = 1 表示"新增",link 指向 bucket;newItem = 0 表示"替换已有 key"。dict.c 的注释(src/dict.c:871-880)强调:dictFindLink 返回的 link/bucket 必须在紧接着的 dictSetKeyAtLink 里用掉,中间不能有任何操作 dict 的调用,否则 link 可能因 rehash 失效。这是一种"短生命周期的游标"约定,靠注释而不是类型系统保证。


13. ZRANGE 家族:一个 handler 的四种实现

13.1 为什么要有 handler

ZRANGE、ZREVRANGE、ZRANGEBYSCORE、ZREVRANGEBYSCORE、ZRANGEBYLEX、ZREVRANGEBYLEX、ZRANGESTORE 七个命令的遍历逻辑完全相同,区别只在两处:

  1. 起点怎么定位(rank / score / lex)
  2. 结果发给谁(客户端 / 新建的 zset)

8.10 把第 2 点抽成了 zrange_result_handler 的四个函数指针(src/t_zset.c:3239-3265):

struct zrange_result_handler {
    zrange_consumer_type                 type;
    client                              *client;
    robj                                *dstkey;
    robj                                *dstobj;
    void                                *userdata;
    int                                  withscores;
    int                                  should_emit_array_length;
    zrangeResultBeginFunction            beginResultEmission;
    zrangeResultFinalizeFunction         finalizeResultEmission;
    zrangeResultEmitCBufferFunction      emitResultFromCBuffer;
    zrangeResultEmitLongLongFunction     emitResultFromLongLong;
};

四个回调覆盖"开始 / 结束 / 发字符串 / 发整数",正好是一个遍历循环需要的全部。

C 里模拟 vtable 的标准做法。收益是 genericZrangebyrankCommand 等三个遍历函数完全不知道结果去哪,代码只有一份;代价是每元素两次间接调用,比直接 addReplyBulkCBuffer 略慢。

13.2 client 版:预知长度 vs 延迟长度

static void zrangeResultBeginClient(zrange_result_handler *handler, long length) {
    if (length > 0) {
        if (handler->withscores && (handler->client->resp == 2)) {
            length *= 2;
        }
        addReplyArrayLen(handler->client, length);
        handler->userdata = NULL;
        return;
    }
    handler->userdata = addReplyDeferredLen(handler->client);
}

src/t_zset.c:3271-3284。length > 0 表示调用方知道结果个数,可直接写 RESP 数组头(*<n>\r\n);否则用 addReplyDeferredLen 占位、遍历完在 finalize 里回填。

length == 0 也走延迟路径,因为判据是 length > 0:addReplyDeferredLen 占位、finalize 回填 0。功能正确,只是多一次 setDeferredArrayLen 写入——length == 0 本可以走 addReplyArrayLen(c, 0) 立即返回。

ZRANGE BYRANK 知道长度(rangelen = end-start+1),ZRANGEBYSCORE 不知道(要给 -1,src/t_zset.c:3533),所以前者走预知路径、后者走延迟路径。

withscores && resp == 2 时 length *= 2:RESP2 下 WITHSCORES 是扁平数组 [ele, score, ele, score, ...],长度翻倍。RESP3 下是嵌套数组 [[ele, score], ...],长度不变、每个元素前加一个 *2\r\n(由 should_emit_array_length 控制,src/t_zset.c:3289-3291)。

注释解释了为什么 RESP3 不用 map(src/t_zset.c:3273-3275):

In case of WITHSCORES, respond with a single array in RESP2, and nested arrays in RESP3. We can't use a map response type since the client library needs to know to respect the order.

map 在语义上无序,客户端库可能重排,而 ZRANGE 的结果顺序是命令的核心语义。

13.3 store 版:边遍历边建

static void zrangeResultBeginStore(zrange_result_handler *handler, long length) {
    handler->dstobj = zsetTypeCreate(length >= 0 ? length : 0, 0);
}

static void zrangeResultEmitCBufferForStore(zrange_result_handler *handler,
    const void *value, size_t value_length_in_bytes, double score)
{
    double newscore;
    int retflags = 0;
    sds ele = sdsnewlen(value, value_length_in_bytes);
    int retval = zsetAdd(handler->dstobj, score, ele, ZADD_IN_NONE, &retflags, &newscore);
    sdsfree(ele);
    serverAssert(retval);
}

src/t_zset.c:3331-3356。

zsetTypeCreate(length, 0) 的第二个参数传 0——因为 ZRANGESTORE 的源可能是跳表(元素可能很长),但 length 是元素数。传 0 表示"value 长度不确定",所以 val_len_hint <= max_listpack_value 恒成立,只由 length 决定初始编码。

每个元素 sdsnewlen 一份拷贝,因为 zsetAdd 不接管所有权(§4.7)。sdsfree 紧跟其后——zslInsert 内部已经复制进节点了。

每次 emit 都是 zsetAdd,也就是 O(log n)。所以 ZRANGESTORE 的总代价是 O(k log k)(k 是结果元素数)而不是 O(k)。理论上能更快——结果本来就是按 (score, ele) 升序来的,每个元素都是"追加到末尾",zslInsertNode 的横向查找一次都不走——但代码没走这条特化路径,因为 REV 会给出降序、"插到头"同样是 O(1) 定位,两种顺序都便宜,通用路径已经够好。

store 时强制 withscores = 1(src/t_zset.c:4032-4034):

if (opt_withscores || store) {
    zrangeResultHandlerScoreEmissionEnable(handler);
}

因为建新 zset 需要 score。ZRANGESTORE 的回复里不体现 score(只回元素数),但 handler->withscores 必须为 1,否则 emitResultFromCBuffer 收到的 score 参数是 0,新 zset 的分数全错。

这是一个"内部需求"与"外部语义"分离的好例子:withscores 这个字段在 client 版控制"是否输出分数",在 store 版控制"是否传递分数"。同一个字段两种含义,靠 type 区分。

13.4 收尾:空结果要删目标键

static void zrangeResultFinalizeStore(zrange_result_handler *handler, size_t result_count) {
    if (result_count) {
        setKey(handler->client, handler->client->db, handler->dstkey, &handler->dstobj, 0);
        addReplyLongLong(handler->client, result_count);
        notifyKeyspaceEvent(NOTIFY_ZSET, "zrangestore", handler->dstkey, handler->client->db->id);
        server.dirty++;
    } else {
        addReply(handler->client, shared.czero);
        if (dbDelete(handler->client->db, handler->dstkey)) {
            keyModified(handler->client, handler->client->db, handler->dstkey, NULL, 1);
            notifyKeyspaceEvent(NOTIFY_GENERIC, "del", handler->dstkey, handler->client->db->id);
            server.dirty++;
        }
        decrRefCount(handler->dstobj);
    }
}

src/t_zset.c:3358-3374。

非空:setKey 把 dstobj 装进 dstkey(所有权转移给 db),回复元素数,发 zrangestore 事件。

空:回复 0,然后如果 dstkey 存在就删掉它——这是 STORE 类命令的通例,空结果意味着"目标键不存在"。dbDelete 返回 1 才是真删了,此时补 del 事件与 server.dirty++;返回 0 说明本来没有,什么都不做。末尾的 decrRefCount(handler->dstobj) 释放那个没人要的空 zset(setKey 分支里不需要,所有权已转移),keyModified 的第三个参数在空分支传 NULL。

13.5 参数解析的四个坑

zrangeGenericCommand 的解析(src/t_zset.c:3948-4001)有四处容易出错:

其一,LIMIT 只允许 BYSCORE/BYLEX。

if (opt_limit != -1 && rangetype == ZRANGE_RANK) {
    addReplyError(c,"syntax error, LIMIT is only supported in combination with either BYSCORE or BYLEX");
    return;
}

opt_limit 的初值是 -1(表示"没给"),而 LIMIT 0 -1 是合法的"取全部"。所以判据是 opt_limit != -1 而不是 opt_limit > 0。如果写成 > 0,ZRANGE k 0 -1 LIMIT 0 -1 就不会报错,但 genericZrangebyrankCommand 根本不接受 offset/limit 参数——静默忽略,与文档不符。

其二,WITHSCORES 不允许 BYLEX。

if (opt_withscores && rangetype == ZRANGE_LEX) {
    addReplyError(c,"syntax error, WITHSCORES not supported in combination with BYLEX");
    return;
}

因为 BYLEX 只在"所有元素分数相同"时有意义(字典序就是分数序),此时列出分数纯粹是噪声。Redis 选择直接拒绝,而不是"允许但返回无意义的分数"。

其三,REV 时交换 minidx/maxidx。

if (direction == ZRANGE_DIRECTION_REVERSE &&
    ((ZRANGE_SCORE == rangetype) || (ZRANGE_LEX == rangetype)))
{
    int tmp = maxidx;
    maxidx = minidx;
    minidx = tmp;
}

ZREVRANGEBYSCORE key max min 的参数顺序是"大在前",而 zslParseRange(min, max) 要"小在前"。交换索引让解析函数可以统一按 (min, max) 处理。

只对 SCORE 和 LEX 交换,不交换 RANK。 因为 ZREVRANGE key start stop 的 start/stop 本来就是"从大到小数的下标",genericZrangebyrankCommand 用 reverse 参数处理(src/t_zset.c:3481-3489),不需要交换。

这个"两种反向语义"是 ZRANGE 家族最容易混淆的地方:REV 对 RANK 是"索引方向反转",对 SCORE/LEX 是"参数顺序反转"。前者在遍历时处理,后者在解析时处理。

其四,store 时不允许 WITHSCORES。 解析里写的是 if (!store && !strcasecmp(..., "withscores")),!store 让 ZRANGESTORE ... WITHSCORES 落到最后的 else 分支报语法错误——ZRANGESTORE 的回复是元素数,WITHSCORES 无处安放(而内部需要的分数由 §13.3 的强制置位解决)。


14. ZCOUNT 与 ZLEXCOUNT:两次定位相减

14.1 跳表分支

zn = zslNthInRange(zsl, &range, 0, &rank);
if (zn != NULL) {
    count = (zsl->length - (rank - 1));
    zn = zslNthInRange(zsl, &range, -1, &rank);
    if (zn != NULL) {
        count -= (zsl->length - rank);
    }
}

src/t_zset.c:3700-3713。

逐步代入:设区间内第一个元素的 rank 是 r1、最后一个的 rank 是 r2(都是 1-based)。

  • zslNthInRange(range, 0, &rank) 返回区间第 0 个元素,rank = r1。count = length - (r1 - 1) = "从 r1 到末尾的元素数"。
  • zslNthInRange(range, -1, &rank) 返回区间最后一个,rank = r2。count -= length - r2 = "从 r2+1 到末尾的元素数"。

所以 count = (length - r1 + 1) - (length - r2) = r2 - r1 + 1,正好是区间内元素数。

两次 O(log n) 定位,不需要遍历区间。这是 ZCOUNT 与 ZRANGEBYSCORE 的本质区别:前者只要数量,后者要内容。

如果第一个 zslNthInRange 返回 NULL,说明区间内没有元素,count 保持 0,直接回复。不需要第二次查询——这是一个短路。

14.2 listpack 分支

eptr = zzlFirstInRange(zl,&range);
if (eptr == NULL) { addReply(c, shared.czero); return; }
sptr = lpNext(zl,eptr);
score = zzlGetScore(sptr);
serverAssertWithInfo(c,zobj,zslValueLteMax(score,&range));

while (eptr) {
    score = zzlGetScore(sptr);
    if (!zslValueLteMax(score,&range)) break;
    else { count++; zzlNext(zl,&eptr,&sptr); }
}

src/t_zset.c:3668-3692。先 O(n) 找到第一个,再 O(k) 数到区间末尾。总 O(n)。

那个 serverAssertWithInfo 值得注意:zzlFirstInRange 返回的元素一定在区间内(zslValueGteMin 成立且 zslValueLteMax 成立才返回),所以这里再检查 LteMax 是冗余的。写成断言是为了"如果 zzlFirstInRange 有 bug,这里立刻炸",而不是"静默多算一个"。

14.3 ZLEXCOUNT 是逐行翻版

zlexcountCommand(src/t_zset.c:3721-3796)与 zcountCommand 结构完全相同,只是把 zslParseRange 换成 zslParseLexRange、zslNthInRange 换成 zslNthInLexRange、zslValueLteMax 换成 zzlLexValueLteMax。

它多了一处 zslFreeLexRange(&range)(src/t_zset.c:3737、3750、3794)——因为 zslParseLexRange 会 sdsnewlen 两个边界字符串,必须释放。而 zslParseRange 不分配内存(边界是 double),所以 zcountCommand 没有对应的释放。

三处释放点对应三条退出路径:类型检查失败、区间为空、正常结束。漏掉任何一处都是内存泄漏,而这类泄漏在"空区间"这种常见路径上会累积得很快。zslFreeLexRange 用"sds 指针是否等于共享哨兵"判断是否该 free(§9.4),所以重复调用是安全的——但 ZLEXRANGE 系列的语义要求"每条路径都调一次"。


15. ZRANK 与 ZREVRANK:对外 0-based,对内 1-based

15.1 两套约定的转换

if (reverse)
    return llen-rank;
else
    return rank-1;

src/t_zset.c:1852-1855(跳表分支)与 1832-1835(listpack 分支)。

zsetRank 内部的 rank 是 1-based(zslGetRankByNode 返回 length - distance_to_end,最左节点是 1)。对外要 0-based,所以:

  • 正向:rank - 1
  • 反向:llen - rank(rank = 1 的最左元素,反向排名是 llen-1,即最后一个)

rank 为 0 表示"未找到",此时 zsetRank 返回 -1,调用方用 rank >= 0 判断(src/t_zset.c:4166)。0 不能同时表示"第 0 个元素"和"未找到",所以内部必须用 1-based——这是 zslGetRank 返回 0 表示失败的连带后果。

15.2 WITHSCORE 的两种 nil

reply = opt_withscore ? shared.nullarray[c->resp] : shared.null[c->resp];
if ((zobj = lookupKeyReadOrReply(c, key, reply)) == NULL || checkType(c, zobj, OBJ_ZSET)) {
    return;
}

src/t_zset.c:4156-4159。ZRANK ... WITHSCORE 在键不存在或元素不存在时回空数组(nullarray),不带 WITHSCORE 时回nil。

因为带 WITHSCORE 的回复是 [rank, score] 二元组,nil 会破坏"回复类型固定"的约定。客户端需要能用同一个解析路径处理"命中"和"未命中",所以未命中要用"同样形状但空"的值。这是 RESP 里 null 与 nullarray 的典型用法差异。

lookupKeyReadOrReply 直接接收 reply 参数并回复它,checkType 不通过时也是回 nil(走 checkType 内部)。两条失败路径共用同一个 reply 变量。

15.3 listpack 分支的 lpCompare 缓存

const size_t ele_len = sdslen(ele);
long long cached_val = 0;
int cached_valid = 0;
rank = 1;
while(eptr != NULL) {
    if (lpCompare(eptr,(unsigned char*)ele,ele_len,&cached_val,&cached_valid))
        break;
    rank++;
    zzlNext(zl,&eptr,&sptr);
}

src/t_zset.c:1818-1827。lpCompare 的 cached_val/cached_valid 是"把待查的 ele 解析成整数一次,后面 n 次比较复用"的缓存——第 13 章 §8.2 讨论的 lpFindCmp 的 vencoding 三态是同一目的的另一套实现,区别是这里用 (long long, int) 两个出参而不是打包进一个 struct。

lpCompare 返回 1 表示"相等"。所以循环条件是"不相等就继续",break 时 eptr 指向命中的元素。

rank 从 1 开始、每次不命中就 ++,所以命中时 rank 是 1-based。没命中则 eptr == NULL,返回 -1。

15.4 为什么 listpack 的 ZRANK 是 O(n)

lpCompare 每次只比一个 entry,但"找到那个 entry"需要从头扫。所以 ZRANK 在 listpack 上是 O(n) 次 lpCompare,每次还要走 zzlNext(两次 lpNext)。

跳表上是 O(1) 哈希 + 32 次指针跳。这是 ZRANK 在两种编码下差得最悬殊的命令:128 元素的 listpack 约 128 次比较 + 256 次 lpNext,跳表约 1 次哈希 + 32 次跳。约 10 倍。


16. 集合运算:zui 抽象与"反向迭代"

16.1 一个抽象适配四种输入

ZUNION/ZINTER/ZDIFF 的输入既可以是 ZSET 也可以是 SET(ZUNIONSTORE dest 2 zsetkey setkey 合法)。所以需要一个"同时能遍历两种类型、四种编码"的迭代器。

zsetopsrc(src/t_zset.c:1366 附近)用 tag + union 表达:

typedef struct zsetopsrc {
    robj *subject;      /* Set or ZSet object */
    int type;           /* OBJ_SET or OBJ_ZSET */
    int encoding;       /* which encoding */
    double weight;      /* weight for ZUNIONSTORE/ZINTERSTORE */
    union {
        iterset set;
        iterzset zset;
    } iter;
    ...
} zsetopsrc;

zuiInitIterator 与 zuiNext 都按 type × encoding 八种组合分派(src/t_zset.c:2365-2405、2467-2532)。SET 的 score 恒为 1.0(src/t_zset.c:2483、2491、2499)——这是 ZUNIONSTORE dest 2 zset set 的语义基础:SET 成员按"分数 1"参与聚合。

16.2 zsetopval 的三态表达

typedef struct zsetopval {
    /* Field populated for either `sds` or `estr` variant. */
    sds ele;
    unsigned char *estr;
    size_t elen;
    long long ell;

    double score;
    int flags;
} zsetopval;

同一个元素可能来自四种地方:sds(跳表的 member、HT Set 的 key)、listpack 内部的字节指针(estr + elen)、整数(ell),或者能解析成整数。所以 zsetopval 同时持有三个表示,靠 flags 标记哪些有效:

  • OPVAL_VALID_LL:ell 有效
  • OPVAL_DIRTY_SDS:ele 是自己 sdsnew 的,需要释放
  • OPVAL_DIRTY_LL:ell 已经尝试过解析(无论成功失败)

zuiDiscardDirtyValue(src/t_zset.c:2436-2442)只在 OPVAL_DIRTY_SDS 时 sdsfree。这是"惰性持有 + 显式释放"的模式:zuiNext 每次开头调它清掉上一轮可能留下的 sds(src/t_zset.c:2471),避免每轮都分配。

zuiLongLongFromValue(src/t_zset.c:2534)先看 OPVAL_DIRTY_LL 决定要不要解析,再对 ele/estr/ell 三种表示分别调 string2ll,成功则置 OPVAL_VALID_LL。两个标志分开是因为"尝试失败"也要记住——否则同一个元素被查两次会重复 string2ll。

16.3 反向迭代:为什么从 tail 开始

zuiInitIterator 对 ZSET 的处理(src/t_zset.c:2384-2401)明确从尾部开始:

/* Sorted sets are traversed in reverse order to optimize for
 * the insertion of the elements in a new list as in
 * ZDIFF/ZINTER/ZUNION */
if (op->encoding == OBJ_ENCODING_LISTPACK) {
    it->zl.eptr = lpSeek(it->zl.zl,-2);
    if (it->zl.eptr != NULL) {
        it->zl.sptr = lpNext(it->zl.zl,it->zl.eptr);
    }
} else if (op->encoding == OBJ_ENCODING_SKIPLIST) {
    it->sl.node = it->sl.zs->zsl->tail;
}

从最大分数开始往小遍历。为什么?因为结果 zset 要按序插入,降序插入意味着每个新元素都比已有的都小,位置永远在表头,zslInsertNode 的查找循环一次都不走(update[i] 全指向 header 或第一层节点)。所以每次插入是 O(zsl->level) 次"落空比较"而不是 O(log n) 次有效走位。

zsetDup 的注释说得更直白(src/t_zset.c:1894-1899):

We copy the skiplist elements from the greatest to the smallest (that's trivial since the elements are already ordered in the skiplist): this improves the load process, since the next loaded element will always be the smaller, so adding to the skiplist will always immediately stop at the head, making the insertion O(1) instead of O(log(N)).

严格说不是 O(1):每次仍要下降 zsl->level 层、每层比较一次然后失败,所以是 O(ZSKIPLIST_MAXLEVEL)。省掉的是"每层的横向走位",而那才是 O(log n) 的来源。

代价:输出顺序是降序。 ZUNION/ZINTER/ZDIFF 的回复必须升序,所以 zunionInterDiffGenericCommand 在回复时用跳表的正向遍历(src/t_zset.c:3170):

zskiplistNode *zn = zsl->header->level[0].forward;
while (zn != NULL) {
    ...
    zn = zn->level[0].forward;
}

构建时降序(为了插入便宜),回复时升序(为了语义)。两次方向不同的遍历,各自最优。

16.4 qsort 的两个比较器

int zuiCompareByCardinality(const void *s1, const void *s2) {
    unsigned long first = zuiLength((zsetopsrc*)s1);
    unsigned long second = zuiLength((zsetopsrc*)s2);
    if (first > second) return 1;
    if (first < second) return -1;
    return 0;
}

static int zuiCompareByRevCardinality(const void *s1, const void *s2) {
    return zuiCompareByCardinality(s1, s2) * -1;
}

src/t_zset.c:2638-2652。前者升序(ZINTER/ZUNION 用),后者降序(ZDIFF 的算法 1 用),降序没有独立实现,就是把升序结果乘 -1。

ZINTER 用升序的原因和第 13 章 SINTER 完全一样:迭代最小集合、查其余集合,总代价 min(|Si|) × (M-1)。

ZDIFF 的算法 1 用降序的原因不同:它要"对 S0 的每个元素,依次在其他集合里找",越大的集合越可能包含该元素,越早 break。所以把大集合排在前面能减少平均查找次数。

ZUNION 也 qsort 了(src/t_zset.c:3009-3013 的 if (op != SET_OP_DIFF)),尽管 UNION 的代价与顺序无关——排序在这里只为了让 src[setnum-1] 正好是最大集合,可给 dictExpand 做下界(§17.1)。

16.5 zuiFind:按编码分派的存在性检查

zuiFind(src/t_zset.c:2597-2636)是"给定一个 zsetopval,判断它是否在这个输入集合里"的存在性检查,六种情况一条链:subject == NULL(已过期的键)直接返回 0,然后按 type × encoding 分派到 intsetFind(O(log n))、dictFind(HT Set,O(1))、zzlFind(listpack zset,O(n))、dictFind(skiplist zset,O(1)),跳表分支顺手把 score 带出来。

这就解释了"为什么 ZINTER 在混合编码下性能难以预测":同一个算法,内层查找成本从 O(1) 到 O(n) 差两个数量级,而 qsort 只按基数排序,并不知道编码。


17. ZUNIONSTORE:先填 dict,后建 skiplist

17.1 两阶段

if (setnum) {
    /* Our union is at least as large as the largest set.
     * Resize the dictionary ASAP to avoid useless rehashing. */
    dictExpand(dstzset->dict,zuiLength(&src[setnum-1]));
}

/* Step 1: Iterate all sorted sets and aggregate scores. */
for (i = 0; i < setnum; i++) {
    ...
    while (zuiNext(&src[i],&zval)) {
        score = zuiWeightedScore(zval.score, src[i].weight, aggregate);
        if (isnan(score)) score = 0;

        dictEntryLink bucket, link;
        link = dictFindLink(dstzset->dict, zuiSdsFromValue(&zval), &bucket);

        if (link == NULL) {
            tmp = zuiNewSdsFromValue(&zval);
            totelelen += sdslen(tmp);
            if (sdslen(tmp) > maxelelen) maxelelen = sdslen(tmp);

            znode = zslCreateNode(dstzset->zsl, zslRandomLevel(), score, tmp);
            dictSetKeyAtLink(dstzset->dict, znode, &bucket, 1);
            sdsfree(tmp);
        } else {
            de = *link;
            znode = dictGetKey(de);
            double newscore = znode->score;
            zunionInterAggregate(&newscore, score, aggregate);
            znode->score = newscore;
        }
    }
    zuiClearIterator(&src[i]);
}

/* Step 2: Done filling dict with nodes and updating scores. Now insert skiplist */
dictInitIterator(&di, dstzset->dict);
while((de = dictNext(&di)) != NULL) {
    zskiplistNode *znode = dictGetKey(de);
    zslInsertNode(dstzset->zsl, znode);
}

src/t_zset.c:3077-3131。

阶段一只碰 dict:新元素用 zslCreateNode 建节点(不入链),dictSetKeyAtLink 把节点指针塞进 dict;已存在的元素直接改 znode->score。

阶段二才建 skiplist:遍历 dict,把每个节点 zslInsertNode 插进去。

17.2 为什么能"直接改 score"

这是关键。在阶段一里,节点还没有被链进 skiplist,所以它没有 backward、没有 forward,score 改成多少都不影响任何结构。znode->score = newscore 是纯粹的字段赋值,O(1)。

如果换成"边聚合边插跳表",同一个元素出现在 3 个输入集合里就要改 2 次分,每次都要 zslUpdateScore——而 zslUpdateScore 的慢路径是 O(log n) 的摘链 + 重插。ZUNIONSTORE 对"重叠度高"的输入(比如 10 个集合都有同一批元素)会退化成 O(L log n)。

两阶段把"聚合"和"排序"彻底分开:聚合是 O(L) 次哈希 + O(1) 改分,排序是 O(K) 次插入。总代价 O(L + K log K),最坏情况比 O(L log n) 好一截。

17.3 dictExpand 用最大集合的基数

zuiLength(&src[setnum-1]) —— 因为前面 qsort 按基数升序排过(§16.4),最后一个就是最大的。UNION 的结果至少和最大输入一样大,所以用它做容量下界。

注释说的是"at least":dictExpand 到 n 只保证桶数 ≥ n,实际元素更多时会照常 rehash 扩容,正好是 n 则一次都不用 rehash——用下界避免最坏情况的反复扩容。它的代价是 O(桶数) 的 calloc:100 万元素的 UNION 提前分配 100 万桶(约 8MB),比"从小往大扩容 20 次"(总拷贝约 200 万次)便宜。

17.4 为什么不用 zslInsert

zslInsert = zslRandomLevel + zslCreateNode + zslInsertNode。阶段一需要"建节点但不入链",只能拆开用(zslCreateNode 是 static,外部模块也拿不到)。若改用 zslInsert,改分就必须"先删后插"——因为 zslInsert 假设元素不在表内,而"先删后插"正是要避免的。

17.5 ZINTER 为什么不这么干

ZINTER 的构建路径(src/t_zset.c:3061-3068)用的是普通的 zslInsert + dictAdd:

} else if (j == setnum) {
    tmp = zuiNewSdsFromValue(&zval);
    znode = zslInsert(dstzset->zsl,score,tmp);
    dictAdd(dstzset->dict, znode, NULL);
    totelelen += sdslen(tmp);
    if (sdslen(tmp) > maxelelen) maxelelen = sdslen(tmp);
    sdsfree(tmp);
}

因为交集里每个元素只可能被插入一次——外层是 src[0] 的迭代,每个元素只出现一次。所以不需要"改分",也就没有两阶段的必要。分数在插入前就算完了(内层循环里 zunionInterAggregate 累加),一次成型。

同样的道理适用于 ZDIFF:zdiffAlgorithm1 和 zdiffAlgorithm2 都是"每个元素判定一次、插入一次"。

只有 UNION 需要两阶段,因为只有 UNION 会遇到"同一元素多次出现、每次都要改分"。

17.6 zuiSdsFromValue 与 zuiNewSdsFromValue 的区别

阶段一用两个不同的函数:

  • zuiSdsFromValue(&zval)(src/t_zset.c:2552):借用,返回一个指向现有数据的 sds 或临时构造的 sds,调用方不能释放。用于 dictFindLink 的查找。
  • zuiNewSdsFromValue(&zval)(src/t_zset.c:2566):新分配,调用方负责释放。用于 zslCreateNode(它内部会再复制一份,所以调用方随后 sdsfree(tmp))。

zuiSdsFromValue 要处理三种情况:val->ele 已是 sds(直接返回)、val->estr 是 listpack 内部指针(sdsnewlen)、只有 ell(sdsfromlonglong);后两种会置 OPVAL_DIRTY_SDS,交给下一轮的 zuiDiscardDirtyValue 清理。

由此有一个隐蔽的生命周期:这个借用指针可能在 zuiNext 下一次调用时被释放,只能在同一轮循环内用——dictFindLink 与 dictSetKeyAtLink 都在同一轮内,符合要求;循环外持有就是 use-after-free。


18. ZINTER、ZINTERCARD 与 ZDIFF

18.1 ZINTER:迭代最小集 × 查其余

if (op == SET_OP_INTER) {
    if (zuiLength(&src[0]) > 0) {
        zuiInitIterator(&src[0]);
        while (zuiNext(&src[0],&zval)) {
            double score, value;
            score = zuiWeightedScore(zval.score, src[0].weight, aggregate);
            if (isnan(score)) score = 0;

            for (j = 1; j < setnum; j++) {
                if (src[j].subject == src[0].subject) {
                    value = zuiWeightedScore(zval.score, src[j].weight, aggregate);
                    zunionInterAggregate(&score,value,aggregate);
                } else if (zuiFind(&src[j],&zval,&value)) {
                    value = zuiWeightedScore(value, src[j].weight, aggregate);
                    zunionInterAggregate(&score,value,aggregate);
                } else {
                    break;
                }
            }
            if (j == setnum && cardinality_only) { ... }
            else if (j == setnum) { ... 插入 ... }
        }
    }
}

src/t_zset.c:3025-3071。结构和第 13 章的 SINTER 几乎一样,但多了三项 zset 特有的东西:

  1. src[j].subject == src[0].subject 的指针相等分支:ZINTER 3 k k k 时,同一个集合的多个"副本"要各算一次权重。不能 zuiFind 自己(那会返回成功但拿到同一个 score),必须用 zval.score 重新加权。这个分支是"同一集合被多次引用"的唯一正确处理方式。
  2. zuiWeightedScore + zunionInterAggregate:每个命中都要按 weights[j] 加权再聚合。WEIGHTS 1 2 3 让第 2 个集合的分数乘以 2。
  3. if (isnan(score)) score = 0:inf × 0 或 inf + (-inf) 产生 NaN,Redis 约定归 0。

break 的位置很关键:一旦某个集合不包含该元素,内层 break,j < setnum,外层判定 j == setnum 失败,元素被丢弃。不需要查完所有集合——这是"交集"的定义带来的天然早退。

18.2 ZINTERCARD:不建结果集

cardinality_only = 1 时:

if (op == SET_OP_INTER) {
    ...
    if (j == setnum && cardinality_only) {
        cardinality++;
        /* We stop the searching after reaching the limit. */
        if (limit && cardinality >= (unsigned long)limit) {
            zuiDiscardDirtyValue(&zval);
            break;
        }
    }
}

src/t_zset.c:3052-3060。三个要点:

其一,dstobj 根本不创建(src/t_zset.c:3019-3022 的 if (!cardinality_only))。所以 ZINTERCARD 的内存开销是 O(1),与结果集大小无关。

其二,limit 一到就 break。 ZINTERCARD 3 a b c LIMIT 10 只需要知道"交集至少 10 个",找到 10 个就可以停。这对"交集很大但只关心是否达到某阈值"的场景(比如限流判断)是决定性的。

其三,zuiDiscardDirtyValue(&zval) 在 break 前必须调。 因为 break 跳出了 while (zuiNext(...)) 循环,后面没有下一轮 zuiNext 来清理这一轮可能产生的 OPVAL_DIRTY_SDS。漏掉这一行就是内存泄漏——每次 ZINTERCARD ... LIMIT n 命中阈值就漏一个 sds。

这是"惰性清理"模式的经典陷阱:清理放在"下一轮开头",那么"提前退出"的路径就必须显式补一次。ZINTER 的非 cardinality_only 路径不需要,因为它只在内层 break、外层继续。limit == 0 表示"不限",判据是 if (limit && ...)。

18.3 ZDIFF:三选一

static int zsetChooseDiffAlgorithm(zsetopsrc *src, long setnum) {
    long long algo_one_work = 0;
    long long algo_two_work = 0;

    for (j = 0; j < setnum; j++) {
        if (j > 0 && src[0].subject == src[j].subject) {
            return 0;
        }
        algo_one_work += zuiLength(&src[0]);
        algo_two_work += zuiLength(&src[j]);
    }

    algo_one_work /= 2;
    return (algo_one_work <= algo_two_work) ? 1 : 2;
}

src/t_zset.c:2825-2857。三种返回值:

  • 0:ZDIFF 2 a b a 这种"要减的集合就是 S0 自己",结果必然为空,直接跳过。
  • 1:算法 1(迭代 S0,逐个查其余)
  • 2:算法 2(全加 S0 进结果,再逐个删)

algo_one_work = setnum × |S0|(算法 1 的工作量:外层 |S0|、内层 setnum-1 次查找),algo_two_work = Σ|Si|(算法 2 的工作量:插入 |S0| 个 + 删除 Σ|Si| - |S0| 个)。

algo_one_work /= 2 是偏袒:算法 1 每次"查找"是一次 dictFind(O(1) 哈希 + 比较),算法 2 每次"删除"是一次 dictUnlink + zslDelete(O(log n),还要改 span)。所以算法 1 的单位操作更便宜,给它 2 倍优惠。

这个 /2 和第 13 章 sunionDiffGenericCommand 的 /2 是同一个数字、同一个理由(第 13 章 §14.3)。

18.4 算法 2 的三件套

} else {
    dictPauseAutoResize(dstzset->dict);
    tmp = zuiSdsFromValue(&zval);
    if (zsetRemoveFromSkiplist(dstzset, tmp)) {
        cardinality--;
    }
    dictResumeAutoResize(dstzset->dict);
}

src/t_zset.c:2797-2802。

zsetRemoveFromSkiplist 内部是 dictUnlink + dictFreeUnlinkedEntry + zslDelete(顺序的必要性见 §1.3)。要注意 dictUnlink 只摘链、不动 ht_used,减计数发生在 dictFreeUnlinkedEntry 里,所以连续摘 n 个元素时 dict 的负载因子不会及时反映"元素在变少",恢复自动扩容后也不会自动缩容。算法 2 因此在循环结束后显式调一次 dictShrinkIfNeeded(dstzset->dict)(src/t_zset.c:2818)——"加了 |S0| 个又删掉大部分"之后,桶数组可能远大于需要。

dictPauseAutoResize / dictResumeAutoResize 的作用是避免每次删除后都检查是否该 rehash,与第 13 章的用法一致。

18.5 算法 2 必须重扫最大长度

*maxelelen = zsetDictGetMaxElementLength(dstzset->dict, totelelen);

src/t_zset.c:2822。算法 1 是"边插边记",maxelelen 和 totelelen 在插入时累加。算法 2 不同:它先把 S0 全部插入(此时记的是"插入时的长度"),然后删掉一部分。所以:

  • totelelen 需要重算(删掉的元素不该计入)
  • maxelelen 需要重算(最长的那个可能被删掉了)

zsetDictGetMaxElementLength(src/t_zset.c:2685-2704)遍历整个 dict,用 zslGetNodeElement 取 sds 长度。这是 O(K) 的额外遍历,但算法 2 本身是 O(L),所以不改变复杂度量级(O(2L) = O(L),注释里明说了,src/t_zset.c:2775-2777)。

重算的目的是后面的 zsetConvertToListpackIfNeeded(dstobj, maxelelen, totelelen)(§11.3)——如果 maxelelen 偏高,会错失一次降级机会;如果 totelelen 偏高,lpSafeToAdd 会误判为不安全。

18.6 WEIGHTS / AGGREGATE 的适用范围

if (op != SET_OP_DIFF && !cardinality_only &&
    remaining >= (setnum + 1) &&
    !strcasecmp(c->argv[j]->ptr,"weights"))

src/t_zset.c:2952-2955。两个 && 限制了适用范围:

  • op != SET_OP_DIFF:ZDIFF 没有分数聚合,加权无意义。
  • !cardinality_only:ZINTERCARD 只数个数,分数不参与。

AGGREGATE 同理(src/t_zset.c:2965-2967)。而 WITHSCORES 还多一个 !dstkey(src/t_zset.c:2984-2986)——STORE 版本不回元素列表。

四组限制条件叠在一起,构成 zunionInterDiffGenericCommand 的参数矩阵:

选项UNIONINTERDIFFCARDSTORE
WEIGHTS✓✓✗✗✓
AGGREGATE✓✓✗✗✓
WITHSCORES✓✓✓✗✗
LIMIT✗✗✗✓✗

LIMIT 只有 ZINTERCARD 有,因为只有它在数个数时能提前退出。

18.7 AGGREGATE COUNT 是什么

inline static double zuiWeightedScore(double score, double weight, int aggregate) {
    return (aggregate == REDIS_AGGR_COUNT) ? weight : weight * score;
}

src/t_zset.c:2659-2661。COUNT 聚合时"分数"被替换成权重本身——所以 ZUNIONSTORE dest 2 a b AGGREGATE COUNT 的结果是"每个成员在多少个输入集合里出现过",WEIGHTS 可以让不同集合计不同的票。

这是 8.10 新增的聚合模式(原本只有 SUM/MIN/MAX),复用了 zunionInterAggregate 的加法分支(src/t_zset.c:2670-2674)。它同样需要 NaN 保护,因为 weight 可以是 inf(WEIGHTS inf 合法),inf + (-inf) 才是 NaN——所以 COUNT 与 SUM 的保护逻辑完全相同,只是被加数不同。

aggregate 是互斥的枚举值而非位标志——REDIS_AGGR_SUM = 1、MIN = 2、MAX = 3、COUNT = 4(src/t_zset.c:2654-2657),所以"同时 SUM 和 COUNT"不可能,zunionInterAggregate 末尾那句 serverPanic("Unknown ZUNION/INTER aggregate type") 是纯安全网。


19. ZPOPMIN/ZPOPMAX/ZMPOP:四种回复形态

19.1 一个函数四种形态

genericZpopCommand 有 use_nested_array 和 emitkey 两个布尔,组合出四种回复(src/t_zset.c:4274-4289):

if (!use_nested_array && !emitkey) {
    /* ZPOPMIN/ZPOPMAX with or without COUNT option in RESP2. */
    addReplyArrayLen(c, rangelen * 2);
} else if (use_nested_array && !emitkey) {
    /* ZPOPMIN/ZPOPMAX with COUNT option in RESP3. */
    addReplyArrayLen(c, rangelen);
} else if (!use_nested_array && emitkey) {
    /* BZPOPMIN/BZPOPMAX in RESP2 and RESP3. */
    addReplyArrayLen(c, rangelen * 2 + 1);
    addReplyBulk(c, key);
} else if (use_nested_array && emitkey) {
    /* ZMPOP/BZMPOP in RESP2 and RESP3. */
    addReplyArrayLen(c, 2);
    addReplyBulk(c, key);
    addReplyArrayLen(c, rangelen);
}
emitkeyuse_nested_array命令形状
00ZPOPMIN k / ZPOPMIN k 3(RESP2)[ele, score, ...] 扁平
01ZPOPMIN k 3(RESP3)[[ele, score], ...]
10BZPOPMIN k1 k2 0[key, ele, score]
11ZMPOP 2 k1 k2 MIN[key, [[ele, score], ...]]

use_nested_array 由 c->resp > 2 && count != -1 决定(src/t_zset.c:4387)。count == -1(单元素弹出)时不用嵌套,即使 RESP3——因为单个元素没有"列表"的层级。BZPOPMIN 的 emitkey = 1 且 use_nested_array = 0,因为阻塞命令的返回是"哪个键 + 一个元素",不需要嵌套。

19.2 count == -1 的特殊性

long count = -1; /* -1 for plain single pop. */
if (c->argc == 3 && getPositiveLongFromObjectOrReply(c, c->argv[2], &count, NULL) != C_OK)
    return;
int use_nested_array = (c->resp > 2 && count != -1);

src/t_zset.c:4381-4387。-1 是"没给 count"的哨兵,进了 genericZpopCommand 之后立刻被修正为 1(src/t_zset.c:4266-4267):

if (count == -1) count = 1;

但 use_nested_array 的判定在修正之前(在 zpopMinMaxCommand 里),所以"没给 count"的语义(扁平数组)被保住了。这是一个"哨兵值在两层之间传递"的例子:-1 既表示"默认 1"又表示"回复形态不同"。

count == 0 单独处理(src/t_zset.c:4258-4262):回空数组,不做任何事。如果直接进循环,rangelen = 0,do { ... } while (--rangelen) 会先执行一次再判断——do-while 至少要跑一轮,会误弹一个元素。

19.3 ZMPOP 的复制改写

if (c->cmd->proc == zmpopCommand) {
    /* Always replicate it as ZPOP[MIN|MAX] with COUNT option instead of ZMPOP. */
    robj *count_obj = createStringObjectFromLongLong((count > llen) ? llen : count);
    rewriteClientCommandVector(c, 3,
                               (where == ZSET_MAX) ? shared.zpopmax : shared.zpopmin,
                               key, count_obj);
    decrRefCount(count_obj);
}

src/t_zset.c:4364-4371。ZMPOP 被改写成 ZPOPMIN key count 传给从库/AOF。

为什么?因为 ZMPOP 是"多键 + 阻塞"的命令,而实际弹出发生在哪个键上取决于执行时的数据状态。如果原样传播 ZMPOP 3 k1 k2 k3 MIN,从库执行时可能因为数据略有差异而弹出不同的键——主从就不一致了。改写成 ZPOPMIN <实际选中的 key> <实际弹出的数量> 把"选择"这个不确定性固化成确定的操作。

count > llen ? llen : count 是因为实际弹出的数量可能少于请求的(集合不够大)。传播"实际数量"而不是"请求数量",保证从库弹出一模一样多的元素。

这是"复制确定性"原则的又一例:凡是依赖执行时状态的选择,都必须在传播时固化。第 12 章 LMOVEM 的 OBO/BULK 反转、第 13 章 SPOP 的"分批 SREM 改写"都是同一个原则。

19.4 updateKeysizesHist 的 -1 约定

int64_t oldlen = llen, newlen = llen - result_count;

if (zsetLength(zobj) == 0) {
    if (deleted) *deleted = 1;
    dbDeleteSkipKeysizesUpdate(c->db, key);
    notifyKeyspaceEvent(NOTIFY_GENERIC,"del",key,c->db->id);
    newlen = -1; 
}
updateKeysizesHist(c->db, OBJ_ZSET, oldlen, newlen);
keyModified(c, c->db, key, (newlen > 0) ? zobj : NULL, 1);

src/t_zset.c:4350-4362。用 dbDeleteSkipKeysizesUpdate 而不是 dbDelete,是因为后者会把 key 从 keysizes_hist 里摘掉,而紧接着的 updateKeysizesHist(c->db, OBJ_ZSET, oldlen, -1) 又要更新同一张直方图——两处都减就重复了。newlen = -1 是"该 key 已从直方图移除"的约定(第 3 章),不是"长度变成 -1",keyModified 的第四个参数正是靠它决定传 zobj 还是 NULL。

三步的顺序不能换:dbDeleteSkipKeysizesUpdate(键没了)→ updateKeysizesHist(直方图减)→ keyModified(通知)。反过来先通知再删键,WATCH 的客户端可能在中间读到不一致状态。


20. ZRANDMEMBER:四个 CASE

20.1 两个常量

#define ZRANDMEMBER_SUB_STRATEGY_MUL 3
#define ZRANDMEMBER_RANDOM_SAMPLE_LIMIT 1000

src/t_zset.c:4498、4503。第一个是"反向策略"的分界系数,第二个是"单批采样上限"。

与第 13 章 SRANDMEMBER 的两个常数完全相同,因为取舍一样:SPOP 用 5(它要真改数据),SRANDMEMBER/ZRANDMEMBER 用 3(只建临时容器)。

20.2 CASE 1:允许重复

if (!uniq || count == 1) {
    if (withscores && c->resp == 2) addReplyArrayLen(c, count*2);
    else addReplyArrayLen(c, count);
    if (zsetobj->encoding == OBJ_ENCODING_SKIPLIST) {
        zset *zs = zsetobj->ptr;
        while (count--) {
            dictEntry *de = dictGetFairRandomKey(zs->dict);
            zskiplistNode *znode = dictGetKey(de);
            ...
        }
    } else if (zsetobj->encoding == OBJ_ENCODING_LISTPACK) {
        ...
        lpRandomPairs(zsetobj->ptr, sample_count, keys, vals, 2);
        ...
    }
}

src/t_zset.c:4536-4575。!uniq 就是 count < 0;count == 1 也走这里("取 1 个不重复的"与"取 1 个"等价,不需要去重容器)。

跳表分支用 dictGetFairRandomKey——从 dict 里随机取,不是从 skiplist。因为 dict 的 dictGetFairRandomKey 是 O(1) 均摊(第 6 章讨论过它的"公平"含义:连续两次 rehash 时按比例取桶),而 skiplist 随机取第 k 个要 O(log n)。

这也是两个索引的额外红利:随机访问走 dict、有序访问走 skiplist。只有 skiplist 的话,每次随机取都要 zslGetElementByRank(rand() % length),O(log n)。

listpack 分支用 lpRandomPairs(第 13 章 §18 讨论的 lpRandomEntries 的"成对"版本),并按 ZRANDMEMBER_RANDOM_SAMPLE_LIMIT = 1000 分批——keys/vals 两个数组各 limit 个 listpackEntry(每个 24 字节),1000 个就是 48KB,不设限的话 ZRANDMEMBER k -1000000000 会尝试分配 48GB。

20.3 CASE 2 与 CASE 2.5

if (count >= size) {
    while (zuiNext(&src, &zval)) { ... }
    zuiClearIterator(&src);
    goto out;
}

if (zsetobj->encoding == OBJ_ENCODING_LISTPACK) {
    keys = zmalloc(sizeof(listpackEntry)*count);
    if (withscores) vals = zmalloc(sizeof(listpackEntry)*count);
    serverAssert(lpRandomPairsUnique(zsetobj->ptr, count, keys, vals, 2) == count);
    zrandmemberReplyWithListpack(c, count, keys, vals);
    ...
}

src/t_zset.c:4595-4626。

CASE 2(count >= size)直接遍历全集。用 zuiNext 而不是编码分支,因为"返回全部"不需要随机性,用统一的迭代器最省代码。

CASE 2.5(listpack 特化)用 lpRandomPairsUnique。注释说明了理由(src/t_zset.c:4607-4614):

Listpack encoded zsets are meant to be relatively small, so ZRANDMEMBER_SUB_STRATEGY_MUL isn't necessary and we rather not make copies of the entries. Instead, we emit them directly to the output buffer.

And it is inefficient to repeatedly pick one random element from a listpack in CASE 4.

lpRandomPairsUnique(src/listpack.c:2020-2052)是一次从左到右的扫描,用 lpNextRandom 的蓄水池式抽样挑出 count 个(第 13 章 §18.4 详细推导过 lpNextRandom 的均匀性)。时间 O(n)、空间 O(count)、精确均匀、无需去重容器。

serverAssert(... == count) 断言"挑够了":count < size(CASE 2 已处理 >=),必然挑得够。该断言在发布版也生效,成本只是一次返回值比较。

20.4 CASE 3:建全集再删

if (count*ZRANDMEMBER_SUB_STRATEGY_MUL > size) {
    dict *d = dictCreate(&sdsReplyDictType);
    dictExpand(d, size);
    while (zuiNext(&src, &zval)) {
        sds key = zuiNewSdsFromValue(&zval);
        dictEntry *de = dictAddRaw(d, key, NULL);
        serverAssert(de);
        if (withscores) dictSetDoubleVal(de, zval.score);
    }
    serverAssert(dictSize(d) == size);

    while (size > count) {
        dictEntry *de = dictGetFairRandomKey(d);
        dictUnlink(d,dictGetKey(de));
        sdsfree(dictGetKey(de));
        dictFreeUnlinkedEntry(d,de);
        size--;
    }
    ...
}

src/t_zset.c:4637-4676。

count × 3 > size 即 count > size/3 时用这条。逻辑是"要得多不如先全拿再扔":CASE 4 的期望尝试次数是 size/(size-count),count = size/2 时是 2 次,count = 3size/4 时是 4 次,count → size 时趋于无穷。CASE 3 的代价是固定的 size 次插入 + size-count 次删除。

dictSetDoubleVal(de, zval.score) 用 dictEntry 的 val union 直接存 double——sdsReplyDictType 没有 no_value,每个 entry 都有一个 val 槽位,不需要额外内存。

删除要三行(dictUnlink + sdsfree + dictFreeUnlinkedEntry),因为 keyDestructor 是 NULL(第 13 章 §18.5 讨论过同样的三步)。而回复时 addReplyBulkSds 接管所有权(src/t_zset.c:4669),所以 dictRelease(d) 又不能 free key——keyDestructor = NULL 同时满足这两头。

20.5 CASE 4:采样去重

else {
    unsigned long added = 0;
    dict *d = dictCreate(&hashDictType);
    dictExpand(d, count);

    while (added < count) {
        listpackEntry key;
        double score;
        zsetTypeRandomElement(zsetobj, size, &key, withscores ? &score: NULL);

        sds skey = zsetSdsFromListpackEntry(&key);
        if (dictAdd(d,skey,NULL) != DICT_OK) {
            sdsfree(skey);
            continue;
        }
        added++;
        ...
    }
    dictRelease(d);
}

src/t_zset.c:4682-4712。用 hashDictType(有 keyDestructor = dictSdsDestructor),所以 dictRelease 会自动 free 所有 key,不需要手动清理。

与 CASE 3 的 sdsReplyDictType 对比:CASE 3 的 dict 是"最终结果",key 要交给客户端(所以不 free);CASE 4 的 dict 只是"去重容器",key 用完就扔(所以有 destructor)。同一个函数里用两个不同的 dictType,各自匹配各自的所有权语义,这是 Redis 里"用类型表达所有权"的典型手法。

zsetTypeRandomElement(src/t_zset.c:1931-1955):

  • 跳表:dictGetFairRandomKey,O(1)
  • listpack:lpRandomPair(zsetobj->ptr, zsetsize, key, &val, 2)——lpSeek 到随机位置,O(n)

所以 CASE 4 在 listpack 上的复杂度是 O(count × n),这也是 CASE 2.5 存在的原因:ZRANDMEMBER k 100 在 128 元素的 listpack 上,CASE 4 是 100 次 O(128) 扫描,CASE 2.5 是一次 O(128) 扫描。

复杂度 O(count × size/(size-count)),与第 13 章 SRANDMEMBER CASE 4 一致;size/3 的分界让"浪费"不超过 50%。

20.6 只有负 count 保证随机顺序

ZRANDMEMBER k 5(正 count)与 k -5(负 count)的差别不只是"是否去重",还有顺序:负 count(CASE 1)每次独立随机,顺序天然随机;正 count 则是 CASE 2 的遍历序、CASE 2.5 的 lpNextRandom 左到右序、CASE 3 的哈希桶序、CASE 4 的"命中即输出"采样序。

只有 CASE 1 的注释明确说了"this is the only one that also needs to return the elements in random order"(src/t_zset.c:4534-4535)。这与第 13 章 SRANDMEMBER 的结论一致:正 count 只保证"选出的集合是均匀随机的",不保证"顺序是随机的"。


21. 陷阱与小结

21.1 十个容易踩的陷阱

#陷阱正确做法
一直接读写 node->level[0].span那是 zskiplistNodeInfo,只能走 zslGetNodeSpanAtLevel 系列(例外:zslSetNodeInfo 与 defrag 的 memcpy 搬迁)
二手写 span 破坏"顶层无后继时 span = 后面的节点数"提层与摘链的更新必须守恒;zslDebugVerifyStruct 只在 DEBUG/测试里跑
三以为 ZSL_OFFSET_MAX_ELE 与 ZSL_OFFSET_NO_ELE 是两个约束同为 UINT16_MAX,偏移到 65535 即被当"无元素",检查只是 debugServerAssert
四把 dictGetKey(de) 当 sds 用它返回 zskiplistNode *;要 sds 用 zslGetNodeElement(node)
五给 zsetDictType 补 keyDestructor必须保持 NULL,节点由 zslFree 释放,否则 double free
六以为 zslInsert 接管 ele它只复制(经典实现才接管);每个调用点都要 sdsfree 自己的副本
七改分时删了重建dict 的 key 是节点指针,换节点就得改 dict;zslUpdateScore 复用同一节点
八以为 ZINCRBY 必须先删后插跳表有快路径(新分落在前后邻居之间就原地改字段),只有 listpack 编码是真删后插
九ZRANGESTORE 时让 withscores 保持 0store 版必须置 1,否则新 zset 的分数全是 0
十ZINTERCARD ... LIMIT 命中阈值直接 break必须先 zuiDiscardDirtyValue,否则每次命中漏一个 sds

21.2 三种编码的分工

编码结构ZADDZSCOREZRANKZRANGE offset内存/元素
listpack扁平 [ele,score,...]O(n)O(n)O(n)O(offset)~10 B
skiplistdict + 跳表O(log n)O(1)O(32) 跳O(log n)~48 B + dict

listpack 在元素少时赢在内存与遍历(连续、无指针追逐),输在单点查找与插入;128 这个阈值就落在"内存省 8 倍"与"查找慢 10 倍"的交叉点附近。

21.3 三个"顺手"的架构决策

其一,dict 存节点指针而不是 sds。 让 ZSCORE 少一次跳转、让 ZRANK 走 zslGetRankByNode、让改分不碰 dict。代价是 keyFromStoredKey 那套回调,以及"节点地址必须稳定"——这条反过来逼出了"单块分配 + 复用节点"。

其二,span 在 level 0 是死的。 4 个字节的无用空间于是变成"层数 + sds 偏移",省掉节点里的 sds 指针和一次分配。

其三,两套索引各司其职。 随机取走 dict、有序走 skiplist、排名靠 span、存在性靠 dict——没有一种结构能全干,组合起来每条路径都最优。

21.4 本章涉及的文件

  • src/t_zset.c(5033 行):ZSet 全部命令与编码逻辑
  • src/server.h 656-658、1788-1819、3771-3802:ZSKIPLIST_* 常量、节点/跳表结构、API 声明
  • src/dict.h 56-66、121-134 与 src/dict.c 98-100、844-881:stored-key/key 的区分、keyFromStoredKey、dictFindLink
  • src/object.c 492-509、584-599:createZsetObject / createZsetListpackObject / freeZsetObject
  • src/listpack.c 1694、1959-2112:lpSeek、lpRandomPairs(Unique)、lpNextRandom
  • src/sds.c 33-42、139-180:sdsReqType、sdsnewplacement
  • src/server.c 370-441、689-708:dictSdsKeyCompare、dictSdsHash、hashDictType/sdsReplyDictType
  • src/config.c 3603-3607:两个 zset-max-listpack-* 配置;src/rdb.c 703-716、3043-3090、3748-3790:RDB 保存与加载
  • src/defrag.c 386-431、701-712:zslUpdateNode、activeDefragZsetNode
  • src/geo.c 810-835:另一处 zsetConvertToListpackIfNeeded 调用点;src/commands/ 下 30 个 z* 命令 JSON

21.5 下一章预告

第 15 章进入 String——Redis 里唯一有三种 robj 编码(RAW/EMBSTR/INT)的类型:EMBSTR 的 44 字节边界怎么来的、为什么它只读(APPEND 会强制转 RAW)、INT 编码怎么把 64 位整数塞进指针、shared.integers 那 10000 个预分配对象何时生效、BITCOUNT 的四种算法(查表 / SWAR / 硬件 popcount / BITFIELD 位域)、SETRANGE 的补零扩张与 SDS_MAX_PREALLOC 的 1MB 阈值、GETRANGE 的负数索引,以及 SETEX/PSETEX/GETEX 的过期语义矩阵。