考察点: 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 跳表的优势
- 实现简单:比红黑树简单很多
- 性能稳定:期望O(log n),最坏O(n),但概率极低
- 支持范围查询:天然有序,范围查询方便
- 动态平衡:通过随机层数,无需旋转平衡
第四部分:压缩列表(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 压缩列表的优缺点
✅ 优点:
- 节省内存:连续内存,无指针开销
- 缓存友好:连续内存,CPU缓存命中率高
❌ 缺点:
- 连锁更新问题:
假设有多个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²)
- 查找慢:需要遍历,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个键值对)
| 底层结构 | 内存占用 | 查找性能 | 适用场景 |
|---|---|---|---|
| ziplist | 5KB | O(n) | 元素少 |
| hashtable | 50KB | O(1) | 元素多 |
| skiplist | 80KB | O(log n) | 有序+范围查询 |
| intset | 4KB | O(log n) | 整数集合 |
记忆口诀 🎵
SDS动态字符串,
长度记录不遍历。
二进制安全存,
预分配减重分。
跳表查找快如飞,
层层索引往上爬。
有序集合它当家,
范围查询不用怕。
压缩列表省内存,
连续存储无指针。
元素太多会转换,
连锁更新要小心。
整数集合intset,
有序紧凑能升级。
二分查找速度快,
降级不支持要记。
QuickList最机智,
链表压缩两结合。
既省内存又灵活,
List实现就用它!
面试要点 ⭐
- SDS vs C字符串:O(1)长度、二进制安全、预分配
- 跳表原理:多层索引、随机层数、O(log n)
- ziplist:连续内存、连锁更新问题
- 编码转换:自动根据阈值转换底层结构
- 内存优化:小对象用紧凑结构,大对象用高效结构
最后总结:
Redis的底层数据结构就像瑞士军刀 🇨🇭,每种场景都有最合适的工具!
- 小数据量:用紧凑结构(ziplist、intset)→ 省内存
- 大数据量:用高效结构(hashtable、skiplist)→ 快速
Redis会自动帮你选择,你只需要知道原理就够了!💪
加油,Redis架构师!🚀