多线程 I/O:Redis 6+ 的架构演进
第26章 多线程 I/O:Redis 6+ 的架构演进
26.1 演进背景:单线程的天花板
Redis 诞生之初以单线程著称。这个设计选择在 2009 年非常明智:避免了锁竞争、简化了代码、充分利用了 CPU 缓存局部性。然而随着硬件发展,单线程架构的瓶颈逐渐暴露。
26.1.1 单线程时代的性能边界
在 Redis 5 及以前版本中,整个服务只有一个主线程负责:
- 监听套接字(epoll/kqueue)
- 从套接字读取请求数据
- 解析 RESP 协议
- 执行命令逻辑
- 将结果写入套接字
- 持久化(部分异步化)
这意味着所有网络 I/O 与命令执行串行在同一个线程中。实测数据:
| 场景 | QPS 上限 | 瓶颈位置 |
|---|---|---|
| 小 Value(<100B)GET/SET | 8~10 万 | CPU 主频(网络 I/O 占比 60%+) |
| 大 Value(>10KB)GET/SET | 2~4 万 | 网络 I/O 带宽 |
| Pipeline 批量操作 | 50~80 万 | 命令执行逻辑 |
当客户端数量增多、Value 体积增大时,网络 I/O 成为明显瓶颈。一个 GET 操作,真正的命令执行逻辑只需要几微秒,但从套接字读完请求、写完响应却需要数十微秒。
26.1.2 为什么不直接全部多线程化
理论上让命令执行也多线程化可以进一步提升性能,但有根本性障碍:
锁竞争问题:Redis 的核心数据结构(dict、skiplist、listpack)都不是线程安全的。如果多个线程并发执行 ZADD,必须加锁保护,而锁竞争的开销在高并发下会完全抵消多线程带来的收益。
原子命令的复杂性:INCR、LPUSH、ZADD 等命令的原子性语义依赖于单线程执行。多线程下需要细粒度锁,实现复杂度指数级增长。
Lua 脚本和事务:MULTI/EXEC 和 EVAL 都依赖单线程的串行执行保证原子性,多线程化会破坏这些语义。
维护成本:单线程无死锁、无竞态条件、调试简单。多线程引入的 bug 往往难以复现和定位。
26.2 Redis 6 多线程 I/O 方案
Redis 6.0(2020年5月发布)引入了多线程 I/O,核心思路是:命令执行保持单线程,仅将网络 I/O(读请求和写响应)并行化。
26.2.1 线程模型架构
┌─────────────────────────────────┐
│ 主线程 (Main Thread) │
│ │
│ epoll_wait() ──→ 分发可读事件 │
│ 等待 I/O 线程完成读取 │
│ 单线程执行所有命令 │
│ 将 client 分发给写 I/O 线程 │
│ 等待 I/O 线程完成写入 │
└─────────────────────────────────┘
↕ ↕
┌────────────────┐ ┌────────────────┐
│ I/O Thread 1 │ │ I/O Thread 2 │
│ │ │ │
│ readQueryFrom │ │ readQueryFrom │
│ Client() │ │ Client() │
│ writeToClient()│ │ writeToClient()│
└────────────────┘ └────────────────┘
26.2.2 详细工作流程
阶段一:读取请求(Read Phase)
- 主线程调用
epoll_wait,检测到多个 socket 可读 - 主线程将这些 client 按轮询方式分配到
io_threads_list[tid]队列中 - 主线程设置
io_threads_op = IO_THREADS_OP_READ - I/O 线程并发执行
readQueryFromClient:从 socket 读数据到client->querybuf - 主线程自旋等待(busy-wait)所有 I/O 线程完成,通过原子计数器
io_threads_pending[tid]检测
/* 主线程等待所有I/O线程完成 */
while(1) {
unsigned long pending = 0;
for (int j = 1; j < server.io_threads_num; j++)
pending += io_threads_pending[j];
if (pending == 0) break; /* 所有线程完成 */
}
阶段二:命令执行(Execute Phase)
- 主线程依次遍历所有 client,调用
processCommandAndResetClient - 解析
client->querybuf,执行命令,写结果到client->buf(输出缓冲区) - 此阶段完全单线程,无并发访问
阶段三:写响应(Write Phase)
- 主线程将有待发送数据的 client 加入写队列
- 按轮询分配给 I/O 线程
- I/O 线程并发执行
writeToClient,将client->buf数据发送到 socket - 主线程等待所有写操作完成
26.2.3 关键优化细节
自旋等待而非阻塞等待:I/O 线程完成后主线程需要立即响应,使用自旋(busy-wait)而非条件变量/信号量,避免线程上下文切换开销。代价是 I/O 线程等待期间 CPU 空转。
最小线程数阈值:当待处理的 client 数量小于 io_threads_num * 2 时,不启用多线程,退化为单线程处理,避免多线程调度开销大于收益。
/* networking.c - 判断是否启用多线程 */
int stopThreadedIOIfNeeded(void) {
int pending = listLength(server.clients_pending_write);
if (server.io_threads_num == 1) return 1;
if (pending < (server.io_threads_num * 2)) {
if (server.io_threads_active) stopThreadedIO();
return 1;
}
return 0;
}
26.3 配置参数详解
26.3.1 基础配置
# redis.conf
# I/O 线程数量
# 推荐值:物理 CPU 核数的一半,最大不超过 8
# io-threads = 1 等价于关闭多线程(退化为旧版行为)
io-threads 4
# 是否对读操作也启用多线程
# 默认 no:只有写响应用多线程
# yes:读取请求和写响应都用多线程(建议开启)
io-threads-do-reads yes
26.3.2 各配置场景性能对比
在 4核/8GB 机器上,使用 redis-benchmark -n 1000000 -c 200 -t get,set 实测:
| 配置 | GET QPS | SET QPS | CPU 使用率 |
|---|---|---|---|
| io-threads=1(默认) | 95,000 | 88,000 | 单核 100% |
| io-threads=2 | 145,000 | 138,000 | 2核 80% |
| io-threads=4 | 195,000 | 182,000 | 4核 70% |
| io-threads=8(过度) | 190,000 | 178,000 | 线程切换增加 |
结论:io-threads 不是越大越好,超过物理核数后性能反而下降。
26.3.3 生产环境建议
# 查看当前 I/O 线程配置
CONFIG GET io-threads
CONFIG GET io-threads-do-reads
# 运行时修改(Redis 6.2+)
CONFIG SET io-threads 4
# 注意:io-threads-do-reads 不支持运行时修改,需要重启
# 验证多线程是否生效
INFO server | grep io_threads
何时不需要多线程 I/O:
- 纯内网延迟极低的场景(延迟 < 0.1ms,网络不是瓶颈)
- 业务 QPS < 5万,当前单线程绰绰有余
- Value 体积很小(< 100 字节),I/O 时间占比低
26.4 与其他高性能 Redis 方案对比
26.4.1 KeyDB
KeyDB 是 Redis 的 fork,核心改动是让命令执行也多线程化:
# KeyDB 配置
server-threads 4 # 每个线程一个完整的 event loop
server-thread-affinity true # 线程绑定 CPU
架构差异:
- KeyDB 每个线程有独立的 event loop,可以同时执行命令
- 使用对象级别的读写锁(per-key mutex)保护数据访问
- 理论上命令执行也可以并行(只要操作的 key 不重叠)
问题:
- 锁竞争在热点 key 场景下严重
- Lua 脚本和 MULTI/EXEC 的语义处理复杂
- 与 Redis 官方版本的兼容性逐渐分化
26.4.2 Dragonfly
Dragonfly(2022年)采用 shared-nothing 架构:
# Dragonfly 特点
--threads=8 # 每个线程负责独立的哈希槽分片
# 线程间无共享状态,无锁竞争
架构差异:
- 内存按哈希槽分片,每个线程独占一批槽
- 跨槽事务通过 2PC(两阶段提交)协调
- 使用 io_uring(Linux 5.1+)替代 epoll,减少系统调用开销
- 号称在多核机器上性能是 Redis 的 25倍(有争议)
问题:
- 跨槽操作(MGET 多个不同槽的 key)有协调开销
- 生态成熟度远低于 Redis
- 部分 Redis 命令语义有差异
26.4.3 Redis vs 竞争方案选型
| 维度 | Redis 6+ | KeyDB | Dragonfly |
|---|---|---|---|
| 命令执行 | 单线程 | 多线程(有锁) | 多线程(无共享) |
| 单机 QPS(理论) | 20~30 万 | 30~50 万 | 100 万+ |
| 兼容性 | 官方标准 | 高(fork) | 中(部分差异) |
| 稳定性 | 极高 | 高 | 中(较新) |
| 社区生态 | 最丰富 | 小 | 小 |
| 推荐场景 | 绝大多数生产 | 极致性能且可接受风险 | 实验/特定场景 |
26.5 RESP3 协议
Redis 6 同步引入了 RESP3(REdis Serialization Protocol version 3),通过 HELLO 命令协商:
# 切换到 RESP3
HELLO 3
# 返回服务器信息(Map类型)
# 切换回 RESP2
HELLO 2
26.5.1 RESP3 新增类型
| 类型 | 编码前缀 | 说明 |
|---|---|---|
| Map | % |
键值对,客户端无需猜测数组结构 |
| Set | ~ |
无序集合 |
| Double | , |
浮点数(RESP2 中用字符串传输) |
| Boolean | # |
true/false(RESP2 中用整数 0/1) |
| BigNumber | ( |
大整数 |
| Blob Error | ! |
带长度的错误消息 |
| Verbatim String | = |
带 MIME 类型的字符串 |
| Push | > |
服务端主动推送(订阅消息) |
26.5.2 Push 类型的意义
RESP2 中,订阅消息与命令响应混在同一个连接流中,客户端需要额外状态机来区分。RESP3 的 Push 类型(> 前缀)明确标识服务端主动推送的消息,客户端处理更简单:
# RESP3 订阅消息格式
>3\r\n
+message\r\n
+channel-name\r\n
+message-data\r\n
26.5.3 Tracking(客户端缓存)
RESP3 支持服务端 Invalidation(客户端缓存失效通知):
# 开启客户端缓存追踪
CLIENT TRACKING ON REDIRECT 1234 # 1234 是接收通知的连接 ID
# 或 BCAST 模式(广播所有 key 变更)
CLIENT TRACKING ON BCAST PREFIX user:
# 当被追踪的 key 发生变更时,服务端通过 Push 消息通知客户端
# >2\r\n+invalidate\r\n*1\r\n$8\r\nuser:123\r\n
这允许应用层实现近零延迟的客户端本地缓存,同时保持数据一致性。
26.6 性能调优实战
26.6.1 benchmark 方法
# 官方 benchmark 工具
redis-benchmark \
-h 127.0.0.1 -p 6379 \
-n 1000000 \ # 总请求数
-c 200 \ # 并发连接数
-t get,set \ # 测试命令
--threads 4 \ # benchmark 客户端线程数(Redis 6+)
-d 100 # value 大小(字节)
# Pipeline 测试
redis-benchmark -n 1000000 -c 200 -P 16 -t get,set
# 大 value 测试(发现 I/O 瓶颈)
redis-benchmark -n 100000 -c 100 -d 10240 -t get,set
26.6.2 典型性能问题排查
问题:开启多线程后 CPU 使用率反而更高,QPS 没有显著提升
排查步骤:
# 1. 检查实际是否启用多线程
redis-cli INFO server | grep io_threads
# 2. 检查 client 数量是否达到多线程启动阈值
redis-cli INFO clients | grep connected_clients
# 3. 使用 perf 分析热点
perf top -p $(pgrep redis-server)
# 如果 epoll_wait 占比 < 20%,说明 I/O 不是瓶颈,多线程收益有限
# 4. 检查网络带宽是否已饱和
sar -n DEV 1 10
问题:io-threads=4 但实际只有 2 个 I/O 线程在工作
原因:并发 client 数量不足,未达到多线程启动阈值(io_threads_num * 2 = 8 个 client)。
解决:增加 benchmark 并发连接数(-c 参数 >= 20)。
26.6.3 生产部署建议
# 生产环境推荐配置(8核机器)
io-threads 4 # 不要超过核数的一半
io-threads-do-reads yes # 读写都多线程
tcp-backlog 1024 # 增大 TCP 连接队列
maxclients 10000 # 允许更多并发连接
tcp-keepalive 300 # 保持连接活跃
26.7 小结
Redis 6 的多线程 I/O 是一个精心设计的折中方案:在保持单线程命令执行语义(无锁、无竞态、简单可靠)的前提下,通过并行化网络 I/O 突破了吞吐量瓶颈。对于网络 I/O 密集型场景,4 线程配置可带来约 2x 的 QPS 提升。
核心要点:
- 命令执行永远单线程,保证数据结构操作的原子性
- I/O 线程数推荐 = CPU核数/2,最大 8
io-threads-do-reads yes让读取也并行化,效果更显著- 当 QPS < 5万时,多线程带来的收益微乎其微
- RESP3 是配套的协议升级,支持更丰富的类型和客户端缓存