第 26 章

多线程 I/O:Redis 6+ 的架构演进

第26章 多线程 I/O:Redis 6+ 的架构演进

26.1 演进背景:单线程的天花板

Redis 诞生之初以单线程著称。这个设计选择在 2009 年非常明智:避免了锁竞争、简化了代码、充分利用了 CPU 缓存局部性。然而随着硬件发展,单线程架构的瓶颈逐渐暴露。

26.1.1 单线程时代的性能边界

在 Redis 5 及以前版本中,整个服务只有一个主线程负责:

这意味着所有网络 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,必须加锁保护,而锁竞争的开销在高并发下会完全抵消多线程带来的收益。

原子命令的复杂性INCRLPUSHZADD 等命令的原子性语义依赖于单线程执行。多线程下需要细粒度锁,实现复杂度指数级增长。

Lua 脚本和事务MULTI/EXECEVAL 都依赖单线程的串行执行保证原子性,多线程化会破坏这些语义。

维护成本:单线程无死锁、无竞态条件、调试简单。多线程引入的 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)

  1. 主线程调用 epoll_wait,检测到多个 socket 可读
  2. 主线程将这些 client 按轮询方式分配到 io_threads_list[tid] 队列中
  3. 主线程设置 io_threads_op = IO_THREADS_OP_READ
  4. I/O 线程并发执行 readQueryFromClient:从 socket 读数据到 client->querybuf
  5. 主线程自旋等待(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)

  1. 主线程依次遍历所有 client,调用 processCommandAndResetClient
  2. 解析 client->querybuf,执行命令,写结果到 client->buf(输出缓冲区)
  3. 此阶段完全单线程,无并发访问

阶段三:写响应(Write Phase)

  1. 主线程将有待发送数据的 client 加入写队列
  2. 按轮询分配给 I/O 线程
  3. I/O 线程并发执行 writeToClient,将 client->buf 数据发送到 socket
  4. 主线程等待所有写操作完成

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


26.4 与其他高性能 Redis 方案对比

26.4.1 KeyDB

KeyDB 是 Redis 的 fork,核心改动是让命令执行也多线程化:

# KeyDB 配置
server-threads 4          # 每个线程一个完整的 event loop
server-thread-affinity true  # 线程绑定 CPU

架构差异

问题

26.4.2 Dragonfly

Dragonfly(2022年)采用 shared-nothing 架构:

# Dragonfly 特点
--threads=8      # 每个线程负责独立的哈希槽分片
                 # 线程间无共享状态,无锁竞争

架构差异

问题

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 提升。

核心要点:

本章评分
4.6  / 5  (5 评分)

💬 留言讨论