前言

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 单线程的局限性

既然是多线程架构,为什么命令处理还要用单线程?

单线程的三大局限:

  1. 不能有耗时操作 — 如果有耗时操作,会阻塞后续所有请求
  2. 不能有 CPU 密集型操作 — CPU 密集型会独占 CPU,导致其他请求等待
  3. 不能有阻塞 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 >= 1dict_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 采用多线程架构,但不同模块使用不同策略:

  1. 命令处理(核心业务逻辑):单线程

    • 避免锁竞争
    • 实现简单,性能足够高
  2. IO 操作:多线程

    • bio 线程:异步关闭文件、刷盘、清理内存
    • io_thd 线程:Redis 6.0+ 用于网络 IO
  3. 内存分配:jemalloc 后台线程

Q2:Redis 单线程为什么这么快?

答:

  1. 内存操作:Redis 数据全在内存,比磁盘快 10 万倍
  2. 高效数据结构:哈希表 O(1)、跳表 O(log N)
  3. 避免锁竞争:单线程无需加锁
  4. IO 多路复用:epoll 高效处理大量并发连接
  5. 优化的哈希函数:SipHash 随机性好,碰撞少
Q3:哈希表扩容时 Redis 会不会卡顿?

答:

不会。Redis 采用渐进式 Rehash

  1. 将扩容工作分摊到多次请求中
  2. 每次增删改查操作顺便迁移一个哈希桶
  3. 定时任务补充迁移(每次最多 1ms)
  4. 查找时会同时查询两个哈希表

只有在 load_factor > 5 时才会强制扩容,这是极端情况。

Q4:SCAN 和 KEYS 的区别?
特性 KEYS SCAN
时间复杂度 O(N) O(1) 每次调用
阻塞 阻塞直到完成 非阻塞,分批返回
返回方式 一次性返回所有 游标迭代
适用场景 测试/开发 生产环境
重复遍历 可能少量重复
Q5:zset 为什么同时用跳表和字典?

答:

zset 需要支持两种操作:

  1. ZRANGE:按分数范围查询 → 需要跳表(O(log N + M))
  2. ZSCORE:获取成员分数 → 需要字典(O(1))

如果只用跳表,单点查询需要 O(log N);
如果只用字典,范围查询需要 O(N)。

Redis 同时使用两个结构,用空间换时间,两种操作都达到最优复杂度。

Q6:Redis 6.0 IO 多线程的作用是什么?

答:

Redis 6.0 引入 IO 多线程,只负责网络 IO:

  1. 读操作:主线程 accept 后,将任务分配给 IO 线程并行读取
  2. 写操作:命令执行完后,将响应分配给 IO 线程并行发送

主线程只负责:协议解析和命令执行(CPU 密集)。

适用场景:万级 QPS 以上、多核 CPU。


结语

本文深入剖析了 Redis 的存储原理和数据模型,包括线程架构、哈希表、渐进式 Rehash、SDS、quicklist、跳表、Reactor 模型、IO 多线程等核心知识点。理解这些内容不仅有助于面试,更能帮助你在实际工作中更好地使用 Redis。

希望本文对你有所帮助!如果觉得有用,欢迎点赞、评论、收藏!


根据零声教育教学写作https://github.com/0voice

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐