难度:⭐⭐⭐⭐⭐ | 适合人群:想深入理解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(紧凑列表)
映射关系:
String → SDS
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变长了(从250变255字节)
↓
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的核心数据结构!"
问题: 有序集合如何快速查找?
普通链表:
1 → 5 → 10 → 15 → 20 → 25 → 30
↓
查找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原因:
- 实现简单(100行代码 vs 1000行)
- 范围查询友好(ZRANGE命令常用)
- 性能相当
- 易于并发
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:元素数量 <= 512(hash-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高性能的底层秘密!
如果这篇文章对你有帮助,请:
- 👍 点赞支持
- ⭐ 收藏备用
- 🔄 转发分享
- 💬 评论交流
感谢阅读,期待下次再见! 👋