Files
cs-note/hhs/Redis/00-Redis架构与线程模型.md
T
2026-05-26 13:52:08 +08:00

467 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [Redis, 缓存, 架构, 线程模型, IO多路复用]
create time: 2026-05-26 10:33
---
# Redis 架构与线程模型
## 概述
Redis 是一个**基于内存的数据结构服务器**(data structure server),支持 String、Hash、List、Set、Sorted Set 等多种结构,并提供持久化、发布订阅、Lua 脚本、Stream 等高级能力。
本文聚焦于一个核心问题:**Redis 的内部架构长什么样?它是怎么做到单线程还这么快的?**
> [!question] 单线程 → 慢?这是直觉误区
> 提到"单线程",大多数人的第一反应是"性能差"。但 Redis 单实例可达 **10 万+ QPS**,延迟在微秒级。为什么?因为对于常规 KV 操作,瓶颈通常不在 CPU,而在**网络 IO 和内存**。单线程执行命令反而省去了锁竞争和上下文切换的开销——这是 Redis 架构设计中最反直觉、也最关键的一个洞察。
## 整体架构
先从进程视角看 Redis 的内部模块组成:
```mermaid
flowchart TB
subgraph Redis进程
direction TB
NET["网络层<br/>epoll/kqueue + IO多路复用"]
EVENT["事件循环<br/>单线程处理所有命令"]
ENGINE["存储引擎<br/>内存数据结构"]
BIO["后台线程池<br/>BIO lazyfree / AOF fsync"]
RDB_CHILD["子进程<br/>BGSAVE / AOF rewrite"]
end
CLIENT["客户端"] -->|"TCP 连接"| NET
NET --> EVENT
EVENT --> ENGINE
ENGINE --> BIO
ENGINE -->|"fork()"| RDB_CHILD
RDB_CHILD -->|"持久化文件落盘"| DISK["磁盘"]
style EVENT fill:#f9d,stroke:#96c
style ENGINE fill:#dfd,stroke:#6c6
style NET fill:#bbf,stroke:#66c
```
> [!tip] 一句话理解 Redis
> Redis = **单线程事件循环** + **IO 多路复用** + **纯内存操作**。这三个要素共同造就了它的高性能。
## 为什么单线程能这么快?
这是面试高频问题,但大多数回答只停留在"避免了锁"。让我们从根上拆解。
### 快的根本原因
| 因素 | 说明 |
|------|------|
| **纯内存操作** | 数据全部驻留在内存中,读写延迟 ~100ns 级,比磁盘快 10 万倍 |
| **单线程无锁** | 不存在线程安全问题,无需加锁/解锁,省去上下文切换开销 |
| **IO 多路复用** | 一个线程同时监听成千上万个连接(epoll),不会被某个慢连接阻塞 |
| **高效数据结构** | SDS、ziplist、quicklist、skiplist 等结构针对内存和性能做了极致优化 |
| **通信协议简单** | RESP(Redis Serialization Protocol)结构极简,解析无状态、无复杂序列化,开销远低于 JSON/XML 类协议 |
> [!question] 那为什么 MySQL 不用单线程?
> 因为瓶颈不同。MySQL 的查询可能涉及**磁盘 IO**(随机读写)、复杂 SQL 解析、事务锁管理——这些操作耗时从微秒到毫秒不等,单线程会被磁盘 IO 阻塞,无法处理其他请求。多线程能让一个线程等磁盘时,其他线程继续处理请求。
>
> Redis 也有 IO——网络 IO(与客户端之间的 socket 读写),但 IO 多路复用(epoll)已经用**单线程 + 事件驱动**高效地解决了这个问题。真正执行命令的环节是纯内存操作,耗时在亚微秒级,CPU 几乎不会空闲,所以单线程足矣。
>
> 简单记:**瓶颈在阻塞型 IO(如磁盘 IO)→ 多线程有意义,避免单线程被阻塞时无法响应其他请求;内存操作足够快、IO 已被事件驱动解决 → 单线程省去锁和上下文切换的开销,反而更高效。**(当然,Redis 6.0 仍对网络 IO 读写做了多线程优化,说明高并发下网络 IO 本身也会成为瓶颈——详见后文。)
### 单线程的局限
单线程也意味着 Redis 的单个实例**无法利用多核 CPU**。如果你的命令执行时间过长(如 `KEYS *`、大集合的 `SORT`),会阻塞后续所有请求——这就是为什么 Redis 社区反复强调**避免慢命令**。
```mermaid
flowchart LR
CMD1["正常命令<br/>SET key val<br/>1us"] --> QUEUE["命令队列"]
CMD2["正常命令<br/>GET key<br/>1us"] --> QUEUE
CMD3["慢命令<br/>KEYS *<br/>500ms"] --> QUEUE
QUEUE --> EXEC["单线程串行执行"]
CMD3 -.->|"阻塞后面所有命令"| BLOCK["后续请求全部等待 500ms"]
style CMD3 fill:#fdd,stroke:#c00
style BLOCK fill:#fdd,stroke:#c00
```
## 网络模型:IO 多路复用
Redis 单线程能同时处理上万连接的秘密在于 **IO 多路复用**(IO Multiplexing)。
### 传统阻塞 IO vs IO 多路复用
```mermaid
sequenceDiagram
participant C1 as "客户端1"
participant C2 as "客户端2"
participant C3 as "客户端3"
participant S as "服务端线程"
Note over S: "传统阻塞IO 一个连接一个线程"
par 三个线程分别阻塞等待
C1->>S: "读请求"
S-->>C1: "阻塞等待数据..."
C2->>S: "读请求"
S-->>C2: "阻塞等待数据..."
C3->>S: "读请求"
S-->>C3: "阻塞等待数据..."
end
```
```mermaid
sequenceDiagram
participant C1 as "客户端1"
participant C2 as "客户端2"
participant C3 as "客户端3"
participant EP as "epoll"
participant S as "单线程事件循环"
Note over S: "IO多路复用 一个线程监控所有连接"
C1->>EP: "注册监听"
C2->>EP: "注册监听"
C3->>EP: "注册监听"
EP->>S: "通知 C2 有数据可读"
S->>C2: "读取并处理"
EP->>S: "通知 C1 有数据可读"
S->>C1: "读取并处理"
```
> [!info] epoll 的核心思想
> epoll 是 Linux 内核提供的 IO 多路复用机制。简单来说:不是"一个线程傻等一个连接",而是"一个线程同时盯住成千上万个连接,谁有数据来了就处理谁"。Redis 用 `ae_epoll.c` 封装了这套机制。
>
> 类比:你是一个餐厅服务员——传统模式是你站在 A 桌旁等他看完菜单(阻塞),而 IO 多路复用是你同时巡视所有桌,谁举手了就去服务(事件驱动)。
Redis 对不同平台做了适配:
| 平台 | 底层实现 | 说明 |
|------|---------|------|
| Linux | `epoll` | 最常用,性能最优 |
| macOS / BSD | `kqueue` | FreeBSD / macOS 原生机制 |
| Solaris | `evport` | Solaris 事件端口 |
| 其他 | `select` | 通用回退方案,连接数受限(FD_SETSIZE,默认 1024) |
### ae 事件库
Redis 自己实现了一个轻量级事件库(`ae.c`),将 IO 事件和定时事件统一抽象:
```mermaid
flowchart TD
AE["ae 事件库<br/>ae.c"] --> IOMULTI["IO 多路复用适配层"]
IOMULTI --> EP["epoll - Linux"]
IOMULTI --> KQ["kqueue - macOS BSD"]
IOMULTI --> EV["evport - Solaris"]
IOMULTI --> SEL["select - 通用回退"]
AE --> TIMER["定时事件<br/>serverCron 等"]
AE --> IO["文件事件<br/>客户端连接读写"]
style AE fill:#bbf,stroke:#66c
```
> [!tip] 事件循环的核心逻辑(伪代码)
> ```
> while (server.running) {
> // 1. beforeSleep 钩子:每轮循环开始前的"预处理"
> beforeSleep(); // 处理过期 key 惰性删除、AOF 缓冲刷新等
> // 2. aeProcessEvents 内部依次完成:
> // a. epoll_wait 阻塞等待(直到有 IO 事件或超时)
> // b. 遍历就绪事件,执行对应的命令读取/响应写出
> aeProcessEvents();
> // 3. 处理到期的定时事件(如 serverCron,每 100ms 一次)
> processTimeEvents();
> }
> ```
>
> 这就是 Redis 口中的「**主线程事件循环**」——一个永不停歇的 `while` 循环,依次处理 IO 事件和定时事件。
> [!info] beforeSleep——容易被忽略的关键钩子
> `beforeSleep()` 在每轮事件循环进入阻塞等待(`aeProcessEvents`)之前被调用,承担了多项"顺手"的清理工作:
>
> | beforeSleep 中的任务 | 说明 |
> |----------------------|------|
> | 过期 key 惰性删除 | 执行命令时发现 key 过期了,在这里统一回收 |
> | AOF 缓冲区刷新 | 如果 AOF 配置为 `everysec`,这里尝试将缓冲区写入内核 |
> | 处理被阻塞的客户端 | 有 key 变更时唤醒 `BLPOP` 等阻塞命令的客户端 |
> | 集群 failover 辅助 | Cluster 模式下处理故障转移相关的清理 |
>
> 简单说,`beforeSleep` 是事件循环"睡着前"的收尾动作,保证每轮循环结束后状态干净。
## RESP 通信协议
客户端与 Redis 之间的通信基于 **RESP**(Redis Serialization Protocol),这也是它高效的原因之一。
> [!question] 为什么不用 HTTP?
> HTTP 是为浏览器设计的文本协议,头部冗余大、解析复杂、无状态但需要反复握手。而 RESP 的设计目标是:**简单、无状态、可逐行流式解析**——正好匹配 Redis "命令 → 响应"的交互模型。
RESP2 是 Redis 自诞生以来一直使用的默认协议(Redis 6.0+ 仍默认使用 RESP2,客户端需通过 `HELLO` 命令主动升级到 RESP3)。RESP2 定义了 5 种数据类型:
| 前缀 | 类型 | 示例 | 说明 |
|------|------|------|------|
| `+` | 简单字符串 | `+OK\r\n` | 状态回复 |
| `-` | 错误 | `-ERR unknown command\r\n` | 错误回复 |
| `:` | 整数 | `:1000\r\n` | 整数回复(如 INCR 结果) |
| `$` | 批量字符串 | `$5\r\nhello\r\n` | 二进制安全,支持最大 512MB |
| `*` | 数组 | `*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n` | 命令和多元素结果均用此格式 |
一个 `SET name hello` 命令在 RESP2 中的编码:
```
*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$5\r\nhello\r\n
```
> [!info] RESP3(Redis 6.0+)
> RESP3 在 RESP2 基础上新增了 Map、Set、布尔、浮点数、Null 等原生类型,以及 Attribute、Push 消息等。客户端启用 `HELLO 3` 后可使用 RESP3,返回值语义更丰富(如 `HGETALL` 直接返回 Map 而非扁平数组)。但协议格式依然保持流式解析的特性。
## Redis 6.0+ 多线程 IO
> [!question] 既然单线程够用,为什么 Redis 6.0 又搞了多线程?
> 单线程事件循环处理命令确实很快,但**网络 IO 读写**(从 socket 读取请求、向 socket 写回响应)随着并发连接数增长,逐渐成为瓶颈。尤其是当响应数据量很大时(如 `LRANGE` 返回几千个元素),write 系统调用的耗时会显著拖慢事件循环。
Redis 6.0(2020 年发布)引入了 **IO 多线程**(Threaded IO),但请注意:
> [!warning] 关键区分:IO 多线程 ≠ 命令多线程
> Redis 6.0 的多线程**只用于网络 IO 的读写**,命令的**解析和执行仍然是单线程**。这保证了数据操作的线程安全性,无需加锁。
### IO 多线程工作流程
```mermaid
sequenceDiagram
participant M as "主线程"
participant T1 as "IO线程1"
participant T2 as "IO线程2"
participant T3 as "IO线程3"
M->>M: "1 接受新连接,放入待处理队列"
M->>T1: "2 分配连接,让IO线程从socket读取请求"
M->>T2: "2 分配连接"
M->>T3: "2 分配连接"
Note over T1,T3: "并行从各自负责的连接中读取数据"
T1-->>M: "3 返回读取到的命令"
T2-->>M: "3 返回读取到的命令"
T3-->>M: "3 返回读取到的命令"
M->>M: "4 主线程串行执行所有命令"
M->>T1: "5 分配响应,让IO线程写回socket"
M->>T2: "5 分配响应"
M->>T3: "5 分配响应"
Note over T1,T3: "并行向各自负责的连接写回响应"
```
> [!tip] 一个形象的比喻
> 想象一个厨师(主线程):
> - **6.0 之前**:厨师自己洗菜(读 IO)、自己炒菜(执行命令)、自己装盘(写 IO)
> - **6.0 之后**:雇了几个帮厨(IO 线程)帮忙洗菜和装盘,但炒菜(执行命令)还是厨师一个人
>
> 炒菜仍然是瓶颈,但洗菜装盘并行化后,整体吞吐量提升了。
### 开启方式(Redis 6.0+)
```conf
# redis.conf
io-threads 4 # IO 线程数(建议 CPU 核心数的一半,最少 2)
io-threads-do-reads yes # 是否对读操作也使用多线程(默认 no)
```
> [!warning] 版本要求
> `io-threads` 和 `io-threads-do-reads` 是 Redis 6.0 新增的配置项,6.0 之前的版本不支持,配置后会报错。
| 配置 | 说明 |
|------|------|
| `io-threads` | 线程数,包含主线程自身。设 4 表示 1 个主线程 + 3 个 IO 线程 |
| `io-threads-do-reads` | 默认只对**写响应**做多线程;设为 `yes` 后读请求也并行化 |
> [!question] 什么时候该开 IO 多线程?
> 以下条件**同时满足**时才值得开启:
> 1. 并发连接数很高(数千以上)
> 2. 单次响应数据量较大(大 Key 读取、大范围查询)
> 3. CPU 核心数充足(至少 4 核)
>
> 如果你的场景是大量小 Key 的 GET/SET(典型缓存场景),瓶颈在命令执行本身而非网络 IO,IO 多线程的收益可能并不明显。
## 后台线程:BIO
除了 IO 线程,Redis 还维护了一组 **BIO(Background IO)后台线程**,专门执行那些不适合在主线程中做的耗时操作,避免阻塞事件循环。
```mermaid
flowchart TD
MAIN["主线程事件循环"] -->|"提交任务"| QUEUE["BIO 任务队列"]
QUEUE --> T1["BIO 线程 1<br/>文件关闭"]
QUEUE --> T2["BIO 线程 2<br/>AOF fsync"]
QUEUE --> T3["BIO 线程 3<br/>lazyfree 异步删除"]
style MAIN fill:#f9d,stroke:#96c
style T1 fill:#bbf,stroke:#66c
style T2 fill:#bbf,stroke:#66c
style T3 fill:#bbf,stroke:#66c
```
BIO 线程池在 Redis 启动时就已创建(默认 3 个线程),各自负责不同类型的任务:
| BIO 线程 | 职责 | 触发场景 |
|----------|------|---------|
| `close` | 异步关闭文件描述符 | AOF 重写完成后关闭旧文件 |
| `fsync` | 异步刷盘 | `appendfsync everysec` 模式下每秒调用 |
| `lazyfree` | 异步释放大对象内存 | `UNLINK`、`FLUSHDB ASYNC`、过期 Key 惰性删除 |
> [!question] `DEL` 和 `UNLINK` 有什么区别?
> - `DEL`:在**主线程**中同步释放内存。如果 Key 是一个包含 100 万元素的 Hash,主线程会被阻塞几毫秒甚至更久
> - `UNLINK`:仅在主线程中断开 Key 的引用(O(1)),实际内存释放交给 BIO 的 `lazyfree` 线程异步完成——主线程几乎不受影响
>
> 生产环境中,删除大 Key **务必使用 `UNLINK`**(需 Redis 4.0+,4.0 以下版本只能用 `DEL`,此时应考虑分批删除或在从库执行)。
## 连接管理
每个客户端连接在 Redis 内部都对应一个 `redisClient` 结构体(存储连接状态、当前数据库、输入/输出缓冲区等),连接数直接影响内存占用和事件循环效率。
```mermaid
flowchart LR
C1["客户端1"] -->|"TCP"| EP["epoll 就绪队列"]
C2["客户端2"] -->|"TCP"| EP
CN["客户端N"] -->|"TCP"| EP
EP -->|"事件循环处理"| MAIN["主线程"]
style EP fill:#bbf,stroke:#66c
style MAIN fill:#f9d,stroke:#96c
```
### 关键配置
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| `maxclients` | 10000 | 最大同时连接数,超过后新连接被拒绝 |
| `tcp-backlog` | 511 | 内核 TCP 连接队列长度,需与系统 `somaxconn` 对齐 |
| `timeout` | 0(不超时) | 客户端空闲多久后断开,0 表示不主动断开 |
| `tcp-keepalive` | 300(Redis 3.2+) | TCP 心跳探测间隔(秒),及时发现死连接;3.2 之前默认为 0(不检测),易导致连接泄漏 |
> [!warning] 连接数暴涨的隐患
> 连接数过多不仅消耗内存(每个连接约占 ~1KB 结构体 + 输出缓冲区),更重要的是 **epoll 事件通知量增大**,每轮循环需要遍历更多就绪文件描述符,主线程在 IO 上的时间占比上升,直接影响命令处理吞吐量。
>
> 生产建议:在客户端使用**连接池**(如 go-redis 的 `PoolSize`),而非每次请求新建连接。
## 完整线程模型总览
把所有线程类型放在一起看:
```mermaid
flowchart TB
subgraph Redis进程
direction TB
subgraph "单线程:事件循环"
EVENT["主线程<br/>命令解析 + 命令执行 + 定时任务"]
end
subgraph "IO 多线程 (Redis 6.0+)"
IOT1["IO 线程 1<br/>网络读写"]
IOT2["IO 线程 2<br/>网络读写"]
IOTN["IO 线程 N..."]
end
subgraph "BIO 后台线程"
BIO1["close 线程"]
BIO2["fsync 线程"]
BIO3["lazyfree 线程"]
end
subgraph "子进程 (fork)"
FORK["RDB BGSAVE<br/>AOF Rewrite"]
end
end
CLIENT["客户端"] --> IOT1
IOT1 -->|"读取命令"| EVENT
EVENT -->|"写响应"| IOT2
EVENT -->|"提交异步任务"| BIO1
EVENT -->|"触发持久化"| FORK
style EVENT fill:#f9d,stroke:#96c
style IOT1 fill:#bbf,stroke:#66c
style IOT2 fill:#bbf,stroke:#66c
style IOTN fill:#bbf,stroke:#66c
style BIO1 fill:#dfd,stroke:#6c6
style BIO2 fill:#dfd,stroke:#6c6
style BIO3 fill:#dfd,stroke:#6c6
style FORK fill:#ffd,stroke:#c90
```
> [!tip] 记忆口诀
> - **主线程**:命令执行的"唯一话事人",保证了无锁设计
> - **IO 线程**:主线程的"快递员",只负责搬数据不负责处理
> - **BIO 线程**:主线程的"后勤兵",处理脏活累活(关文件、刷盘、删大 Key)
> - **子进程**:持久化的"外包队",fork 出来做 RDB 快照和 AOF 重写
## serverCron 定时任务
主线程事件循环中,除了处理客户端命令,还会周期性执行 `serverCron`(默认每秒执行 `hz` 次,默认 `hz=10`,即每 100ms 一次)。这是 Redis 内部的"心跳":
| 任务 | 频率 | 说明 |
|------|------|------|
| 过期 Key 扫描 | 每次 serverCron | 随机抽样检查,惰性删除已过期的 Key |
| 增量 rehash | 每次 serverCron | 对正在扩容的 Hash 表做渐进式迁移 |
| AOF 缓冲区刷新 | 每次 serverCron | 检查 aof_buf 是否需要写入/同步 |
| RDB / AOF 持久化检查 | 每次 serverCron | 检查是否满足 BGSAVE / BGREWRITEAOF 触发条件 |
| 集群心跳 | 每次 serverCron | Cluster 模式下发送 Gossip 消息 |
| 统计信息更新 | 每次 serverCron | 更新 INFO 中的 QPS、内存使用等指标 |
| 客户端超时断开 | 每次 serverCron | 检查 idle 超时的客户端连接 |
> [!question] 为什么 `hz` 不能调太高?
> `serverCron` 在**主线程**中执行,如果频率过高(如 `hz=1000`),定时任务本身会占用更多 CPU 时间片,反而影响命令处理吞吐量。官方建议保持默认 `hz=10`,除非你有明确的调优需求。
## fork 子进程与 COW 内存风险
Redis 的 RDB 持久化和 AOF 重写都依赖 `fork()` 创建子进程。`fork()` 本身是 O(1) 的(Linux 使用 Copy-On-Write),但**后续的内存增长可能非常危险**。
```mermaid
flowchart LR
P["父进程<br/>10GB 数据"] -->|"fork()"| C["子进程<br/>共享同一份物理页"]
C -->|"写入触发 COW"| NEW["复制物理页<br/>额外消耗内存"]
style P fill:#f9d,stroke:#96c
style C fill:#ffd,stroke:#c90
style NEW fill:#fdd,stroke:#c00
```
> [!warning] fork 后内存可能翻倍
> 子进程刚 fork 时与父进程共享物理内存页(COW)。但如果父进程在子进程做 RDB/AOF 期间**持续写入大量数据**,每次写操作都会触发 COW——被修改的内存页会被复制一份。
>
> 最坏情况:**fork 瞬间实际可用内存需要为当前数据量的 2 倍**。如果你的 Redis 实例用了 10GB,系统剩余内存不足 10GB,fork 后可能触发 OOM Killer,导致 Redis 进程被系统杀死。
>
> 生产建议:
> - 设置 `maxmemory`(如 `maxmemory 8gb`),并将其控制在物理内存的 **60%~70%**,为 fork COW 预留空间
> - 监控 `latest_fork_usec` 指标,超过 1ms 说明数据量大、fork 开销高
> - 写入密集时避免同时触发 RDB 和 AOF rewrite
## Redis 7.0+ 架构演进
Redis 7.0(2022 年发布)在持久化和线程模型方面带来了重要改进:
### Multi-Part AOF
Redis 7.0 重新设计了 AOF 机制,将单一 AOF 文件拆分为多个部分:
```mermaid
flowchart LR
BASE["base AOF<br/>RDB 格式全量快照"] -->|"append"| INCR["incr AOF<br/>增量命令日志"]
INCR -->|"rewrite 时合并"| NEW_BASE["新 base AOF"]
style BASE fill:#dfd,stroke:#6c6
style INCR fill:#bbf,stroke:#66c
```
| 特性 | 6.x 及之前 | 7.0+ |
|------|-----------|------|
| AOF 文件结构 | 单文件,rewrite 时写临时文件再 rename | base(RDB)+ incr(AOF 增量),独立管理 |
| rewrite 风险 | rewrite 期间出错可能损坏原 AOF | base 和 incr 分离,rewrite 失败不影响已有数据 |
| 加载速度 | 全量回放 AOF 命令 | 先加载 base(RDB 快),再回放 incr(少量日志) |
### IO 线程模型改进
Redis 7.0 对 IO 线程做了性能优化:
- **更精细的负载均衡**:主线程根据各连接的待处理数据量分配 IO 线程,避免某些线程"吃不饱"
- **减少锁竞争**:IO 线程与主线程之间的队列同步机制优化,降低临界区长度
> [!tip] 版本选择建议
> 如果是新项目,直接使用 **Redis 7.2+**(稳定版)。Multi-Part AOF 解决了老版 AOF rewrite 的数据安全问题,IO 线程优化也让高并发场景下的表现更稳定。
## 关联笔记
- [[hhs/Redis/01-安装与部署]] — 安装与配置文件详解
- [[hhs/Redis/04-RDB持久化]] — fork 子进程做快照的具体过程
- [[hhs/Redis/05-AOF持久化]] — 主线程与 BIO fsync 线程的协作
- [[hhs/Redis/09-高级特性]] — 慢命令排查与 Lua 脚本的原子性
- [[hhs/Redis/11-运维与性能调优]] — `hz` 调优、慢查询日志、大 Key 治理