Skip to content
10. Redis 底层数据结构

10. Redis 底层数据结构

理解 Redis 为何如此高效的关键,在于它精心设计的数据结构。本章深入剖析 SDS、IntSet、Dict、ZipList、Listpack、QuickList、SkipList 以及 RedisObject 的设计思路与实现细节。


1. 概述

1.1 Redis 为什么自己实现数据结构

C 标准库提供了基本的数据结构,但 Redis 没有直接使用它们,原因如下:

C 标准库 问题 Redis 方案
char* (C字符串) O(n) 获取长度、二进制不安全、缓冲区溢出 SDS
malloc/free 频繁分配导致碎片、无引用计数 RedisObject + jemalloc
无内置 无哈希表、跳表、压缩列表 Dict、SkipList、ZipList
无内置 无内存友好的整数集合 IntSet

核心诉求:Redis 是一个内存数据库,内存效率与操作速度是生命线。 C 标准库的数据结构不够"贴身",必须量身定做。

1.2 数据结构层次

Redis 的数据组织分为三个层次:

┌─────────────────────────────────────────────────────┐
│                    用户可见类型                        │
│    String    Hash    List    Set    ZSet             │
├─────────────────────────────────────────────────────┤
│                    底层实现结构                        │
│  SDS   IntSet   Dict   ZipList   QuickList           │
│  Listpack   SkipList   RedisObject                   │
├─────────────────────────────────────────────────────┤
│                    内存分配层                         │
│              jemalloc (默认) / libc                   │
└─────────────────────────────────────────────────────┘

1.3 类型映射关系

下面这张表展示了 5 种高级类型在不同场景下使用的底层数据结构,是理解 Redis 内存模型的核心:

                     ┌─────────┐
                     │ STRING  │
                     └────┬────┘
          ┌───────────────┼───────────────┐
          ▼               ▼               ▼
    ┌──────────┐   ┌───────────┐   ┌──────────┐
    │   INT    │   │  EMBSTR   │   │   RAW    │
    │ (整数)   │   │ (≤44字节) │   │ (>44字节)│
    └──────────┘   └───────────┘   └──────────┘
                          │               │
                          ▼               ▼
                    ┌─────────────────────────┐
                    │          SDS            │
                    └─────────────────────────┘

                     ┌─────────┐
                     │  HASH   │
                     └────┬────┘
          ┌───────────────┴───────────────┐
          ▼                               ▼
    ┌──────────┐                  ┌──────────┐
    │ ZIPLIST  │ (数据小)          │   HT     │ (数据大)
    │/Listpack │                  │  (Dict)  │
    └──────────┘                  └──────────┘

                     ┌─────────┐
                     │  LIST   │
                     └────┬────┘
                          │
                          ▼
                   ┌────────────┐
                   │ QUICKLIST  │ (Redis 3.2+)
                   └────────────┘
                   内部: ZipList/Listpack 节点组成的双向链表

                     ┌─────────┐
                     │   SET   │
                     └────┬────┘
          ┌───────────────┴───────────────┐
          ▼                               ▼
    ┌──────────┐                  ┌──────────┐
    │  INTSET  │ (全整数)          │   HT     │ (其他)
    └──────────┘                  │  (Dict)  │
                                  └──────────┘

                     ┌─────────┐
                     │  ZSET   │
                     └────┬────┘
          ┌───────────────┴───────────────┐
          ▼                               ▼
    ┌──────────┐               ┌──────────────────┐
    │ ZIPLIST  │ (数据小)       │   SKIPLIST + HT  │ (数据大)
    │/Listpack │               │ (跳表+字典双索引) │
    └──────────┘               └──────────────────┘

2. SDS(Simple Dynamic String)

SDS 是 Redis 中字符串的底层实现,几乎所有字符串操作都基于它。

2.1 C 语言字符串的问题

C 原生字符串 (char*) 有三个致命缺陷:

缺陷一:获取长度 O(n)

// C 字符串需要遍历到 \0
size_t len = 0;
while (s[len] != '\0') len++;  // O(n)

Redis 的 STRLEN 命令必须 O(1)——如果一个 Key 存了几 MB 的字符串,每次 O(n) 遍历是不可接受的。

缺陷二:二进制不安全

C 字符串以 \0 作为结束标记,这意味着无法存储包含 \0 的二进制数据(如图片、序列化对象等)。Redis 作为通用存储,必须支持任意二进制数据。

缺陷三:缓冲区溢出风险

char dest[10];
strcat(dest, "this is a very long string"); // 溢出!

C 字符串不记录自身容量,拼接操作很容易越界。

2.2 🔬 SDS 结构设计

SDS 的设计用一个字可以概括:“带元数据的字符串”

2.2.1 结构定义

sdshdr8 为例(最常见的中短字符串类型):

  sdshdr8 结构布局:
  ┌────────┬────────┬────────┬──────────────┬───┐
  │  len   │ alloc  │ flags  │  buf[...]    │\0 │
  │ 1字节  │ 1字节  │ 1字节  │  len+1字节   │   │
  └────────┴────────┴────────┴──────────────┴───┘
   ← 3字节头部 →                  ← 数据区 →

  len   : 当前已使用的字节数(不含 \0)
  alloc : 总容量(不含头部和 \0 终止符)
  flags : 低 3 位标识 SDS 类型,高 5 位未使用
  buf[] : 柔性数组,实际存储字符串数据

C 伪代码结构定义:

// 注:以 sdshdr8 为例,实际 Redis 中有 5 种变体

struct sdshdr8 {
    uint8_t  len;      // 已使用长度(最大 255)
    uint8_t  alloc;    // 总分配容量(最大 255,不含头部与 \0)
    unsigned char flags; // 低 3 位为类型标记 (SDS_TYPE_8 = 2)
    char     buf[];    // 柔性数组 (flexible array member)
};

2.2.2 五种 SDS 类型

Redis 为了极致节省内存,按字符串长度设计了 5 种不同的 SDS 头部:

类型 sdshdr5 sdshdr8 sdshdr16 sdshdr32 sdshdr64
len 占用 5位(在flags中) 1字节 2字节 4字节 8字节
alloc占用 1字节 2字节 4字节 8字节
头部大小 1字节 3字节 5字节 9字节 17字节
最大容量 32字节 255字节 65535字节 ~4GB 极大
使用场景 极小字符串 ≤255B短串 ≤64KB 中长串 巨型串
// sdshdr16 结构(字符串 ≤ 64KB 时使用)
struct sdshdr16 {
    uint16_t len;         // 2字节:已用长度
    uint16_t alloc;       // 2字节:总容量
    unsigned char flags;  // 1字节:低3位 = SDS_TYPE_16 (3)
    char buf[];
};

// sdshdr32 结构(字符串 ≤ 4GB 时使用)
struct sdshdr32 {
    uint32_t len;         // 4字节
    uint32_t alloc;       // 4字节
    unsigned char flags;  // 1字节:低3位 = SDS_TYPE_32 (4)
    char buf[];
};

2.2.3 设计哲学:按需选型

字符串长度          →    自动选择的SDS类型
────────────────────────────────────────────
≤ 31 字节           →    sdshdr5(头部仅1字节,len复用在flags中)
≤ 255 字节          →    sdshdr8(头部3字节)
≤ 65535 字节 (64KB) →    sdshdr16(头部5字节)
≤ 4GB               →    sdshdr32(头部9字节)
> 4GB               →    sdshdr64(头部17字节)

💡 为什么这样设计? 如果所有字符串都用 uint64_t 存长度,每个字符串浪费 7 字节。在有千万 Key 的 Redis 实例中,这意味着上百 MB 的浪费。“短字符串用小头” 是 Redis 内存效率的核心哲学。

2.2.4 SDS 指针技巧

// Redis 操作字符串时使用这个宏来获取 SDS 头部指针
// s 是用户可见的 buf 指针(指向实际字符串数据)
#define SDS_HDR(s, type) ((struct sdshdr##type *)((s) - sizeof(struct sdshdr##type)))

// 例如:从 buf 指针获取 sdshdr8 的 len
static inline size_t sdslen(const sds s) {
    unsigned char flags = s[-1];                    // flags 在 buf 前面紧邻
    switch(flags & SDS_TYPE_MASK) {
        case SDS_TYPE_8:
            return SDS_HDR(8, s)->len;              // 回退到头部读取 len
        case SDS_TYPE_16:
            return SDS_HDR(16, s)->len;
        // ... 其他类型
    }
}

内存布局示意:

低地址                                             高地址
  ┌────────┬────────┬────────┬─────────────────────┐
  │  len   │ alloc  │ flags  │  buf (实际字符串)   │
  │        │        │        │  ▲                  │
  └────────┴────────┴────────┴──┼──────────────────┘
        ← 头部固定偏移 →    sds 指针指向这里 (buf 起始)

2.3 🔬 SDS 的优势

优势一:O(1) 获取长度

得益于 len 字段,sdslen(s) 直接读取,恒为 O(1)。对比 C 字符串需要遍历到 \0

优势二:二进制安全

SDS 不依赖 \0 判断结尾,而是通过 len 字段明确记录字符串长度。可以存储任意二进制数据,包括图片、序列化的 Protobuf 等。

// SDS 可以存储含有 \0 的数据
sds bin_data = sdsnewlen("\x00\x01\x02\x00\x03", 5);
// sdslen(bin_data) == 5,不受中间 \0 影响

优势三:预分配空间(Pre-allocation)

减少内存重分配次数,具体策略如下:

新长度 newlen  →  分配策略
──────────────────────────────────────────
newlen < 1MB   →  alloc = newlen * 2 + 1
                    (翻倍 + 1字节存\0)
newlen ≥ 1MB   →  alloc = newlen + 1MB + 1
                    (额外增加1MB)

示例:

// 假设当前 SDS 是 "hello" (len=5, alloc=5)
// 追加 " world" (6字节), newlen = 11
// 11 < 1MB → alloc = 11 * 2 + 1 = 23
// 实际分配 23 字节,使用 11 字节,剩余 12 字节预留

性能效果:连续追加 N 次小数据时,内存重分配次数从 O(N) 降至 O(log N)。

优势四:惰性空间释放(Lazy Free)

缩短字符串时不会立即 realloc 回收多余空间,而是只更新 len。预留空间可用于后续增长操作,避免频繁重分配。

// sds sdstrim(sds s, const char *cset) 裁剪后的行为
// s = "hello   "    len=8  alloc=16
// sdstrim(s, " ")   →  s = "hello"   len=5  alloc=16
//                       alloc 不变!预留 11 字节待用

优势五:兼容 C 字符串函数

SDS 的 buf 末尾始终保留 \0(由 alloc + 1 保证),因此可以安全地传给 printfstrlen 等 C 标准库函数。

sds s = sdsnew("hello");
printf("%s\n", s);      // ✅ 正常工作,因为 buf 末尾有 \0
// 同时也保留 len 字段,支持二进制安全操作

2.4 SDS 主要操作

操作 含义 时间复杂度 说明
sdsnew() 创建 SDS O(n) 复制 C 字符串
sdsnewlen() 创建指定长度 SDS O(n) 可含 \0,二进制安全
sdsempty() 创建空 SDS O(1) len=0, alloc=0
sdsfree() 释放 SDS O(1)
sdslen() 获取长度 O(1) 直接读 len 字段
sdsavail() 获取可用空间 O(1) alloc - len
sdscat() 追加 C 字符串 O(n) 自动扩容
sdscatlen() 追加指定长度数据 O(n) 二进制安全追加
sdscpy() 复制覆盖 O(n) 尽可能复用已有空间
sdstrim() 去除首尾字符 O(n*m) 惰性释放空间
sdsrange() 截取子串 O(n) 惰性释放
sdscmp() 比较两个 SDS O(n)
sdssplitlen() 分割 SDS O(n*m) 返回 SDS 数组
sdscatvprintf() 格式化追加 O(n) 类似 vprintf
sdsupdatelen() \0 更新 len O(n) strlen 后更新 len
sdsMakeRoomFor() 确保容量 O(1)或O(n) 扩容核心逻辑

sdsMakeRoomFor —— 扩容核心逻辑

sds sdsMakeRoomFor(sds s, size_t addlen) {
    size_t avail = sdsavail(s);
    if (avail >= addlen) return s;   // 空间足够,无需扩容

    size_t len = sdslen(s);
    size_t newlen = len + addlen;

    // 🔑 预分配策略
    if (newlen < SDS_MAX_PREALLOC)   // SDS_MAX_PREALLOC = 1MB
        newlen *= 2;                  // 翻倍
    else
        newlen += SDS_MAX_PREALLOC;   // 加 1MB

    // 根据 newlen 选择合适类型的 SDS
    int oldtype = sdsReqType(len);
    int newtype = sdsReqType(newlen);

    if (oldtype == newtype) {
        // 类型没变,直接 realloc
        s = sds_realloc(sh, sizeof(struct sdshdr##newtype) + newlen + 1);
    } else {
        // 类型升级(如 sdshdr8 → sdshdr16),重新分配并迁移
        s = sds_malloc(sizeof(struct sdshdr##newtype) + newlen + 1);
        memcpy(s, old_buf, len + 1);
        sds_free(old_sh);
    }
    // ... 更新 alloc 等字段
}

3. IntSet(整数集合)

IntSet 是 Redis 为 纯整数 Set 设计的高效紧凑存储结构。

3.1 🔬 结构定义

IntSet 内存布局:
┌──────────┬──────────┬──────────────────────────────────┐
│ encoding │  length  │         contents[]               │
│  4字节   │  4字节   │    (length * 编码宽度)字节       │
└──────────┴──────────┴──────────────────────────────────┘
 ↑                    ↑
 头部(8字节)         柔性数组(按encoding宽度解读)
struct intset {
    uint32_t encoding;    // 编码类型
    uint32_t length;      // 元素个数
    int8_t   contents[];  // 实际数据(柔性数组,注意:声明为int8_t但实际按encoding解释)
};

🚨 contents 声明为 int8_t[] 只是为了占位,实际通过 encoding 字段按不同宽度访问,不是真的存 int8_t

3.2 编码类型

编码常量 元素范围 每元素宽度
INTSET_ENC_INT16 2 [-32768, 32767] 2 字节
INTSET_ENC_INT32 4 [-2^31, 2^31 - 1] 即约±21亿 4 字节
INTSET_ENC_INT64 8 [-2^63, 2^63 - 1] 8 字节
// 获取 IntSet 中第 pos 个元素的值
// 关键是 (int64_t*) 强制转换:contents 按 encoding 指定的宽度解读
static int64_t _intsetGetEncoded(intset *is, int pos, uint8_t enc) {
    if (enc == INTSET_ENC_INT64) {
        return ((int64_t*)is->contents)[pos];      // 按 8 字节读
    } else if (enc == INTSET_ENC_INT32) {
        return ((int32_t*)is->contents)[pos];      // 按 4 字节读
    } else {
        return ((int16_t*)is->contents)[pos];      // 按 2 字节读
    }
}

3.3 🔬 编码升级(Upgrade)

3.3.1 何时触发

当插入的新元素超出当前编码范围时触发升级,不可逆。

当前 encoding = INTSET_ENC_INT16
当前元素:[10, 20, 30]    每个占 2 字节,共 6 字节

插入 100000(超出 [-32768, 32767]) → 触发升级到 INTSET_ENC_INT32

3.3.2 升级过程(分步详解)

假设 IntSet 当前状态:
  encoding = INTSET_ENC_INT16
  length = 3
  contents: [10, 20, 30]   (每个2字节)

插入元素 100000 (int32)

Step 1: 计算新编码所需空间
  new_encoding = INTSET_ENC_INT32 (每元素4字节)
  new_size = 8(头部) + (3+1)*4 = 24 字节

Step 2: 重新分配内存
  ┌──┬──┬──┬──┬──┬──┐                  realloc后:
  │10│20│30│         │   ──────────→   │10│20│30│  │  │  │  │
  └──┴──┴──┴──┴──┴──┘                  └──┴──┴──┴──┴──┴──┴──┘
   旧空间(每个2B)                          新空间(每个4B,有空洞)

Step 3: 从后向前移动元素(避免覆盖)
  位置 pre_idx=2 (原值30):
    _intsetSet(is, 2, 30)     → contents[8..11] = 30  (int32格式)
  位置 pre_idx=1 (原值20):
    _intsetSet(is, 1, 20)     → contents[4..7]  = 20  (int32格式)
  位置 pre_idx=0 (原值10):
    _intsetSet(is, 0, 10)     → contents[0..3]  = 10  (int32格式)

Step 4: 插入新元素 100000
  contents[12..15] = 100000

最终:
  encoding = INTSET_ENC_INT32
  length = 4
  contents: [10, 20, 30, 100000]   (每个4字节,总数据区16字节)

从后向前移动 是升级算法的关键——如果从前向后,第二步写入就会覆盖掉未处理的原始数据。

3.3.3 为什么不可逆?

// 伪代码:为什么不降级
void intsetRemove(intset *is, int64_t value) {
    // ... 删除元素后
    // ❌ 缺失的逻辑:
    // if (当前最大元素可以用 int16 表示) {
    //     降级到 INTSET_ENC_INT16  // Redis 没有这个代码
    // }
}

降级不可逆的原因:

  1. 判断成本:每次删除都要检查当前最大元素是否适合更小编码 → 需要遍历整个集合
  2. 收益低:编码升级后元素数量通常已经较多,降级节省的空间有限
  3. 设计简单:避免"降级→再升级"的抖动

🚨 陷阱:假设 Set 中有 500 个 int16 范围的整数(每元素 2 字节),突然插入 1 个 int64 范围的大整数,所有 501 个元素全部变成 8 字节。内存从 500×2=1KB 暴涨到 501×8≈4KB,翻了 4 倍。

3.4 IntSet 特点总结

特性 说明
有序性 元素按升序排列,支持二分查找 O(log n)
唯一性 保证不重复
内存布局 连续存储,无指针开销,缓存友好
查找 二分查找 O(log n)
插入/删除 O(n)(需要移动元素)
适用条件 Set 类型中元素全为整数,且数量 ≤ set-max-intset-entries(默认 512)

4. Dict(字典 / 哈希表)

Dict 是 Redis 中使用最广泛的数据结构之一。全局键空间、过期键字典、Hash 类型的 HT 编码、Set 类型的 HT 编码都基于 Dict。

4.1 🔬 结构定义

三层结构:dict(顶层) → dictht(哈希表) → dictEntry(键值对节点)

dict
 ┌──────────────┐
 │ type         │──→ dictType (函数指针表)
 │ privdata     │    特定类型的私有数据
 │ ht[0]        │──→ dictht (主哈希表)
 │ ht[1]        │──→ dictht (rehash 目标表,平时为空)
 │ rehashidx    │    -1: 未rehash; ≥0: rehash进行中
 │ iterators    │    安全迭代器计数
 └──────────────┘

dictht
 ┌──────────────┐
 │ table        │──→ [ * → * → * → * → ... ]  (桶数组,每个桶是链表头)
 │ size         │    桶数量 = 2^n
 │ sizemask     │    size - 1 (用于 & 取模)
 │ used         │    已使用桶数量
 └──────────────┘

dictEntry
 ┌───────────┬───────────┬──────────┐
 │ key       │ v{union}  │ next     │──→ 下一个 dictEntry (或 NULL)
 └───────────┴───────────┴──────────┘

C 伪代码:

// 顶层结构
struct dict {
    dictType   *type;       // 类型特定函数(hash, keyCompare, keyDup, valDup, keyDestructor, valDestructor)
    void       *privdata;   // 类型特定私有数据
    dictht      ht[2];      // 两个哈希表,ht[1] 仅在 rehash 时使用
    long        rehashidx;  // rehash 进度,-1 表示不在 rehash
    int16_t     pauserehash; // 暂停 rehash 标志 (>0 时暂停)
};

// 哈希表
struct dictht {
    dictEntry **table;      // 桶数组指针 (数组元素是链表头指针)
    unsigned long size;     // 桶数量 (始终为 2^n)
    unsigned long sizemask; // size - 1,用于计算桶索引: hash & sizemask
    unsigned long used;     // 已有节点数
};

// 键值对节点
struct dictEntry {
    void *key;              // 键
    union {                 // 值(联合体减少内存)
        void     *val;
        uint64_t  u64;
        int64_t   s64;
        double    d;
    } v;
    struct dictEntry *next; // 下一个节点 (链地址法解决冲突)
};

可视化——一个 size=4 的哈希表:

table:
  [0] ──→ ┌───────────┐    ┌───────────┐
          │ key="foo" │───→│ key="bar" │───→ NULL
          │ val=1     │    │ val=2     │
          └───────────┘    └───────────┘
  [1] ──→ NULL
  [2] ──→ ┌───────────┐
          │ key="baz" │───→ NULL
          │ val=3     │
          └───────────┘
  [3] ──→ NULL

size=4, sizemask=3 (0b11), used=3
hash("foo") & 3 = 0
hash("bar") & 3 = 0  ← 冲突,头插到桶[0]链表
hash("baz") & 3 = 2

4.2 哈希算法

Redis 使用两种哈希算法:

算法 特点 使用版本
MurmurHash2 速度快、分布均匀 早期默认
SipHash 安全性更好(防 HashDos 攻击) Redis 5.0+ 默认

哈希种子:Redis 启动时随机生成一个哈希种子,每次计算哈希时混入种子值。这使得攻击者无法预测键的哈希分布,防止 HashDos(哈希碰撞拒绝服务攻击)。

// 简化的哈希计算逻辑
uint64_t dictGenHashFunction(const void *key, int len) {
    // siphash(key, len, dict_hash_function_seed)
    return siphash_nocase(key, len);
}

// 计算桶索引
idx = hash(key) & ht[0].sizemask;   // sizemask = size-1 = 2^n-1

4.3 🔬 渐进式 Rehash(Progressive Rehash)

这是 Dict 设计中最精妙的部分,也是 Redis 能在重负载下保持低延迟的关键。

4.3.1 为什么需要渐进式

假设一个 1 亿 Key 的 Redis 实例。如果一次性 rehash,需要:

  • 分配新哈希表 (~数 GB)
  • 重新计算 1 亿次哈希
  • 拷贝 1 亿个 dictEntry

这个过程可能耗时数秒,Redis 在这段时间内完全阻塞,所有客户端请求超时。

渐进式 rehash 的核心思想:把大象切成小块,一口一口吃。

4.3.2 过程详解

初始状态:
  ht[0]       ┌─────────────────────────────┐
              │ size=4, used=3, sizemask=3  │
              │ [0]→key_a→key_b→NULL        │
              │ [1]→NULL                    │
              │ [2]→key_c→NULL              │
              │ [3]→NULL                    │
              └─────────────────────────────┘
  ht[1]       NULL
  rehashidx = -1

Step 1 — 分配 ht[1]:
  计算 ht[1].size = 大于 ht[0].used 的最小 2^n
  即:> 3 的最小 2^n = 4 (实际不会缩容,假设这是缩容场景)
  正常扩容: > (3*2) 的最小 2^n = 8

  ht[1] = ┌─────────────────────────────┐
          │ size=8, used=0, sizemask=7  │
          │ [0]→NULL [1]→NULL ...       │
          │           [7]→NULL          │
          └─────────────────────────────┘
  rehashidx = 0

Step 2 — 渐进迁移:
  每次对 dict 执行增/删/改/查时,额外迁移 rehashidx 指向的桶:

  ┌─────────────────────────────────────────────────────┐
  │              rehashidx=0,迁移 ht[0].table[0]        │
  │                                                     │
  │  ht[0].table[0] 有 key_a, key_b                     │
  │  重新计算: hash(key_a) & 7 = 3 → ht[1].table[3]     │
  │            hash(key_b) & 7 = 5 → ht[1].table[5]     │
  │                                                     │
  │  迁移后: ht[0].table[0] = NULL             │
  │          ht[0].used = 1                    │
  │          ht[1].used = 2                    │
  │          rehashidx = 1                     │
  └─────────────────────────────────────────────────────┘

  下次操作时迁移 rehashidx=1 的桶,以此类推...

Step 3 — 定时器辅助迁移:
  即使没有客户端操作,Redis 的 serverCron 也会每 1ms 执行一次 dictRehashMilliseconds(1),
  每次迁移最多 100 个桶。防止长期没有操作时 rehash 停滞。

Step 4 — 完成:
  当 ht[0].used == 0 时,rehash 完成:
    ht[1] → ht[0]     (交换指针)
    释放旧 ht[0]       (原 ht[0] 的内存)
    ht[1] = NULL
    rehashidx = -1

4.3.3 Rehash 期间的操作规则

操作      期间行为
───────────────────────────────────────────
查找      先查 ht[0],未命中再查 ht[1]
插入      只写入 ht[1]
删除      在两个表中都尝试删除
更新      先查 ht[0],命中则更新;未命中查 ht[1] 并更新

这样保证了新增数据逐渐流向新表,而旧表只减不增。

4.4 Rehash 触发条件

扩容条件

// 负载因子 = ht[0].used / ht[0].size

// 条件一:无后台进程时
if (load_factor >= dict_force_resize_ratio) {  // dict_force_resize_ratio = 1
    dictExpand(d, ht[0].used * 2);
}

// 条件二:有后台进程(BGSAVE / BGREWRITEAOF)时
if (load_factor >= dict_force_resize_ratio) {  // = 5
    dictExpand(d, ht[0].used * 2);
}

💡 考点:为什么有 BGSAVE 时把阈值从 1 提高到 5?

BGSAVE 依赖 fork() 创建子进程。fork() 使用 Copy-On-Write (COW) 机制——父子进程共享同一块物理内存,直到某方修改时才复制。如果在 BGSAVE 期间 rehash,大量内存页被修改,触发大量 COW 复制,导致内存翻倍。阈值提高到 5 是为了延迟 rehash,减少 COW 开销

缩容条件

// used / size < HASHTABLE_MIN_FILL = 0.1
// 即:大量桶是空的,负载极低
if (used * 100 / size < HASHTABLE_MIN_FILL) {  // < 10%
    dictResize(d);  // size 缩小到大于 used 的最小 2^n
}

4.5 特点总结

特性 说明
冲突解决 链地址法(Chaining)
插入方式 头插法(O(1),天然 LRU 语义)
扩容 渐进式 rehash,不阻塞服务
缩容 条件触发,同样渐进式
查找 平均 O(1),最坏 O(n)(极端冲突时)
删除 O(1)(已知桶索引和链表节点)

5. ZipList(压缩列表)

ZipList 是 Redis 为小数据量场景设计的内存紧凑结构,Hash/List/ZSet 在小数据时都会使用它。

⚠️ Redis 7.0 起,ZipList 被 Listpack 全面替代。但理解 ZipList 是理解 Listpack 的前提,也是面试高频考点。

5.1 🔬 整体结构

ZipList 内存布局:
┌──────────┬──────────┬──────────┬───────┬───────┬───────┬──────────┐
│ zlbytes  │ zltail   │ zllen    │entry1 │entry2 │entry3 │ zlend    │
│  4字节   │  4字节   │  2字节   │       │       │       │  1字节   │
└──────────┴──────────┴──────────┴───────┴───────┴───────┴──────────┘
                ←────── 头部(10字节) ──────→    ↑            ↑
                                           entries      0xFF 结束
字段 大小 含义
zlbytes 4 字节 整个 ZipList 占用的字节总数(含自身)
zltail 4 字节 最后一个 entry 的起始偏移量(支持快速尾部操作)
zllen 2 字节 entry 数量(超过 65535 时需要遍历才能得到准确值)
zlend 1 字节 固定值 0xFF,标记结束

5.2 🔬 Entry 结构

每个 entry 由三部分组成:

Entry 结构:
┌──────────┬───────────┬─────────────────┐
│ prevlen  │ encoding  │ entry-data      │
│ 1或5字节 │ 1/2/5字节 │ 变长            │
└──────────┴───────────┴─────────────────┘

5.2.1 prevlen(前驱长度)

记录了前一个 entry 的总长度,用于从后向前遍历。

前驱长度 < 254 字节:
  ┌──────────┐
  │ prevlen  │ = 前驱长度值 (1字节, 0x00~0xFD)
  └──────────┘

前驱长度 ≥ 254 字节:
  ┌──────┬─────────────────────────┐
  │ 0xFE │ 4字节前驱长度           │
  │1字节 │ (uint32_t, 小端)        │
  └──────┴─────────────────────────┘

5.2.2 encoding(编码信息)

encoding 字段同时记录了数据类型数据长度

字符串编码:
  00pp pppp                            6位存长度, ≤63字节
  01pp pppp qqqq qqqq                 14位存长度, ≤16383字节
  1000 0000 qqqq qqqq rrrr rrrr ...   大字符串, 后续4字节存长度

整数编码:
  1100 0000                            int16_t  (2字节)
  1101 0000                            int32_t  (4字节)
  1110 0000                            int64_t  (8字节)
  1111 0000                            24位有符号整数 (3字节)
  1111 1110                            int8_t   (1字节)
  1111 1111 0000 xxxx                  4位整数 (0~12), 编码中直接存储

5.3 🔬 连锁更新(Chain Update)—— ZipList 的阿喀琉斯之踵

5.3.1 问题描述

连锁更新是 ZipList 最经典的缺陷案例,也是 Listpack 诞生的直接原因。

5.3.2 触发场景

初始状态:所有 entry 长度都是 253 字节
ZipList: [entry1(253B)] [entry2(253B)] [entry3(253B)] ...
         prevlen=0        prevlen=1B      prevlen=1B
         (首节点)         (存前驱长度     (存前驱长度
                           253, 1字节)    253, 1字节)

现在修改 entry1,使其长度变为 254 字节:

Step 1: entry1 长度从 253→254
  现在 entry1 长度为 254

Step 2: entry2 的 prevlen 需要更新
  原来 prevlen=1字节(存253)
  现在 prevlen=5字节(0xFE+4字节存254)

  但 prevlen 从 1 字节变 5 字节,entry2 总长度从 253→257!

Step 3: entry3 的 prevlen 需要更新
  原来 prevlen=1字节(存253)
  现在 prevlen=5字节(0xFE+4字节存257)
  entry3 总长度也会变化... 连锁!

Step 4: 继续向后传播
  entry4, entry5, entry6 ... 全部跟着变化

图示:

更新前:
  entry1(253B)   entry2(253B)   entry3(253B)
  ┌────────┐ ┌┬──┴────────┐ ┌┬──┴────────┐
  │...data │ ││ p=1B│253B │ ││ p=1B│253B │ ...
  └────────┘ │└───────────┘ │└───────────┘
             长度=254(1B存)  长度=254(1B存)

更新后 (entry1 增长 1 字节):
  entry1(254B)    entry2(257B)    entry3(257B)    全部连锁!
  ┌────────┐ ┌┬───┴──────────┐ ┌┬───┴──────────┐
  │...data │ ││p=5B│253B     │ ││p=5B│253B     │ ...
  └────────┘ │└──────────────┘ │└──────────────┘
             长度=258(5B存)    长度=258(5B存)

5.3.3 影响分析

  • 时间复杂度:O(n^2) 最坏情况(每个 entry 更新都要重新分配整个 ZipList)
  • 空间开销:每次 prevlen 从 1B 变 5B,每个后续 entry 增加 4 字节
  • 实际风险:大量数据时会引起 Redis 明显卡顿

💡 缓解措施:通过配置限制 ZipList 的 entry 数量(hash-max-ziplist-entries 默认 512、zset-max-ziplist-entries 默认 128),让 ZipList 只在数据量小时存在,避免严重连锁更新。

5.4 ZipList 特点总结

特性 说明
优势 连续内存,无指针开销;缓存友好;适合小数据
劣势 操作 O(n);连锁更新;中间插入需移动后续元素
适用 Hash/List/ZSet 在小数据量时
致命缺陷 连锁更新
替代者 Listpack (Redis 7.0+)

6. Listpack(Redis 7.0+)

Listpack 是 ZipList 的继任者,设计目标只有一个:彻底消灭连锁更新

6.1 核心改进思路

ZipList 的连锁更新源于一个设计缺陷:后继节点记录了前驱的长度

Listpack 的修正:每个元素记录自己的长度,不再记录前驱长度。

ZipList 的遍历方式(导致连锁更新):
  从后向前: 读当前 entry 的 prevlen → 跳转到前一个 entry
  问题: prevlen 可能 1B 变 5B → 连锁

Listpack 的遍历方式(无连锁更新):
  从后向前: 读当前 element 末尾的 element_len → 知道当前 element 总长 → 跳转
  优势: element_len 只描述自己,修改不影响邻居

6.2 🔬 结构设计

Listpack 整体布局:
┌──────────┬──────────┬───────────┬─────┬───────────┬──────────┐
│tot-bytes │num-elem  │ element1  │ ... │ elementN  │ end-byte │
│  4字节   │  2字节   │           │     │           │  1字节   │
└──────────┴──────────┴───────────┴─────┴───────────┴──────────┘
字段 大小 含义
tot-bytes 4 字节 整个 Listpack 的总字节数
num-elem 2 字节 元素数量(超过 65535 时需遍历)
end-byte 1 字节 固定值 0xFF,标记结束

每个 Element 的结构:

element 结构:
┌───────────┬──────────────┬──────────────┐
│ encoding  │    data      │ element-len  │
│  变长     │    变长      │   变长       │
└───────────┴──────────────┴──────────────┘
                        ↑
              从后向前遍历时先读这里
  • encoding:与 ZipList 类似,记录数据类型和数据长度
  • data:实际数据
  • element-len:当前这个 element 的总长度(encoding + data + element-len 的总字节数)

element-len 的编码:

element_len 值范围      编码方式
────────────────────────────────────────
1 ~ 127                1字节直接存
128 ~ 16383            2字节: 最高位=1, 低7位+下一字节
16384 ~ 2097151        3字节
2097152 ~ 268435455    4字节
268435456 ~ ...        5字节

从后向前遍历的过程:

// 简化的反向遍历逻辑
unsigned char *lpPrev(unsigned char *lp, unsigned char *p) {
    // p 指向当前 element 的 encoding 起始位置
    // 需要跳到前一个 element 的 encoding 起始位置

    if (p - lp == LP_HDR_SIZE) {
        return NULL;  // 已经是第一个元素
    }

    // 读取 p 前面紧挨着的 element-len 字段
    // element-len 决定了前一个 element 的总大小
    uint64_t prev_len = lpDecodeBacklen(p);  // 从 p-1 开始向前解析

    // 跳转到前一个 element 的起始位置
    return p - prev_len;
}

6.3 与 ZipList 的对比

特性 ZipList Listpack
核心元数据 每个 entry 记录前驱长度 (prevlen) 每个 element 记录自身长度 (element-len)
连锁更新 存在——改一个 entry 可能触发全部后续更新 不存在——修改只影响自身
前向遍历 O(1)(读 prevlen 跳转) O(1)(读 element-len 跳转,但需要解析变长编码)
后向遍历 无直接支持 无直接支持
内存紧凑度 类似 类似(element-len 可能多占 1~2 字节)
复杂度 较复杂(prevlen 两种长度) 略复杂(element-len 变长编码)
Redis 版本 ≤ 6.x ≥ 7.0

🔬 设计哲学对比

ZipList:
  Entry 知道 "我是谁"  (encoding+data)
  Entry 也知道 "我前面是谁"  (prevlen)
  → 邻居耦合,修改会传播

Listpack:
  Element 知道 "我是谁"  (encoding+data)
  Element 也知道 "我有多长"  (element-len)
  → 自包含,修改不影响邻居

6.4 适用场景

Listpack 在 Redis 7.0+ 中替代 ZipList 用于:

  • Hash 小数据编码(替代 OBJ_ENCODING_ZIPLIST
  • ZSet 小数据编码(替代 OBJ_ENCODING_ZIPLIST
  • Stream 中的消费组等内部结构

7. QuickList(Redis 3.2+)

QuickList 是 Redis 3.2 引入的 List 类型默认编码,是一个精巧的混合结构。

7.1 设计动机:两个极端都不完美

方案 A: 纯双向链表 (LinkedList)
  [node1] ⇄ [node2] ⇄ [node3] ⇄ [node4]
  优势: 两端操作 O(1),无连锁更新
  劣势: 每个节点有 prev+next 指针 (16字节) + 内存分配器开销
        对于小元素 (如 int),指针开销远大于数据本身

方案 B: 纯 ZipList
  [entry1|entry2|entry3|...|entry100000]
  优势: 内存紧凑,无指针开销
  劣势: 操作 O(n),连锁更新风险,大列表操作极慢

方案 C: QuickList (折中)
  [Ziplist1] ⇄ [Ziplist2] ⇄ [Ziplist3] ⇄ [Ziplist4]
  优势: 结合两者优点——内存紧凑 + 分段降低连锁更新影响

7.2 🔬 结构设计

QuickList 整体:
┌───────────────────────────────────────────────────────────┐
│ quicklist                                                  │
│  head────┐                              ┌────tail         │
│  count   │  (总 entry 数)               │                 │
│  len=3   │  (节点数)                    │                 │
│  fill    │  (填充因子)                  │                 │
│  compress│  (压缩深度)                  │                 │
└──────────┼──────────────────────────────┼─────────────────┘
           ▼                              ▼
   ┌──────────────┐    ┌──────────────┐    ┌──────────────┐
   │quicklistNode │◄──►│quicklistNode │◄──►│quicklistNode │
   │ prev=NULL    │    │ prev ────────┼────│ prev ────────┼──...
   │ next ────────┼────│ next ────────┼────│ next=NULL    │
   │ zl(ziplist)  │    │ zl(ziplist)  │    │ zl(ziplist)  │
   │ sz(字节数)   │    │ sz           │    │ sz           │
   │ count        │    │ count        │    │ count        │
   │ encoding     │    │ encoding     │    │ encoding     │
   │ container    │    │ container     │    │ container    │
   │ recompress   │    │ recompress   │    │ recompress   │
   └──────┬───────┘    └──────┬───────┘    └──────┬───────┘
          ▼                   ▼                   ▼
   ┌──────────────┐    ┌──────────────┐    ┌──────────────┐
   │ ZipList 或   │    │ ZipList 或   │    │ ZipList 或   │
   │ Listpack     │    │ Listpack     │    │ Listpack     │
   │ (实际数据)   │    │ (实际数据)   │    │ (实际数据)   │
   └──────────────┘    └──────────────┘    └──────────────┘

C 伪代码:

struct quicklist {
    quicklistNode *head;        // 头节点
    quicklistNode *tail;        // 尾节点
    unsigned long  count;       // 所有 entry 总数
    unsigned long  len;         // quicklistNode 节点数
    int            fill : 16;   // 填充因子 (正数:最大entry数, 负数:最大字节)
    unsigned int   compress : 16; // 不压缩的端节点数 (LZF压缩深度)
    // ... bookmarks 等辅助字段
};

struct quicklistNode {
    quicklistNode *prev;        // 前驱节点
    quicklistNode *next;        // 后继节点
    unsigned char *zl;          // 指向内部的 ZipList/Listpack
    unsigned int   sz;          // zl 的字节数
    unsigned int   count : 16;  // 本节点内的 entry 数量
    unsigned int   encoding : 2; // 1=RAW(未压缩), 2=LZF(已压缩)
    unsigned int   container : 2; // 1=NONE, 2=ZIPLIST
    unsigned int   recompress : 1; // 曾是压缩节点 (临时解压标记)
    unsigned int   attempted_compress : 1; // 测试用
    unsigned int   extra : 10;  // 预留
};

7.3 配置参数

# redis.conf

# 每个 quicklistNode 包含的 ZipList 最大大小
# 负值:按字节数限制
#   -1 = 4KB,  -2 = 8KB,  -3 = 16KB,  -4 = 32KB,  -5 = 64KB
# 正值:按 entry 数量限制(最多该数量的 entry)
list-max-ziplist-size -2         # 默认: 每个节点最多 8KB

# 压缩深度
# 0 = 不压缩
# 1 = 两端各 1 个节点不压缩,中间节点压缩
# 2 = 两端各 2 个节点不压缩,中间节点压缩
# ...
list-compress-depth 0            # 默认: 不压缩

fill 参数详解

fill = -1 → 每个节点最多 4KB     (适合极小元素)
fill = -2 → 每个节点最多 8KB     (默认,通用场景)
fill = -3 → 每个节点最多 16KB    (适合偏大元素)
fill = -4 → 每个节点最多 32KB
fill = -5 → 每个节点最多 64KB    (适合大元素,接近纯 ZipList)
fill = N  → 每个节点最多 N 个entry (适合固定数量场景)

7.4 LZF 压缩

list-compress-depth > 0 时,QuickList 使用 LZF 算法压缩中间的节点:

compress-depth = 2:
                   不压缩              压缩              不压缩
               ┌─────┐ ┌─────┐ ┌──────────┐ ┌──────────┐  ...
  head ──────► │ N1  │ │ N2  │ │ N3(LZF)  │ │ N4(LZF)  │  ... ──► tail
               └─────┘ └─────┘ └──────────┘ └──────────┘
                  ↑       ↑                        ↑
              两端各2个不压缩                   中间全部压缩

设计思路:

  • 两端不压缩:List 最频繁的操作是 LPUSH/RPOP 等两端操作,不压缩保证速度
  • 中间压缩:中间节点访问频率低,牺牲一些 CPU 换取内存节省
  • LZF 压缩率约 50%,对文本类数据效果好

7.5 QuickList 的设计优点

优点 说明
内存效率 链表节点内的 ZipList/Listpack 消除大量指针开销
操作效率 两端操作 O(1),中间操作比纯 ZipList 快(分段后数据量小)
无连锁更新风险 每个节点的 ZipList 较小,即使发生连锁更新影响也有限
可配置 fill 和 compress-depth 允许按场景调优
中间压缩 LZF 压缩减少内存,两端不压缩保证热点数据访问速度

8. SkipList(跳表)

SkipList(跳跃表)是 ZSet 的核心数据结构之一,也是算法面试中的经典考点。

8.1 🔬 结构定义

跳表整体结构:

  header                                               tail
  ┌───┐  ┌─────┐      ┌─────┐      ┌─────┐      ┌─────┐  ┌────┐
  │   │  │     │      │     │      │     │      │     │  │    │
  │ L3│─►│     │─────►│     │─────►│     │─────►│     │  │NULL│
  │ L2│─►│     │─────►│     │─────►│     │─────►│     │  │NULL│
  │ L1│─►│     │──┐   │     │──┐   │     │──┐   │     │  │NULL│
  │ L0│─►│Node1│──┼──►│Node2│──┼──►│Node3│──┼──►│Node4│  │NULL│
  └───┘  │     │  │   │     │  │   │     │  │   │     │  └────┘
         │ ◄───┼──┘   │ ◄───┼──┘   │ ◄───┼──┘   │ ◄───┘
         │ bw  │      │ bw  │      │ bw  │      │ bw  │
         └─────┘      └─────┘      └─────┘      └─────┘
          score=1      score=3      score=5      score=7
          ele="a"      ele="b"      ele="c"      ele="d"

C 伪代码:

// 跳表
struct zskiplist {
    struct zskiplistNode *header;  // 头节点 (不存储实际数据)
    struct zskiplistNode *tail;    // 尾节点
    unsigned long length;          // 节点总数 (不含 header)
    int level;                     // 当前跳表的最大层数
};

// 跳表节点
struct zskiplistNode {
    sds ele;                       // 成员 (member),唯一
    double score;                  // 分值,可重复
    struct zskiplistNode *backward; // 后退指针 (仅第0层使用)
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 前向指针
        unsigned long span;            // 跨度 (到 forward 节点跨越的节点数)
    } level[];                     // 柔性数组 (每个节点层数不同)
};

关键字段解读:

  • score (double):排序依据,可以相同(相同时按 ele 字典序排)
  • ele (SDS):成员,必须唯一
  • backward:只在 L0 层使用,形成双向链表,支持逆序操作
  • span:跨度 = 从当前节点到同层 forward 节点之间跨越的节点数(含 forward 自身),用于 O(log n) 排名计算
  • level[]:柔性数组,不同节点的层数不同

8.2 为什么用跳表而不用红黑树

这是 Redis 面试的经典问题。答案涉及实现复杂度、功能需求和设计哲学三个层面:

维度 SkipList 红黑树
实现复杂度 简单 (~200行C代码) 复杂 (旋转、变色、大量边界情况)
范围查询 天然支持 O(log n + m) 需要中序遍历,不如跳表直觉
排名查询 span 字段 O(log n) 需要维护子树大小(增加复杂度)
并发友好 可分段加锁 旋转影响大范围节点
内存占用 平均 1.33 层/节点,每层 2 个指针 3 个指针 (left/right/parent) + color
平衡性 概率平衡,期望 O(log n) 严格平衡,保证 O(log n)

💡 Redis 的选择逻辑

“Keep It Simple” — Salvatore Sanfilippo (Redis 作者)

跳表在工程上足够好:概率平衡在实际负载下表现与红黑树相当,但代码量只有后者的 1/5,调试和维护成本极低。对于 Redis 这种追求极致稳定性的基础设施而言,简单 > 精妙。

8.3 🔬 层级生成算法

每个新节点的层数由随机算法生成,算法简单却精妙:

#define ZSKIPLIST_MAXLEVEL 32   // 最大 32 层
#define ZSKIPLIST_P 0.25        // 升级概率 1/4

int zslRandomLevel(void) {
    int level = 1;
    // 每次有 25% 的概率升级一层
    while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

层级分布(概率):

层级    概率          每多少个节点出现一次
─────────────────────────────────────────
L1      100%          1
L2      25%           4
L3      6.25%         16
L4      1.56%         64
L5      0.39%         256
...
L32     0.25^31 ≈ 2.3×10^-19   极其罕见

期望层数 = 1/(1-0.25) = 1.33 层/节点

也就是说,每个节点平均只有 1.33 层指针,内存开销非常低。

8.4 核心操作

8.4.1 查找过程

查找 score=5 的节点:

                  ┌───┐
   header L3 ───► │   │──────────────────────────► NULL
                  └───┘
                  ┌───┐         ┌─────┐
   header L2 ───► │   │───────► │Node3│─────────► NULL
                  └───┘         │sc=5 │
                                └─────┘
                  ┌───┐  ┌─────┐┌─────┐  ┌─────┐
   header L1 ───► │   │─►│Node1││Node2│─►│Node3│─► ...
                  └───┘  │sc=1 ││sc=3 │  │sc=5 │
                         └─────┘└─────┘  └─────┘
  步骤:
  1. 从 header 的最高层 L3 开始
  2. L3 forward 指向的目标 score=5 → 不大于 5 → 下降一层
  3. L2 forward → Node3(score=5) → 不大于 5? 不小于也不大于 → 当前就是!
  4. 如果跳到 score=7 说明走过了 → 下降一层重新找

伪代码:

zskiplistNode *zslFind(zskiplist *zsl, double score, sds ele) {
    zskiplistNode *x = zsl->header;
    for (int i = zsl->level - 1; i >= 0; i--) {
        // 在本层向前移动,直到下一个节点的 score 大于目标
        while (x->level[i].forward &&
               (x->level[i].forward->score < score ||
                (x->level[i].forward->score == score &&
                 sdscmp(x->level[i].forward->ele, ele) < 0)))
        {
            x = x->level[i].forward;
        }
    }
    // x 现在指向目标节点的前驱(或就是目标)
    x = x->level[0].forward;
    if (x && x->score == score && sdscmp(x->ele, ele) == 0)
        return x;
    return NULL;
}

8.4.2 插入过程

zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    // 1. update[] 数组:记录每层需要更新的前驱节点
    // 2. rank[] 数组:记录每层从头到 update[i] 的跨度
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL];
    unsigned long rank[ZSKIPLIST_MAXLEVEL];

    // 3. 从高层向低层查找插入位置
    x = zsl->header;
    for (i = zsl->level - 1; i >= 0; i--) {
        rank[i] = (i == zsl->level - 1) ? 0 : rank[i+1];
        while (x->level[i].forward &&
               (x->level[i].forward->score < score || ...)) {
            rank[i] += x->level[i].span;
            x = x->level[i].forward;
        }
        update[i] = x;  // 记录第 i 层的前驱
    }

    // 4. 随机生成新节点的层数
    level = zslRandomLevel();
    if (level > zsl->level) {
        // 新节点层数超过当前最大层 → 用 header 补齐
        for (i = zsl->level; i < level; i++) {
            rank[i] = 0;
            update[i] = zsl->header;
            update[i]->level[i].span = zsl->length;
        }
        zsl->level = level;
    }

    // 5. 创建新节点,逐层插入(类似链表插入)
    x = zslCreateNode(level, score, ele);
    for (i = 0; i < level; i++) {
        x->level[i].forward = update[i]->level[i].forward;
        update[i]->level[i].forward = x;

        // 更新 span
        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }

    // 6. 更新未涉及层的 span
    for (i = level; i < zsl->level; i++) {
        update[i]->level[i].span++;
    }

    // 7. 设置 backward 指针
    x->backward = (update[0] == zsl->header) ? NULL : update[0];
    if (x->level[0].forward)
        x->level[0].forward->backward = x;
    else
        zsl->tail = x;

    zsl->length++;
    return x;
}

8.4.3 删除过程

删除与插入对称:逐层更新 forward 指针,调整 span,更新 backward。

8.4.4 排名计算

// ZRANK 命令的实现核心
unsigned long zslGetRank(zskiplist *zsl, double score, sds ele) {
    zskiplistNode *x = zsl->header;
    unsigned long rank = 0;

    for (int i = zsl->level - 1; i >= 0; i--) {
        while (x->level[i].forward &&
               (x->level[i].forward->score < score || ...)) {
            rank += x->level[i].span;  // 累加跨度
            x = x->level[i].forward;
        }
    }

    if (x && x->score == score && sdscmp(x->ele, ele) == 0) {
        return rank;  // rank 就是从 header 到目标节点的跨度之和
    }
    return 0;
}

8.5 Span(跨度)的作用

span 不是"层级",而是跳跃的步数

示例:
  header ────────────────► Node3       (L2: span=3, 跳过 Node1, Node2)
  header ──► Node1 ──────► Node3       (L1: span=1, span=2)
  header ──► Node1 ──► Node2 ──► Node3 (L0: span=1, span=1, span=1)

  计算 Node3 的排名:
    从 header 的 L2 走到 Node3 → rank += 3 → 排名 = 3

  ZRANK key member3 → O(log n) 而不是 O(n)!

这就是 Redis 的 ZRANK / ZREVRANK 能 O(log n) 返回排名的原因。


9. RedisObject

RedisObject 是 Redis 所有数据类型的统一外壳,是理解 Key-Value 存储模型的基石

9.1 🔬 结构定义

struct redisObject {
    unsigned type:4;       // 外部类型 (5种: string/list/hash/set/zset)
    unsigned encoding:4;   // 内部编码 (10+种: int/embstr/raw/ziplist/ht/...)
    unsigned lru:24;       // LRU 时钟 (24位可存约194天) 或 LFU 信息
    int refcount;          // 引用计数
    void *ptr;             // 指向实际数据结构的指针
};
// 总大小: 4bit+4bit+24bit=32bit=4字节 + 4字节(refcount) + 8字节(ptr) = 16字节

内存布局(x86-64):

低位地址                                              高位地址
┌────────────────────────────────┬──────────┬────────────────┐
│ type:4 | encoding:4 | lru:24  │ refcount │ ptr (8字节)    │
│        4 字节                  │  4 字节  │   8 字节       │
└────────────────────────────────┴──────────┴────────────────┘
                            ←──── 共 16 字节 ────→

9.2 Type 与 Encoding 对照表

用户类型 (type) 内部编码 (encoding) 使用的数据结构 触发条件
OBJ_STRING (0) OBJ_ENCODING_INT (1) 直接存整数 (ptr 当整数用) 整数值(long范围)
OBJ_STRING (0) OBJ_ENCODING_EMBSTR (8) embstr编码SDS 字符串 ≤ 44 字节
OBJ_STRING (0) OBJ_ENCODING_RAW (0) 普通SDS 字符串 > 44 字节
OBJ_LIST (1) OBJ_ENCODING_QUICKLIST (9) QuickList 默认 (3.2+)
OBJ_HASH (4) OBJ_ENCODING_LISTPACK (11) Listpack field ≤ 512 且 value ≤ 64
OBJ_HASH (4) OBJ_ENCODING_HT (2) Dict 超过阈值
OBJ_SET (2) OBJ_ENCODING_INTSET (6) IntSet 全整数且数量 ≤ 512
OBJ_SET (2) OBJ_ENCODING_HT (2) Dict (value=NULL) 非整数或超阈值
OBJ_ZSET (3) OBJ_ENCODING_LISTPACK (11) Listpack 元素 ≤ 128 且 member ≤ 64
OBJ_ZSET (3) OBJ_ENCODING_SKIPLIST (7) SkipList + Dict 超过阈值

注:Redis 7.0 之前,OBJ_ENCODING_LISTPACK 对应的是 OBJ_ENCODING_ZIPLIST

9.3 🔬 EMBSTR vs RAW

这是 String 类型的两种 SDS 编码,区别在于内存分配方式:

EMBSTR (embedded string):
  ┌────────────────────────────────────────────┐
  │         一次 malloc 分配                    │
  │  ┌──────────────┬──────────────────────┐   │
  │  │ RedisObject  │  SDS(sdshdr8 + buf)  │   │
  │  │   (16字节)   │   (≤48字节)          │   │
  │  └──────────────┴──────────────────────┘   │
  └────────────────────────────────────────────┘
  总大小 = 16 + 3(sdshdr8头) + len + 1(\0) ≤ 64字节
  len ≤ 44字节 (64 - 16 - 3 - 1 = 44)

RAW (raw string):
  ┌─────────────────┐     ┌──────────────────────┐
  │   RedisObject   │     │  SDS(sdshdr + buf)   │
  │   (16字节)      │──→  │  (独立分配)          │
  │ malloc #1       │     │  malloc #2           │
  └─────────────────┘     └──────────────────────┘
  两次 malloc,内存不连续
特性 EMBSTR RAW
内存分配次数 1 次 2 次
内存释放次数 1 次 2 次
内存连续性 RedisObject 和 SDS 连续 不连续
缓存友好性 (一次缓存行加载) 差(两次间接访问)
可修改性 不可修改(只读) 可修改
适用场景 ≤44字节的短字符串 >44字节或有修改需求
修改行为 任何修改自动转为 RAW 直接在 SDS 上操作

EMBSTR 的限制:EMBSTR 的字符串不可变。任何修改操作(APPEND、SETRANGE 等)都会触发 EMBSTR → RAW 的转换,并重新分配内存。

// Redis 源码中的处理逻辑(简化)
void dbAdd(redisDb *db, robj *key, robj *val) {
    // ...
}
// 当对 EMBSTR 执行修改操作时:
// 1. 创建新的 RAW 编码的 RedisObject
// 2. 复制原 EMBSTR 的数据
// 3. 执行修改
// 4. 释放原 EMBSTR 对象

9.4 引用计数与内存共享

// Redis 启动时创建 0~9999 的整数共享对象
// 这些对象在全局可复用,避免重复分配
for (int j = 0; j < 10000; j++) {
    shared.integers[j] = createObject(OBJ_STRING, (void*)(long)j);
    shared.integers[j]->encoding = OBJ_ENCODING_INT;
}

🔬 为什么只共享 0~9999,不共享更大的范围或字符串?

共享对象的前提是:查找已有对象比创建新对象更快。对于整数,查找是 O(1)(直接数组索引)。对于字符串,需要在共享池中搜索匹配的字符串 → O(n) 在最坏情况下比直接分配还慢。Redis 选择了"简单 > 极致内存节省"。

9.5 🔬 编码转换规则详解

9.5.1 String 编码转换

INT ──────► RAW
  触发: APPEND 非数字内容
        INCRBYFLOAT
        任何修改操作

EMBSTR ───► RAW
  触发: APPEND, SETRANGE, SETBIT
        任何修改操作
  EMBSTR 是只读的,修改必须转 RAW

RAW ──/──► INT / EMBSTR
  不会自动转回 (没有降级逻辑)

9.5.2 Hash 编码转换

# 触发阈值 (redis.conf)
hash-max-listpack-entries 512   # field 数量 > 512 → 转 HT
hash-max-listpack-value 64      # 单个 value 长度 > 64 → 转 HT
Listpack ──────► Dict (HT)
  触发: field数量 > 512 或 value > 64字节
  方向: 单向,不可逆
  🚨 陷阱: 先写入大量数据触发转HT → 再删除大部分数据
         → 仍然使用HT,浪费大量内存

9.5.3 Set 编码转换

set-max-intset-entries 512       # 元素数量 > 512 → 转 HT
IntSet ──────► Dict (HT)
  触发: 元素数量 > 512 或 插入非整数元素
  方向: 单向,不可逆
  🚨 陷阱: SADD key "hello"  # 一个字符串让整个IntSet转HT
          内存: n*2字节 → n*24字节 (dictEntry) + RedisObject开销

9.5.4 ZSet 编码转换

zset-max-listpack-entries 128    # 元素数量 > 128 → 转 Skiplist
zset-max-listpack-value 64       # 单个 member 长度 > 64 → 转 Skiplist
Listpack ──────► Skiplist + Dict
  触发: 元素 > 128 或 member > 64字节
  方向: 单向,不可逆

9.6 🔬 为什么 ZSet 同时用 SkipList + Dict

这是 ZSet 的设计精髓:

查询类型                需要的结构
─────────────────────────────────────────
ZSCORE key member       → Dict (O(1) 查找 member 的 score)
ZRANK key member        → SkipList (O(log n) 排名)
ZRANGE key 0 10          → SkipList (范围遍历)
ZRANGEBYSCORE            → SkipList (按 score 范围查找)

所以 ZSet 同时维护:
  ┌──────────┐        ┌──────────────┐
  │  Dict    │        │  SkipList    │
  │          │        │              │
  │ member→score      │ score,member → 排序链表
  │ (哈希表) │        │ (跳表)       │
  └──────────┘        └──────────────┘
       ↑                    ↑
       └─── 同一份数据的两种索引 ───┘
// ZSet 的结构
struct zset {
    dict *dict;           // member → score 的映射
    zskiplist *zsl;       // score → member 的有序集合
};
  • 两个结构指向同一批 zskiplistNode 的内存(通过 dictEntry 的指针)
  • 插入/删除时同时更新两个结构(原子性由单线程保证)
  • 额外开销:dict 的键指针 + dictEntry 开销(约 24 字节/元素)
  • 收益:ZSCORE O(1)、ZRANK O(log n)、ZRANGE O(log n + m)

💡 为什么不只用跳表? 只用跳表的话 ZSCORE 是 O(log n)。Redis 认为 ZSCORE 是高频操作,值得用额外内存换取 O(1)。

9.7 OBJECT 命令

OBJECT ENCODING key       # 查看值的内部编码
# 返回: "int", "embstr", "raw", "listpack", "ht",
#        "quicklist", "intset", "skiplist"

OBJECT REFCOUNT key       # 查看引用计数 (通常为 1)

OBJECT IDLETIME key       # 查看自上次访问以来的秒数 (LRU模式)

OBJECT FREQ key           # 查看 LFU 访问频率计数

示例:

> SET small "hello"
OK
> OBJECT ENCODING small
"embstr"                        # ≤44字节

> SET big "this is a very long string that exceeds forty-four bytes"
OK
> OBJECT ENCODING big
"raw"                           # >44字节

> SET num 100
OK
> OBJECT ENCODING num
"int"                           # 整数范围内

> APPEND num "abc"
(integer) 6
> OBJECT ENCODING num
"raw"                           # 修改后 INT → RAW (不可逆)

10. 总结

10.1 五大类型对应的底层实现总表

用户类型 编码枚举 底层数据结构 触发条件 内存特征
String OBJ_ENCODING_INT (1) long long (ptr存值) 整数值 无额外分配
String OBJ_ENCODING_EMBSTR (8) SDS (embstr) ≤44字节字符串 一次分配,64B内
String OBJ_ENCODING_RAW (0) SDS (raw) >44字节或修改后 两次分配
Hash OBJ_ENCODING_LISTPACK (11) Listpack field≤512且val≤64B 紧凑连续
Hash OBJ_ENCODING_HT (2) Dict 超过阈值 指针多,开销大
List OBJ_ENCODING_QUICKLIST (9) QuickList (双向链表+Listpack) 默认 分段紧凑
Set OBJ_ENCODING_INTSET (6) IntSet 全整数且≤512 紧凑有序
Set OBJ_ENCODING_HT (2) Dict (val=NULL) 非整数或>512 指针开销
ZSet OBJ_ENCODING_LISTPACK (11) Listpack ≤128个且member≤64B 紧凑连续
ZSet OBJ_ENCODING_SKIPLIST (7) SkipList + Dict 超过阈值 双索引内存

10.2 数据结构选择决策树

需要快速 O(1) 查找?          → Dict (哈希表)
需要有序 + 范围查询?         → SkipList (跳表)
需要紧凑存储 + 数据量小?     → Listpack / ZipList
需要两端快速操作?            → QuickList (List类型)
纯整数集合 + 数量少?         → IntSet
紧凑字符串 ≤ 44字节?         → EMBSTR
大字符串 / 可修改?           → RAW (SDS)

10.3 🚨 编码相关陷阱速查表

陷阱 场景 后果 规避方法
编码不可逆 Hash大量写入触转HT后删除 内存永久膨胀 使用 CONFIG SET 调大阈值
IntSet升级 set中500个int16,新增1个int64 内存×4 避免混用大小整数
EMBSTR只读 对短字符串执行APPEND 转RAW,多一次分配 一次性SET代替APPEND
ZipList连锁更新 修改临界值(253→254)的entry O(n²)级卡顿 限制entry数量
ZSet双索引 数据量大时 内存翻倍 评估是否需要ZSCORE O(1)
BGSAVE时rehash fork后大量写入 COW导致内存翻倍 阈值从1提到5

10.4 内存估算速查

用于评估内存占用 —— 尤其是大量小 Key 的场景:

组件 大小 (x86-64) 说明
redisObject 16 字节 type(4b)+encoding(4b)+lru(24b)+refcount(4B)+ptr(8B)
dictEntry 24 字节 key(8B)+val(8B)+next(8B)
sdshdr8 3 字节 + data len(1B)+alloc(1B)+flags(1B)
sdshdr16 5 字节 + data len(2B)+alloc(2B)+flags(1B)
sdshdr32 9 字节 + data len(4B)+alloc(4B)+flags(1B)
sdshdr64 17 字节 + data len(8B)+alloc(8B)+flags(1B)
zskiplistNode ~32B + level×16B ele(8B)+score(8B)+backward(8B)+header开销
quicklistNode ~48 字节 不含内部 zl 数据
intset 8 字节 encoding(4B)+length(4B)
listpack 6 字节 tot-bytes(4B)+num-elem(2B)

🔬 小 Key 内存估算示例

一个简单的 SET key "hello":
  dictEntry(key="key")    = 24 字节
  dictEntry(val)          = 24 字节
  redisObject(key)        = 16 字节 (key 也是 redisObject)
  redisObject(val)        = 16 字节
  SDS(EMBSTR "hello")     = 16(redisObject)+3(sdshdr8)+5(data)+1(\0)=25 字节
                           但 embstr 是连续分配的: 总共 64 字节
                           (redisObject 16 + 剩余 48)
  总计 ≈ 24 + 24 + 16 + 64 = 128 字节 (存一个 "hello")

  实际数据 "hello" 只有 5 字节,但由于各种对象头和指针,
  总占用达到了 ~128 字节,开销比 > 25:1

  💡 这就是为什么 Redis 不适合存大量极小键值对——
     元数据开销可能远超数据本身。

10.5 设计哲学总结

Redis 底层数据结构的设计遵循几个核心原则:

  1. 内存至上:5 种 SDS 头部、IntSet 按需升级、EMBSTR 连续分配——每字节都精打细算
  2. 渐进式设计:rehash 不阻塞、SDS 预分配不频繁重分配——服务平稳优先
  3. Keep It Simple:跳表而不是红黑树、引用计数而不是 GC——实现简单才可靠
  4. 编码自适应:小数据用紧凑结构、大数据用高效结构——在时空之间动态平衡
  5. 不可逆设计:编码只升不降——复杂降级逻辑的风险 > 其内存收益

理解这些结构与原则,是真正掌握 Redis 性能调优与问题排查的基础。