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 命令为例:

  1. 主线程接受连接acceptTCPHandler 创建客户端 socket,注册读事件。
  2. 数据到达readQueryFromClient 被触发,将任务放入 clients_pending_read 队列。
  3. 任务分派:主线程通过轮询将读任务分配给 IO 线程的专属队列。
  4. IO 线程并行处理:各 IO 线程从队列取出任务,调用 read 读取数据并解析为 Redis 命令(协议解码)。
  5. 主线程执行命令:等待所有 IO 线程完成后,主线程依次执行命令(如 setCommand)。
  6. 响应写入:命令结果放入 clients_pending_write 队列,再次分派给 IO 线程进行编码和发送。
  7. 剩余数据发送:若内核缓冲区满,注册写事件,由主线程或 IO 线程在可写时继续发送。

3. 配置与调试

  • 启用多线程:在 redis.conf 中设置 io-threads 4(通常建议不超过 CPU 核心数)。
  • 调试时可在相关函数(如 handleClientsWithPendingReadsUsingThreads)设置断点,观察任务分配与处理流程。

注意:IO 多线程只对大量网络数据读写有效,对于短命令或小数据量,单线程可能更快,需根据实际场景测试。

五、总结

Redis 的高性能源于其精妙的数据结构和线程模型:

  • 全局字典通过渐进式 rehash 保证扩容/缩容不影响服务。
  • 跳表为有序集合提供了高效的范围查询。
  • IO 多线程将网络读写与命令执行分离,进一步提升吞吐量。
posted @ 2026-03-26 22:00  xggx  阅读(25)  评论(0)    收藏  举报