vault backup: 2026-05-26 10:44:04

This commit is contained in:
hhs
2026-05-26 10:44:04 +08:00
parent f7c3c83c2b
commit cbfa18a9e5
2 changed files with 467 additions and 0 deletions
+461
View File
@@ -0,0 +1,461 @@
---
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**,延迟在微秒级。为什么?因为它的瓶颈从来不在 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 解析、事务锁管理——这些操作耗时从微秒到毫秒不等,单线程会让 CPU 大量空转等待 IO。而 Redis 操作都在内存中完成,单个命令执行时间在亚微秒级,CPU 根本不会闲着。
>
> 简单记:**瓶颈在 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 5.0 及之前)有 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 线程)帮忙洗菜和装盘,但炒菜(执行命令)还是厨师一个人
>
> 炒菜仍然是瓶颈,但洗菜装盘并行化后,整体吞吐量提升了。
### 开启方式
```conf
# redis.conf
io-threads 4 # IO 线程数(建议 CPU 核心数的一半,最少 2)
io-threads-do-reads yes # 是否对读操作也使用多线程(默认 no)
```
| 配置 | 说明 |
|------|------|
| `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 内部都对应一个 `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 | TCP 心跳探测间隔(秒),及时发现死连接 |
> [!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` 设为物理内存的 **60%~70%**,预留 fork 空间
> - 监控 `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 治理
+6
View File
@@ -17,6 +17,12 @@ create time: 2026-05-15 18:10
## 知识体系
### 〇、架构总览
| # | 主题 | 链接 |
|---|------|------|
| 0.1 | 架构与线程模型 | [[hhs/Redis/00-Redis架构与线程模型]] — 单线程事件循环、IO 多路复用、BIO 后台线程、Redis 6.0 多线程 IO |
### 一、入门基础
| # | 主题 | 链接 |