🏗️ Redis数据结构底层实现:揭秘"快"的秘密

16 阅读11分钟

考察点: SDS、跳表、ziplist、intset、动态字符串、压缩列表、内存优化

🎬 开场:一个关于"工具箱"的故事

想象你是个木匠 🔨,有两个工具箱:

工具箱A(普通工具):

榔头、螺丝刀、锯子... 
能完成工作,但效率一般

工具箱B(专业工具):

电动锤钻、激光水平仪、气动射钉枪...
不仅能完成工作,而且又快又好!

Redis就像工具箱B,它没有直接用C语言的字符串、数组,而是自己实现了一套高性能的底层数据结构!这就是Redis快的秘密!⚡


第一部分:Redis数据结构全景图 🗺️

1.1 用户视角 vs 底层实现

用户看到的(5种)        底层实现(多种)
┌──────────┐          ┌────────────────┐
│  String  │ ────────→│ SDS / int / embstr│
│  字符串  │          └────────────────┘
└──────────┘

┌──────────┐          ┌────────────────┐
│   Hash   │ ────────→│ ziplist / hashtable│
│   哈希   │          └────────────────┘
└──────────┘

┌──────────┐          ┌────────────────┐
│   List   │ ────────→│ ziplist / linkedlist / quicklist│
│   列表   │          └────────────────┘
└──────────┘

┌──────────┐          ┌────────────────┐
│    Set   │ ────────→│ intset / hashtable│
│   集合   │          └────────────────┘
└──────────┘

┌──────────┐          ┌────────────────┐
│   ZSet   │ ────────→│ ziplist / skiplist + hashtable│
│ 有序集合 │          └────────────────┘
└──────────┘

关键点: Redis会根据数据量自动选择最优的底层结构!


第二部分:SDS(Simple Dynamic String)📝

2.1 为什么不用C字符串?

C语言字符串的问题:

char str[] = "hello";

问题1:获取长度O(n)
strlen(str)  // 要遍历到'\0'才知道长度

问题2:不能包含'\0'
char str[] = "hello\0world";  // 只能存储"hello"

问题3:缓冲区溢出
strcat(str, "world");  // 如果空间不够,直接溢出!

问题4:频繁重新分配内存
每次修改都可能需要realloc

2.2 SDS结构

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

示例:

存储 "Redis"
┌────────────────────────┐
│ len: 5                 │  ← 长度5
├────────────────────────┤
│ free: 5                │  ← 还有5字节空闲
├────────────────────────┤
│ buf: ['R','e','d','i', │
│      's','\0',' ',' ', │  ← 实际数组(10字节)
│      ' ',' ']          │
└────────────────────────┘

2.3 SDS的优势

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

// C字符串
int len = strlen(str);  // O(n) 要遍历

// SDS
int len = sds->len;     // O(1) 直接读取

优势2:二进制安全

// C字符串:遇到'\0'就结束
char str[] = "hello\0world";  // 只能存"hello"

// SDS:根据len判断,可以存任意二进制数据
SDS sds;
sds.len = 11;
sds.buf = "hello\0world";  // 完整存储11字节

应用场景: 存储图片、视频等二进制数据

优势3:避免缓冲区溢出

// C字符串:可能溢出
char str[10] = "hello";
strcat(str, "world");  // 如果空间不够,溢出!

// SDS:自动扩容
sdscat(sds, "world");
// 内部逻辑:
// 1. 检查 free 是否足够
// 2. 不够就先扩容(重新分配内存)
// 3. 再拼接字符串

优势4:减少内存重分配(空间预分配 + 惰性释放)

空间预分配:

修改前:
len=5, free=0, buf="Redis"

执行:sdscat(sds, " is fast")

修改后(预分配策略):
len=14, free=14, buf="Redis is fast"
                        ↑ 多分配了14字节空闲空间

原因:预判可能还会追加,提前分配,减少重新分配次数

预分配规则:

if (修改后len < 1MB) {
    free = len;  // 分配同样大小的空闲空间
} else {
    free = 1MB;  // 最多预分配1MB
}

惰性释放:

删除字符串:
len=14, free=0, buf="Redis is fast"

执行:sdstrim(sds, " is fast")

修改后(不立即释放内存):
len=5, free=9, buf="Redis         "9字节空闲,但不释放

好处:
- 如果后续又要追加,直接用 free 空间
- 减少内存分配次数
- 真需要释放时,调用 sds_clear()

2.4 SDS的变种(Redis 3.2+)

为了节省内存,Redis使用不同的SDS类型:

// 根据字符串长度选择不同类型

// sdshdr5: 长度 < 32字节
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags;  // 3位存储类型,5位存储长度
    char buf[];
};

// sdshdr8: 长度 < 256字节
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len;        // 1字节
    uint8_t alloc;      // 1字节
    unsigned char flags;
    char buf[];
};

// sdshdr16: 长度 < 65536字节
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len;       // 2字节
    uint16_t alloc;
    unsigned char flags;
    char buf[];
};

// sdshdr32、sdshdr64 类似

优点: 小字符串占用更少内存


第三部分:跳表(Skip List)🪜

3.1 为什么需要跳表?

场景: 有序集合(Sorted Set)需要:

  • 快速查找元素(O(log n))
  • 快速范围查询
  • 支持按分数排序

候选方案:

数据结构查找插入范围查询缺点
数组O(log n)O(n)O(n)插入慢
链表O(n)O(1)O(n)查找慢
红黑树O(log n)O(log n)O(n)实现复杂
跳表O(log n)O(log n)O(log n)✅ 完美!

3.2 跳表的结构

单层链表(普通链表):

查找 30,需要遍历 5 次
1 → 5 → 10 → 20 → 30 → 40 → 50
                    ↑ 找到了!

两层链表(加一层索引):

第2层(索引层):  1 ──────────→ 20 ──────────→ 40 ──────────→ 50
                       ↓              ↓              ↓
第1层(数据层):  1 → 5 → 10 → 20 → 30 → 35 → 40 → 45 → 50

查找 30:
1. 在第2层:1 → 20 → 40(发现40>30,往下走)
2. 在第1层:20 → 30(找到!)
只需 4 次比较!

多层索引(跳表):

第4层:1 ─────────────────────────────────────────────→ 50
            ↓                                          ↑
第3层:1 ──────────────────→ 30 ─────────────────────→ 50
            ↓                  ↓                       ↑
第2层:1 ──────→ 15 ────────→ 30 ──────→ 40 ────────→ 50
            ↓        ↓          ↓         ↓           ↑
第1层:1 → 5 → 10 → 15 → 20 → 30 → 35 → 40 → 45 → 50

查找 30:
1. 第4层:1 → 50(太大,往下)
2. 第3层:1 → 30(找到!)
只需 3 次比较!

3.3 Redis的跳表实现

// 跳表节点
typedef struct zskiplistNode {
    sds ele;                // 成员对象(字符串)
    double score;           // 分数
    struct zskiplistNode *backward;  // 后退指针
    struct zskiplistLevel {
        struct zskiplistNode *forward;  // 前进指针
        unsigned long span;             // 跨度(用于计算排名)
    } level[];  // 层级数组
} zskiplistNode;

// 跳表结构
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;  // 头尾指针
    unsigned long length;   // 节点数量
    int level;              // 最大层数
} zskiplist;

示例:

header
  |
  v
┌──────────────────────────────────────────────────┐
 L4: NULL                                         
 L3: ────────────────────> node3                  
 L2: ────────> node2 ────> node3 ────> node5      
 L1: node1 ─> node2 ─> node3 ─> node4 ─> node5   
└──────────────────────────────────────────────────┘
                                             
       分数1      分数2      分数3      分数4      分数5

3.4 跳表的操作

插入元素:

def insert(skiplist, score, member):
    # 1. 随机生成层数(1-32)
    level = random_level()  
    
    # 2. 创建新节点
    node = create_node(level, score, member)
    
    # 3. 从最高层开始查找插入位置
    # 4. 插入节点,更新指针

层数生成规则:

def random_level():
    level = 1
    while random() < 0.25 and level < 32:  # 25%概率晋升
        level += 1
    return level

层数分布:

L1: 100%
L2: 25%
L3: 6.25%
L4: 1.56%
...

查找元素:

def search(skiplist, score):
    # 从最高层开始
    x = skiplist.header
    for i in range(skiplist.level, -1, -1):
        # 在当前层向右走
        while x.level[i].forward and x.level[i].forward.score < score:
            x = x.level[i].forward
        # 往下一层走
    
    # 到达第0层
    x = x.level[0].forward
    if x and x.score == score:
        return x
    return None

3.5 跳表的优势

  1. 实现简单:比红黑树简单很多
  2. 性能稳定:期望O(log n),最坏O(n),但概率极低
  3. 支持范围查询:天然有序,范围查询方便
  4. 动态平衡:通过随机层数,无需旋转平衡

第四部分:压缩列表(ZipList)💼

4.1 什么是压缩列表?

压缩列表 是一种紧凑的连续内存块,用于节省内存。

使用场景:

  • Hash元素少时
  • List元素少时
  • ZSet元素少时
普通链表:
[node1][node2][node3]
  ↑         ↑         ↑
每个节点都有指针开销(8字节)

压缩列表:
[zlbytes][zltail][zllen][entry1][entry2][entry3][zlend]
         ↑ 连续内存,无指针开销!

4.2 ZipList结构

完整结构:
┌─────────┬─────────┬────────┬─────────┬─────────┬─────────┬────────┐
│ zlbytes │ zltail  │ zllen  │ entry1  │ entry2  │ entry3  │ zlend  │
│ (4字节) │ (4字节) │(2字节) │         │         │         │(1字节) │
└─────────┴─────────┴────────┴─────────┴─────────┴─────────┴────────┘

zlbytes: 整个ziplist占用的字节数
zltail:  最后一个entry的偏移量(快速定位尾部)
zllen:   entry数量
entry:   实际存储的元素
zlend:   结束标志(0xFF)

4.3 Entry结构

每个entry:
┌──────────────┬──────────┬─────────┐
│ previous_len │ encoding │ content │
│ 前一个entry  │ 编码类型 │ 实际数据│
│ 的长度       │          │         │
└──────────────┴──────────┴─────────┘

previous_len: 
- 前一个entry长度
- 用于从后向前遍历
- < 254字节:用1字节存储
- >= 254字节:用5字节存储

encoding:
- 数据类型和长度
- 整数编码:1字节
- 字符串编码:1-5字节

content:
- 实际数据

4.4 压缩列表的优缺点

✅ 优点:

  1. 节省内存:连续内存,无指针开销
  2. 缓存友好:连续内存,CPU缓存命中率高

❌ 缺点:

  1. 连锁更新问题
假设有多个entry,长度都是253字节:
[entry1(253)] [entry2(253)] [entry3(253)] [entry4(253)]
     ↓              ↓              ↓              ↓
previous_len:   1字节         1字节         1字节

插入一个254字节的entry:
[new_entry(254)] [entry1(253)] [entry2(253)] [entry3(253)]

问题:
- entry1的previous_len要从1字节扩展到5字节
- entry1长度变成258字节
- entry2的previous_len也要扩展
- entry2长度变成258字节
- entry3的previous_len也要扩展
- ...
- 连锁反应!每个entry都要重新分配内存!

最坏情况:O(n²)
  1. 查找慢:需要遍历,O(n)

解决方案: Redis限制了ziplist的大小

# redis.conf
hash-max-ziplist-entries 512   # 最多512个元素
hash-max-ziplist-value 64      # 单个元素最大64字节

# 超过阈值,自动转换成hashtable

第五部分:整数集合(IntSet)🔢

5.1 IntSet结构

typedef struct intset {
    uint32_t encoding;  // 编码方式
    uint32_t length;    // 元素数量
    int8_t contents[];  // 实际存储数组
} intset;

编码方式:

INTSET_ENC_INT16: int16_t  (-32768 ~ 32767)
INTSET_ENC_INT32: int32_t  (-21亿 ~ 21亿)
INTSET_ENC_INT64: int64_t  (更大范围)

5.2 升级机制

初始:只有小整数
encoding: INT16
contents: [1, 2, 3, 5, 10]

插入大整数:65535
encoding: INT16 → INT32(升级)
contents: 重新分配内存
          [1, 2, 3, 5, 10] 每个都扩展到32位
          [1, 2, 3, 5, 10, 65535]

优点:节省内存(小整数用小类型)
缺点:升级时需要重新分配内存(O(n))

5.3 特点

有序:使用二分查找,O(log n)
紧凑:连续内存,无冗余
自动升级:灵活适应数据
不支持降级:一旦升级,不会降回来


第六部分:QuickList(快速列表)⚡

6.1 为什么需要QuickList?

Redis 3.2之前,List有两种实现:

  • ziplist:省内存,但连锁更新慢
  • linkedlist:操作快,但浪费内存

QuickList = ziplist + linkedlist 的结合!

QuickList结构:
┌────────┐    ┌────────┐    ┌────────┐    ┌────────┐
│ ziplist│ ←→ │ ziplist│ ←→ │ ziplist│ ←→ │ ziplist│
│ [1,2,3]│    │ [4,5,6]│    │ [7,8,9]│    │[10,11] │
└────────┘    └────────┘    └────────┘    └────────┘
     ↑             ↑             ↑             ↑
 链表节点      链表节点       链表节点      链表节点

6.2 QuickList结构

// quicklist节点
typedef struct quicklistNode {
    struct quicklistNode *prev;  // 前驱节点
    struct quicklistNode *next;  // 后继节点
    unsigned char *zl;           // 指向ziplist
    unsigned int sz;             // ziplist字节数
    unsigned int count : 16;     // ziplist元素个数
    unsigned int encoding : 2;   // 是否压缩
    // ...
} quicklistNode;

// quicklist
typedef struct quicklist {
    quicklistNode *head;  // 头节点
    quicklistNode *tail;  // 尾节点
    unsigned long count;  // 总元素个数
    unsigned long len;    // 节点数量
    // ...
} quicklist;

6.3 优势

平衡内存和性能:每个节点是小ziplist
避免连锁更新:分散成多个小ziplist
支持压缩:中间节点可以LZF压缩


第七部分:数据结构转换 🔄

7.1 Hash的转换

# 配置
hash-max-ziplist-entries 512
hash-max-ziplist-value 64

# 场景1:元素少,用ziplist
HSET user:1 name "Tom"
HSET user:1 age 25
# 底层:ziplist(节省内存)

# 场景2:元素多,转hashtable
HSET user:2 field1 "value1"
HSET user:2 field2 "value2"
# ... 插入513个字段
HSET user:2 field513 "value513"
# 底层:ziplist → hashtable(性能优先)

# 场景3:值太大,转hashtable
HSET user:3 bio "一段很长的个人简介,超过64字节..."
# 底层:ziplist → hashtable

7.2 查看底层编码

127.0.0.1:6379> SET key1 "hello"
OK

127.0.0.1:6379> OBJECT ENCODING key1
"embstr"  # 短字符串

127.0.0.1:6379> SET key2 12345
OK

127.0.0.1:6379> OBJECT ENCODING key2
"int"  # 整数

127.0.0.1:6379> HSET user:1 name "Tom"
127.0.0.1:6379> OBJECT ENCODING user:1
"ziplist"  # 压缩列表

# 插入很多元素后...
127.0.0.1:6379> OBJECT ENCODING user:1
"hashtable"  # 自动转换!

🎓 总结:数据结构选择决策

内存占用对比(存储1000个键值对)

底层结构内存占用查找性能适用场景
ziplist5KBO(n)元素少
hashtable50KBO(1)元素多
skiplist80KBO(log n)有序+范围查询
intset4KBO(log n)整数集合

记忆口诀 🎵

SDS动态字符串,
长度记录不遍历。
二进制安全存,
预分配减重分。

跳表查找快如飞,
层层索引往上爬。
有序集合它当家,
范围查询不用怕。

压缩列表省内存,
连续存储无指针。
元素太多会转换,
连锁更新要小心。

整数集合intset,
有序紧凑能升级。
二分查找速度快,
降级不支持要记。

QuickList最机智,
链表压缩两结合。
既省内存又灵活,
List实现就用它!

面试要点 ⭐

  1. SDS vs C字符串:O(1)长度、二进制安全、预分配
  2. 跳表原理:多层索引、随机层数、O(log n)
  3. ziplist:连续内存、连锁更新问题
  4. 编码转换:自动根据阈值转换底层结构
  5. 内存优化:小对象用紧凑结构,大对象用高效结构

最后总结:

Redis的底层数据结构就像瑞士军刀 🇨🇭,每种场景都有最合适的工具!

  • 小数据量:用紧凑结构(ziplist、intset)→ 省内存
  • 大数据量:用高效结构(hashtable、skiplist)→ 快速

Redis会自动帮你选择,你只需要知道原理就够了!💪

加油,Redis架构师!🚀