04 线程模型与高性能原理
"Redis 是单线程的,为什么还这么快?"——这是 Redis 面试最高频的问题之一,也是理解 Redis 性能边界的关键。本章深入剖析 Redis 的线程模型、IO 多路复用、RESP 协议,以及 6.0 的多线程改造。
4.1 单线程模型的本质
4.1.1 严格意义上的"单线程"
Redis 核心处理流程(6.0 前):
客户端连接 ─┐
客户端连接 ─┼─→ [事件循环 Reactor] ─→ [单线程命令队列] ─→ [命令执行] ─→ 响应
客户端连接 ─┘
整个过程只有一个线程在跑:
· 网络读写(socket recv/send)
· 协议解析(RESP 解码)
· 命令查找与执行
· 响应构造
· 持久化 fork 之外的所有操作为什么选单线程?
Redis 的作者 antirez 在《Redis 为什么使用单线程》一文中给出的理由:
| 原因 | 说明 |
|---|---|
| CPU 不是瓶颈 | Redis 的瓶颈通常是内存容量或网络带宽,而非 CPU。单线程下也能把网卡打满 |
| 避免锁竞争 | 多线程访问共享数据结构必须加锁,锁竞争、死锁、上下文切换的代价往往比"并行收益"更大 |
| 避免上下文切换 | 线程切换有 CPU 开销(Cache 失效、寄存器保存),单线程没有这个问题 |
| 实现简单 | 不需要考虑并发问题,代码更简洁、bug 更少、更易维护 |
| 天然原子性 | 每个命令都是原子的,用户无需操心并发问题(分布式锁、INCR 都基于此) |
核心洞察: Redis 的设计哲学是"让每个命令都快"——通过内存操作 + 高效数据结构 + 避免锁开销,单线程也能达到单机 10 万+ QPS。这比"多线程但每条命令都慢"更好。
4.1.2 单线程的两个推论
推论 1:每个命令都是原子的
# INCR 在高并发下仍然正确,不需要加锁
# 1000 个客户端同时 INCR counter,最终一定是 1000
INCR counter原因:命令执行过程不会被其他命令打断,所有命令按到达顺序串行执行。这就是为什么 Redis 能用 SET NX 实现分布式锁、用 INCR 做原子计数。
推论 2:慢命令会阻塞一切
# 一个慢命令会卡住所有后续命令(包括其他客户端的!)
KEYS * # O(N),N 是库中所有 key 数量,大库下可能秒级
HGETALL user:huge_hash # O(N),N 是字段数,万级字段下秒级
SMEMBERS big_set # O(N),N 是集合元素数
LRANGE mylist 0 -1 # O(N),N 是列表长度
FLUSHALL # 删除所有 key铁律:生产环境禁止执行复杂度 O(N) 且 N 很大的命令。
- 用
SCAN替代KEYS- 用
HSCAN替代HGETALL(大 hash 场景)- 用
UNLINK替代DEL(大 key 异步删除)- 用
lazyfree-lazy-*配置让淘汰、过期、删除都异步
4.2 IO 多路复用:单线程管理万级连接
4.2.1 多路复用的本质
单线程同时管理成千上万个 socket 连接:
传统模型(每个连接一个线程):
Thread-1 ←→ Socket-1
Thread-2 ←→ Socket-2
Thread-3 ←→ Socket-3
...
Thread-10000 ←→ Socket-10000
问题:1 万个线程的创建、切换、内存占用是灾难
多路复用模型(Redis 采用):
┌─── Socket-1 (有数据可读)
单个线程 ←→ ──┼─── Socket-2 (可写)
└─── Socket-3 (有异常)
用 epoll/kqueue/select 等机制,
一个线程"监听"所有 socket 的事件,
哪个 socket 有事件就处理哪个,不为每个连接开线程4.2.2 Redis 的 Reactor 模式
Redis 的 Reactor 实现(基于 epoll):
┌─────────────────────────────────────────┐
│ fileEvent 表 │
│ (每个 socket 注册的事件:读/写/异常) │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ aeEventLoop(事件循环) │
│ 1. epoll_wait 等待事件 │
│ 2. 遍历触发的事件 │
│ 3. 调用对应的 handler │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 事件类型 │
│ · AE_READABLE → 读处理器 │
│ · AE_WRITABLE → 写处理器 │
│ · AE_BARRIER → 屏障(写先于读) │
└─────────────────────────────────────────┘// Redis 事件循环的简化伪代码(ae.c)
void aeProcessEvents(aeEventLoop *loop) {
// 1. 计算需要等待的时间(最近的定时器事件)
int timeout = aeComputeNearestTimer(loop);
// 2. 调用多路复用 API 等待事件(epoll_wait)
int numevents = aeApiPoll(loop, timeout);
// 3. 处理就绪的 socket 事件
for (int j = 0; j < numevents; j++) {
aeFileEvent *fe = get FiredEvent(loop, j);
// 读事件:读取客户端命令
if (fe->mask & AE_READABLE) {
fe->rfileProc(loop, fe->fd, fe->clientData);
}
// 写事件:向客户端发送响应
if (fe->mask & AE_WRITABLE) {
fe->wfileProc(loop, fe->fd, fe->clientData);
}
}
// 4. 处理时间事件(如过期 key 定期清理、持久化等)
aeProcessTimeEvents(loop);
}4.2.3 各平台的多路复用实现
Redis 自动选择平台最优的多路复用 API:
| 平台 | API | 性能 | 说明 |
|---|---|---|---|
| Linux | epoll | ⭐⭐⭐⭐⭐ | O(1) 事件通知,无 fd 数量限制,Linux 默认 |
| macOS / BSD | kqueue | ⭐⭐⭐⭐⭐ | 性能与 epoll 相当 |
| Solaris | event ports | ⭐⭐⭐⭐ | — |
| 通用 | select | ⭐⭐ | O(N) 遍历,fd 上限 1024,兜底方案 |
| 通用 | poll | ⭐⭐ | 类似 select,无 1024 上限 |
Redis 在 Linux 上默认用 epoll,在 macOS 上用 kqueue,性能都能轻松支持数万并发连接。
maxclients默认 10000,可根据需求调整(同时需要调整内核somaxconn与tcp-backlog)。
4.2.4 为什么多路复用能让单线程高效
| 能力 | 传统 select/poll | epoll/kqueue |
|---|---|---|
| 检测活跃连接 | O(N) 遍历所有 fd | O(1) 只返回有事件的 fd |
| 最大连接数 | 1024(select)/ 无限制 | 无限制(受内存与 fd 限制) |
| 事件通知方式 | 水平触发(level-triggered) | 默认边缘触发(edge-triggered) |
| 内核开销 | 每次都要拷贝 fd 集合 | 共享内存(epoll) |
假设 10000 个连接,其中 10 个活跃:
select:每次遍历 10000 个 fd,找到 10 个活跃的 → O(10000)
epoll:直接返回 10 个活跃 fd 的列表 → O(10)
在高并发但实际活跃连接少的场景(大多数 Web 应用),
epoll 的性能优势极为显著4.3 一次命令的完整执行流程
客户端执行 SET key value 的完整流程:
① 客户端发送:SET key value\r\n
↓ socket 写
② 服务端 socket 接收缓冲区
↓ epoll 检测到 AE_READABLE 事件
③ readQueryFromClient():
· read() 读数据到输入缓冲区
· 解析 RESP 协议,得到命令名与参数
↓
④ processCommand():
· 查找命令表(dict)找到 setCommand 函数
· 权限检查、参数校验
↓
⑤ setCommand():
· 调用 setKey() 把 key-value 存入 dict
· 触发 keyspace 通知(如果有)
· 记录 AOF(如果开启)
· 更新 key 的 LRU 时间
↓
⑥ 把响应 "+OK\r\n" 写入输出缓冲区
↓ epoll 检测到 AE_WRITABLE 事件
⑦ sendReplyToClient():
· write() 把响应发送给客户端
· 清空缓冲区
↓
⑧ 返回事件循环,处理下一个事件整个流程中:
- 没有锁竞争
- 没有线程切换
- 没有复杂的调度
- 单线程把所有 CPU 时间都用在"干活"上
4.4 为什么单线程仍然这么快(五大原因总结)
| 原因 | 详细 |
|---|---|
| ① 纯内存操作 | 数据在内存,读写都是纳秒级;内存访问比磁盘随机 IO 快 10 万倍 |
| ② 单线程无锁开销 | 没有 mutex/condition variable、没有上下文切换、没有死锁可能 |
| ③ IO 多路复用 | epoll 让单线程同时高效管理数万连接,活跃连接少的场景性能极佳 |
| ④ 高效数据结构 | 每种类型底层都针对性优化(SDS、skiplist、listpack、hashtable) |
| ⑤ 简洁的 RESP 协议 | 文本协议,解析快;没有 SQL 解析、没有优化器、没有执行计划 |
性能实测(redis-benchmark):
redis-benchmark -t set,get -n 1000000 -q
SET: 141043.83 requests per second
GET: 146198.83 requests per second
单核单线程,QPS 轻松突破 10 万
瓶颈在网卡(千兆网卡约 12 万 packet/s)而非 CPU4.5 Redis 6.0 的多线程改造
4.5.1 为什么引入多线程
Redis 6.0 之前的瓶颈分析:
Redis 的"瓶颈"通常不是 CPU,而是:
· 内存容量
· 网络带宽
· 客户端响应延迟
但在极高并发场景(数万连接、每秒百万包)下,
【网络 IO 的读写与协议解析】开始占用大量 CPU 时间
—— 单线程既要读 socket、又要解析 RESP、又要执行命令,忙不过来
解决方案:把"读 socket + 解析 RESP"和"写 socket + 编码 RESP"
交给专门的 IO 线程,命令执行仍由主线程负责4.5.2 6.0 的线程模型
Redis 6.0 的线程分工:
┌─ IO-Thread-1:读 socket A + 解码 ─┐
客户端连接 ───────→├─ IO-Thread-2:读 socket B + 解码 ─┼─→ [命令队列]
└─ IO-Thread-N:读 socket C + 解码 ─┘
↓
┌───────────────────────────────────┐ │
│ 主线程(单线程): │ ↓
│ · 串行执行命令 │←──┘
│ · 持久化、复制、集群等 │
└───────────────────────────────────┘
↓
┌─ IO-Thread-1:编码响应 + 写 socket A
客户端 ←───────────├─ IO-Thread-2:编码响应 + 写 socket B
└─ IO-Thread-N:编码响应 + 写 socket C
核心要点:
· IO 线程只负责:网络数据读写 + RESP 协议解析/编码
· 主线程仍然串行执行命令 → 命令执行保持原子性
· IO 线程与主线程通过【无锁队列】协作
· 默认关闭,需要手动开启4.5.3 开启多线程 IO
# redis.conf(6.0+)
io-threads-do-reads yes # 读也用多线程(默认 no)
io-threads 4 # IO 线程数
# 建议值:CPU 核数的 3/4,不超过 8
# 4 核机器:io-threads 3~4
# 8 核机器:io-threads 4~6
# 16 核机器:io-threads 8性能对比(典型场景):
| 场景 | 单线程 | 多线程 IO | 提升 |
|---|---|---|---|
| SET/GET 小包高并发 | 10 万 QPS | 15~20 万 QPS | +50%~100% |
| 大 value(MGET 多 key) | 受网卡限制 | 受网卡限制 | 几乎无提升 |
| 复杂命令(单命令耗时) | — | — | 无提升(命令执行仍单线程) |
何时开启多线程 IO?
- ✅ 高并发(> 10 万 QPS)、小包、网卡/CPU 瓶颈
- ❌ 数据量大、命令复杂、并发低 → 开了反而有线程协调开销
- ❌ 命令执行本身就是瓶颈(慢查询)→ 多线程救不了
大多数场景不需要开:Redis 6.0 默认关闭,说明作者也认为多数场景单线程够用。
4.5.4 命令执行仍然单线程(关键辨析)
常见误解:"Redis 6.0 是多线程了,命令可以并行执行"
事实:
· 命令执行仍然是单线程串行执行
· 多线程只在【命令执行前后】的网络 IO 阶段
· 命令队列是单线程处理的,保证命令执行的原子性不变
为什么不全并行?
① 命令间有数据依赖(如 MULTI/EXEC、WATCH、Lua)
② 命令并行需要细粒度锁,得不偿失
③ 破坏"单命令原子性"这一核心优势(分布式锁就失效了)# 验证:即使开启多线程 IO,下面两条命令的执行仍然是串行的
# 客户端 A:
MULTI
INCR counter
INCR counter
EXEC
# 返回一定是 [1, 2] 或 [2, 3] 等连续值,不会交错
# 客户端 B 同时:
INCR counter
# 最终结果一定与"串行执行所有命令"等价4.6 命令处理流程中的关键数据结构
4.6.1 全局命令表
// server.c 中的全局命令表(dict)
struct redisServer {
dict *commands; // 命令表:key 是命令名,value 是 redisCommand 结构
dict *orig_commands; // 命令别名表
...
};
struct redisCommand {
char *name; // 命令名(如 "set")
redisCommandProc *proc; // 命令处理函数指针(如 setCommand)
int arity; // 参数个数(-2 表示至少 2 个)
char *sflags; // 标志位("r"读、"w"写、"F"快速...)
...
};
// 启动时加载几百个命令到 dict
// 命令查找是 O(1) 的 dict 查询,极快4.6.2 全局 key 空间
struct redisServer {
redisDb *db; // 数据库数组(db0 ~ db15)
int dbnum; // 数据库数量
...
};
struct redisDb {
dict *dict; // key 空间:所有 key-value 都在这里
dict *expires; // 过期表:key → 过期时间戳
dict *blocking_keys; // BLPOP 等阻塞命令的等待列表
dict *ready_keys; // 已就绪的阻塞 key
dict *watched_keys; // WATCH 监控的 key(MULTI 事务用)
...
};
// dict 是 Redis 的核心数据结构:
// · 哈希表实现,拉链法解决冲突
// · 渐进式 rehash(避免一次性迁移卡顿)
// · 负载因子超过阈值自动扩容4.6.3 客户端连接结构
typedef struct client {
uint64_t id; // 客户端唯一 ID
int fd; // socket 文件描述符(-1 表示伪客户端)
redisDb *db; // 当前选择的数据库
sds name; // 客户端名(CLIENT SETNAME 设置)
robj *argv; // 当前命令的参数数组
int argc; // 参数个数
sds querybuf; // 输入缓冲区(客户端发来的原始数据)
list *reply; // 输出缓冲区(准备发给客户端的响应)
int flags; // 客户端标志(主/从/Lua/AOF 加载中等)
time_t ctime; // 创建时间
time_t lastinteraction; // 最后交互时间(timeout 用)
...
} client;
// 客户端信息可通过 CLIENT LIST 查看
// 客户端 ID 可用于 CLIENT KILL ID xxx 断开指定连接4.7 RESP 协议:Redis 的通信语言
RESP(REdis Serialization Protocol)是 Redis 客户端与服务端通信的文本协议。
4.7.1 RESP2(默认,兼容所有版本)
五种数据类型:
① 简单字符串(Simple Strings):+OK\r\n
② 错误(Errors): -ERR unknown command\r\n
③ 整数(Integers): :1000\r\n
④ 批量字符串(Bulk Strings): $6\r\nfoobar\r\n (长度+内容)
⑤ 数组(Arrays): *2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n
\r\n 是行分隔符(CRLF)示例:SET key value 的传输
客户端发送:
*3\r\n ← 数组长度 3
$3\r\nSET\r\n ← 元素 1:"SET"(长度 3)
$3\r\nkey\r\n ← 元素 2:"key"
$5\r\nvalue\r\n ← 元素 3:"value"
服务端响应:
+OK\r\n ← 简单字符串 "OK"4.7.2 RESP3(6.0+,可选)
RESP3 新增数据类型:
· Null: _\r\n
· Double: ,1.23\r\n
· Boolean: #t\r\n 或 #f\r\n
· Blob error: !21\r\nSYNTAX invalid syntax\r\n
· Verbatim string: =15\r\ntxt:Some string\r\n
· Big number: (3492890328409238509324850943850943825024385\r\n
· Array: *2\r\n... (同 RESP2)
· Map: %2\r\n... (键值对)
· Set: ~5\r\n... (无序集合)
· Attribute: |1\r\n... (元信息)
· Push: >2\r\n... (服务端主动推送)
RESP3 让客户端能直接拿到强类型数据,不必在客户端自己转换# 开启 RESP3(客户端协商)
HELLO 3 [AUTH username password] [SETNAME myname]
# 切换到 RESP3,返回服务器信息4.7.3 为什么选文本协议
| 文本协议(RESP) | 二进制协议(Thrift/Protobuf) |
|---|---|
| ✅ 人类可读,调试方便 | ❌ 需要解码工具 |
| ✅ 实现简单,跨语言容易 | ⚠️ 需生成代码 |
| ⚠️ 略占空间 | ✅ 紧凑 |
| ⚠️ 类型弱(都是字符串) | ✅ 强类型 |
Redis 选 RESP 是典型的工程取舍:简单 > 极致效率。实际场景中,协议本身占用的额外空间相比 value 本身通常可以忽略。
4.8 与多线程数据库的对比
| 数据库 | 线程模型 | 锁机制 | 并发能力 | 适用场景 |
|---|---|---|---|---|
| Redis | 单线程命令执行(6.0 IO 多线程) | 无 | 单机 10 万+ QPS | 缓存、KV、计数器 |
| MySQL | 每连接一线程 + InnoDB 多线程 | 行锁 + MVCC | 数万 QPS | 关系型 OLTP |
| PostgreSQL | 每连接一进程 + 多工作进程 | 行锁 + MVCC | 数万 QPS | 关系型 + 复杂查询 |
| MongoDB | 多线程 + WiredTiger 并发 | 文档级锁 | 数万 QPS | 文档型 |
| Memcached | 多线程 | 分段锁 | 数十万 QPS | 纯缓存 |
Redis vs Memcached: Memcached 也是 KV 缓存,但是多线程。Memcached 在高核数机器上 QPS 更高,但 Redis 数据结构更丰富、支持持久化与集群。Redis 已经基本取代 Memcached 成为缓存首选。
4.9 单线程下的性能优化建议
4.9.1 应用层
# ① Pipeline 减少 RTT(见 [[后端/数据库/Redis/Redis数据类型与命令]])
pipe = r.pipeline()
for i in range(1000):
pipe.set(f'key{i}', f'value{i}')
pipe.execute()
# ② MGET/MSET 批量操作
r.mset({'a': 1, 'b': 2, 'c': 3})
values = r.mget(['a', 'b', 'c'])
# ③ 避免大 key 操作
# ❌ HGETALL big_hash → ✅ HSCAN big_hash
# ❌ SMEMBERS big_set → ✅ SSCAN big_set
# ❌ KEYS * → ✅ SCAN
# ④ 用 UNLINK 替代 DEL(大 key 异步删除)
r.unlink('bigkey')4.9.2 配置层
# 启用异步删除(推荐)
lazyfree-lazy-eviction yes # 淘汰时异步
lazyfree-lazy-expire yes # 过期时异步
lazyfree-lazy-server-del yes # 隐式删除异步
lazyfree-lazy-user-del yes # DEL = UNLINK
# 调整慢查询阈值
slowlog-log-slower-than 10000 # 10ms
slowlog-max-len 256
# 禁用危险命令
rename-command KEYS ""
rename-command FLUSHALL ""
rename-command FLUSHDB ""
# 高并发场景可开启 IO 多线程
io-threads-do-reads yes
io-threads 44.9.3 系统层
# 关闭 THP(Transparent Huge Pages),避免 fork 卡顿
echo never > /sys/kernel/mm/transparent_hugepage/enabled
# 调整内核参数
sysctl -w vm.overcommit_memory=1 # 允许 fork 时超额提交
sysctl -w net.core.somaxconn=2048 # 增大 TCP 队列
sysctl -w vm.swappiness=0 # 禁用 swap(Redis 数据换出 = 灾难)
# 绑定 CPU 亲和性(避免 Redis 主线程被调度到不同核)
taskset -c 0 redis-server /etc/redis/redis.conf4.10 常见问题与最佳实践
- Redis 单线程指的是命令执行单线程,6.0 起网络 IO 多线程,但命令执行仍单线程
- 快的五大原因:内存 + 单线程无锁 + epoll 多路复用 + 高效数据结构 + 简洁协议
- 单命令原子性是核心优势:分布式锁、INCR 计数、Lua 脚本都基于此
- 慢命令是性能杀手:禁止
KEYS *、HGETALL(大 hash)、SMEMBERS(大 set) - IO 多线程默认关闭,只在高并发小包场景(>10 万 QPS)才考虑开启
- 命令执行仍然串行:多线程不能解决慢命令、不能并行执行命令
- 生产开启 lazyfree 系列:让淘汰、过期、删除都异步,避免主线程卡顿
- 调整内核参数:
somaxconn、overcommit_memory、关闭 swap - RESP3 提供强类型(6.0+),但多数客户端仍默认 RESP2
- Redis 不是万能的:CPU 密集的计算、大事务、复杂查询不适合,让 MySQL/ES 来做
本章小结
- Redis 6.0 前是严格单线程:网络 IO + 命令执行都在一个线程
- 6.0 起网络 IO 多线程、命令执行仍单线程:解决高并发下的网卡/CPU 瓶颈
- 单线程快的五大原因:内存操作、无锁无切换、epoll 多路复用、高效数据结构、简洁 RESP
- Reactor 模式 + epoll/kqueue 让单线程管理数万连接
- 命令表(dict)+ key 空间(dict)+ 过期表(dict)是 Redis 内部的核心数据结构
- 慢命令会阻塞整个实例:
KEYS *、HGETALL(大 hash)、SMEMBERS(大 set)必须避免 - RESP 协议简洁高效,RESP3(6.0+)增加强类型与主动推送
- 开启多线程 IO 的判定:QPS > 10 万、小包、网卡/CPU 瓶颈,否则单线程足够
- 性能优化三层:应用层(Pipeline/批量/避免大 key)+ 配置层(lazyfree)+ 系统层(内核参数)
- 单命令原子性是 Redis 的核心优势,多线程改造没有破坏这一特性
练习:把「04 线程模型与高性能原理」变成可验证能力
- 从本文任选一个示例,在可丢弃环境重跑;记录运行时/服务版本、输入、关键输出和清理动作。
- 保持其他条件不变,只制造一个边界:空值、重复请求、并发、超时、断连、权限不足或资源耗尽。
- 用四句话复盘:现象是什么、原理在哪一层、证据是什么、生产中如何止损。
验收清单
- [ ] 示例能从空环境重复执行,必要依赖和版本已写明。
- [ ] 同时保留成功路径与至少一个失败路径,不用“应该没问题”冒充证据。
- [ ] 结论写明适用边界,并能链接回对应学习路线的能力域。
- [ ] 练习结束后已清理临时数据、凭据、端口或进程。
对照 数据库练习答案与复盘尺。先交自己的证据再翻答案;答案是镜子,不是传送门。