t zset编码转换

t_zset 编码转换源码分析(listpack 与 skiplist+dict)

ZSet 一个逻辑类型背后有两套物理编码:小集合用一块连续内存的 listpack,大集合用 skiplist+dict 双结构。
核心思路是按数据规模换数据结构——元素少时 O(N) 顺序扫比指针跳转还快且省内存,超过阈值(zset-max-listpack-entries / zset-max-listpack-value)就一次性转成双结构,让 ZSCORE 走 dict O(1)、ZRANK/ZRANGE 走跳表 O(logN)。

// 基于本地 Redis 仓 (unstable, commit 1abd489), src/object.c
// 两种编码在创建时就分家:listpack 版 ptr 指向一块裸字节数组,skiplist 版 ptr 指向 zset 结构体(dict + zsl 各一个)
robj *createZsetObject(void) {
    zset *zs = zmalloc(sizeof(*zs));
    robj *o;

    zs->dict = dictCreate(&zsetDictType);   // 双结构从出生起就成对出现,之后所有写路径必须同时维护两边
    zs->zsl = zslCreate();
    o = createObject(OBJ_ZSET,zs);
    o->encoding = OBJ_ENCODING_SKIPLIST;
    return o;
}

robj *createZsetListpackObject(void) {
    unsigned char *lp = lpNew(0);           // 就是一块连续内存,member 和 score 交替平铺,没有任何节点/指针开销
    robj *o = createObject(OBJ_ZSET,lp);
    o->encoding = OBJ_ENCODING_LISTPACK;
    return o;
}
// 基于本地 Redis 仓 (unstable, commit 1abd489), src/t_zset.c
// zadd 入口链路:zaddCommand -> zaddGenericCommand。编码分支不在命令层做,命令层只负责"选对初始编码/提前扩容"

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)   // 两个阈值:条数 + 单个 member 长度,任一超标都不给 listpack
    {
        return createZsetListpackObject();
    }

    robj *zobj = createZsetObject();      // 一次 ZADD 塞 200 个元素的新 key,直接建 skiplist,省掉"先建小的再转"
    zset *zs = zobj->ptr;
    dictExpand(zs->dict, size_hint);      // 按提示预扩容 dict,避免插入过程中反复 rehash
    return zobj;
}

/* Check if the existing zset should be converted to another encoding based off the
 * the size hint. */
void zsetTypeMaybeConvert(robj *zobj, size_t size_hint) {
    if (zobj->encoding == OBJ_ENCODING_LISTPACK &&
        size_hint > server.zset_max_listpack_entries)     // 老 key 也一样:这批元素肯定会撑爆 listpack,就先转再插
    {
        zsetConvertAndExpand(zobj, OBJ_ENCODING_SKIPLIST, size_hint);
    }
}

void zaddGenericCommand(client *c, int flags) {
    // ... 解析 NX/XX/GT/LT/INCR/CH 选项,先把所有 score 参数解析完(有语法错就整条不执行)

    /* Lookup the key and create the sorted set if does not exist. */
    zobj = lookupKeyWrite(c->db,key);
    if (checkType(c,zobj,OBJ_ZSET)) goto cleanup;
    if (zobj == NULL) {
        if (xx) goto reply_to_client; /* No key + XX option: nothing to do. */
        robj *o = zsetTypeCreate(elements, sdslen(c->argv[scoreidx + 1]->ptr));  // 新 key:用元素个数当 size_hint 选编码
        zobj = dbAdd(c->db,key,&o);
    } else {
        // ...
        zsetTypeMaybeConvert(zobj, elements);   // 老 key:批量插入前预判要不要升级编码
        // ...
    }

    // ...
    for (j = 0; j < elements; j++) {
        double newscore;
        score = scores[j];
        int retflags = 0;

        ele = c->argv[scoreidx+1+j*2]->ptr;
        int retval = zsetAdd(zobj, score, ele, flags, &retflags, &newscore);  // 真正的编码分支在 zsetAdd 里
        if (retval == 0) {
            addReplyError(c,nanerr);
            // ...
            goto cleanup;
        }
        if (retflags & ZADD_OUT_ADDED) added++;
        if (retflags & ZADD_OUT_UPDATED) updated++;
        if (!(retflags & ZADD_OUT_NOP)) processed++;
        score = newscore;
    }
    // ... server.dirty 累加、回复客户端、keyspace 通知
}
// 基于本地 Redis 仓 (unstable, commit 1abd489), src/t_zset.c
// zsetAdd:单个元素的增/改,两种编码各一个分支;listpack 分支里藏着转换触发点

int zsetAdd(robj *zobj, double score, sds ele, int in_flags, int *out_flags, double *newscore) {
    // ... in_flags 拆成 incr/nx/xx/gt/lt 布尔量;score 是 NaN 直接报错返回 0

    /* Update the sorted set according to its encoding. */
    if (zobj->encoding == OBJ_ENCODING_LISTPACK) {
        unsigned char *eptr;

        if ((eptr = zzlFind(zobj->ptr,ele,&curscore)) != NULL) {   // O(N) 线性找 member,listpack 没有索引
            /* NX? Return, same element already exists. */
            if (nx) {
                *out_flags |= ZADD_OUT_NOP;
                return 1;
            }
            // ... incr 则 score += curscore;GT/LT 不满足则 NOP

            if (newscore) *newscore = score;

            /* Remove and re-insert when score changed. */
            if (score != curscore) {
                zobj->ptr = zzlDelete(zobj->ptr,eptr);      // listpack 改分数只能删了重插:位置由 score 决定,原地改不了
                zobj->ptr = zzlInsert(zobj->ptr,ele,score);
                *out_flags |= ZADD_OUT_UPDATED;
            }
            return 1;
        } else if (!xx) {
            /* check if the element is too large or the list
             * becomes too long *before* executing zzlInsert. */
            if (zzlLength(zobj->ptr)+1 > server.zset_max_listpack_entries ||   // 条数超限(默认 128)
                sdslen(ele) > server.zset_max_listpack_value ||                // 单元素超长(默认 64 字节)
                !lpSafeToAdd(zobj->ptr, sdslen(ele)))                          // listpack 总大小的安全上限兜底
            {
                zsetConvertAndExpand(zobj, OBJ_ENCODING_SKIPLIST, zsetLength(zobj) + 1);
                // 注意:转换后不 return,直接落到下面的 skiplist 分支完成插入,不用递归
            } else {
                zobj->ptr = zzlInsert(zobj->ptr,ele,score);
                if (newscore) *newscore = score;
                *out_flags |= ZADD_OUT_ADDED;
                return 1;
            }
        } else {
            *out_flags |= ZADD_OUT_NOP;
            return 1;
        }
    }

    /* Note that the above block handling listpack would have either returned or
     * converted the key to skiplist. */
    if (zobj->encoding == OBJ_ENCODING_SKIPLIST) {
        zset *zs = zobj->ptr;
        zskiplistNode *znode;
        dictEntry *de;
        dictEntryLink bucket, link;

        /* Use dictFindLink to find the element and get the bucket for potential insertion.
         * This avoids a second lookup in dictAdd() if the element doesn't exist. */
        link = dictFindLink(zs->dict, ele, &bucket);   // 查找和"没找到时该插哪个桶"一次搞定,省一次 hash 定位

        if (link != NULL) {
            /* Element exists - get the dictEntry from the link */
            de = *link;
            // ... NX 则 NOP;incr 累加;GT/LT 不满足则 NOP

            /* Get the node pointer from dict entry */
            znode = dictGetKey(de);        // dict 的 key 直接是跳表节点指针,member 字符串内嵌在节点里
            curscore = znode->score;
            // ...

            /* Remove and re-insert when score changes. */
            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;          // dict 完全不用动:它存的是节点指针,分数变了指针没变
            }
            return 1;
        } else if (!xx) {
            /* Element doesn't exist - create node with embedded sds and add to skiplist */
            znode = zslInsert(zs->zsl, score, ele);      // 先插跳表(member 被拷贝进节点)

            /* Add node pointer to dict using the bucket we already found */
            dictSetKeyAtLink(zs->dict, znode, &bucket, 1);   // 再把节点指针挂进 dict,两边共享同一份 member 内存

            *out_flags |= ZADD_OUT_ADDED;
            if (newscore) *newscore = score;
            return 1;
        } else {
            *out_flags |= ZADD_OUT_NOP;
            return 1;
        }
    } else {
        serverPanic("Unknown sorted set encoding");
    }
    return 0; /* Never reached. */
}

/* Insert (element,score) pair in listpack. This function assumes the element is
 * not yet present in the list. */
unsigned char *zzlInsert(unsigned char *zl, sds ele, double score) {
    unsigned char *eptr = lpSeek(zl,0), *sptr;
    double s;

    while (eptr != NULL) {                 // 从头顺序扫:listpack 里 (member, score) 成对存放,按 score 升序
        sptr = lpNext(zl,eptr);
        serverAssert(sptr != NULL);
        s = zzlGetScore(sptr);

        if (s > score) {
            /* First element with score larger than score for element to be
             * inserted. This means we should take its spot in the list to
             * maintain ordering. */
            zl = zzlInsertAt(zl,eptr,ele,score);
            break;
        } else if (s == score) {
            /* Ensure lexicographical ordering for elements. */
            if (zzlCompareElements(eptr,(unsigned char*)ele,sdslen(ele)) > 0) {   // 同分按 member 字典序,和跳表规则一致
                zl = zzlInsertAt(zl,eptr,ele,score);
                break;
            }
        }

        /* Move to next element. */
        eptr = lpNext(zl,sptr);
    }

    /* Push on tail of list when it was not yet inserted. */
    if (eptr == NULL)
        zl = zzlInsertAt(zl,NULL,ele,score);
    return zl;
}

static unsigned char *zzlInsertAt(unsigned char *zl, unsigned char *eptr, sds ele, double score) {
    char scorebuf[MAX_D2STRING_CHARS];
    int scorelen = 0;
    long long lscore;
    int score_is_long = double2ll(score, &lscore);   // 整数分值直接按整数编码存,比 double 转字符串省不少字节
    if (!score_is_long)
        scorelen = d2string(scorebuf,sizeof(scorebuf),score);

    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);   // member+score 一次批量插,只触发一次 realloc/memmove

    return zl;
}

/* Converts a zset to the specified encoding, pre-sizing it for 'cap' elements. */
void zsetConvertAndExpand(robj *zobj, int encoding, unsigned long cap) {
    zset *zs;
    zskiplistNode *node, *next;
    sds ele;
    double score;

    if (zobj->encoding == encoding) return;
    if (zobj->encoding == OBJ_ENCODING_LISTPACK) {
        unsigned char *zl = zobj->ptr;
        // ...

        zs = zmalloc(sizeof(*zs));
        zs->dict = dictCreate(&zsetDictType);
        zs->zsl = zslCreate();

        /* Presize the dict to avoid rehashing */
        dictExpand(zs->dict, cap);            // 元素个数已知,一次扩到位,转换过程中零 rehash

        eptr = lpSeek(zl,0);
        // ...
        while (eptr != NULL) {                // 顺序扫 listpack,逐对搬进双结构
            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);   // dict 里存节点指针,value 为空
            sdsfree(ele); /* zslInsert copied it, we can free our copy */
            zzlNext(zl,&eptr,&sptr);
        }

        zfree(zobj->ptr);                     // 老 listpack 整块释放
        zobj->ptr = zs;
        zobj->encoding = OBJ_ENCODING_SKIPLIST;
    } else if (zobj->encoding == OBJ_ENCODING_SKIPLIST) {
        // ... 反方向(skiplist -> listpack):沿跳表底层链表顺序 append,zadd 路径不会走到
    } else {
        serverPanic("Unknown sorted set encoding");
    }
}

/* dictType for zset's dict (maps sds to zskiplistNode*) */
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 */    // dict 不负责释放:member 内存归跳表节点所有
    NULL,               /* val destructor */
    NULL,               /* allow to expand */
    .no_value = 1,      /* no values stored (only nodes) */                     // 无 value 的 dict,一个 entry 只存一个节点指针
    .keyFromStoredKey = zslGetNodeElementForDict,  /* extract embedded sds from node */  // 查找时从节点里抠出内嵌 sds 来比较
};

int zsetScore(robj *zobj, sds member, double *score) {
    if (!zobj || !member) return C_ERR;

    if (zobj->encoding == OBJ_ENCODING_LISTPACK) {
        if (zzlFind(zobj->ptr, member, score) == NULL) return C_ERR;   // 小集合:O(N) 扫,N<=128 时无所谓
    } else if (zobj->encoding == OBJ_ENCODING_SKIPLIST) {
        zset *zs = zobj->ptr;
        dictEntry *de = dictFind(zs->dict, member);    // ZSCORE 走 dict:O(1) 拿到跳表节点
        if (de == NULL) return C_ERR;
        zskiplistNode *znode = dictGetKey(de);
        *score = znode->score;                         // 分数就在节点上,dict 只是索引
    } else {
        serverPanic("Unknown sorted set encoding");
    }
    return C_OK;
}

原理串讲

一次 ZADD key 10 member 的完整链路:zaddCommand -> zaddGenericCommand 先把所有选项和 score 解析完(任何语法错误都在改数据之前拦下,保证命令要么全执行要么不执行),然后 lookupKeyWrite 查 key。
key 不存在时走 zsetTypeCreate,用”这次要插几个元素、第一个 member 多长”当 hint 直接选编码——如果一条命令就要塞 500 个元素,何必先建 listpack 再转一次?key 已存在时 zsetTypeMaybeConvert 做同样的预判,把”插到第 129 个才在 zsetAdd 里触发转换”提前成”插入前一次转好”,顺便把 dict 按最终容量 dictExpand 到位。
这是第一处”为什么这么设计”:转换和 rehash 都是 O(N) 的重操作,能预知就绝不在插入循环里被动触发。

进入 zsetAdd 后按 encoding 分支。listpack 分支里 zzlFind 线性扫描找 member:找到了就处理 NX/XX/GT/LT/INCR,分数变了只能 zzlDelete + zzlInsert 删了重插,因为 listpack 是按 score 排序的连续字节流,没有”原地改分数”这种操作。
没找到且不是 XX,就到了转换触发点:三个条件(条数 +1 超过 zset-max-listpack-entries、member 长度超过 zset-max-listpack-value、lpSafeToAdd 总大小兜底)在 zzlInsert 执行之前检查。
为什么要在插入前而不是插入后检查?插进去再转,等于先付一次 listpack 的 memmove 再全量搬一次家,白做一遍;先转的话这个新元素直接以 skiplist 的方式插入。
转换本身在 zsetConvertAndExpand:新建 dict+zsl,dict 按 cap 一次扩容到位,顺序扫 listpack 把每对 (member, score) zslInsert 进跳表、节点指针 dictAdd 进 dict,最后整块释放老 listpack。
转换完 zsetAdd 不返回也不递归,代码直接落进下面的 skiplist 分支完成本次插入——两个 if 顺序排列而不用 else if,就是为了让”转换后接着插”自然发生。

skiplist 分支的双结构一致性是这版源码最值得看的地方。dict 的 key 直接存 zskiplistNode 指针(no_value dict),member 字符串内嵌在跳表节点尾部,dict 查找时通过 keyFromStoredKey 回调从节点里取出 sds 参与哈希比较。
这是第二处”为什么这么设计”:老版本 dict 存 member->score 两份独立数据,member 的 sds 被 dict 和跳表各引用一次、score 存两份,改分数要同时更新两处;现在整个 ZSet 里 member 和 score 都只有一份(在跳表节点上),dict 纯粹是”member 到节点”的索引。
所以 zsetAdd 更新分数时只调 zslUpdateScore 让跳表调整节点位置,dict 一个字节都不用动——节点指针没变。
读路径各取所长:zsetScore 走 dictFind 拿节点直接读 score,O(1);ZRANK/ZRANGE 这类跟顺序、排名相关的操作走跳表,靠每层的 span 累加 O(logN) 算出排名。
删除时(zsetRemoveFromSkiplist)顺序必须是先删 dict 再删跳表,因为 member 内存归跳表节点所有,反过来就是 use-after-free。

代码块JAVA · 2 行收起展开
为什么小 ZSet 非要用 listpack?算一笔账:一个跳表节点要 score(8B) + backward 指针(8B) + 若干层 forward+span(每层 16B),外加 dict entry、哈希桶,一个元素几十字节纯开销;listpack 里一个元素就是"编码头 + 数据"平铺,整数 score 甚至只占几个字节。
更重要的是缓存局部性:128 个元素的 listpack 就几 KB,一两次缓存预取就扫完了;跳表的 O(logN) 每一跳都是一次随机内存访问,小数据量下"理论复杂度更优"输给"常数更小"

阈值卡 128 条 / 64 字节,就是卡在线性扫描还没输给指针跳转的经验区间内。

设计取舍

  • 编码分支放在 zsetAdd 数据层而不是命令层,ZINCRBY/GEOADD 等所有写路径自动共享转换逻辑,命令层只做 size_hint 预判优化。
  • zadd 路径的转换是单向的:只升不降。ZREM 删到只剩 3 个元素也不会退回 listpack,因为反复增删会导致来回转换抖动;降级只发生在 GEO/ZUNIONSTORE 等生成新结果集的路径(zsetConvertToListpackIfNeeded)。
  • 两个阈值缺一不可:entries 限制扫描长度,value 限制单元素大小——一个 10KB 的 member 会让每次 memmove 都很贵,条数再少也不该进 listpack。
  • 双结构不是”两份数据”,是”一份数据 + 一个索引”:member/score 只存在跳表节点上,dict 存节点指针。常见误区是说 dict 里存 member->score 映射,这版源码已经不是了。
  • listpack 和跳表用同一套排序规则(score 升序,同分按 member 字典序),转换只是顺序搬运,无需重排。

延伸阅读