20 KiB
tags, create time
| tags | 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 的内部模块组成:
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 社区反复强调避免慢命令。
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 多路复用
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
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 事件和定时事件统一抽象:
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 多线程工作流程
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.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 多线程? 以下条件同时满足时才值得开启:
- 并发连接数很高(数千以上)
- 单次响应数据量较大(大 Key 读取、大范围查询)
- CPU 核心数充足(至少 4 核)
如果你的场景是大量小 Key 的 GET/SET(典型缓存场景),瓶颈在命令执行本身而非网络 IO,IO 多线程的收益可能并不明显。
后台线程:BIO
除了 IO 线程,Redis 还维护了一组 BIO(Background IO)后台线程,专门执行那些不适合在主线程中做的耗时操作,避免阻塞事件循环。
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 结构体(存储连接状态、当前数据库、输入/输出缓冲区等),连接数直接影响内存占用和事件循环效率。
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),而非每次请求新建连接。
完整线程模型总览
把所有线程类型放在一起看:
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),但后续的内存增长可能非常危险。
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 文件拆分为多个部分:
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 治理