Redis底层数据结构深度解析:从SDS到跳表,彻底搞懂Redis为什么快!

难度:⭐⭐⭐⭐⭐ | 适合人群:想深入理解Redis底层原理的开发者


💥 开场:一次"打脸"的面试

时间: 上周
地点: 某互联网大厂
事件: 技术三面

面试官: "你说Redis很快,为什么快?"

我: "因为是内存数据库,单线程,IO多路复用..." 😊(背得很溜)

面试官: "还有吗?"

我: "呃...数据结构高效?" 🤔

面试官: "那你说说String底层是怎么实现的?"

我: "就是字符串啊..." 😅

面试官: "C语言的字符串?"

我: "应该...是吧?" 😰(心虚)

面试官: "那为什么不直接用C语言的字符串?Redis为什么要自己实现SDS?"

我: "这..." 😭(语塞)

面试官: "ZSet底层是什么数据结构?"

我: "有序集合嘛..." 😓(完全不知道)

面试官: "skiplist跳表听说过吗?"

我: "没...没听过。" 😱(凉透了)


回家后,我痛定思痛:

我: "必须搞懂Redis的底层数据结构!"

查资料、看源码、做笔记...

终于明白了: "原来Redis快,底层数据结构才是核心!" 💡


🎯 第一问:Redis的数据结构分层

对外 vs 底层

哈吉米: "Redis有两层数据结构。"

对外层(用户看到的):
├─ String(字符串)
├─ List(列表)
├─ Hash(哈希)
├─ Set(集合)
└─ ZSet(有序集合)

底层层(实际实现):
├─ SDS(简单动态字符串)
├─ linkedlist(双向链表)
├─ ziplist(压缩列表)
├─ hashtable(哈希表)
├─ intset(整数集合)
├─ skiplist(跳表)
├─ quicklist(快速列表)
└─ listpack(紧凑列表)

映射关系:

StringSDS
List → quicklist(linkedlist + ziplist)
Hash → hashtable 或 ziplist
Set → hashtable 或 intset
ZSet → skiplist + hashtable 或 ziplist

📝 第二问:SDS(简单动态字符串)

C字符串的问题

南北绿豆: "先看C语言的字符串有什么问题。"

C字符串:

char *str = "hello";

// 内存布局:
['h']['e']['l']['l']['o']['\0']
                         ↑
                      结束符

4个问题:

1. 获取长度慢
   strlen(str) → O(n)遍历到'\0'

2. 不能存二进制数据
   '\0'被当作结束符
   图片、视频等二进制数据无法存储

3. 缓冲区溢出
   strcat(str, " world") → 可能溢出

4. 内存重分配频繁
   每次修改都要重新分配内存

SDS结构

Redis自己实现的SDS:

struct sdshdr {
    int len;        // 字符串长度
    int free;       // 未使用空间
    char buf[];     // 字节数组
};

示例:

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

SDS的优势

阿西噶阿西: "SDS解决了C字符串的所有问题!"

1. O(1)获取长度

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

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

2. 支持二进制数据

// C字符串
char *str = "hello\0world";  // 只能读到hello

// SDS
{
    len: 11,
    buf: ['h']['e']['l']['l']['o']['\0']['w']['o']['r']['l']['d']['\0']
}
// 可以完整存储,因为用len判断长度,不依赖'\0'

3. 避免缓冲区溢出

// C字符串
char str[5] = "hello";
strcat(str, " world");  // 溢出!没有足够空间

// SDS
sds = sdscatlen(sds, " world", 6);
// 自动检查空间,不够会自动扩容

4. 减少内存重分配

空间预分配:

// 修改SDS,追加内容
sds = sdscat(sds, " world");

// SDS策略:
if (新长度 < 1MB) {
    分配 新长度 × 2 的空间
} else {
    分配 新长度 + 1MB 的空间
}

// 示例:
原来:len=5, free=0, buf[5]
追加6字节后:len=11, free=11, buf[22]
         ↓
预留了11字节空闲空间
下次追加不需要重新分配(除非超过11字节)

惰性空间释放:

// 缩短SDS
sds = sdstrim(sds, "world");  // 删除"world"

// SDS策略:
不立即释放内存,而是增加free
    ↓
len减少,free增加
    ↓
下次追加内容可以直接用free空间

🔗 第三问:linkedlist(双向链表)

结构定义

// 链表节点
typedef struct listNode {
    struct listNode *prev;  // 前驱节点
    struct listNode *next;  // 后继节点
    void *value;            // 节点值
} listNode;

// 链表
typedef struct list {
    listNode *head;    // 头节点
    listNode *tail;    // 尾节点
    unsigned long len; // 长度
    
    // 函数指针
    void *(*dup)(void *ptr);   // 复制函数
    void (*free)(void *ptr);    // 释放函数
    int (*match)(void *ptr, void *key);  // 匹配函数
} list;

特点:

  • ✅ 双向链表(可从头或尾遍历)
  • ✅ 有头尾指针(O(1)获取头尾)
  • ✅ 带长度计数(O(1)获取长度)
  • ✅ 多态(void*可存任意类型)

📦 第四问:ziplist(压缩列表)

什么是ziplist?

南北绿豆: "ziplist是一种紧凑的列表结构,省内存!"

内存布局:

<zlbytes><zltail><zllen><entry1><entry2>...<entryN><zlend>

zlbytes: 4字节,整个ziplist占用的字节数
zltail:  4字节,尾节点的偏移量
zllen:   2字节,节点数量
entry:   节点数据
zlend:   1字节,结束标记(0xFF)

entry结构:

<prevlen><encoding><data>

prevlen:  前一个节点的长度(用于反向遍历)
encoding: 编码类型和长度
data:     实际数据

ziplist的优势

优点:
✅ 内存紧凑(连续内存)
✅ 节省内存(没有指针)
✅ 缓存友好(连续内存,CPU缓存命中率高)

缺点:
❌ 插入删除慢(O(n),需要移动数据)
❌ 更新可能触发连锁更新
❌ 数据量大时性能差

连锁更新问题

阿西噶阿西: "ziplist有个致命问题:连锁更新!"

原始ziplist:
entry1(250字节) entry2(250字节) entry3(250字节)
    ↑
每个entry的prevlen占1字节(前一个<254字节)

插入一个300字节的entry0:
entry0(300字节) entry1(250字节) entry2(250字节) entry3(250字节)
    ↓
entry1的prevlen要改成5字节(前一个>=254字节)
    ↓
entry1变长了(从250255字节)
    ↓
entry2的prevlen也要改成5字节
    ↓
entry2也变长了
    ↓
entry3的prevlen也要改...
    ↓
连锁更新!性能差!

🚀 第五问:quicklist(快速列表)

什么是quicklist?

quicklist = linkedlist + ziplist

结构:

linkedlist的节点
    ↓
每个节点存一个ziplist
    ↓
既有linkedlist的灵活性
又有ziplist的内存效率

图示:

quicklist:
head → [ziplist1][ziplist2][ziplist3] ← tail
         ↓             ↓             ↓
      [e1][e2]     [e3][e4][e5]   [e6][e7]

C结构:

// quicklist节点
typedef struct quicklistNode {
    struct quicklistNode *prev;  // 前驱
    struct quicklistNode *next;  // 后继
    unsigned char *zl;           // ziplist
    unsigned int sz;             // ziplist字节数
    unsigned int count : 16;     // ziplist中的元素数
    // ...
} quicklistNode;

// quicklist
typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;    // 总元素数
    unsigned long len;      // 节点数
    int fill : 16;          // 每个节点的填充因子
    // ...
} quicklist;

优势

解决了:
- linkedlist:内存碎片多,指针占用大
- ziplist:数据量大时性能差

quicklist:
✅ 每个节点的ziplist不会太大(避免连锁更新)
✅ linkedlist连接多个ziplist(灵活插入删除)
✅ 综合了两者优点

Redis 3.2+的List类型使用quicklist


🏗️ 第六问:hashtable(哈希表)

结构定义

// 哈希表节点
typedef struct dictEntry {
    void *key;              // 键
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;                    // 值
    struct dictEntry *next; // 下一个节点(拉链法解决冲突)
} dictEntry;

// 哈希表
typedef struct dictht {
    dictEntry **table;      // 哈希表数组
    unsigned long size;     // 哈希表大小
    unsigned long sizemask; // 哈希表大小掩码(size - 1)
    unsigned long used;     // 已有节点数
} dictht;

// 字典
typedef struct dict {
    dictType *type;   // 类型特定函数
    void *privdata;   // 私有数据
    dictht ht[2];     // 两个哈希表(用于渐进式rehash)
    long rehashidx;   // rehash索引(-1表示未进行)
} dict;

渐进式rehash

哈吉米: "Redis的rehash很巧妙,不会阻塞!"

为什么需要rehash?

哈希表大小:1000
已存储key:950(负载因子0.95)
    ↓
哈希冲突增多
性能下降
    ↓
需要扩容(rehash

传统rehash(一次性):

1. 创建新哈希表(大小翻倍)
2. 遍历旧哈希表所有key
3. 重新计算哈希值
4. 插入新哈希表
5. 释放旧哈希表

问题:
- 100万个key rehash
- 需要几百毫秒甚至几秒
- 阻塞主线程
- Redis假死!

渐进式rehash(分步进行):

sequenceDiagram
    participant Client
    participant Redis
    participant ht[0](旧表)
    participant ht[1](新表)
    
    Note over Redis: 触发rehash<br/>创建新表ht[1]
    
    Client->>Redis: SET key1 val1
    Redis->>ht[0](旧表): 迁移索引0的数据到ht[1]
    Redis->>ht[1](新表): 插入key1
    
    Client->>Redis: GET key2
    Redis->>ht[0](旧表): 迁移索引1的数据到ht[1]
    Redis->>ht[0](旧表): 查找key2
    Note over Redis: 同时查询两个表
    
    Client->>Redis: DEL key3
    Redis->>ht[0](旧表): 迁移索引2的数据到ht[1]
    Redis->>ht[1](新表): 删除key3
    
    Note over Redis: 逐步迁移完成
    Note over ht[0](旧表): 释放旧表

流程:

1. 为ht[1]分配空间
    ↓
2. rehashidx = 0(开始rehash)
    ↓
3. 每次增删改查时:
    ├─ 迁移ht[0]中索引rehashidx的数据到ht[1]
    ├─ rehashidx++
    └─ 同时在ht[0]和ht[1]中查找
    ↓
4. 迁移完成:
    ├─ 释放ht[0]
    ├─ ht[1]变成ht[0]
    └─ rehashidx = -1

优势:

  • ✅ 分散到多次操作
  • ✅ 每次只迁移少量数据
  • ✅ 不阻塞
  • ✅ 对用户透明

🎲 第七问:intset(整数集合)

结构定义

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

编码方式:

INTSET_ENC_INT16  // 16位整数
INTSET_ENC_INT32  // 32位整数
INTSET_ENC_INT64  // 64位整数

升级机制

场景:

原来:intset存储 [1, 2, 3](都是小整数,用INT16)
    ↓
插入:65535(需要INT32)
    ↓
升级:整个intset从INT16升级到INT32
    ↓
结果:[1, 2, 3, 65535](全部用INT32)

流程:

1. 根据新元素类型,确定新编码
2. 分配新空间
3. 将现有元素转换为新编码
4. 插入新元素
5. 更新encoding和length

注意: 只支持升级,不支持降级!


🏔️ 第八问:skiplist(跳表)- 重点!

什么是跳表?

哈吉米: "跳表是ZSet的核心数据结构!"

问题: 有序集合如何快速查找?

普通链表:
151015202530
    ↓
查找25:需要遍历O(n)

有序数组:
[1, 5, 10, 15, 20, 25, 30]
    ↓
二分查找:O(logn)
但插入删除O(n)(需要移动元素)

跳表: 在链表基础上加"索引层"!


跳表结构

Level 3:  1 ────────────────────────→ 30
                                     
Level 2:  1 ─────────→ 15 ──────────→ 30
                                   
Level 1:  1 ────→ 10  15  20 ─────→ 30
                                 
Level 0:  1  5  10  15  20  25  30

查找25的过程:

1. 从Level 3的头节点开始(1)
2. 1 → 30(超过25,回到Level 2)
3. 1 → 15(小于25,继续)→ 30(超过,回到Level 1)
4. 15 → 20(小于25,继续)→ 30(超过,回到Level 0)
5. 20 → 25(找到!)

只比较了6次,比遍历(7次)快

跳表实现

// 跳表节点
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;

为什么ZSet用skiplist?

南北绿豆: "为什么不用红黑树或B+树?"

skiplist vs 红黑树:

维度skiplist红黑树
实现复杂度简单 ⭐⭐复杂 ⭐⭐⭐⭐⭐
范围查询简单(顺序遍历)复杂(中序遍历)
插入删除O(logn)O(logn)
查找O(logn)O(logn)
内存占用稍多(多层指针)

Redis选择skiplist原因:

  1. 实现简单(100行代码 vs 1000行)
  2. 范围查询友好(ZRANGE命令常用)
  3. 性能相当
  4. 易于并发

skiplist查找演示

// 模拟跳表查找
public Node find(int target) {
    Node current = header;
    
    // 从最高层开始
    for (int level = maxLevel; level >= 0; level--) {
        
        // 在当前层向右查找
        while (current.forward[level] != null && 
               current.forward[level].score < target) {
            current = current.forward[level];
            System.out.println("Level " + level + ": 移动到 " + current.score);
        }
        
        // 下降到下一层
        if (level > 0) {
            System.out.println("下降到 Level " + (level - 1));
        }
    }
    
    // 找到目标或最接近的节点
    return current.forward[0];
}

🎯 第九问:ZSet的双重数据结构

为什么ZSet用两种结构?

阿西噶阿西: "ZSet同时使用skiplist和hashtable!"

typedef struct zset {
    dict *dict;        // 哈希表(member → score)
    zskiplist *zsl;    // 跳表(按score排序)
} zset;

为什么要两个?

skiplist:
    ✅ 按score排序
    ✅ 范围查询快(ZRANGE)
    ❌ 通过member查score慢(O(logn))

hashtable:
    ✅ 通过member查score快(O(1))
    ❌ 不支持排序

两者结合:
    ✅ ZSCORE命令 → 用hashtable(O(1))
    ✅ ZRANGE命令 → 用skiplist(O(logn))
    ✅ 空间换时间

示例:

ZSet: rank

skiplist(按score排序):
score:100"张三"
score:95"李四"
score:90"王五"

hashtable(快速查score):
"张三"100
"李四"95
"王五"90

执行 ZSCORE rank "张三":
    → 查hashtable → O(1) → 返回100

执行 ZRANGE rank 0 1:
    → 查skiplist → O(logn) → 返回["张三", "李四"]

📦 第十问:listpack(紧凑列表)

什么是listpack?

南北绿豆: "listpack是Redis 7.0引入的新结构,替代ziplist!"

为什么要替代?

ziplist的问题:
- 连锁更新
- 性能不可控

listpack:
- 解决了连锁更新
- 每个entry独立编码
- 更新不影响其他entry

结构对比:

ziplist entry:
<prevlen><encoding><data>
    ↑ 依赖前一个节点长度(连锁更新的根源)

listpack entry:
<encoding><data><entry-len>
    ↑ 不依赖前一个节点
    ↑ entry-len是自己的长度(反向遍历用)

优势:

  • ✅ 解决连锁更新
  • ✅ 性能更稳定
  • ✅ 内存效率高

📊 第十一问:底层结构选择规则

Hash的底层实现

阿西噶阿西: "Hash会根据数据量自动选择底层结构。"

条件1:元素数量 <= 512hash-max-ziplist-entries)
条件2:所有值长度 <= 64字节(hash-max-ziplist-value)
    ↓
满足条件:使用ziplist
不满足:使用hashtable

示例:

# 小数据量:ziplist
redis> HSET user:1 name "张三" age "25"
redis> OBJECT ENCODING user:1
"ziplist"

# 添加大字段:转换为hashtable
redis> HSET user:1 bio "很长很长的个人简介..."  # 超过64字节
redis> OBJECT ENCODING user:1
"hashtable"

List的底层实现

Redis 3.2之前:
    ziplist 或 linkedlist

Redis 3.2+:
    统一使用 quicklist

Set的底层实现

条件:所有元素都是整数 && 元素数量 <= 512
    ↓
满足:使用intset
不满足:使用hashtable

示例:

# 整数集合:intset
redis> SADD nums 1 2 3 4 5
redis> OBJECT ENCODING nums
"intset"

# 添加字符串:转换为hashtable
redis> SADD nums "abc"
redis> OBJECT ENCODING nums
"hashtable"

ZSet的底层实现

条件1:元素数量 <= 128(zset-max-ziplist-entries)
条件2:所有值长度 <= 64字节(zset-max-ziplist-value)
    ↓
满足:使用ziplist
不满足:使用skiplist + hashtable

💡 知识点总结

Redis底层结构核心要点

SDS(简单动态字符串)

  • O(1)获取长度
  • 支持二进制数据
  • 空间预分配
  • 惰性空间释放

linkedlist(双向链表)

  • 双向遍历
  • O(1)获取头尾
  • O(1)获取长度

ziplist(压缩列表)

  • 内存紧凑
  • 连续内存
  • 有连锁更新问题

quicklist(快速列表)

  • linkedlist + ziplist
  • 避免ziplist连锁更新
  • List类型使用

hashtable(哈希表)

  • 渐进式rehash
  • 不阻塞
  • 两个哈希表切换

intset(整数集合)

  • 紧凑存储整数
  • 自动升级编码
  • 不支持降级

skiplist(跳表)

  • 多层索引
  • O(logn)查找
  • 实现简单
  • 范围查询友好

listpack(紧凑列表)

  • 替代ziplist
  • 解决连锁更新
  • Redis 7.0+

记忆口诀

Redis数据结构精,
SDS字符串改良型。
O(1)获取长度快,
二进制数据也能装。

链表双向有头尾,
长度计数O(1)取。
压缩列表省内存,
连续存储缓存友。

快速列表两结合,
链表加压缩列表组。
哈希表渐进rehash,
不阻塞来性能好。

整数集合紧凑存,
自动升级不降级。
跳表多层索引快,
ZSet底层就是它。

空间时间要权衡,
Redis设计有深意。

🤔 常见面试题

Q1: String是使用什么存储的?为什么不用C字符串?

A:

String使用SDS(Simple Dynamic String)存储

C字符串的问题:
1. 获取长度O(n)
2. 不能存二进制数据('\0'问题)
3. 缓冲区溢出风险
4. 频繁内存重分配

SDS的优势:
1. O(1)获取长度(len字段)
2. 二进制安全(用len判断,不依赖'\0')
3. 避免溢出(自动扩容)
4. 空间预分配和惰性释放(减少重分配)

Q2: ZSet底层是怎么实现的?

A:

ZSet使用两种数据结构:

小数据量(<128个元素,每个<64字节):
- ziplist(压缩列表)

大数据量:
- skiplist(跳表)+ hashtable(哈希表)

为什么用两个?
- skiplist:按score排序,范围查询快
- hashtable:member查score快O(1)

命令对应:
- ZSCORE → 用hashtable O(1)
- ZRANGE → 用skiplist O(logn)
- 空间换时间

Q3: 跳表是怎么实现的?

A:

跳表 = 有序链表 + 多层索引

结构:
Level 2:  1 ────────→ 20 ─────→ 40
          ↓           ↓         ↓
Level 1:  1 ──→ 10 → 20 → 30 → 40
          ↓    ↓    ↓    ↓    ↓
Level 0:  1 → 5 → 10 → 15 → 20 → 25 → 30 → 35 → 40

查找过程:
1. 从最高层开始
2. 向右找到小于目标的最大值
3. 下降一层
4. 重复,直到Level 0

时间复杂度:O(logn)

优势:
- 实现简单
- 范围查询友好
- 增删改查都是O(logn)

Q4: 哈希表是怎么扩容的?

A:

渐进式rehash:

问题:
- 一次性rehash会阻塞
- 数据量大时不可接受

方案:
1. 创建新哈希表ht[1]
2. rehashidx = 0
3. 每次操作时:
   - 迁移ht[0][rehashidx]到ht[1]
   - rehashidx++
4. 查询时同时查两个表
5. 完成后,ht[1]变成ht[0]

优势:
- 分散到多次操作
- 不阻塞
- 平滑扩容

💬 写在最后

从SDS到跳表,我们深入学习了Redis的底层数据结构:

  • 📝 理解了SDS的设计巧思
  • 🔗 掌握了链表和压缩列表
  • 🏔️ 学会了跳表的原理
  • 🏗️ 完成了渐进式rehash分析

这篇文章,希望能让你理解Redis高性能的底层秘密!

如果这篇文章对你有帮助,请:

  • 👍 点赞支持
  • ⭐ 收藏备用
  • 🔄 转发分享
  • 💬 评论交流

感谢阅读,期待下次再见! 👋