Redis 底层数据结构深度解析:从 SDS 到 ListPack
Redis 之所以能成为最受欢迎的内存数据库,离不开其精心设计的底层数据结构。本文将深入剖析 Redis 的 8 种基础数据结构,带你理解高性能背后的设计智慧。
📖 目录
- 为什么要了解 Redis 底层数据结构?
- 1. 简单动态字符串(SDS)
- 2. 双向链表(LinkedList)
- 3. 字典/哈希表(Dict)
- 4. 跳跃表(SkipList)
- 5. 整数集合(IntSet)
- 6. 压缩列表(ZipList)
- 7. 快速列表(QuickList)
- 8. 紧凑列表(ListPack)
- 数据结构对比总结
- 实战建议
- 参考资料
为什么要了解 Redis 底层数据结构?
在使用 Redis 时,我们经常会遇到这些问题:
- ❓ 为什么 Redis 单线程却能支撑 10 万+ QPS?
- ❓ 为什么有时候插入数据会突然变慢?
- ❓ 如何选择合适的数据类型优化内存占用?
- ❓ BigKey 问题的本质是什么?
答案都藏在 Redis 的底层数据结构中。理解了底层原理,你就能:
- ✅ 正确选择数据类型,避免性能陷阱
- ✅ 优化内存使用,降低成本
- ✅ 快速定位和解决线上问题
- ✅ 在技术面试中脱颖而出
1. 简单动态字符串(SDS)
为什么不用 C 字符串?
C 语言的字符串存在诸多问题:
// C 字符串的问题
char str[] = "Redis";
// ❌ 获取长度需要遍历 - O(n)
size_t len = strlen(str);
// ❌ 不能包含空字符 \0(二进制不安全)
char binary[] = "Redis\0Cluster"; // 只能读到 "Redis"
// ❌ 容易造成缓冲区溢出
char s1[10] = "Redis";
char s2[] = " Cluster";
strcat(s1, s2); // 💥 缓冲区溢出!
SDS 结构设计
Redis 实现了自己的动态字符串:
// Redis 7.0+ 的 SDS 结构(根据长度优化)
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; // 已使用长度(最大 255)
uint8_t alloc; // 总分配长度
unsigned char flags; // 类型标识
char buf[]; // 字节数组
};
// 类似的还有 sdshdr16、sdshdr32、sdshdr64
内存布局示例:
存储 "Redis":
┌─────────┬─────────┬───────┬─────────────────┐
│ len = 5 │alloc = 5│flags=8│ buf="Redis\0" │
└─────────┴─────────┴───────┴─────────────────┘
扩容后(预分配空间):
┌─────────┬──────────┬───────┬──────────────────┐
│ len = 5 │alloc = 10│flags=8│ buf="Redis\0... "│
│ │ │ │ (总长度 11) │
└─────────┴──────────┴───────┴──────────────────┘
核心优势
1️⃣ O(1) 复杂度获取长度
// C 字符串
size_t len = strlen(str); // O(n) 需要遍历
// SDS
size_t len = sdslen(s); // O(1) 直接读取 len 字段
2️⃣ 杜绝缓冲区溢出
// SDS 自动扩容
sds s1 = sdsnew("Redis");
sds s2 = sdsnew(" Cluster");
s1 = sdscat(s1, s2); // ✅ 自动检查并扩容,安全!
3️⃣ 减少内存重分配
空间预分配策略:
if (newlen < 1MB) {
alloc = newlen * 2; // 小于 1MB:分配 2 倍空间
} else {
alloc = newlen + 1MB; // 大于 1MB:额外分配 1MB
}
惰性空间释放:
sds s = sdsnew("Redis Cluster"); // len=13, alloc=13
sdstrim(s, "Redis"); // len=5, alloc=13(保留空间)
4️⃣ 二进制安全
// SDS 可以存储任意二进制数据
sds s = sdsnewlen("Redis\0Cluster", 13);
printf("len = %d", sdslen(s)); // len = 13(完整存储)
5️⃣ 兼容 C 字符串函数
sds s = sdsnew("Redis");
printf("%s", s); // ✅ 兼容 C 函数
int cmp = strcmp(s, "Redis"); // ✅ 可以直接使用
💡 应用场景:
- Redis 所有字符串值
- AOF 缓冲区
- 客户端输入输出缓冲区
2. 双向链表(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;
结构图示
┌──────────────────────────────────────────────┐
│ List (len=3) │
│ head ↓ tail ↓ │
└───────┼──────────────────────────────┼───────┘
↓ ↓
NULL←→[Node1]←→[Node2]←→[Node3]←→NULL
↓ ↓ ↓
"value1" "value2" "value3"
核心特性
- ✅ 双向:可以双向遍历
- ✅ 无环:头尾指向 NULL
- ✅ 带头尾指针:O(1) 获取头尾
- ✅ 带长度计数器:O(1) 获取长度
- ✅ 多态:void* 可保存任意类型
性能分析
| 操作 | 时间复杂度 |
|---|---|
| 头部/尾部插入 | O(1) |
| 删除节点(已知位置) | O(1) |
| 查找节点 | O(n) |
缺点
- ❌ 内存开销大:每个节点需要 16 字节(prev + next)
- ❌ 缓存不友好:节点不连续存储
- ❌ 查找效率低:需要遍历
💡 应用场景(Redis 3.2 前):
- List 数据类型
- 发布订阅的订阅者列表
- 慢查询日志
- 监视器
注意:Redis 3.2+ 使用 QuickList 替代纯链表。
3. 字典/哈希表(Dict)
字典是 Redis 最核心的数据结构,Redis 数据库本身就是用字典实现的。
结构定义
// 哈希表节点
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next; // 解决哈希冲突
} dictEntry;
// 哈希表
typedef struct dictht {
dictEntry **table; // 哈希表数组
unsigned long size; // 哈希表大小(2^n)
unsigned long sizemask; // 掩码(size - 1)
unsigned long used; // 已有节点数
} dictht;
// 字典
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2]; // 两个哈希表(用于渐进式 rehash)
long rehashidx; // rehash 索引(-1 表示未进行)
int iterators;
} dict;
结构图示
┌─────────────────────────────────────────┐
│ Dict │
│ ht[0] (主哈希表) ht[1] (rehash 用) │
└────────┬────────────────────────────────┘
↓
┌─────────────────────┐
│ Hash Table (size=8)│
├─────────────────────┤
│ [0] → Entry → NULL │
│ [1] → Entry → NULL │
│ [2] → NULL │
│ [3] → Entry → Entry │ (链地址法)
│ [4] → NULL │
│ [5] → Entry → NULL │
│ [6] → NULL │
│ [7] → Entry → NULL │
└─────────────────────┘
渐进式 Rehash
这是 Redis 的核心优化之一,避免一次性 rehash 导致服务停顿。
触发条件
load_factor = ht[0].used / ht[0].size
// 扩容条件
if (load_factor >= 1 && 未在执行BGSAVE/BGREWRITEAOF) {
rehash(); // 扩展为第一个 >= used * 2 的 2^n
}
if (load_factor >= 5) {
rehash(); // 强制扩容
}
// 缩容条件
if (load_factor < 0.1) {
rehash(); // 缩小为第一个 >= used 的 2^n
}
Rehash 流程
初始状态:
ht[0]: size=4, used=4 (负载因子 1.0)
ht[1]: 空
rehashidx = -1
步骤 1:开始 rehash
ht[0]: size=4, used=4
ht[1]: size=8, used=0 ← 分配新空间
rehashidx = 0 ← 开始迁移
步骤 2:每次操作时,顺带迁移一个桶
迁移 ht[0].table[0] → ht[1]
rehashidx = 1
步骤 3:继续迁移...
rehashidx = 2, 3, 4...
步骤 N:迁移完成
ht[0] → 释放
ht[1] → 成为新的 ht[0]
rehashidx = -1
Rehash 期间的操作
// 查找:先查 ht[0],再查 ht[1]
dictEntry *dictFind(dict *d, const void *key) {
if (d->rehashidx != -1) {
entry = findInHashTable(&d->ht[0], key);
if (entry) return entry;
return findInHashTable(&d->ht[1], key);
}
return findInHashTable(&d->ht[0], key);
}
// 插入:新节点只插入 ht[1]
// 删除:同时在 ht[0] 和 ht[1] 中删除
💡 应用场景:
- ✅ Redis 数据库(整个 DB 就是一个 dict)
- ✅ Hash 数据类型
- ✅ 过期键字典
- ✅ Cluster 节点映射
4. 跳跃表(SkipList)
跳跃表是一种有序数据结构,通过多层索引实现快速查找。
为什么需要跳跃表?
普通有序链表查找 "50":
1 → 10 → 20 → 30 → 40 → 50 (需要 6 步)
跳跃表查找 "50":
Level 3: 1 ───────────────→ 50
Level 2: 1 ──────→ 20 ────→ 50
Level 1: 1 → 10 → 20 → 30 → 40 → 50
(只需要 3 步)
结构定义
// 跳跃表节点
typedef struct zskiplistNode {
sds ele; // 成员对象
double score; // 分值(排序依据)
struct zskiplistNode *backward; // 后退指针
struct zskiplistLevel {
struct zskiplistNode *forward; // 前进指针
unsigned long span; // 跨度
} level[]; // 层(最多 32 层)
} zskiplistNode;
// 跳跃表
typedef struct zskiplist {
struct zskiplistNode *header;
struct zskiplistNode *tail;
unsigned long length;
int level;
} zskiplist;
结构图示
┌────────────────────────────────────────────┐
│ ZSkipList (length=4, level=3) │
└────┬───────────────────────────────────────┘
↓
┌────────┐
│ Header │
├────────┤
│ L3 ────┼──────────────────────────→ NULL
│ L2 ────┼────────→ [20] ───────────→ NULL
│ L1 ────┼──→ [10] → [20] → [30] → [40] → NULL
└────────┘ ↑ ↑ ↑ ↑
score score score score
=10 =20 =30 =40
核心算法
1️⃣ 随机层数生成
#define ZSKIPLIST_MAXLEVEL 32
#define ZSKIPLIST_P 0.25
int zslRandomLevel(void) {
int level = 1;
while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF)) {
level += 1;
}
return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
// 层数分布:
// L1: 100%
// L2: 25%
// L3: 6.25%
// L4: 1.56%
2️⃣ 查找节点(O(log n))
zskiplistNode *zslSearch(zskiplist *zsl, double score, sds ele) {
zskiplistNode *x = zsl->header;
// 从最高层开始查找
for (int i = zsl->level - 1; i >= 0; i--) {
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
sdscmp(x->level[i].forward->ele, ele) < 0))) {
x = x->level[i].forward;
}
}
x = x->level[0].forward;
if (x && x->score == score && sdscmp(x->ele, ele) == 0) {
return x;
}
return NULL;
}
性能分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 查找 | O(log n) | 平均情况 |
| 插入 | O(log n) | 包含查找 + 插入 |
| 删除 | O(log n) | 包含查找 + 删除 |
| 范围查询 | O(log n + m) | m 是返回的元素数 |
| 获取排名 | O(log n) | 通过 span 计算 |
跳跃表 vs 平衡树
| 特性 | 跳跃表 | 红黑树/AVL树 |
|---|---|---|
| 实现复杂度 | 简单 | 复杂(需要旋转) |
| 范围查询 | 高效 | 需要中序遍历 |
| 插入删除 | 不需要旋转 | 可能多次旋转 |
| 并发友好 | 高 | 低 |
Redis 为什么选择跳跃表?
- ✅ 实现简单,代码易维护
- ✅ 范围查询效率高
- ✅ 支持反向遍历
- ✅ 不需要复杂的平衡操作
💡 应用场景:
- ✅ ZSet(有序集合)
- ✅ Cluster 节点管理
5. 整数集合(IntSet)
当 Set 中只包含整数值元素,且数量不多时,使用 IntSet 优化内存。
结构定义
typedef struct intset {
uint32_t encoding; // 编码方式
uint32_t length; // 元素数量
int8_t contents[]; // 元素数组(实际类型取决于 encoding)
} intset;
// 编码类型
#define INTSET_ENC_INT16 (sizeof(int16_t)) // -32768 ~ 32767
#define INTSET_ENC_INT32 (sizeof(int32_t)) // -2^31 ~ 2^31-1
#define INTSET_ENC_INT64 (sizeof(int64_t)) // -2^63 ~ 2^63-1
示例
存储小整数 [1, 5, 10, 100]:
┌──────────────────────────────┐
│ encoding = INT16 │
│ length = 4 │
│ contents = [1, 5, 10, 100] │
│ (每个元素 2 字节) │
└──────────────────────────────┘
插入 65535 后升级:
┌──────────────────────────────────┐
│ encoding = INT32 │
│ length = 5 │
│ contents = [1, 5, 10, 100, 65535]│
│ (每个元素 4 字节) │
└──────────────────────────────────┘
编码升级
当插入的元素超出当前编码范围时,IntSet 会自动升级:
// 升级流程
1. 根据新编码分配空间
2. 从后往前迁移元素(避免覆盖)
3. 插入新元素(必定在头部或尾部)
4. 更新 encoding 和 length
升级示例:
初始 (INT16): [5, 10, 20]
插入 65535:
步骤 1: 分配 INT32 空间
[ ][ ][ ][ ]
步骤 2: 从后往前迁移
[ ][0005][000A][0014]
步骤 3: 插入到尾部
[0005][000A][0014][FFFF]
完成: [5, 10, 20, 65535]
特点
| 特性 | 说明 |
|---|---|
| ✅ 内存紧凑 | 连续存储,无指针开销 |
| ✅ 有序存储 | 支持二分查找 O(log n) |
| ✅ 自动升级 | 动态调整编码 |
| ❌ 不支持降级 | 一旦升级,不会降级 |
| ❌ 插入删除慢 | 需要移动元素 O(n) |
💡 应用场景:
- Set 类型(元素都是整数且数量 < 512)
6. 压缩列表(ZipList)
压缩列表是 Redis 为了节省内存设计的顺序型数据结构。
注意:Redis 7.0 开始被 ListPack 替代,但理解它有助于理解 Redis 的优化思想。
结构设计
完整结构:
┌────────┬────────┬────────┬─────────┬─────────┬────────┐
│zlbytes │ zltail │ zllen │ entry1 │ entry2 │ zlend │
│ (4B) │ (4B) │ (2B) │ │ │ (1B) │
└────────┴────────┴────────┴─────────┴─────────┴────────┘
Entry 节点:
┌──────────────┬──────────┬──────────┐
│ previous_ │ encoding │ content │
│ entry_length │ │ │
└──────────────┴──────────┴──────────┘
连锁更新问题
这是 ZipList 的经典问题:
场景:多个长度为 253 字节的节点
初始:
[e1:253B] [e2:253B] [e3:253B] [e4:253B]
↑ previous_entry_length = 253 (1 字节)
插入 254 字节的 e0:
[e0:254B] [e1:253B] [e2:253B] [e3:253B] [e4:253B]
连锁反应:
1. e1 的 previous_entry_length 需要记录 254
→ 从 1 字节扩展为 5 字节
→ e1 长度变为 257 字节
2. e2 的 previous_entry_length 需要记录 257
→ 也要扩展为 5 字节
→ e2 长度变为 257 字节
3. e3、e4... 依次连锁更新
最坏情况:O(n²) 时间复杂度
特点
| 特性 | 说明 |
|---|---|
| ✅ 内存极致压缩 | 连续内存,紧凑编码 |
| ✅ 遍历高效 | CPU 缓存友好 |
| ❌ 插入删除慢 | 需要重新分配内存 |
| ❌ 连锁更新风险 | 最坏 O(n²) |
💡 应用场景(Redis 7.0 前):
- List、Hash、ZSet(小数据)
7. 快速列表(QuickList)
QuickList 是 Redis 3.2 引入的数据结构,结合了 ZipList 和 LinkedList 的优点。
设计思想
LinkedList 问题:内存开销大,缓存不友好
ZipList 问题:插入删除慢,连锁更新
QuickList 方案:
链表 + 压缩列表的混合结构
每个链表节点是一个 ziplist
结构定义
// QuickList
typedef struct quicklist {
quicklistNode *head;
quicklistNode *tail;
unsigned long count; // 总元素数
unsigned long len; // 节点数
int fill : QL_FILL_BITS; // 填充因子
unsigned int compress : QL_COMP_BITS; // 压缩深度
} 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; // RAW=1, LZF=2
} quicklistNode;
结构图示
┌────────────────────────────────────────┐
│ QuickList (count=500, len=5) │
└────┬───────────────────────────────────┘
↓
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Node1 │──→│ Node2 │──→│ Node3 │
│count=100│ │count=100│ │count=100│
└────┬────┘ └────┬────┘ └────┬────┘
↓ ↓ ↓
┌────────┐ ┌────────┐ ┌────────┐
│ ZipList│ │ ZipList│ │ ZipList│
│ (压缩) │ │(未压缩)│ │ (压缩) │
└────────┘ └────────┘ └────────┘
配置参数
# redis.conf
# 填充因子:控制每个 ziplist 的大小
list-max-ziplist-size -2
# -1: 每个 ziplist 最多 4KB
# -2: 每个 ziplist 最多 8KB(默认)
# -3: 每个 ziplist 最多 16KB
# 压缩深度:中间节点压缩
list-compress-depth 0
# 0: 不压缩(默认)
# 1: 首尾各 1 个不压缩
# 2: 首尾各 2 个不压缩
优势
| 特性 | QuickList | LinkedList | ZipList |
|---|---|---|---|
| 内存占用 | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
| 插入删除 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ |
| 查找速度 | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ |
| 缓存友好 | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
💡 应用场景:
- ✅ Redis List 数据类型(Redis 3.2+)
8. 紧凑列表(ListPack)
ListPack 是 Redis 7.0 引入的数据结构,用于替代 ZipList,解决了连锁更新问题。
核心改进
ZipList Entry:
┌──────────────┬──────────┬─────────┐
│ previous_ │ encoding │ content │ ← 依赖前一个节点
│ entry_length │ │ │
└──────────────┴──────────┴─────────┘
ListPack Entry:
┌──────────┬─────────┬───────────────┐
│ encoding │ content │ entry_length │ ← 存储自己的长度
│ │ │ (backlen) │ 在尾部
└──────────┴─────────┴───────────────┘
关键:不存储前一个节点的长度,避免连锁更新!
结构设计
完整结构:
┌─────────┬─────────┬───────┬───────┬─────────┐
│Total │Num │Entry1 │Entry2 │ End │
│Bytes │Elements │ │ │ (0xFF) │
│(4 bytes)│(2 bytes)│ │ │(1 byte) │
└─────────┴─────────┴───────┴───────┴─────────┘
示例
存储 ["hello", 42, "world"]
Entry1 ("hello"):
┌────────┬─────────────┬────────┐
│10000101│ h e l l o │ 08 │
│(编码: │ (5字节) │(backlen)│
│6位字符串│ │ │
│长度=5) │ │ │
└────────┴─────────────┴────────┘
Entry2 (42):
┌────────┬──────┬────────┐
│00101010│ (无) │ 01 │
│(编码: │ │(backlen)│
│7位整数,│ │ │
│值=42) │ │ │
└────────┴──────┴────────┘
ListPack vs ZipList
插入对比:
ZipList:
插入 254 字节节点
↓
触发连锁更新
↓
可能影响所有后续节点(O(n²))
ListPack:
插入任意大小节点
↓
只修改自己的 entry_length
↓
不影响其他节点(O(1))
优势
| 特性 | ListPack | ZipList |
|---|---|---|
| 内存紧凑 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 插入删除 | ⭐⭐⭐⭐ | ⭐⭐ |
| 连锁更新 | ✅ 无 | ❌ 有 |
| 反向遍历 | ✅ 支持 | ✅ 支持 |
💡 应用场景(Redis 7.0+):
- ✅ Hash 类型
- ✅ ZSet 类型
- ✅ Stream 类型
- ✅ QuickList 底层存储
数据结构对比总结
| 数据结构 | 时间复杂度 | 内存占用 | 主要特点 | 应用场景 |
|---|---|---|---|---|
| SDS | O(1) 获取长度 | 较低 | 二进制安全,减少重分配 | 所有字符串 |
| LinkedList | O(1) 头尾操作 | 高 | 双向链表 | 发布订阅 |
| Dict | O(1) 平均 | 中 | 渐进式 rehash | 数据库、Hash |
| SkipList | O(log n) | 较高 | 有序,范围查询快 | ZSet |
| IntSet | O(log n) 查找 | 极低 | 有序整数集合 | Set(整数) |
| ZipList | O(n) | 极低 | 紧凑,有连锁更新 | 废弃(7.0) |
| QuickList | O(1) 头尾 | 低 | 混合结构 | List |
| ListPack | O(n) | 极低 | 无连锁更新 | Hash/ZSet |
实战建议
1. 内存优化
# 优化 Hash 类型内存
hash-max-listpack-entries 512
hash-max-listpack-value 64
# 当字段数 < 512 且每个值 < 64 字节时
# 使用 ListPack(内存紧凑)
# 否则使用 Dict(性能更好)
2. 避免 BigKey
# 查找大键
redis-cli --bigkeys
# 大键的危害:
# - 占用大量内存
# - 删除耗时长(阻塞主线程)
# - 网络传输慢
3. 选择合适的数据类型
| 场景 | 推荐类型 | 原因 |
|---|---|---|
| 简单 KV | String | 最基础,性能好 |
| 对象存储 | Hash | 节省内存,支持单字段操作 |
| 排行榜 | ZSet | 有序,支持范围查询 |
| 队列 | List | 支持头尾操作 |
| 去重 | Set | 自动去重 |
| 统计 UV | HyperLogLog | 极省内存 |
4. 监控关键指标
# 查看内存统计
INFO memory
# 关键指标:
# - used_memory: 已使用内存
# - mem_fragmentation_ratio: 内存碎片率
# - evicted_keys: 淘汰的键数量
# 慢查询日志
SLOWLOG GET 10
参考资料
- 📚 Redis 设计与实现 - 黄健宏
- 📚 Redis 源码
- 📚 Redis 官方文档
- 📄 Redis 7.0 Release Notes
- 📄 Antirez's Blog
总结
Redis 的高性能离不开其精心设计的底层数据结构:
- SDS 解决了 C 字符串的性能和安全问题
- Dict 的渐进式 rehash 避免阻塞
- SkipList 平衡了实现复杂度和查询性能
- IntSet 通过编码升级优化内存
- QuickList 结合了链表和压缩列表的优点
- ListPack 解决了 ZipList 的连锁更新问题
理解这些底层原理,能帮助你:
- ✅ 正确选择数据类型
- ✅ 优化内存使用
- ✅ 避免性能陷阱
- ✅ 快速排查问题
💡 下一篇预告:《Redis 对象系统深度解析:从 RedisObject 到五大数据类型》
敬请期待!
如果觉得本文对你有帮助,欢迎点赞、收藏、分享!
有任何问题或建议,欢迎在评论区留言交流。