Skip to content

底层数据结构 ​

Redis 对外暴露多种数据类型,对内则由一套底层数据结构支撑:SDS、quicklist、字典、跳表、整数集合、listpack。String、List、Hash、Set、ZSet 并不一一对应某个结构,Redis 会按数据类型、元素数量和内容选择不同表示,在内存占用与操作性能之间取舍。

1. SDS:动态字符串 ​

Redis 没有直接使用 C 语言的字符串(以 \0 结尾的字符数组),而是自建了 SDS(Simple Dynamic String)。

1.1 结构 ​

c
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len;       // 已用长度
    uint8_t alloc;     // 分配的总空间(不含头和 \0)
    unsigned char flags; // 类型标识(低3位表示sdshdr类型)
    char buf[];        // 字节数组
};

Redis 7.0 有5种 SDS 类型(sdshdr5/8/16/32/64),按字符串长度自动选择:

类型len 范围说明
sdshdr50~31最短,长度存 flags 高 5 位,无独立 len/alloc
sdshdr80~255最常用
sdshdr160~65535中等长度
sdshdr320~4GB长字符串
sdshdr64>4GB超长字符串

1.2 相对 C 字符串的优势 ​

能力C 字符串SDS
获取长度O(n),需遍历到 \0O(1),直接读 len
二进制安全遇到 \0 截断以 len 记录长度,可存任意字节
缓冲区溢出需手动检查长度按已分配容量判断是否扩容
内存管理调用方自行管理容量空间预分配 + 惰性释放

1.3 空间预分配 ​

SDS 扩容时会多分配一些空间,减少后续修改的内存分配次数:

txt
修改后 len < 1MB → alloc = len * 2(翻倍预分配)
修改后 len ≥ 1MB → alloc = len + 1MB(固定预分配 1MB)

惰性释放:缩短字符串时不立即回收空间,而是更新 len,等下次修改时再决定是否回收。

2. listpack(压缩列表替代) ​

Redis 7.0 用 listpack 替代了 ziplist。listpack 解决了 ziplist 的「连锁更新」问题。

2.1 ziplist 的连锁更新问题 ​

ziplist 每个节点的 previous_entry_length 记录前一个节点的长度(1字节或5字节)。当某个节点从短变长(跨越254字节阈值),后续所有节点的 previous_entry_length 都要从1字节扩展到5字节,可能引发连锁反应。

txt
连锁更新:节点A变长 → 节点B的prevlen扩展 → 节点B变长 → 节点C的prevlen扩展 → ...
最坏情况:O(n^2) 时间复杂度

2.2 listpack 的改进 ​

listpack 去掉了 previous_entry_length,每个节点记录自己的长度而非前一个节点的长度:

txt
listpack 节点:[encoding][data][self-len]

节点长度变化只影响自己,不再因前序节点长度变化引发级联扩展。不过 listpack 仍是连续内存,插入、删除时需要移动后续数据。

3. 链表与 quicklist ​

3.1 双向链表 ​

Redis 的 List 底层最初是双向链表:

c
typedef struct listNode {
    struct listNode *prev;
    struct listNode *next;
    void *value;
} listNode;

typedef struct list {
    listNode *head;
    listNode *tail;
    unsigned long len;
    // ... 迭代器、复制、释放函数
} list;

纯链表的问题:每个节点单独分配内存,指针开销大(prev+next 16字节/节点),内存碎片多。

3.2 quicklist:链表 + listpack ​

Redis 7.0 的 List 底层是 quicklist——一个双向链表,每个节点通常是一个连续内存的 listpack:

quicklist 是三层结构:quicklist(外层对象)→ quicklistNode(链表节点)→ listpack(连续内存存多个元素)。

c
typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;          // 所有 listpack 中元素总数
    unsigned long len;            // quicklistNode 数量
    int fill : 16;                // 节点大小上限(对应 list-max-listpack-size)
    unsigned int compress : 16;   // 中间节点压缩深度(对应 list-compress-depth)
} quicklist;

为什么要「链表 + listpack」混合? 纯链表的每个元素都单独 malloc 一个 listNode,指针开销大、内存碎片多;纯 listpack(一整块连续内存)虽然省内存,但中间插入、删除要移动后续所有元素,O(n)。quicklist 在两者之间取折中:链表便于高效执行两端操作,listpack 把一批元素塞进连续内存降低指针开销。

节点填满才分裂:向 List 追加元素时,先塞进当前尾部 listpack;塞不下(超过节点大小上限)才新建一个 quicklistNode。避免了纯链表那样每插一个元素就 malloc 一次。

bash
list-max-listpack-size -2   # 每个 listpack 节点最大 8KB(默认 -2)
list-compress-depth 0       # 中间节点 LZF 压缩深度(0=不压缩,默认)

中间节点压缩:List 最常用的是两端操作(LPUSH/LPOP/RPUSH/RPOP),中间元素很少被访问。list-compress-depth 指定两端各保留几个节点不压缩,其余中间节点用 LZF 压缩存储,进一步省内存;访问被压缩的节点时才解压。

4. 字典(dict) ​

字典是 Redis 最核心的结构——所有键值对都存在字典里。它是一个哈希表:用桶数组定位 key,用链表解决冲突,负载过高时通过渐进式 rehash 扩容。任何一次 GET / SET 都要先在这个字典里定位 key。

dict 同时承担三类职责:

职责实例说明
全局键空间redisDb.dict每个库的主字典,存全部 key → value
过期字典redisDb.expires独立 dict,存 key → 过期时间戳(见过期与淘汰)
类型底层编码Set / Hash 的 hashtable 编码元素多、非整数时,Set 和 Hash 内部就是一个 dict(见对象系统与编码)

注意区分两个名词:dict 是底层数据结构(dict.c 里的通用哈希表,用户不可见),Hash 是对外数据类型(HSET / HGET 命令族)。Hash 可能用 dict 作为底层编码,但并非必然——field 少、值小时用 listpack 编码(见对象系统与编码),此时底层不是 dict;反过来,redisDb.dict、redisDb.expires 都是 dict,却都不是 Hash 类型。

4.1 结构 ​

c
typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];          // 两个哈希表(rehash 用)
    long rehashidx;        // rehash 进度(-1 表示不在 rehash)
    // ...
} dict;

typedef struct dictht {
    dictEntry **table;     // 桶数组
    unsigned long size;    // 桶数量
    unsigned long sizemask; // size - 1,用于取模
    unsigned long used;    // 已存放的键值对数量
} dictht;

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;  // 链地址法解决冲突
} dictEntry;

核心设计:ht[2] 两个哈希表,正常时只用 ht[0],第二个表留作 rehash 期间存放迁移数据,平时闲置;rehashidx 记录迁移进度,为 -1 表示不在 rehash。这两个字段如何配合,见 3.4。

4.2 一次查找的流程 ​

以 GET user:1001 为例,dict 定位 key 的完整链路:

txt
1. 计算哈希值
   hash = dictType->hashFunction("user:1001")
   // 常规字符串键默认用 SipHash,把 key 映射成 64 位整数(dictType 可自定义)

2. 定位桶下标
   index = hash & sizemask
   // sizemask = size - 1,size 恒为 2 的幂
   // 因此 hash & sizemask 等价于 hash % size,但位运算更快

3. 取到桶内链表
   entry = ht[0].table[index]
   // 桶内是 dictEntry 单链表,链地址法解决哈希冲突

4. 遍历链表找 key
   先比指针,指针不同再比较内容
   // key == he->key 时直接命中(同一个对象)
   // 指针不同才调用 dictType 的键比较函数比较内容

SET 与 GET 共用这条链路,区别只在第 4 步之后:命中则更新 value,未命中则头插新节点。

两个细节值得注意:

为什么用 & sizemask 而不是 %? Redis 的桶数 size 永远是 2 的幂,hash & (size - 1) 与 hash % size 结果相同。

使用位运算可以避免运行时取模的计算开销。Redis 的 size 是运行时变量,编译器无法把 % 优化成 &,因此把桶数固定为 2 的幂、用 & 计算下标,既保证正确性,也避开了慢速除法。至于具体能快多少,取决于编译器和处理器,不必给出固定数字。

为什么先比指针、再比内容? dictEntry 里不存哈希值,遍历链表时先 key == he->key 判断是否同一个对象,相等直接命中;指针不同才调用 dictType 的键比较函数,字符串键才会比较内容。这样把昂贵的字符串比较次数降到最低。

4.3 为什么必须 rehash ​

rehash 表面是「把数据从 ht[0] 搬到 ht[1]」,背后是扩容、缩容、渐进式迁移三件事。

扩容。dict 用链地址法解决冲突,负载因子 = used / size(已存键值对数 / 桶总数)。当 used 远大于 size,每个桶挂的链表变长,查找从 O(1) 退化成 O(链表长度)。扩容让桶变多、链表变短,把查找拉回 O(1)。

缩容。大量 key 被删除后,桶多、数据少,负载因子掉到 0.1 以下,空桶浪费内存。缩容回收多余桶。

渐进式迁移。Redis 是单线程,若一次性把几百万个 key 从 ht[0] 搬到 ht[1],主线程会阻塞数百毫秒甚至数秒,期间无法响应任何命令。渐进式把「一次性大迁移」拆成「每次增删改查顺带搬一个桶」,降低单次迁移造成长时间停顿的风险。

4.4 渐进式 rehash 的实现 ​

ht[2] 双表让新旧数据在迁移期间并存,rehashidx 记录「搬到哪了」,迁移因此可以随时暂停、随时继续。

触发条件:

txt
负载因子 ≥ 1(没有后台持久化时)
负载因子 > 5(有 BGSAVE / BGREWRITEAOF 等后台持久化时,减少写时复制开销)
负载因子 < 0.1(缩容,以 dict.c 的 htNeedsResize 判断为准)

迁移步骤:

txt
1. 分配 ht[1](容量为 2 的幂,通常为 ht[0] 的两倍)
2. rehashidx = 0(开始迁移)
3. 每次对字典的增删改查,额外迁移 ht[0] 中 rehashidx 位置的一个桶
4. rehashidx++,直到所有桶迁移完毕
5. 释放 ht[0],ht[1] 变为 ht[0],新建空 ht[1]

迁移期间的查询与插入:

txt
查询 key:先查 ht[0],没找到再查 ht[1]
插入 key:直接插入 ht[1](新数据不进 ht[0])

渐进式 rehash 期间,字典同时使用两个哈希表:查找时两个表都查,插入时只进新表。这避免了单次全量迁移的长停顿,但每次操作仍会承担少量额外搬迁成本。

5. 跳表(skiplist) ​

跳表是 ZSet 的底层结构之一(当元素较多时),支持 O(log n) 的范围查询。

5.1 结构 ​

c
typedef struct zskiplistNode {
    sds ele;                  // 成员名
    double score;             // 分值
    struct zskiplistNode *backward; // 后退指针(用于逆序遍历)
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 前进指针
        unsigned long span;            // 跨度(用于计算排名)
    } level[];                // 柔性数组,层数随机
} zskiplistNode;

typedef struct zskiplist {
    zskiplistNode *header, *tail;
    unsigned long length;
    int level;                // 当前最大层数
} zskiplist;

span 支撑了 ZRANK / ZREVRANK 排名命令。如果没有 span,要算某个成员的排名只能从头逐个遍历计数,复杂度 O(n);有了 span,查找路径上累加各层跨度即可,复杂度 O(log n)。

以 ZRANK scores 40 为例,假设有序集合按分值升序排列,成员为 10、30、40、50。查找时从最高层向下跳,每跨过一个节点就把该层的 span 累加进排名,最终累加值就是目标成员的排名:

txt
40 前面有 10、30 两个成员,查找路径累加的 span 之和为 2
ZRANK 从 0 起算,因此 40 的排名为 2

span 之所以能支撑排名,是因为它记录的是「当前层从本节点到下一个节点之间跨越了多少个节点」,而非简单的指针距离。层数越低跨越越少,累加结果就是精确排名。

5.2 层高随机化 ​

每个节点的层数是随机的,概率为:

txt
P(level ≥ 1) = 1
P(level ≥ 2) = 1/4
P(level ≥ 3) = 1/16
...
P(level ≥ n) = 1/4^(n-1)

层数越高概率越低,形成「金字塔」结构——底层密集、高层稀疏,使跳表在通常情况下保持 O(log n) 的期望查找效率。

5.3 跳表 vs 红黑树 ​

维度跳表红黑树
查找O(log n)O(log n)
范围查询O(log n + k)(天然支持)需要中序遍历
实现复杂度简单复杂(旋转、染色)
内存指针较多指针较少

Redis 选择跳表的原因:实现简单、范围查询天然支持(ZRANGEBYSCORE)、比红黑树更容易维护。

延伸:跳表可视化动画

6. 整数集合(intset) ​

Set 有两种底层编码:元素多或含非整数时用 hashtable 编码,元素少且全是整数时用 intset。Redis 7.2 起,Set 还支持 listpack 编码,本节以 Redis 7.0 的实现为准。

intset 解决的是内存问题。hashtable 编码本质是 dict(见第 4 节),每存一个整数都要分配一个 dictEntry,含 key 指针、value、next 指针,单个整数就要数十字节开销。intset 用连续数组紧凑存储整数,没有指针、没有桶数组,在小整数集合场景下显著省内存。

6.1 结构 ​

c
typedef struct intset {
    uint32_t encoding;  // 元素编码:16/32/64 位
    uint32_t length;    // 元素数量
    int8_t contents[];  // 有序整数数组
} intset;

contents 声明为 int8_t 只是占位,实际每个元素的宽度由 encoding 决定:16 位时 2 字节、32 位时 4 字节、64 位时 8 字节。

intset 的三项设计:

特点作用
连续数组无指针、无链表,免去 hashtable 的指针和桶开销
自适应编码全是小整数时用 16 位而非 64 位,进一步压缩
有序查找可用二分 O(log n);插入、删除需移动后续元素

6.2 只升级,不降级 ​

intset 的编码升级是单向的:一旦因为存入大整数从 16 位升到 32 位(或 64 位),之后即使删掉那个大整数,编码也不会降回 16 位。

bash
SADD nums 1 3 5        # 全是小整数,encoding = 16 位
SADD nums 70000        # 超出 16 位范围,整体升级为 32 位
SREM nums 70000        # 删掉大整数后,encoding 仍是 32 位,不回退

编码只升不降,是因为降级需要把整个 contents 数组重新拷贝一遍(元素宽度从 4 字节缩成 2 字节),而 intset 只服务于「数量少」的集合——超过 set-max-intset-entries(默认 512)就会转成 hashtable。在元素数量有限时,降级省下的内存有限,却要付出一次全量拷贝的代价,因此 Redis 只升级、不降级。

7. 小结 ​

结构用途核心优势
SDSString 底层O(1) 长度、二进制安全、预分配
quicklistList 底层链表 + 连续内存,适合高效执行两端操作
dict所有键值对渐进式 rehash,避免单次长停顿
skiplistZSet 底层O(log n) 范围查询,实现简单
intsetSet(纯小整数)有序数组,自动升级编码
listpackHash/ZSet(小数据)连续内存,无连锁更新

📖 延伸阅读

Released under the MIT License.