Administrator
发布于 2025-09-19 / 152 阅读
0

Redis ZSet 底层实现原理深度解析

在 Redis 的核心数据类型中,ZSet(有序集合)凭借有序性、唯一性的双重特性,成为排行榜、优先级队列、范围查询等场景的核心支撑。与 Set 仅通过 “元素是否存在” 定义集合不同,ZSet 为每个元素(member)绑定一个分数(score),并基于分数实现元素的自动排序。其底层实现同样采用 “动态编码” 策略,根据数据规模与元素特征,在ziplist(压缩列表)skiplist(跳表)+ dict(字典) 两种结构间自动切换,既保证小数据场景的内存紧凑性,又兼顾大数据场景的高效操作性能。本文将从数据结构本质、核心操作逻辑、编码转换机制三个维度,全面拆解 ZSet 的底层实现。

一、ZSet 结构的核心特性与设计思路

在深入底层前,需先明确 ZSet 区别于其他数据类型的核心特性,这是理解其底层设计的基础:

  1. 元素唯一性 + 分数可重复:集合中每个 member 唯一,不允许重复插入;但不同 member 可绑定相同 score(如多个用户同积的排行榜)。

  1. 分数有序性:元素默认按 score 升序排列,支持通过 ZREVRANGE 等命令实现降序查询,且排序状态会随元素插入 / 删除 / 分数更新自动维护。

  1. 高效范围操作:原生支持按 score 范围(如 ZRANGEBYSCORE 100 200)或排名范围(如 ZRANGE 0 9)查询元素,时间复杂度远优于普通集合。

Redis 设计 ZSet 底层结构的核心思路是 “分场景优化”

  • 当 ZSet 中元素数量少、member 和 score 占用内存小时,使用 ziplist 编码 —— 通过连续内存块存储数据,最大化节省内存(每个元素仅需存储 member 和 score,无额外指针开销)。

  • 当元素数量超过阈值或元素体积过大时,自动切换为 skiplist + dict 组合编码 —— 利用 skiplist 实现高效的有序范围操作,利用 dict 实现 member 到 score 的快速映射,二者通过共享数据保证一致性,平衡 “有序查询” 与 “单点查询” 性能。

二、ziplist:小数据场景的紧凑编码

ziplist(压缩列表)是 Redis 为 “小体积、有序数据” 设计的紧凑存储结构,适用于 ZSet 的初始化阶段或小规模数据场景。其核心优势是 内存占用极低—— 通过 “连续内存块 + 变长编码” 存储元素,避免链表节点的指针开销,且元素按 score 升序排列,无需额外维护排序结构。

1. ziplist 编码的 ZSet 存储格式

当 ZSet 使用 ziplist 编码时,元素按 “score(小端字节序)→ member(字符串)” 的顺序成对存储,且整体保持 score 升序排列。例如,存储 member: "user1", score: 10 和 member: "user2", score: 20 的 ZSet,其 ziplist 内部结构如下(简化版):

[zlbytes(4字节)] → [zltail(4字节)] → [zllen(2字节)] → 
[score1(变长)] → [member1(变长)] → [score2(变长)] → [member2(变长)] → 
[zlend(1字节)]

各字段含义如下:

  • zlbytes:记录整个 ziplist 的总字节数,用于快速计算内存大小。

  • zltail:记录最后一个元素到 ziplist 起始地址的偏移量,用于快速定位尾元素(避免遍历)。

  • zllen:记录 ziplist 中元素对的数量(即 ZSet 的元素个数),若超过 65535 则需遍历获取真实数量。

  • score + member 对:每个 ZSet 元素对应两个 ziplist 节点,score 采用 “变长整数编码”(如 1 字节存储小整数,5 字节存储大整数),member 采用 “变长字符串编码”(存储字符串长度 + 内容),且所有元素对按 score 升序排列。

  • zlend:标记 ziplist 结束,固定为 0xFF。

这种存储格式的核心优势是 无冗余开销:每个元素仅需存储 “score + member” 的原始数据,无指针、哈希表等额外结构,内存利用率远超 skiplist + dict 组合。

2. ziplist 编码的核心操作逻辑

ziplist 编码的 ZSet 操作依赖 “有序遍历 + 二分查找”,核心操作包括 插入(ZADD)、查询(ZSCORE/ZRANK)、删除(ZREM)、范围查询(ZRANGE),需兼顾 “有序性维护” 与 “内存紧凑性”。

(1)单点查询:member → score(ZSCORE)

要获取某个 member 对应的 score,需遍历 ziplist 中的 “score + member” 对:

  1. 从 ziplist 头部开始,依次读取每对 “score 节点” 和 “member 节点”;

  1. 对比当前 member 与目标 member:若匹配,返回对应的 score;若不匹配,继续遍历下一对;

  1. 若遍历至尾部仍未找到,返回 nil。

时间复杂度:O (n)(n 为 ZSet 元素个数)—— 因 ziplist 无索引,需线性遍历。这也是 ziplist 仅适用于小数据场景的核心原因(当 n 超过 128 时,遍历效率会显著下降)。

(2)插入操作(ZADD)

插入操作需保证 “member 唯一性” 和 “score 有序性”,步骤如下:

  1. 先通过遍历检查 member 是否已存在:若存在,更新其 score(需删除原 “score + member” 对,再插入新对);若不存在,执行下一步。

  1. 通过 二分查找 确定新元素的插入位置(因 ziplist 已按 score 升序排列,可通过对比 score 快速定位):

  • 例如,插入 score=15 的元素,需找到第一个 score>15 的元素对,在其前方插入新对。

  1. 调整 ziplist 内存:若插入位置的内存空间不足,需扩展 ziplist 总长度,然后将插入位置后的所有元素对向后移动(避免覆盖);

  1. 插入新的 “score + member” 对,更新 zlbytes、zltail、zllen 等元数据。

关键注意点:若插入后元素数量或单个元素体积超过阈值(如 zset-max-ziplist-entries=128、zset-max-ziplist-value=64),ZSet 会自动转换为 skiplist + dict 编码。

(3)范围查询(ZRANGE/ZRANGEBYSCORE)

范围查询是 ZSet 的核心场景,ziplist 编码通过 “有序存储” 天然支持高效范围遍历:

  • 按排名范围查询(如 ZRANGE 0 9):直接从 ziplist 头部开始,读取前 10 对 “score + member” 对,无需排序;

  • 按 score 范围查询(如 ZRANGEBYSCORE 10 20):通过遍历找到第一个 score≥10 的元素对,继续遍历直至 score>20,收集期间的所有元素对。

时间复杂度:O (n)(n 为范围内元素个数)—— 因无需额外排序,仅需遍历目标范围,效率优于普通数组。

(4)删除操作(ZREM)

删除操作需先定位元素位置,再调整内存,步骤如下:

  1. 遍历 ziplist 找到目标 member 对应的 “score + member” 对,记录其起始地址与长度;

  1. 将该元素对后方的所有元素对向前移动,覆盖待删除区域;

  1. 更新 zlbytes(减少待删除区域的字节数)、zltail(若删除的是尾元素,需重新计算偏移量)、zllen(减 1),并填充删除后的空闲内存(避免脏数据)。

注意:ziplist 不支持 “随机删除” 后的内存收缩(仅在元素删除后标记空闲区域,待后续插入复用),避免频繁内存重分配影响性能。

3. ziplist 编码的适用场景与局限性

适用场景:

  • ZSet 元素数量少(默认 ≤128,可通过 zset-max-ziplist-entries 配置);

  • member 为短字符串(默认长度 ≤64 字节,可通过 zset-max-ziplist-value 配置)、score 为小整数(如排行榜分数、优先级数值)。

典型场景:用户个人的 “最近浏览商品排名”(元素数通常 <50)、小型团队的 “任务优先级列表” 等。

局限性:

  • 单点查询效率低(O (n)):当元素数量超过 100 时,遍历耗时明显增加;

  • 插入 / 删除性能随元素数量下降:元素移动操作的时间复杂度为 O (n),数据量越大,移动开销越高;

  • 不支持高效的 score 更新:若需修改某个 member 的 score,需先删除原元素对再插入新对,相当于两次 O (n) 操作。

当 ZSet 数据规模超出 ziplist 承载能力时,Redis 会自动触发编码转换,切换为 skiplist + dict 组合结构。

三、skiplist + dict:大数据场景的高效编码

当 ZSet 中元素数量超过阈值(默认 128)或元素体积过大(member 长度 >64 字节)时,底层编码会切换为 “skiplist(跳表)+ dict(字典)” 组合结构。这两种结构各司其职、数据共享,共同支撑 ZSet 的核心能力:

  • skiplist:按 score 升序存储所有元素,支持高效的 范围查询(如 ZRANGEBYSCORE)和 排名操作(如 ZRANK),时间复杂度 O (log n)。

  • dict:以 member 为键、score 为值,支持高效的 单点查询(如 ZSCORE)和 单点删除(如 ZREM),时间复杂度 O (1)。

二者通过 共享元素节点 保证数据一致性:skiplist 节点与 dict 节点指向同一组 “member + score” 数据,避免数据冗余存储。例如,当执行 ZADD user1 10 时,Redis 会同时在 skiplist 中插入一个包含 “user1 + 10” 的节点,在 dict 中插入一个 “key=user1, value=10” 的键值对,且两个节点共享同一块存储 “user1” 和 “10” 的内存。

1. 核心结构:skiplist(跳表)

skiplist(跳表)是一种 “有序链表的优化结构”,通过在原始链表(最底层)之上建立多层 “索引链表”,实现类似平衡树的 O (log n) 级查找效率,且实现复杂度远低于红黑树(无需维护旋转平衡)。其本质是 “用空间换时间”,通过少量额外索引节点,减少查询时的比较次数。

(1)skiplist 的数据结构定义(Redis 实现)

Redis 中的跳表(zskiplist)由 跳表节点(**zskiplistNode)** 和 跳表头结构(**zskiplist)** 两部分组成,C 语言定义简化如下:

// 跳表节点
typedef struct zskiplistNode {
    // 元素的 member(字符串)
    sds ele;
    // 元素的 score(排序依据)
    double score;
    // 后退指针:指向当前节点的前一个节点(仅最底层链表有,用于反向遍历)
    struct zskiplistNode *backward;
    // 层结构:每个节点包含多层索引,每层对应一个索引链表
    struct zskiplistLevel {
        // 前进指针:指向当前层的下一个节点
        struct zskiplistNode *forward;
        // 跨度:当前节点到 forward 节点的距离(用于快速计算排名)
        unsigned int span;
    } level[];
} zskiplistNode;
​
// 跳表头结构:管理跳表的整体信息
typedef struct zskiplist {
    // 跳表的头节点和尾节点(方便快速定位首尾)
    struct zskiplistNode *header, *tail;
    // 跳表中元素的总数量(即 ZSet 的元素个数)
    unsigned long length;
    // 跳表的最大层数(所有节点的最大 level 数)
    int level;
} zskiplist;

其结构特点可通过以下关键字段理解:

  • 多层索引(level []):每个节点的层数(level 数组长度)是随机生成的(Redis 中默认最大层数为 32),层数越高,该节点在索引中的 “优先级” 越高(如 3 层节点会出现在第 1、2、3 层索引中)。通过多层索引,查询时可从最高层开始,快速跳过大量节点(类似二分查找)。

  • 跨度(span):记录当前节点到 forward 节点的距离(即中间包含的节点数),用于快速计算元素的排名(如从表头出发,累加经过节点的 span,即可得到当前节点的排名)。

  • 后退指针(backward):仅最底层链表的节点有该指针,用于支持反向遍历(如 ZREVRANGE 命令),避免从头重新查询。

(2)skiplist 的核心操作:查询与插入

① 查询操作(按 score 或排名)

skiplist 的查询逻辑是 “从高层索引到低层链表,逐步缩小范围”,以按 score 查询某个元素为例:

  1. 从跳表的 最高层(header->level [max_level-1]) 开始,设置当前节点为 header;

  1. 对比当前节点的 forward 节点的 score:

  • 若 forward 节点的 score < 目标 score,当前节点移动到 forward 节点,继续在当前层查询;

  • 若 forward 节点的 score ≥ 目标 score 或 forward 为 NULL,当前层向下移动一层(level-1);

  1. 重复步骤 2,直至降至最底层(level=0);

  1. 在最底层链表中,遍历 forward 节点,对比 member 是否匹配(因 score 可重复,需确认 member 唯一性),找到则返回节点,否则返回 NULL。

时间复杂度:O (log n)—— 每一层的查询步数平均为 O (1),总层数为 O (log n)(因节点层数遵循泊松分布,平均层数为 log n)。

此外,skiplist 还支持按排名查询(如 ZRANK user1):通过累加查询路径中各节点的 span 值,即可得到目标节点的排名(从 0 开始),无需遍历所有节点。

② 插入操作(ZADD)

插入操作需先找到插入位置,再创建节点并更新索引,步骤如下:

  1. 查找插入位置:执行类似查询的逻辑,记录每一层中 “最后一个 score < 目标 score” 的节点(称为 “前驱节点”),并存储在 update[] 数组中;同时记录目标节点的预期排名(通过累加 span 计算)。

  1. 生成节点层数:通过随机算法生成新节点的层数(Redis 中默认最大层数 32,层数概率随层数增加递减,如 1 层概率 50%、2 层 25%、3 层 12.5%...)。

  1. 创建新节点:初始化新节点的 ele(member)、score,以及 level 数组(长度为生成的层数)。

  1. 更新索引链表

  • 对每一层 i(从 0 到新节点层数 - 1),将新节点的 forward 指向 update[i] 的 forward,update[i] 的 forward 指向新节点;

  • 计算新节点的 span:update[i] 原 span 减去 “新节点到 update[i] forward 的距离”,新节点的 span 设为 “update[i] 原 span - 新 span”(确保 span 准确性)。

  1. 更新后退指针:若 update[0] 是 header(即新节点是第一个元素),新节点的 backward 设为 NULL;否则指向 update[0];同时,若新节点的 forward 非 NULL,将 forward 节点的 backward 指向新节点。

  1. 更新跳表元数据:跳表的 length 加 1,若新节点层数超过跳表当前最大 level,更新跳表的 level。

时间复杂度:O (log n)—— 查找插入位置为 O (log n),更新索引链表的步骤数与节点层数成正比(平均 O (log n))。

2. 辅助结构:dict(字典)

dict(字典)即 Redis 中的哈希表结构,与 Set 底层的 hashtable 完全一致(详见前文 Set 解析)。在 ZSet 中,dict 的核心作用是 ** 快速