Skip to content

Redis 底层数据结构

  Redis 的高性能不仅因为内存操作,更因为精心设计的底层数据结构。从 SDS 到跳表,从压缩列表到快速列表,每个结构都在时间和空间上做了极致优化。本章深入源码,剖析 Redis 的数据结构设计。

一、SDS(简单动态字符串)

1.1 为什么不用 C 字符串?

c
// C 原生字符串的问题
char *str = "hello";
strlen(str);  // O(n) 遍历,每次都要扫描

// 拼接操作
strcat(str1, str2);  // 如果 str1 空间不够 → 缓冲区溢出

1.2 SDS 结构

c
struct sdshdr {
    int len;     // 已使用长度
    int free;    // 剩余可用空间
    char buf[];  // 字节数组(柔性数组)
};

// 存储 "hello" 的 SDS
// len=5, free=0
// buf = ['h','e','l','l','o','\0']

1.3 SDS vs C 字符串

维度C 字符串SDS
获取长度O(n) 遍历O(1) 直接读 len
缓冲区溢出可能溢出自动扩容,不会溢出
内存分配N 次修改 N 次分配预分配 + 惰性释放
二进制安全不能存 \0用 len 判断结束,可存二进制
兼容性-兼容 C 字符串 API

空间预分配:

SDS 扩容时,不仅分配需要的空间,还会额外分配:

  len < 1MB:分配 len + len + 1(翻倍)
  len ≥ 1MB:分配 len + 1MB + 1

  减少内存分配次数,提升性能

惰性空间释放:

缩短字符串时,不立即释放空间,而是增加 free 值
需要时调用 SDS API 真正释放

二、Dict(字典)

2.1 结构

c
typedef struct dict {
    dictType *type;      // 类型特定函数
    void *privdata;      // 私有数据
    dictht ht[2];        // 两个哈希表(用于渐进式 rehash)
    int 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; } v;
    struct dictEntry *next;  // 链表法解决冲突
} dictEntry;

2.2 哈希冲突解决

链地址法:同一个槽位上的多个元素通过链表连接

  table[3] → entry1(key="a") → entry2(key="m") → NULL
                        ↑                  ↑
                   hash("a")%size=3  hash("m")%size=3

2.3 渐进式 Rehash

Redis 是单线程,如果一次性 rehash 全量数据,会阻塞服务。因此采用渐进式 rehash,分多次完成。

渐进式 Rehash 流程:

1. 为 ht[1] 分配空间(通常为 ht[0] 的两倍)
2. 设置 rehashidx = 0(开始 rehash)
3. 每次对字典的增删改查操作,顺带将 ht[0] 在 rehashidx 上的键值对迁移到 ht[1]
4. rehashidx++(指向下一个要迁移的槽)
5. 迁移完成后,ht[1] 变为 ht[0],释放旧 ht[0],rehashidx = -1

  ht[0](旧表)              ht[1](新表,2 倍大)
  ┌───┬───┬───┬───┐        ┌───┬───┬───┬───┬───┬───┬───┬───┐
  │   │   │   │   │        │   │   │   │   │   │   │   │   │
  └───┴───┴───┴───┘        └───┴───┴───┴───┴───┴───┴───┴───┘
    ↑ rehashidx=0
    逐步迁移

  查询时:先查 ht[0],再查 ht[1]
  新增时:直接写入 ht[1]

触发条件:

扩容:
  - 没有 BGSAVE/AOF 重写:负载因子 ≥ 1
  - 有 BGSAVE/AOF 重写:负载因子 ≥ 5

缩容:
  - 负载因子 < 0.1

  负载因子 = used / size

三、Skiplist(跳表)

3.1 为什么用跳表?

有序数据结构对比:

  ┌──────────┬──────────┬──────────┬──────────┐
  │          │ 查找     │ 插入     │ 删除     │
  ├──────────┼──────────┼──────────┼──────────┤
  │ 数组     │ O(log N) │ O(N)     │ O(N)     │
  │ 链表     │ O(N)     │ O(1)     │ O(1)     │
  │ 平衡树   │ O(log N) │ O(log N) │ O(log N) │
  │ 跳表     │ O(log N) │ O(log N) │ O(log N) │
  └──────────┴──────────┴──────────┴──────────┘

  跳表 vs 平衡树:
  ✅ 实现简单(不需要旋转)
  ✅ 范围查找快(找到起点后沿第 0 层遍历)
  ✅ 适合 ZSet 的范围查询(ZRANGE、ZRANGEBYSCORE)

3.2 跳表原理

普通链表(O(n)):
  1 ──→ 3 ──→ 5 ──→ 7 ──→ 9 ──→ 12 → NULL
  查找 12:遍历 6 次

跳表(O(log N)):
  层级
  4:  1 ──────────────────────→ 12     ← 高速通道
  3:  1 ──────────→ 7 ────────→ 12
  2:  1 ────→ 5 ──→ 7 ──→ 9 ──→ 12
  1:  1 → 3 → 5 → 7 → 9 → 12 → NULL    ← 原始链表

  查找 12:
  4 层:1 → 12(12 不大于 12,找到)
  查询 3 次

  查找 8:
  4 层:1 → 12 > 8,回退到 1
  3 层:1 → 7 < 8,前进
  3 层:7 → 12 > 8,回退到 7
  2 层:7 → 9 > 8,回退到 7
  1 层:7 → 9 > 8,回退到 7
  结果:8 不存在
  查询 5 次

3.3 Redis 跳表结构

c
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;   // 节点数量
    int level;              // 最大层数
} zskiplist;

typedef struct zskiplistNode {
    sds ele;                // 元素值
    double score;           // 分数
    struct zskiplistNode *backward;  // 后退指针
    struct zskiplistLevel {
        struct zskiplistNode *forward;  // 前进指针
        unsigned long span;             // 跨度(到下一个节点的距离)
    } level[];  // 柔性数组,每层一个
} zskiplistNode;

层数生成:

c
// 随机生成层数,概率分布
// 第 1 层:100%
// 第 2 层:25%
// 第 3 层:6.25%
// 第 4 层:1.56%
// ...

int zslRandomLevel() {
    int level = 1;
    while ((random() & 0xFFFF) < (0.25 * 0xFFFF))
        level += 1;
    return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

四、Ziplist(压缩列表)

4.1 结构

ziplist 内存布局:

  ┌────────┬────────┬────────┬────────┬─────┬────────┬────────┐
  │zlbytes │zltail  │zllen   │entry1  │ ... │entryN  │zlend   │
  │4 字节  │4 字节  │2 字节  │        │     │        │1 字节  │
  └────────┴────────┴────────┴────────┴─────┴────────┴────────┘

  zlbytes:整个 ziplist 占用的字节数
  zltail:最后一个 entry 的偏移量(用于从尾部遍历)
  zllen:entry 数量(超过 2^16-1 时需要遍历统计)
  zlend:结束标记 0xFF

每个 entry 的结构:
  ┌───────────┬───────────┬───────────┐
  │prevlen    │encoding   │data       │
  │前一个长度  │编码+长度   │实际数据    │
  └───────────┴───────────┴───────────┘

4.2 特点

优点:
  ✅ 连续内存,无指针,内存利用率高
  ✅ 利用 CPU 缓存,遍历效率高

缺点:
  ❌ 插入/删除时可能触发连锁更新(cascade update)
  ❌ 不能存储大量数据

适用场景:
  Hash 小对象(field < 512 且 value < 64 字节)
  ZSet 小集合(元素 < 128 且 value < 64 字节)
  List 小列表(3.2 之前)

4.3 连锁更新

问题:prevlen 字段受前一个 entry 长度影响

  entry1(250 字节)→ prevlen=1 字节
  entry2(250 字节)→ prevlen=1 字节
  entry3(250 字节)→ prevlen=1 字节

  如果 entry1 更新为 254 字节:
  entry2 的 prevlen 需要从 1 字节 → 5 字节 → entry2 长度增加
  entry3 的 prevlen 需要从 1 字节 → 5 字节 → entry3 长度增加
  ... 连锁反应

  这就是 Redis 限制 ziplist 大小的原因

五、Quicklist(快速列表)

5.1 结构

Redis 3.2+ 的 List 底层实现:

  quicklist = ziplist 的链表

  ┌──────────┐   ┌──────────┐   ┌──────────┐
  │quicklist │→  │quicklist │→  │quicklist │
  │  Node    │   │  Node    │   │  Node    │
  │ ┌──────┐ │   │ ┌──────┐ │   │ ┌──────┐ │
  │ │ziplist│ │   │ │ziplist│ │   │ │ziplist│ │
  │ │(8KB) │ │   │ │(8KB) │ │   │ │(8KB) │ │
  │ └──────┘ │   │ └──────┘ │   │ └──────┘ │
  └──────────┘   └──────────┘   └──────────┘

  每个 quicklist Node 包含一个 ziplist(默认最大 8KB)
  节点间通过链表连接

5.2 设计思想

问题:纯 ziplist 插入删除慢,纯 linkedlist 指针开销大
解决:quicklist = 两者结合

  ziplist 的好处:连续内存,无指针开销
  linkedlist 的好处:插入删除快,无连锁更新

  quicklist:将大 ziplist 切成多个小 ziplist
  ✅ 每个 ziplist 较小,避免连锁更新
  ✅ 节点间用链表连接,插入删除快
  ✅ 内存利用率高,指针开销可控

六、Intset(整数集合)

6.1 结构

c
typedef struct intset {
    uint32_t encoding;  // 编码方式:INTSET_ENC_INT16/32/64
    uint32_t length;    // 元素数量
    int8_t contents[];  // 元素数组(实际类型取决于 encoding)
} intset;

6.2 编码升级

当 Set 全是整数时,使用 intset 存储:

  Set {1, 2, 3} → encoding=INT16,每个元素 2 字节
  SADD 40000     → 40000 > INT16_MAX → 升级到 INT32
  → 分配新数组,复制旧数据,新元素追加

  升级后不可降级(即使删除大元素,仍保持 INT32)

七、数据结构与类型的对应关系

数据类型底层实现(小数据)底层实现(大数据)
StringSDSSDS
Listquicklistquicklist
Setintsetdict
ZSetziplistskiplist + dict
Hashziplistdict

转换阈值:

conf
# Hash
hash-max-ziplist-entries 512    # field 数量
hash-max-ziplist-value 64       # 单个 value 长度

# ZSet
zset-max-ziplist-entries 128    # 元素数量
zset-max-ziplist-value 64       # 单个元素长度

# Set
set-max-intset-entries 512      # 元素数量

八、面试要点

Q1:SDS 和 C 字符串相比有什么优势?

  O(1) 获取长度、自动扩容防溢出、预分配减少内存分配、惰性释放减少内存碎片、二进制安全(可存任意字节)。

Q2:跳表是如何实现的?

  多层索引的链表结构。每层节点按概率随机生成(第 1 层 100%,第 2 层 25%,逐层递减)。查找时从最高层开始,逐层下降,时间复杂度 O(log N)。

Q3:渐进式 Rehash 是什么?

  Redis 字典扩容时,不一次性迁移所有数据,而是分多次迁移,每次操作顺带迁移一部分。避免了单线程阻塞,保证服务不中断。

Q4:为什么 ZSet 同时用跳表和字典?

  跳表按 score 排序,支持范围查询(ZRANGEBYSCORE);字典按 member 查询分数,O(1) 获取 score。两者互补,同时维护。