--- 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["网络层
epoll/kqueue + IO多路复用"] EVENT["事件循环
单线程处理所有命令"] ENGINE["存储引擎
内存数据结构"] BIO["后台线程池
BIO lazyfree / AOF fsync"] RDB_CHILD["子进程
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["正常命令
SET key val
1us"] --> QUEUE["命令队列"] CMD2["正常命令
GET key
1us"] --> QUEUE CMD3["慢命令
KEYS *
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 事件库
ae.c"] --> IOMULTI["IO 多路复用适配层"] IOMULTI --> EP["epoll - Linux"] IOMULTI --> KQ["kqueue - macOS BSD"] IOMULTI --> EV["evport - Solaris"] IOMULTI --> SEL["select - 通用回退"] AE --> TIMER["定时事件
serverCron 等"] AE --> IO["文件事件
客户端连接读写"] 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
文件关闭"] QUEUE --> T2["BIO 线程 2
AOF fsync"] QUEUE --> T3["BIO 线程 3
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["主线程
命令解析 + 命令执行 + 定时任务"] end subgraph "IO 多线程 (Redis 6.0+)" IOT1["IO 线程 1
网络读写"] IOT2["IO 线程 2
网络读写"] IOTN["IO 线程 N..."] end subgraph "BIO 后台线程" BIO1["close 线程"] BIO2["fsync 线程"] BIO3["lazyfree 线程"] end subgraph "子进程 (fork)" FORK["RDB BGSAVE
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["父进程
10GB 数据"] -->|"fork()"| C["子进程
共享同一份物理页"] C -->|"写入触发 COW"| NEW["复制物理页
额外消耗内存"] 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
RDB 格式全量快照"] -->|"append"| INCR["incr AOF
增量命令日志"] 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 治理