Skip to content

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 yes

3.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.0MySQL / PostgreSQLMemcached
命令执行单线程(无锁)多线程(有锁)多线程(有锁)
网络 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 性能的完整拼图。