Redis 存储原理与数据模型
Redis 存储原理与数据模型
一、Redis 线程模型
1. 单线程与多线程的构成
Redis 并非严格意义上的单线程,而是命令处理在主线程中单线程执行,同时利用辅助线程处理耗时操作。
| 线程类型 | 功能 | 说明 |
|---|---|---|
| 主线程 | 处理客户端命令(SET/GET 等) | 保证命令的原子性,避免锁开销 |
| BIO 线程 | 处理阻塞 IO 操作(关闭文件、持久化刷盘、大内存释放) | 避免主线程等待磁盘或释放操作 |
| IO 线程 | 处理网络读写(数据请求与响应) | 提升大量数据收发时的吞吐量 |
| jemalloc 线程 | 管理内存池分配与释放 | 优化内存操作性能 |
2. 单线程快的原因
- 内存数据库:所有数据操作在内存中完成,无需等待磁盘(除非使用 AOF always 策略)。
- 高效数据结构:哈希表、跳表等均针对内存访问优化。
- 非阻塞 I/O:使用 epoll 等事件驱动模型,单线程可处理大量并发连接。
3. 单线程的局限与优化
单线程串行执行命令,若某命令耗时过长(如 KEYS *、大键操作),会阻塞其他请求。Redis 通过以下方式优化:
- 磁盘 IO:通过
fork子进程进行 RDB 或 AOF 持久化,避免阻塞主线程。 - 网络 IO:启用 IO 多线程(6.0 以后)处理大量数据的读写。
- CPU 密集型:
- 根据数据量动态切换数据结构(如哈希从 ziplist 转为 dict)。
- 渐进式 rehash,将数据迁移分摊到多个命令中。
二、Redis 存储结构
1. 全局字典(dict)
Redis 所有键值对存储在一个字典结构中,每个 redisDb 对象包含多个字典:
struct redisDb {
dict *dict; // 存储所有键值对
dict *expires; // 存储过期键
dict *blocking_keys; // 阻塞连接的键
dict *watched_keys; // 事务 WATCH 的键
// ...
};
dict 结构采用双哈希表设计,支持渐进式 rehash:
struct dict {
dictType *type; // 类型特定函数(哈希、比较等)
void *privdata; // 私有数据
dictht ht[2]; // 两个哈希表
long rehashidx; // rehash 进度索引,-1 表示未进行
int16_t pauserehash; // 是否暂停 rehash
};
每个哈希表 dictht 包含一个指针数组(bucket),以及 size、used 等字段。
2. 哈希表扩容与缩容
2.1 扩容条件
- 常规扩容:
used >= size时触发。 - 特殊场景:若正在进行子进程持久化(RDB/AOF rewrite)且
used > size * 5,则立即扩容(避免长时间内存占用)。
扩容后新数组长度为第一个大于等于 used * 2 的 2 的幂。
2.2 缩容条件
- 当
used < size * 0.1(即负载因子低于 0.1)且size > 4时触发。
缩容后新数组长度为第一个大于等于 used 的 2 的幂,最小为 4。
3. 渐进式 rehash
为了避免全量迁移导致的阻塞,Redis 采用渐进式 rehash:
- 操作驱动迁移:每次对字典的增删改查操作,都会将当前
rehashidx指向的索引位置的所有键值对迁移到新哈希表,然后将rehashidx++。 - 空闲时间迁移:通过时间事件(serverCron)执行批量迁移,每次最多 1 毫秒,以 100 个索引为单位。
查询时,先在新哈希表查找,若未找到则到旧哈希表查找,确保数据不丢失。
3.1 rehash 的数学特性
扩容时(长度翻倍),原哈希槽中的元素只会出现在新哈希表的两个位置:原索引 或 原索引 + 原数组长度。这得益于哈希值的位运算特性,也为 SCAN 命令的可靠遍历提供了基础。
4. SCAN 命令原理
SCAN 命令用于遍历所有键,避免 KEYS * 的阻塞风险。它采用游标(cursor) 分批次返回数据,并利用 rehash 的特性保证遍历完整性。
-
遍历顺序:按照二进制高位优先的顺序遍历哈希表槽位,即使扩容缩容,也能保证每个键至少被返回一次(可能重复,需客户端去重)。
-
命令示例:
SCAN 0 MATCH user:* COUNT 100返回下一个游标和当前批次的键列表。当返回游标为 0 时表示遍历完成。
三、跳表(Skip List)
1. 跳表原理
跳表是一种概率平衡的数据结构,通过多级索引实现近似二分查找,平均时间复杂度 O(log n)。
- 基础层:有序链表存储所有元素。
- 上层索引:按概率(通常 1/2)选取节点作为索引,形成多级跳跃链表。
- 查询:从最高层开始,向右移动直到超过目标,然后下沉一层继续,最终在底层定位到元素。
2. 插入与随机层级
插入时,通过随机算法决定新节点的层高(概率逐层减半),从而保持结构平衡,无需全局重排。
随机层高伪代码:
int zslRandomLevel(void) {
int level = 1;
while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level += 1;
return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
3. Redis 中的应用
Redis 的有序集合(ZSet)在元素数量超过阈值(默认 128)或成员长度超过 64 字节时,底层由 listpack 切换为 字典 + 跳表 实现:
- 字典提供 O(1) 的成员查找。
- 跳表提供按分数排序的范围查询(如
ZRANGE)。
配置参数:
zset-max-ziplist-entries(默认 128)zset-max-ziplist-value(默认 64)
四、IO 多线程
1. 背景与设计思想
Redis 6.0 引入了 IO 多线程,主要解决网络读写(请求解析、响应编码)成为瓶颈的问题,而命令执行仍然保持单线程,避免锁复杂度。
核心思想:
- 主线程负责事件循环、命令执行。
- IO 线程负责将请求数据从 socket 读取并解析为命令,以及将响应结果编码后发送。
2. 工作流程
以客户端发送一个 SET 命令为例:
- 主线程接受连接:
acceptTCPHandler创建客户端 socket,注册读事件。 - 数据到达:
readQueryFromClient被触发,将任务放入clients_pending_read队列。 - 任务分派:主线程通过轮询将读任务分配给 IO 线程的专属队列。
- IO 线程并行处理:各 IO 线程从队列取出任务,调用
read读取数据并解析为 Redis 命令(协议解码)。 - 主线程执行命令:等待所有 IO 线程完成后,主线程依次执行命令(如
setCommand)。 - 响应写入:命令结果放入
clients_pending_write队列,再次分派给 IO 线程进行编码和发送。 - 剩余数据发送:若内核缓冲区满,注册写事件,由主线程或 IO 线程在可写时继续发送。
3. 配置与调试
- 启用多线程:在
redis.conf中设置io-threads 4(通常建议不超过 CPU 核心数)。 - 调试时可在相关函数(如
handleClientsWithPendingReadsUsingThreads)设置断点,观察任务分配与处理流程。
注意:IO 多线程只对大量网络数据读写有效,对于短命令或小数据量,单线程可能更快,需根据实际场景测试。
五、总结
Redis 的高性能源于其精妙的数据结构和线程模型:
- 全局字典通过渐进式 rehash 保证扩容/缩容不影响服务。
- 跳表为有序集合提供了高效的范围查询。
- IO 多线程将网络读写与命令执行分离,进一步提升吞吐量。

浙公网安备 33010602011771号