Redis 存储原理与数据模型深度解析
前言
Redis 是后端开发中最重要的内存数据库之一,以高性能著称。但很多同学对 Redis 的认知只停留在「KV 存储」层面,对其底层的存储原理、数据模型、多线程架构等了解不够深入。本文将从线程模型、存储结构、数据编码、网络 IO 等多个维度,全面剖析 Redis 的内部原理,帮助你彻底理解 Redis 为什么这么快。
一、Redis 线程模型:单线程还是多线程?
1.1 常见的误解
很多人认为 Redis 是单线程的,这是一个不完全准确的认知。
实际上,Redis 采用了多线程架构,只是不同线程负责不同的工作:
┌─────────────────────────────────────────────────────────────────┐
│ Redis 进程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 主线程 │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ 命令处理(单线程) │ │ │
│ │ │ processCommand() - 核心业务逻辑 │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │
│ │ bio_ │ │ bio_ │ │ io_thd_ │ │ jemalloc_bg_ │ │
│ │ close_ │ │ aof_ │ │ * │ │ thd │ │
│ │ file │ │ fsync │ │ (IO多线程)│ │ (jemalloc后台) │ │
│ │ 关闭文件 │ │ AOF刷盘 │ │ 网络IO │ │ 内存分配器 │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────────────┘ │
│ │
│ bio_lazy_free - 异步清理大块内存 │
│ │
└─────────────────────────────────────────────────────────────────┘
1.2 Redis 的线程分类
| 线程类型 | 名称 | 功能 |
|---|---|---|
| 主线程 | main | 命令处理、核心业务逻辑 |
| bio 线程 | bio_close_file | 异步关闭大文件 |
| bio 线程 | bio_aof_fsync | 异步 AOF 刷盘 |
| bio 线程 | bio_lazy_free | 异步清理大块内存 |
| io 线程 | io_thd_* | IO 多线程处理网络 IO |
| jemalloc 线程 | jemalloc_bg_thd | jemalloc 后台线程 |
1.3 核心结论
Redis 的命令处理(核心业务逻辑)是单线程的,但整个 Redis 进程不是单线程的。
为什么这样设计?
- 命令处理是 CPU 密集型,单线程足够高效
- IO 操作(网络、磁盘)可以由专门的线程处理
- 避免复杂的锁竞争,简化代码实现
二、命令处理为什么是单线程?
2.1 单线程的局限性
既然是多线程架构,为什么命令处理还要用单线程?
单线程的三大局限:
- 不能有耗时操作 — 如果有耗时操作,会阻塞后续所有请求
- 不能有 CPU 密集型操作 — CPU 密集型会独占 CPU,导致其他请求等待
- 不能有阻塞 IO — 阻塞 IO 会导致线程卡住,无法处理其他请求
Redis 的核心要求:
- Redis 是内存数据库,性能极高
- 每个请求的响应延迟要求很低(微秒级)
- 必须保证请求的串行化处理
2.2 如果用多线程会怎样?
多线程命令处理的问题:
线程 A:GET key1 ─┐
线程 B:SET key2 value ├─ 并发执行
线程 C:DEL key3 ─┘
问题 1:加锁复杂
- string、list、hash、set、zset 的底层数据结构各不相同
- 锁的颗粒度难以控制
- 可能出现死锁
问题 2:锁竞争激烈
- 多个线程修改同一个 key
- 线程等待锁,消耗 CPU
- 上下文切换开销抵消多线程优势
问题 3:数据竞争
- 线程 A 读取 key
- 线程 B 修改 key
- 线程 A 使用的 key 已经是旧数据
2.3 为什么单线程够用?
Redis 的特点:
- 内存操作,延迟极低(纳秒级)
- 大部分操作是 O(1) 或 O(log n)
- 单线程已经能够处理百万级 QPS
核心原因:
Redis 单线程性能瓶颈不在 CPU,而在网络 IO
Redis 6.0 之前:
- 主线程处理网络 IO 和命令执行
- 单线程处理百万级 QPS 绰绰有余
Redis 6.0 之后:
- 将网络 IO 交给多线程处理
- 主线程只负责命令执行
- 进一步提升性能
三、IO 密集型 vs CPU 密集型
3.1 基本概念
| 类型 | 说明 | Redis 中的体现 |
|---|---|---|
| IO 密集型 | 等待 IO 完成的操作占总时间的大部分 | 网络 IO、磁盘 IO |
| CPU 密集型 | 计算为主的操作占总时间的大部分 | 数据结构操作、协议解析 |
3.2 Redis 的 IO 密集型场景
网络 IO:
- 服务多个客户端连接
- 请求和响应的数据量可能很大
- Redis 采用 IO 多路复用(epoll/select/kqueue)
磁盘 IO:
- RDB 持久化(fork 子进程快照)
- AOF 持久化(异步刷盘)
解决方案:
// 磁盘 IO - 通过其他进程/线程处理
1. RDB 持久化:fork 子进程,在子进程中完成持久化
2. AOF 刷盘:bio 线程异步执行 fsync
// 网络 IO - IO 多线程 + IO 多路复用
1. epoll/select/kqueue 检测 socket 就绪状态
2. 就绪后通过回调函数处理
3. Redis 6.0+ 引入 io_thr_* 多线程处理网络 IO
3.3 Redis 的 CPU 密集型场景
数据结构操作:
- 哈希表查找
- 跳表搜索
- 压缩列表遍历
解决方案:
1. 数据结构选择
- 根据实际场景切换数据结构
- O(1) vs O(log n) 的选择
2. 分治策略
- 大数据拆分成小数据
- 分批处理
3. 渐进式迁移
- 耗时工作分成小批次
- 每个请求处理一小部分
四、单线程为什么这么快?
4.1 硬件层面的差距
┌─────────────────────────────────────────────────────────────────┐
│ 内存 vs 磁盘 速度对比 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ CPU 寄存器: 0.5 ns │
│ L1 缓存: 1 ns │
│ L2 缓存: 3 ns │
│ L3 缓存: 10 ns │
│ 内存: 100 ns ◀ Redis 在这个层级 │
│ NVMe SSD: 100,000 ns (100 µs) │
│ 机械硬盘: 10,000,000 ns (10 ms) │
│ │
│ 内存比磁盘快 100,000 倍! │
│ │
└─────────────────────────────────────────────────────────────────┘
结论:
- Redis 数据全在内存,操作速度极快
- 即使单线程,也比磁盘数据库快几个数量级
4.2 数据结构的效率
Redis 使用了高效的数据结构:
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| GET/SET | O(1) | 哈希表直接访问 |
| HGET/HSET | O(1) | 哈希表操作 |
| LPUSH/RPOP | O(1) | 双向链表 |
| ZADD | O(log N) | 跳表 |
| LRANGE | O(N) | 需要遍历 |
4.3 高效的哈希函数
Redis 采用 SipHash 作为哈希函数:
- 随机性好
- 碰撞概率低
- 对于相近的 key 也能产生差异较大的哈希值
4.4 避免锁竞争
单线程模型天然避免了锁竞争:
- 无需加锁
- 无死锁风险
- 无上下文切换开销
五、哈希表(Hash Table)原理
5.1 Redis 哈希表结构
Redis 的哈希表是所有 KV 存储的基础:
// Redis 哈希表结构
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小(2^N)
long size;
// 哈希表大小掩码(用于计算索引)
long sizemask;
// 已使用的节点数量
long used;
} dictht;
// 哈希表节点
typedef struct dictEntry {
// 键
void *key;
// 值(可以是任意类型)
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
// 指向下一个节点的指针(拉链法解决冲突)
struct dictEntry *next;
} dictEntry;
5.2 哈希表工作原理
┌─────────────────────────────────────────────────────────────────┐
│ Redis 哈希表工作原理 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ SET key value 的存储过程: │
│ │
│ 1. 哈希函数计算 │
│ key → SipHash → 64位整数 │
│ │
│ 2. 计算索引 │
│ index = hash(key) % size │
│ index = hash(key) & (size - 1) // 位运算更快 │
│ │
│ 3. 存入哈希桶 │
│ table[index] → [key:value] → [key:value] → ... │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ table(哈希桶数组) │ │
│ │ │ │
│ │ [0] → NULL │ │
│ │ [1] → {key1:val1} → {key2:val2} │ │
│ │ [2] → NULL │ │
│ │ [3] → {key3:val3} │ │
│ │ ... │ │
│ │ [7] → NULL │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
5.3 哈希表查找过程
GET key 的查找过程:
1. 哈希函数计算
key → SipHash → 64位整数
2. 计算索引
index = hash(key) & (size - 1)
3. 遍历哈希桶
table[index] → 遍历链表比较 key
4. 找到目标或返回 NULL
时间复杂度:O(1)(理想情况下)
5.4 哈希冲突与解决
哈希冲突: 当多个 key 计算出的索引相同时,就会产生冲突。
Redis 采用拉链法(Separate Chaining):
- 每个哈希桶是一个链表
- 冲突的元素挂在同一个桶的链表上
- 查找时需要遍历链表
[1] → [key1:val1] → [key2:val2] → [key3:val3]
哈希冲突,挂在同一个桶
5.5 负载因子与扩容
负载因子(Load Factor):
load_factor = used / size
- used:已存储的元素个数
- size:哈希表的槽位数(2^N)
扩容规则:
- 当
load_factor > 5时,立即扩容(冲突太严重) - 当
load_factor > 1且 Redis 在后台执行 Rehash 时,扩容 - 扩容策略:翻倍扩容(size × 2)
缩容规则:
- 当
load_factor < 0.1时,缩容 - 缩容规则:
2^N(如 9 个元素 → 16 个槽位)
为什么负载因子小可以减少冲突?
负载因子越小,槽位越充裕,冲突概率越低
负载因子 = 0.5:每两个槽位放一个元素
负载因子 = 2.0:每个槽位平均放两个元素(冲突多)
六、渐进式 Rehash
6.1 什么是 Rehash?
当哈希表需要扩容或缩容时,需要重新计算所有 key 的哈希值,将它们移动到新的哈希表中。这个过程叫做 Rehash。
Rehash 前的状态:
ht[0]: [0]→a [1]→b [2]→c [3]→NULL
size=4, used=3
Rehash 后的状态(扩容):
ht[1]: [0]→NULL [1]→a [2]→b [3]→c [4]→NULL [5]→NULL [6]→NULL [7]→NULL
size=8, used=3
6.2 为什么需要渐进式 Rehash?
问题: 如果哈希表有上百万个 key,一次性 Rehash 会导致 Redis 卡顿!
场景:2GB 哈希表扩容到 4GB
一次性 Rehash:
- 需要重新计算所有 key 的哈希值
- 移动所有 key 到新表
- 耗时可能达到几秒钟
- Redis 完全阻塞,无法处理请求
这对于一个高并发服务来说是不可接受的!
6.3 渐进式 Rehash 策略
Redis 采用渐进式 Rehash,将耗时的工作分散到多次请求中:
┌─────────────────────────────────────────────────────────────────┐
│ 渐进式 Rehash 原理 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Redis 的 Rehash 策略: │
│ │
│ 1. 准备两个哈希表 │
│ ht[0] - 当前使用的哈希表 │
│ ht[1] - 扩容/缩容的目标哈希表 │
│ │
│ 2. 分摊迁移 │
│ - 每次增删改查操作,顺便迁移一个哈希桶 │
│ - 多次请求分散 Rehash 的开销 │
│ │
│ 3. 定时器补充 │
│ - Redis 有个定时任务(100ms 周期) │
│ - 每次最多迁移 1ms 的数据,步长 100 个索引 │
│ │
│ 迁移过程: │
│ │
│ 初始:ht[0] 有数据,ht[1] 为空 │
│ │
│ 请求1:迁移 ht[0][0],ht[1][0..1] 填充完毕 │
│ 请求2:迁移 ht[0][1],ht[1][2..3] 填充完毕 │
│ 请求3:迁移 ht[0][2],ht[1][4..5] 填充完毕 │
│ ... │
│ 请求N:ht[0] 全部迁移完成,交换 ht[0] 和 ht[1] │
│ │
└─────────────────────────────────────────────────────────────────┘
6.4 Rehash 的触发时机
| 触发条件 | 动作 |
|---|---|
load_factor >= 1 且 dict_can_resize = 1 |
后台开始 Rehash |
load_factor > 5 |
强制扩容(冲突太严重) |
load_factor < 0.1 |
缩容 |
| Fork 子进程期间 | 禁止 Rehash(COW 机制) |
6.5 Rehash 期间的查找
查找时会查询两个哈希表:
dictEntry* dictFind(dict *d, const void *key) {
// ...
// 如果正在 Rehash,查询 ht[1]
if (d->rehashidx != -1) {
// 先查 ht[0]
// 再查 ht[1]
} else {
// 只查 ht[0]
}
// ...
}
七、SCAN 命令详解
7.1 为什么不用 KEYS 命令?
KEYS 命令的问题:
KEYS * # 遍历所有 key
问题:
- O(N) 时间复杂度,N 是所有 key 的数量
- 一次性返回所有匹配结果
- 阻塞 Redis 直到执行完毕
- 大数据量时可能导致 Redis 卡顿
SCAN 命令的优势:
SCAN 0 MATCH pattern COUNT 100
特点:
- 基于游标(cursor)的增量迭代
- 每次返回一小批 key
- 非阻塞,分批执行
- 可以随时停止
7.2 SCAN 的工作原理
┌─────────────────────────────────────────────────────────────────┐
│ SCAN 命令工作原理 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ SCAN 采用「高位进位加法」遍历: │
│ │
│ 假设哈希表有 8 个桶(0-7): │
│ │
│ 二进制表示: │
│ 000 → 0 001 → 1 010 → 2 011 → 3 │
│ 100 → 4 101 → 5 110 → 6 111 → 7 │
│ │
│ 高位进位加法遍历顺序: │
│ 000 → 100 → 010 → 110 → 001 → 101 → 011 → 111 │
│ 0 → 4 → 2 → 6 → 1 → 5 → 3 → 7 │
│ │
│ 优点: │
│ - 遍历顺序连续(逻辑相邻的 key 物理位置也相邻) │
│ - Rehash 时不会漏掉或重复遍历 │
│ │
└─────────────────────────────────────────────────────────────────┘
7.3 Rehash 期间的 SCAN 行为
关键问题: SCAN 在遍历过程中,如果发生 Rehash,会怎样?
场景:
1. SCAN 开始遍历 ht[0]
2. 遍历到一半,Rehash 开始了
3. 部分 key 迁移到了 ht[1]
问题:
- 可能漏掉某些 key(还在 ht[0] 但已经跳过了)
- 可能重复遍历某些 key(迁移过程中)
解决:
Redis 在 Rehash 期间,会遍历两个哈希表
保证:不重复、不遗漏
7.4 连续缩容导致的重复问题
问题原因: 连续两次缩容时,可能出现数据重复。
假设:
- 第一次缩容:8 → 4
- 第二次缩容:4 → 2
高位进位加法:
- 8 个桶遍历顺序:0→4→2→6→1→5→3→7
- 4 个桶遍历顺序:0→2→1→3
如果遍历 8 个桶时发生两次缩容:
- 某些 key 会被重复返回
这并不是 bug,而是 SCAN 的设计权衡:
- 优先保证不遗漏
- 允许少量重复(可以通过去重解决)
八、字符串(String)数据结构
8.1 Redis String 的多种编码
Redis 的 String 并不只是一个简单的字符串,根据数据特点有多种编码:
| 编码 | 条件 | 说明 |
|---|---|---|
| INT | 字符串是整数,且能用 64 位表示 | 直接存储为整数 |
| EMBSTR | 字符串长度 ≤ 44 字节 | 嵌入式字符串 |
| RAW | 字符串长度 > 44 字节 | 动态字符串 |
8.2 为什么用 44 字节作为分界线?
关键因素:
Redis 对象头(redisObject):16 字节
SDS 头部:
- sdshdr8:3 字节(长度 + 预留 + flags)
- 结尾 '\0':1 字节
总头部:16 + 3 + 1 = 20 字节
Redis 单个对象最大:64 字节(CPU cache line)
44 = 64 - 20
8.3 SDS(Simple Dynamic String)
Redis 的字符串采用 SDS 结构:
// SDS 头结构
struct __attribute__ ((__packed__)) sdshdr8 {
// 字符串长度
uint8_t len;
// 分配空间(不包括头部和结尾 '\0')
uint8_t alloc;
// 头部类型(sdshdr8/sdshdr16/sdshdr32/sdshdr64)
unsigned char flags;
// 柔性数组(必须是结构体最后一个成员)
char buf[];
};
8.4 SDS vs C 字符串
| 特性 | C 字符串 | SDS |
|---|---|---|
| 获取长度 | O(N) - 需要遍历 | O(1) - 直接读取 len |
| 追加操作 | 可能溢出 | 自动扩容 |
| 二进制安全 | 否(以 \0 结尾) | 是(以长度区分) |
| 内存分配 | 频繁分配 | 预分配 + 惰性释放 |
8.5 SDS 内存分配策略
追加字符串(APPEND)的内存分配:
原始字符串:"hello" (len=5, alloc=5)
APPEND " world" 后:
- 新字符串长度:5 + 6 = 11
- 如果 alloc < 11,需要扩容
- 扩容策略:
- 长度 < 1MB:扩容 2 倍
- 长度 >= 1MB:扩容 + 1MB
8.6 为什么 Redis 字符串以 64 字节为分界线?
原因 1:CPU Cache Line
CPU Cache Line 通常是 64 字节
如果一个 Redis 对象占用超过 64 字节:
- 无法在一个 Cache Line 中存储
- 需要多次内存访问
- 性能下降
所以 Redis 尽量让每个对象 <= 64 字节
原因 2:jemalloc 内存分配器
jemalloc 按 2^N 大小分配内存:
- 32 字节
- 64 字节
- 128 字节
- ...
64 字节是一个常用的分配单位
九、列表(List)数据结构
9.1 列表的底层编码
Redis List 有两种底层实现:
| 编码 | 条件 | 说明 |
|---|---|---|
| quicklist | 通用场景 | 双向链表 + 压缩列表的组合 |
| listpack | Redis 7.0+ 默认 | 紧凑列表(quicklist 的替代) |
9.2 quicklist 结构
quicklist = 双向链表 + 压缩列表(ziplist):
┌─────────────────────────────────────────────────────────────────┐
│ quicklist 结构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ quicklist │
│ ├── head → ziplist ←→ ziplist ←→ ziplist │
│ └── tail → ziplist ←→ ziplist │
│ │
│ 每个 ziplist 是一个紧凑的连续内存块 │
│ 多个 ziplist 通过双向指针连接 │
│ │
│ 优点: │
│ - 插入/删除 O(1)(在链表层面) │
│ - 每个节点是 ziplist,占用紧凑 │
│ - 避免单个 ziplist 过大导致的内存分配问题 │
│ │
└─────────────────────────────────────────────────────────────────┘
9.3 压缩列表(ziplist)
ziplist 是一种紧凑的连续内存结构:
┌─────────────────────────────────────────────────────────────────┐
│ ziplist 结构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┬─────────────────────┬────────┐ │
│ │ zlbytes │ zlentries │ zltail │ ── 元数据 │
│ │ (4字节) │ (2字节) │ (4字节)│ │
│ └──────────┴─────────────────────┴────────┘ │
│ │
│ ┌─────────┬──────────┬─────────┬──────────┐ │
│ │ prevlen │ encoding │ data │ ... │ ── 节点 │
│ └─────────┴──────────┴─────────┴──────────┘ │
│ │
│ ┌─────────┐ │
│ │ zlend │ ── 结束标记 (0xFF) │
│ └─────────┘ │
│ │
│ prevlen:前一个节点的长度(可变长编码) │
│ encoding:当前节点的编码类型和长度 │
│ data:实际数据 │
│ │
└─────────────────────────────────────────────────────────────────┘
ziplist 的特点:
- 连续内存,节省指针开销
- 节省内存,但插入/删除需要内存移动
- 节点数量少时效率高
9.4 listpack(Redis 7.0+)
listpack 是 ziplist 的替代方案:
区别:
- ziplist:prevlen 可变长,遍历时需要从后往前计算
- listpack:prevlen 固定长度(或使用特殊编码),支持正向遍历
优点:
- 更简单的遍历实现
- 更安全的增量删除
9.5 quicklist 的切换条件
ziplist 切换到 quicklist:
| 条件 | 阈值 |
|---|---|
| 元素数量 | > 512 个 |
| 单个元素长度 | > 64 字节 |
配置参数:
list-max-ziplist-size -2 # 节点数量限制
十、字典(Dict)数据结构
10.1 字典的应用场景
Redis 的数据库(db)就是一个字典:
┌─────────────────────────────────────────────────────────────────┐
│ Redis DB 结构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ redisDb { │
│ dict *dict; // 主键字典 │
│ dict *expires; // 过期时间字典 │
│ dict *blocking_keys; // 阻塞的 key(BLPOP) │
│ dict *watched_keys; // WATCH 的 key │
│ } │
│ │
│ 每个 redisDb 是一个独立的字典空间 │
│ Redis 默认有 16 个数据库(db0 ~ db15) │
│ │
└─────────────────────────────────────────────────────────────────┘
10.2 字典的结构
typedef struct dict {
// 字典类型(包含哈希函数、比较函数等)
dictType *type;
// 私有数据
void *privdata;
// 两个哈希表(用于 Rehash)
dictht ht[2];
// Rehash 索引(-1 表示未进行)
long rehashidx;
// 迭代器数量
int iterators;
} dict;
10.3 过期字典(expires)
redisDb.expires 存储 key 的过期时间:
key → expire_timestamp
127.0.0.1:6379> SET mykey "hello"
127.0.0.1:6379> EXPIRE mykey 60
expires 字典:
{
"mykey": 1699999999 // 60秒后的 Unix 时间戳
}
10.4 字典的 Rehash 过程
与哈希表的 Rehash 相同(见第六章)。
十一、有序集合(Zset)数据结构
11.1 zset 的两种编码
| 编码 | 条件 | 说明 |
|---|---|---|
| ziplist | 元素数量 ≤ 128 且所有元素长度 ≤ 64 | 压缩列表 |
| skiplist + dict | 其他情况 | 跳表 + 字典 |
11.2 跳表(Skip List)原理
跳表是一种有序链表,通过多级索引实现快速查找:
┌─────────────────────────────────────────────────────────────────┐
│ 跳表结构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Level 2: ──────────────────────→ [5] ──────────────→ [9] │
│ Level 1: ────────→ [2] ────────→ [5] ────────→ [9] ────→ │
│ Level 0: [1] ──→ [2] ──→ [3] ──→ [5] ──→ [7] ──→ [9] ──→ │
│ │
│ 查找 5: │
│ 1. Level 2 从头开始:[1] → 跳过,直接到 [5] ← 一步到位! │
│ 2. 或者 Level 1 逐步查找 │
│ │
│ 时间复杂度:O(log N) │
│ │
└─────────────────────────────────────────────────────────────────┘
11.3 为什么用跳表而不是红黑树?
| 特性 | 跳表 | 红黑树 |
|---|---|---|
| 查找复杂度 | O(log N) | O(log N) |
| 插入/删除 | O(log N) | O(log N) |
| 范围查询 | O(log N + M) | O(log N + M) |
| 实现复杂度 | 简单 | 复杂 |
| 面试难度 | 中等 | 困难 |
Redis 选择跳表的原因:
- 实现简单,容易理解和维护
- 范围查询(ZRANGE)更直接
- 可以用更少的代码实现相同的功能
11.4 跳表节点的随机层级
// 生成随机层级的算法
int randomLevel() {
int level = 1;
while (random() < 0.25) { // 25% 概率
level++;
}
return level < MAX_LEVEL ? level : MAX_LEVEL;
}
// MAX_LEVEL = 32
// 平均层级 = 1 / (1 - 0.25) = 1.33
11.5 zset 的双重结构
zset 同时使用跳表和字典:
typedef struct zset {
// 字典:O(1) 通过 member 找到 score
dict *dict;
// 跳表:O(log N) 范围查询、排序
skiplist *zsl;
} zset;
为什么需要两个结构?
需求:
1. ZRANGE key 0 100(按分数范围查询)→ 需要跳表
2. ZSCORE key member(获取成员分数)→ 需要字典
如果只用跳表:
- 范围查询 O(log N + M) ✓
- 单点查询 O(log N) ✗ 理想是 O(1)
如果只用字典:
- 单点查询 O(1) ✓
- 范围查询 O(N) ✗
所以 Redis 同时使用两个结构,空间换时间!
十二、Redis 网络 IO 与 Reactor 模型
12.1 Redis 的网络 IO 流程
┌─────────────────────────────────────────────────────────────────┐
│ Redis 网络 IO 完整流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ │
│ │ 客户端 │ │
│ └──────┬──────┘ │
│ │ TCP 连接 │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ 主线程 │ │
│ │ │ │
│ │ 1. acceptConnection() → 接收新连接 │ │
│ │ ↓ │ │
│ │ 2. readQueryFromClient() → 读取请求(epoll 触发) │ │
│ │ ↓ │ │
│ │ 3. processInputBuffer() → 解析协议 │ │
│ │ ↓ │ │
│ │ 4. processCommand() → 执行命令(核心业务逻辑) │ │
│ │ ↓ │ │
│ │ 5. addReply() → 添加响应到缓冲区 │ │
│ │ ↓ │ │
│ │ 6. writeToClient() → 发送响应(epoll 触发) │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
12.2 Reactor 模型概述
Reactor 模型是一种高性能的事件处理模式:
┌─────────────────────────────────────────────────────────────────┐
│ Reactor 模型 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ │
│ │ Handler │ ← 事件处理器 │
│ └────┬────┘ │
│ │ │
│ ┌────┴────┐ │
│ │ Reactor │ ← 事件分发器 │
│ └────┬────┘ │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │Handler │ │Handler │ │Handler │ │
│ │ (FD1) │ │ (FD2) │ │ (FD3) │ │
│ └────────┘ └────────┘ └────────┘ │
│ │
│ 核心组件: │
│ 1. Event Loop(事件循环) │
│ 2. Acceptor(连接接受器) │
│ 3. Handler(事件处理器) │
│ 4. Synchronous Event Demultiplexer(同步事件分发器) │
│ - select / poll / epoll / kqueue │
│ │
└─────────────────────────────────────────────────────────────────┘
12.3 Redis 的 IO 多路复用
Redis 使用 aeEventLoop 实现事件驱动:
// 事件循环结构
typedef struct aeEventLoop {
// 最大文件描述符
int maxfd;
// 已注册的文件描述符数量
int setsize;
// 事件状态数组
aeFileEvent *events;
// 已触发事件数组
aeFiredEvent *fired;
// 事件处理器
aeEventLoop *el;
// 事件处理函数
void *apidata;
// 定时器
aeTimeEvent *timeEventHead;
} aeEventLoop;
支持的 IO 多路复用实现:
| 实现 | 平台 | 特点 |
|---|---|---|
| epoll | Linux | 高效,O(1) 复杂度 |
| kqueue | macOS / BSD | 高效,O(1) 复杂度 |
| select | 通用 | 实现简单,有 FD 数量限制 |
| evport | Solaris | 高效 |
12.4 Redis 6.0 之前的 IO 模型
┌─────────────────────────────────────────────────────────────────┐
│ Redis 6.0 之前的 IO 模型(单线程) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 主线程: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ epoll_wait() → 等待事件 │ │
│ │ ↓ │ │
│ │ 处理已就绪的 socket │ │
│ │ ↓ │ │
│ │ read() → 读取数据 │ │
│ │ ↓ │ │
│ │ decode → 解析协议 │ │
│ │ ↓ │ │
│ │ compute → 执行命令 │ │
│ │ ↓ │ │
│ │ encode → 编码响应 │ │
│ │ ↓ │ │
│ │ write() → 发送响应 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 问题: │
│ - 网络 IO 和命令执行都在主线程 │
│ - IO 操作耗时会影响命令执行 │
│ │
└─────────────────────────────────────────────────────────────────┘
十三、IO 多线程优化
13.1 为什么需要 IO 多线程?
单线程 IO 的问题:
场景:大量并发连接,每个连接数据量较大
主线程瓶颈:
1. read() 读取数据 → 可能阻塞
2. write() 发送数据 → 可能阻塞(发送缓冲区满)
3. 这两个操作都比较耗时
结果:
- 主线程忙于 IO,无法专注命令执行
- 性能无法充分利用
13.2 Redis 6.0 IO 多线程架构
┌─────────────────────────────────────────────────────────────────┐
│ Redis 6.0 IO 多线程架构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 主线程 │ │
│ │ │ │
│ │ 1. epoll_wait() 等待事件 │ │
│ │ 2. 接收新连接 │ │
│ │ 3. 将任务分配给 IO 线程 │ │
│ │ 4. 等待所有 IO 线程完成 │ │
│ │ 5. 执行命令(processCommand) │ │
│ │ 6. 分配响应给 IO 线程发送 │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↑↓ ↑↓ ↑↓ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │IO线程1 │ │IO线程2 │ │IO线程3 │ │IO线程4 │ │IO线程N │ │
│ │读/写数据 │ │读/写数据 │ │读/写数据 │ │读/写数据 │ │读/写数据 │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ 特点: │
│ - IO 多线程只处理网络 IO(read/write) │
│ - 命令执行仍在主线程 │
│ - 通过队列协调主线程和 IO 线程 │
│ │
└─────────────────────────────────────────────────────────────────┘
13.3 IO 多线程工作流程
┌─────────────────────────────────────────────────────────────────┐
│ IO 多线程完整工作流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 主线程 accept 新连接 │
│ ↓ │
│ 2. 主线程将 socket 加入 epoll │
│ ↓ │
│ 3. epoll 返回可读事件(多个 socket) │
│ ↓ │
│ 4. 主线程创建任务,分配给 IO 线程队列 │
│ ↓ │
│ 5. IO 线程并行读取数据 │
│ 6. IO 线程解码协议 │
│ ↓ │
│ 7. 主线程等待所有 IO 线程完成 │
│ ↓ │
│ 8. 主线程执行命令(processCommand) │
│ ↓ │
│ 9. 主线程编码响应 │
│ ↓ │
│ 10. 主线程将响应任务分配给 IO 线程 │
│ ↓ │
│ 11. IO 线程并行发送数据 │
│ ↓ │
│ 12. 回到步骤 3 │
│ │
└─────────────────────────────────────────────────────────────────┘
13.4 如何开启 IO 多线程?
配置文件:
# redis.conf
io-threads 4 # IO 线程数量(建议 CPU 核心数 - 1)
io-threads-do-reads yes # 是否在 IO 线程中执行读操作
默认配置:
io-threads 1 # 默认单线程(无 IO 多线程)
io-threads-do-reads no
13.5 IO 多线程的注意事项
适用场景:
- 多核 CPU
- 高并发(万级 QPS 以上)
- 网络带宽不是瓶颈
不适用场景:
- CPU 密集型任务
- 单核环境
- 低并发场景(增加复杂度不值得)
十四、面试要点总结
14.1 核心知识点汇总
| 模块 | 核心概念 | 面试重点 |
|---|---|---|
| 线程模型 | 主线程 + bio + io_thd | 命令处理单线程,IO 多线程 |
| 命令处理单线程 | 避免锁竞争、简化实现 | 为什么单线程够用 |
| 哈希表 | 数组 + 链表、SipHash | 扩容、rehash |
| 渐进式 rehash | 分摊开销、定时补充 | 保证 Redis 不卡顿 |
| SCAN | 高位进位加法、增量遍历 | vs KEYS、重复问题 |
| SDS | 柔性数组、O(1) 长度获取 | vs C 字符串、44 字节 |
| quicklist | 双向链表 + ziplist | 压缩列表切换条件 |
| 跳表 | 多级索引、O(log N) | vs 红黑树、为什么用跳表 |
| zset | 跳表 + 字典 | 双重结构、空间换时间 |
| Reactor | 事件循环、IO 多路复用 | epoll vs select |
| IO 多线程 | Redis 6.0+ | 架构、适用场景 |
14.2 面试标准回答
Q1:Redis 是单线程还是多线程?
答:
Redis 采用多线程架构,但不同模块使用不同策略:
-
命令处理(核心业务逻辑):单线程
- 避免锁竞争
- 实现简单,性能足够高
-
IO 操作:多线程
- bio 线程:异步关闭文件、刷盘、清理内存
- io_thd 线程:Redis 6.0+ 用于网络 IO
-
内存分配:jemalloc 后台线程
Q2:Redis 单线程为什么这么快?
答:
- 内存操作:Redis 数据全在内存,比磁盘快 10 万倍
- 高效数据结构:哈希表 O(1)、跳表 O(log N)
- 避免锁竞争:单线程无需加锁
- IO 多路复用:epoll 高效处理大量并发连接
- 优化的哈希函数:SipHash 随机性好,碰撞少
Q3:哈希表扩容时 Redis 会不会卡顿?
答:
不会。Redis 采用渐进式 Rehash:
- 将扩容工作分摊到多次请求中
- 每次增删改查操作顺便迁移一个哈希桶
- 定时任务补充迁移(每次最多 1ms)
- 查找时会同时查询两个哈希表
只有在 load_factor > 5 时才会强制扩容,这是极端情况。
Q4:SCAN 和 KEYS 的区别?
| 特性 | KEYS | SCAN |
|---|---|---|
| 时间复杂度 | O(N) | O(1) 每次调用 |
| 阻塞 | 阻塞直到完成 | 非阻塞,分批返回 |
| 返回方式 | 一次性返回所有 | 游标迭代 |
| 适用场景 | 测试/开发 | 生产环境 |
| 重复遍历 | 无 | 可能少量重复 |
Q5:zset 为什么同时用跳表和字典?
答:
zset 需要支持两种操作:
- ZRANGE:按分数范围查询 → 需要跳表(O(log N + M))
- ZSCORE:获取成员分数 → 需要字典(O(1))
如果只用跳表,单点查询需要 O(log N);
如果只用字典,范围查询需要 O(N)。
Redis 同时使用两个结构,用空间换时间,两种操作都达到最优复杂度。
Q6:Redis 6.0 IO 多线程的作用是什么?
答:
Redis 6.0 引入 IO 多线程,只负责网络 IO:
- 读操作:主线程 accept 后,将任务分配给 IO 线程并行读取
- 写操作:命令执行完后,将响应分配给 IO 线程并行发送
主线程只负责:协议解析和命令执行(CPU 密集)。
适用场景:万级 QPS 以上、多核 CPU。
结语
本文深入剖析了 Redis 的存储原理和数据模型,包括线程架构、哈希表、渐进式 Rehash、SDS、quicklist、跳表、Reactor 模型、IO 多线程等核心知识点。理解这些内容不仅有助于面试,更能帮助你在实际工作中更好地使用 Redis。
希望本文对你有所帮助!如果觉得有用,欢迎点赞、评论、收藏!
根据零声教育教学写作https://github.com/0voice
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)