Appearance
Redis 6.0 多线程模型
Redis 6.0(2020 年发布)是 Redis 历史上最重要的版本之一,最引人注目的变化就是引入了 IO 多线程。但这个"多线程"和很多人想的不一样——它不是把命令执行改成多线程,而是仅对网络 IO 做了多线程优化。本文从"为什么要改"到"怎么改的",彻底讲清楚。
前置阅读:理解 Redis 6.0 多线程的前提,是先理解 Redis 单线程模型 的优势和局限。
一、为什么单线程不够用了?
1.1 单线程的瓶颈在变化
Redis 4.0 时代(2017 年):
硬件:千兆网卡、4 核 CPU 是主流
瓶颈:命令执行足够快,网络 IO 也不是瓶颈
单线程 QPS 轻松达到 10 万+
Redis 6.0 时代(2020 年):
硬件:万兆网卡普及、16 核/32 核 CPU 成为常态
瓶颈:单线程处理网络 IO 成为"木桶最短的板"
万兆网卡带宽 ~1.25GB/s,单线程读 socket 极限 ~200MB/s
→ 网络带宽只利用了 16%,CPU 大部分在空转1.2 瓶颈在哪?
瓶颈不是固定的,取决于场景。 以下按实际情况分类:
场景一:小 Value + 简单命令 + 万兆网卡(最常见的高 QPS 场景)
GET key → "ok"(几十字节)
主线程时间消耗:
┌──────────────┬──────────────┬──────────────┐
│ epoll_wait │ read/write │ 命令执行 │
│ (syscall) │ (syscall) │ (内存操作) │
│ 大 │ 大 │ 极小 │
└──────────────┴──────────────┴──────────────┘
→ 瓶颈:syscall 开销。read/write 系统调用本身耗 CPU
→ 6.0 IO 多线程有效:把 read/write 分摊到多个线程
场景二:大 Value + GET/SET
GET bigkey → 1MB 数据
主线程时间消耗:
┌──────────────┬──────────────┬──────────────┐
│ 内核拷贝数据 │ 输出缓冲区 │ 命令执行 │
│ (内存拷贝) │ 管理 │ │
│ 大 │ 中 │ 小 │
└──────────────┴──────────────┴──────────────┘
→ 瓶颈:内存拷贝(内核空间 → 用户空间)和输出缓冲区管理
→ 6.0 IO 多线程有部分帮助(分担 write 操作)
场景三:复杂命令(ZINTERSTORE、SORT、聚合计算)
ZINTERSTORE result 3 set1 set2 set3
主线程时间消耗:
┌──────────────┬──────────────┬──────────────┐
│ read/write │ 命令执行 │ 命令执行 │
│ (syscall) │ (CPU 密集) │ (CPU 密集) │
│ 小 │ 大 │ 大 │
└──────────────┴──────────────┴──────────────┘
→ 瓶颈:CPU 计算(命令执行本身)
→ 6.0 IO 多线程无效(命令执行仍然是单线程)核心结论: 在高并发小 Value 场景下(这也是 Redis 最典型的使用场景),单线程的 CPU 时间大量消耗在 read/write 系统调用上,而非命令执行本身。6.0 的 IO 多线程正是针对这个场景——把网络 IO 的 syscall 开销分摊到多个线程,而命令执行保持单线程不变。
1.3 多线程的诱惑和陷阱
方案 A:命令执行改成多线程?
❌ 需要引入锁 → 性能下降
❌ 代码复杂度爆炸 → 维护成本高
❌ Redis 的数据结构都是为单线程设计的 → 大量重构
方案 B:只把网络 IO 改成多线程,命令执行保持单线程?
✅ 命令执行不需要锁 → 性能不受影响
✅ 代码改动小 → 只改网络层
✅ 充分利用多核 → 网络吞吐大幅提升antirez 选择了方案 B——最小化改动,最大化收益。
二、6.0 多线程究竟做了什么?
2.1 核心原则:命令执行永远是单线程
Redis 6.0 多线程的本质:
主线程 IO 线程组
┌──────────────┐ ┌──────────────┐
│ │ event │ IO Thread 1 │ 读取 socket 数据
│ 事件循环 │────────→│ │ 解析 RESP 协议
│ aeEventLoop │ ├──────────────┤
│ │ event │ IO Thread 2 │ 读取 socket 数据
│ │────────→│ │ 解析 RESP 协议
│ │ ├──────────────┤
│ │ event │ IO Thread 3 │ 读取 socket 数据
│ │────────→│ │ 解析 RESP 协议
│ │ └──────────────┘
│ │ │
│ ⬇ 等待 IO 线程完成 │ 返回解析后的命令
│ │←───────────────┘
│ │
│ ⬇ 执行命令(单线程)
│ ┌──────────────────────┐
│ │ 执行业务逻辑 │ ← 这里永远是单线程!
│ │ 修改内存数据 │ 不需要锁,和之前一样
│ └──────────────────────┘
│ │
│ │ event
│ │────────→ IO 线程组写回响应
│ │←────────
│ │
└──────────────┘2.2 执行流程
一次完整的请求处理流程(Redis 6.0):
Step 1: 主线程通过 epoll 获取可读事件
Step 2: 主线程将可读的 socket 分发给 IO 线程
Step 3: IO 线程并行读取 socket 数据 + 解析 RESP 协议
Step 4: 主线程等待所有 IO 线程完成
Step 5: 主线程串行执行所有命令(单线程!)
Step 6: 主线程将响应分发给 IO 线程
Step 7: IO 线程并行写回响应
Step 8: 主线程继续 epoll 等待2.3 源码视角
c
// Redis 6.0 网络 IO 多线程核心逻辑(简化版)
// IO 线程主函数
void *IOThreadMain(void *myid) {
long id = (unsigned long)myid;
while(1) {
// 等待主线程分配任务
for (int j = 0; j < 1000000; j++) {
if (io_threads_pending[id] != 0) break;
}
// 获取任务列表
list *clients = server.io_threads_list[id];
if (getIOPendingType(id) == IO_THREADS_OP_READ) {
// 读:从 socket 读取数据并解析 RESP 协议
readQueryFromClientWhilePenging(c);
} else {
// 写:将响应数据写入 socket
writeToClientWhilePenging(c);
}
io_threads_pending[id] = 0;
}
}
// 主线程:处理读事件
int handleClientsWithPendingReadsUsingThreads(void) {
// 1. 将客户端分配给 IO 线程
int item_id = 0;
while((c = getNextClient()) != NULL) {
int target_id = item_id % server.io_threads_num;
listAddNodeTail(server.io_threads_list[target_id], c);
item_id++;
}
// 2. 设置计数器,通知 IO 线程开始工作
for (int i = 0; i < server.io_threads_num; i++) {
io_threads_pending[i] = server.io_threads_list[i]->len;
}
// 3. 主线程自己也参与 IO 处理
// (主线程是 IO 线程 0,也处理一个客户端)
// 4. 等待所有 IO 线程完成(自旋等待)
while(1) {
unsigned long pending = 0;
for (int i = 0; i < server.io_threads_num; i++)
pending += io_threads_pending[i];
if (pending == 0) break;
}
// 5. 命令执行:这里仍然是单线程串行执行!
// 修改数据、执行 Lua 脚本等都在这里
while((c = getNextClient()) != NULL) {
processCommand(c); // ← 单线程执行,无锁!
}
}三、配置与调优
3.1 开启 IO 多线程
bash
# redis.conf
# 关闭(默认值,Redis 6.0 兼容模式)
io-threads 1
# 开启 IO 多线程(建议值:CPU 核数的 1/2 到 3/4)
io-threads 4
# 读操作也使用多线程(默认只对写操作开启多线程)
io-threads-do-reads yes3.2 为什么要限制线程数?
线程数不是越多越好:
CPU 核数 推荐 io-threads 原因
───────────────────────────────────────
4 核 2-3 留 1-2 核给主线程
8 核 4-6 主线程 + 后台线程也需要 CPU
16 核 6-10 超过 8 个线程收益递减
注意:io-threads 包含主线程
如 io-threads 4 = 主线程 + 3 个 IO 线程3.3 什么是"只对写操作开启多线程"?
默认情况下(io-threads-do-reads no):
读取:主线程单独处理
执行:单线程
写入:多线程并行处理
为什么这样设计?
- 写操作数据量大(响应结果),IO 压力大
- 读操作数据量小(请求体),单线程足够
- 读操作使用多线程需要额外同步,收益不大3.4 性能收益
Redis 官方压测数据(8 核 CPU,万兆网卡):
配置 QPS 提升
─────────────────────────────────────
io-threads 1 120,000 基准
io-threads 2 200,000 +67%
io-threads 3 260,000 +117%
io-threads 4 300,000 +150%
io-threads 6 320,000 +167%
io-threads 8 310,000 +158%(线程过多,竞争反而下降)
最佳实践:io-threads 设置为 CPU 核数的 1/2 到 3/4四、和真正的多线程数据库有什么区别?
| 维度 | Redis 6.0 | MySQL / PostgreSQL | Memcached |
|---|---|---|---|
| 命令执行 | 单线程(无锁) | 多线程(有锁) | 多线程(有锁) |
| 网络 IO | 多线程 | 多线程 | 多线程 |
| 数据一致性 | 天然保证(无并发) | 需要 MVCC / 锁 | 需要 CAS |
| 事务隔离 | 天然串行化 | 多级隔离 | 无事务 |
| 单核性能 | 极高 | 一般 | 高 |
| 多核扩展 | 有限(仅 IO) | 好 | 好 |
Redis 的设计哲学是:让单核跑到极致,而不是让多核算力平均分配。这和其他数据库的"充分利用多核"思路完全不同。
五、常见面试题
Q1:Redis 6.0 是真正的多线程吗?
不是。Redis 6.0 的多线程只用于网络 IO(读 socket、写 socket、解析协议),命令执行仍然是单线程的。修改数据、执行 Lua 脚本、事务等核心操作都没有多线程化。它更像是一个"IO 多线程辅助单线程执行"的模型。
Q2:为什么命令执行不改成多线程?
三个原因:1) 命令执行本身极快(微秒级),多线程的锁开销可能比执行本身还大;2) Redis 的数据结构都是为单线程设计的,改多线程需要大量重构;3) 单线程意味着代码简洁、无锁、无死锁,维护成本极低。
Q3:Redis 6.0 多线程和 Memcached 多线程有什么区别?
Memcached 是真多线程——命令执行也是多线程的,内部使用锁保护共享数据。Redis 6.0 是IO 多线程 + 命令执行单线程,命令执行层面不需要锁,数据一致性保证更简单。
Q4:什么场景下需要开启 IO 多线程?
1) 网络带宽足够大(万兆+);2) CPU 核心数多(4 核+);3) QPS 成为瓶颈,单线程 IO 处理不过来。如果 QPS 不高(< 10 万),单线程完全够用,开启多线程反而增加上下文切换开销。
Q5:Redis 7.0 的多线程有变化吗?
Redis 7.0 延续了 6.0 的 IO 多线程模型,没有本质变化。但优化了 IO 线程的调度策略,减少了自旋等待的 CPU 消耗。核心原则不变:命令执行永远单线程。
六、总结
Redis 6.0 多线程模型的核心:
1. 只对网络 IO 做多线程,命令执行保持单线程
→ 最小化改动,最大化收益
2. 设计哲学:让单核跑到极致
→ 命令执行速度不是瓶颈,不需要多线程
→ 网络 IO 才是瓶颈,用多线程解决
3. 配置建议:
io-threads 4 # 4-8 核 CPU
io-threads-do-reads yes # 读也开启多线程
4. 不是银弹:
- 慢命令仍然会阻塞(单线程执行)
- 无法利用多核算力加速复杂计算
- QPS < 10 万时,单线程反而更优从 2009 年的纯单线程,到 2020 年的 IO 多线程,Redis 的演变体现了 antirez 务实的工程哲学:不为了技术炫技而改架构,只在真正有瓶颈的地方做优化。单线程模型的简洁性造就了 Redis 的稳定和高性能,而 IO 多线程则是对网络瓶颈的精准补强——两者结合,才是 Redis 性能的完整拼图。
