Redis 底层数据结构深度解析

78 阅读15分钟

Redis 底层数据结构深度解析:从 SDS 到 ListPack

Redis 之所以能成为最受欢迎的内存数据库,离不开其精心设计的底层数据结构。本文将深入剖析 Redis 的 8 种基础数据结构,带你理解高性能背后的设计智慧。

📖 目录


为什么要了解 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 为什么选择跳跃表?

  1. ✅ 实现简单,代码易维护
  2. ✅ 范围查询效率高
  3. ✅ 支持反向遍历
  4. ✅ 不需要复杂的平衡操作

💡 应用场景

  • ✅ 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 个不压缩

优势

特性QuickListLinkedListZipList
内存占用⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
插入删除⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
查找速度⭐⭐⭐⭐⭐⭐⭐⭐⭐
缓存友好⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

💡 应用场景

  • ✅ 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))

优势

特性ListPackZipList
内存紧凑⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
插入删除⭐⭐⭐⭐⭐⭐
连锁更新✅ 无❌ 有
反向遍历✅ 支持✅ 支持

💡 应用场景(Redis 7.0+):

  • ✅ Hash 类型
  • ✅ ZSet 类型
  • ✅ Stream 类型
  • ✅ QuickList 底层存储

数据结构对比总结

数据结构时间复杂度内存占用主要特点应用场景
SDSO(1) 获取长度较低二进制安全,减少重分配所有字符串
LinkedListO(1) 头尾操作双向链表发布订阅
DictO(1) 平均渐进式 rehash数据库、Hash
SkipListO(log n)较高有序,范围查询快ZSet
IntSetO(log n) 查找极低有序整数集合Set(整数)
ZipListO(n)极低紧凑,有连锁更新废弃(7.0)
QuickListO(1) 头尾混合结构List
ListPackO(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. 选择合适的数据类型

场景推荐类型原因
简单 KVString最基础,性能好
对象存储Hash节省内存,支持单字段操作
排行榜ZSet有序,支持范围查询
队列List支持头尾操作
去重Set自动去重
统计 UVHyperLogLog极省内存

4. 监控关键指标

# 查看内存统计
INFO memory

# 关键指标:
# - used_memory: 已使用内存
# - mem_fragmentation_ratio: 内存碎片率
# - evicted_keys: 淘汰的键数量

# 慢查询日志
SLOWLOG GET 10

参考资料


总结

Redis 的高性能离不开其精心设计的底层数据结构:

  1. SDS 解决了 C 字符串的性能和安全问题
  2. Dict 的渐进式 rehash 避免阻塞
  3. SkipList 平衡了实现复杂度和查询性能
  4. IntSet 通过编码升级优化内存
  5. QuickList 结合了链表和压缩列表的优点
  6. ListPack 解决了 ZipList 的连锁更新问题

理解这些底层原理,能帮助你:

  • ✅ 正确选择数据类型
  • ✅ 优化内存使用
  • ✅ 避免性能陷阱
  • ✅ 快速排查问题

💡 下一篇预告:《Redis 对象系统深度解析:从 RedisObject 到五大数据类型》

敬请期待!


如果觉得本文对你有帮助,欢迎点赞、收藏、分享!

有任何问题或建议,欢迎在评论区留言交流。