一、核心结论:单线程命令处理 + 多线程辅助

Redis 的线程模型常被概括为“单线程”,但这其实是一个逐渐过时的说法。准确描述应该是:

  • Redis 6.0 之前:完全单线程
  • Redis 6.0 之后:
    • 网络 I/O 读写:多线程
    • 命令执行:仍然单线程

所以 Redis 始终不存在命令执行层面的并发竞争,也就不需要锁,这是它快的关键。

二、为什么 Redis 选择单线程执行命令?

2.1 历史原因与设计哲学

  • CPU 不是瓶颈:Redis 的性能瓶颈通常是内存和网络带宽,而非 CPU 核心数。
  • 避免锁竞争:多线程需要处理数据竞争,引入锁会降低性能、增加复杂度和调试难度。
  • 简单可靠:单线程模型下,命令天然串行,无需考虑事务隔离、死锁等问题,开发维护成本低。

2.2 单线程为什么能那么快?

  • 内存操作:所有数据在内存中,读写极快(纳秒级)。
  • 高效数据结构:专门优化的哈希表、跳表、压缩列表等,时间复杂度 O(1) 或 O(logN)。
  • 非阻塞 I/O 多路复用:单线程通过 epoll/kqueue 同时监听多个连接,处理海量并发。
  • 避免上下文切换:没有线程切换开销和 CPU 缓存失效。

实测单节点 Redis 可达 10 万+ QPS(纯内存操作),足以应对绝大多数场景。

三、Redis 单线程模型核心

Redis 将客户端的连接、读写请求抽象为文件事件,利用 I/O 多路复用 程序监听多个套接字,并将事件派发给对应的事件处理器。这个模型称为 Reactor 模式。

3.1 四个核心组件

┌─────────────────────────────────────────────────┐
│                  Redis 主线程                    │
│  ┌──────────┐    ┌───────────────┐              │
│  │ 套接字   │───▶│ I/O多路复用    │              │
│  │ (Socket) │    │ (epoll/select)│              │
│  └──────────┘    └───────┬───────┘              │
│                          │ 事件通知              │
│                          ▼                       │
│              ┌─────────────────────┐             │
│              │ 文件事件分派器        │            │
│              └─────────┬───────────┘             │
│                        │                         │
│        ┌───────────────┼───────────────────┐    │
│        ▼               ▼                   ▼    │
│  ┌──────────┐    ┌──────────┐       ┌──────────┐│
│  │连接应答   │    │命令请求  │       │命令回复   ││
│  │处理器    │    │处理器    │        │处理器    ││
│  └──────────┘    └──────────┘       └──────────┘│
└─────────────────────────────────────────────────┘

3.1.1 套接字(Socket)

每个客户端连接对应一个套接字,Redis 会监听其上的可读、可写事件。

3.1.2 I/O 多路复用程序

封装了底层的 select、epoll、evport、kqueue 等系统调用。它负责:

  • 监听多个套接字
  • 当套接字就绪(可读/可写),通知文件事件分派器

Redis 会根据系统自动选择最高效的多路复用函数(Linux 下为 epoll)。

3.1.3 文件事件分派器

接收多路复用程序传来的事件,根据事件类型(读/写)和套接字,调用对应的事件处理器。

3.1.4 事件处理器

  • 连接应答处理器:处理客户端连接请求(accept)。
  • 命令请求处理器:读取客户端发送的命令。
  • 命令回复处理器:将命令执行结果写回客户端。

3.2 文件事件类型

文件事件有两种类型:读事件(AE_READABLE)、写事件(AE_WRITABLE)。

  • AE_READABLE:主要指的是客户端发送到redis服务器的所有请求命令(包含get、set等等命令)时产生的事件。
  • AE_WRITABLE:主要指的是redis执行完客户端发送的请求命令后,返回执行结果到客户端是产生的事件。

四、事件处理流程

在 Redis 6.0 之前,网络 I/O 读写和命令执行都在同一个主线程中完成。流程如下:

客户端请求 → 主线程通过 epoll 感知可读 → 读取数据(单线程)→ 解析命令 → 执行命令 → 写回响应(单线程)

这种模型在处理大量并发连接时,如果某个客户端的请求包很大(例如 SET 一个 10MB 的 value),读取数据会阻塞主线程,影响其他客户端。

4.1 连接流程:

在这里插入图片描述

  1. 首先在redis启动初始化的时候,redis中的I/O多路复用程序会监听serverSocket并且将事件处理器中的连接应答处理器和AE_READABLE事件关联起来。
  2. 当客户端跟redis发起连接时,serverSocket会产生一个AE_READABLE事件,然后被I/O多路复用程序监听到后传递给文件事件分派器。
  3. 文件事件分派器再分配给redis初始化时与AE_READABLE事件关联的连接应答处理器去处理。
  4. 连接应答处理器完成与客户端的连接后,会创建一个与客户端一 一对应的socket,同时将这个socket的AE_READABLE事件跟命令请求处理器关联起来,后续该客户端的所有请求都是在这个socket上进行操作。我们可以把这个新建的socket叫作socket01,以便与其他客户端连接成功生成的socket进行区分,不同的客户端与serverSocket完成连接后都会创建一个与之一一对应的socket。

4.2 命令执行流程:

在这里插入图片描述

  1. 客户端发送一个set key value命令到socket01。
  2. socket01会产生一个AE_READABLE(读事件)事件。
  3. I/O多路复用程序监听到socket产生的AE_READABLE事件。
  4. I/O多路复用程序将该AE_READABLE事件放入到队列中。
  5. 文件事件分派器读取队列中的AE_READABLE事件,并从客户端socket01中读取请求数据交由命令请求处理器去处理。
  6. 命令请求处理器解析为具体的命令(如 GET、SET 等)并调用相应的命令处理函数执行命令,命令执行过程中,Redis 会操作内存中的数据(如读取或修改键值对)。
  7. 命令执行完成后,Redis 会将结果数据写入客户端socket01中,并将socket01的AE_WRITABLE事件和命令回复处理器关联起来。
  8. 当客户端准备接收redis返回的数据时,会产生一个AE_WRITABLE事件,该事件被I/O多路复用程序监听到后放入队列中,再由文件事件分派器分配给该事件关联的命令回复处理器去处理。
  9. 最后由命令回复处理器将结果发送给客户端后会删除这个socket01的AE_WRITABLE事件和命令回复处理器的关联。

五、I/O 多路复用机制详解

5.1 为什么需要多路复用?

单线程处理多个连接时,如果阻塞在某个 socket 的 read/write,其他连接就无法服务。多路复用让 Redis 可以同时监听多个 socket,只在有数据时才去读取,避免无谓等待。

5.2 底层实现抽象

Redis 对多路复用进行了封装,提供了统一的接口:

// ae.c 中的核心事件循环
int aeProcessEvents(aeEventLoop *eventLoop) {
    // 调用多路复用 API 等待事件
    numevents = aeApiPoll(eventLoop, tvp);
    for (j = 0; j < numevents; j++) {
        // 处理每个就绪的事件
        aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
        if (fe->mask & AE_READABLE) fe->rfileProc(...);
        if (fe->mask & AE_WRITABLE) fe->wfileProc(...);
    }
}

不同平台下的实现:

  • Linux:epoll
  • BSD/macOS:kqueue
  • Solaris:evport
  • 其他(旧系统):select

Redis 在编译时会自动选择最高效的可用机制。

六、单线程模型的优缺点

6.1 优点:

  1. 避免锁和并发竞争,实现天然原子性

    • 无锁设计:所有命令在单线程中串行执行,无需对数据结构加锁(如哈希表、跳表)。
    • 原子操作:单个命令天然是原子的,无需 MULTI/EXEC 就能保证并发安全。
  2. 避免上下文切换和 CPU 缓存失效

    • 无线程切换开销:单线程不会因为时间片耗尽而被动切换,也没有线程创建/销毁成本。
    • CPU 缓存友好:数据结构和热点变量一直停留在 L1/L2 缓存中,不会因线程切换导致缓存失效。
    • 可预测的性能:单线程的性能曲线是平滑的,不会因为线程数增加而出现性能抖动。
  3. 简单且可维护

    • 易于调试:所有操作顺序执行,没有竞态条件、死锁、数据竞争等并发 Bug。
    • 实现简洁:事件循环、命令表、数据结构都可以用最简单的同步方式编写。
  4. 对内存数据结构的极致优化

    • 单线程使得 Redis 可以使用复杂但高效的数据结构(如跳表、压缩列表),而不必担心并发修改。
  5. 组合操作的原子性

    • 虽然 Redis 不支持多命令的事务回滚,但通过 Lua 脚本 可以在单线程中原子执行一串命令。
    • 单线程保证了 Lua 脚本执行期间,不会有其他命令插入,实现复杂的原子更新(如扣库存、转账)。

6.2 缺点:

  1. 单个慢命令阻塞整个服务

    • 这是最严重的缺陷。因为所有命令串行执行,任何一个耗时操作都会导致后续所有请求排队等待。
    • 典型案例:
      • KEYS *:扫描全库,千万级 key 可能耗时数秒。
      • HGETALL 大 Hash:读取百万字段的 Hash,耗时数百毫秒。
      • ZRANGE 大 ZSet:返回大量数据,序列化和传输耗时。
      • SORT 复杂排序:O(N log N) 操作,数据量大时阻塞。
      • Lua 脚本死循环或长时间循环:彻底卡死 Redis。
    • 后果:
      • 其他客户端请求超时。
      • 哨兵可能误判节点下线(PING 不响应)。
      • 集群节点可能被标记为 FAIL。
    • 解决方案:
      • 使用 SCAN、HSCAN、SSCAN、ZSCAN 代替全量遍历。
      • 对大 key 进行拆分(如将大 Hash 拆成多个小 Hash)。
      • 使用 UNLINK 异步删除大 key。
      • 监控慢查询(slowlog get),设置合理阈值(如 10ms)。
      • Lua 脚本中避免循环操作大量数据。
  2. 无法充分利用多核 CPU

    • 单线程只能使用一个 CPU 核心,即使服务器有 64 核,Redis 也只能跑满一个核。
    • 对于 CPU 密集型操作(如大量 SINTER、ZUNIONSTORE),单核成为瓶颈。
    • 解决方案:
      • 部署多个 Redis 实例:一台物理机部署多个 Redis 进程(如 8 核部署 8 个实例),使用端口区分,业务层进行分片(客户端一致性哈希或 Redis Cluster)。
      • 使用 Redis Cluster:将数据分散到多个 Master 节点,每个 Master 使用一个核心。
      • 多线程 I/O(Redis 6.0+):可提升网络吞吐,但不能解决命令执行阶段的 CPU 瓶颈。
  3. 大对象操作的内存释放阻塞

    • 删除一个包含百万元素的 Hash 或 List 时,DEL 命令会逐个释放内存,耗时较长(O(N))。
    • 在单线程中,释放过程会阻塞事件循环。
    • 解决方案:
      • 使用 UNLINK 代替 DEL:Redis 4.0+ 支持,将实际释放操作交给后台线程,主线程立即返回。
  4. 网络吞吐量受限于单核处理能力

    • 即使命令执行很快(如 PING、GET 小 key),网络读写(解析 RESP 协议、分配缓冲区)也会消耗 CPU。
    • 当 QPS 超过 10 万时,单核可能满载,网络包处理成为瓶颈。
    • 解决方案:
      • Redis 6.0 多线程 I/O:将网络读写分给多个 I/O 线程,主线程只负责命令执行,可提升 2~3 倍吞吐量。
      • 批量操作(Pipeline)减少网络往返。

七、Redis 6.0+:多线程 I/O(核心变化)

7.1 设计目标

将网络 I/O 读写从主线程中分离出去,由多个 I/O 线程并行处理,而命令执行仍然在主线程串行。这样既保证了原子性,又提升了网络吞吐量。

7.2 整体架构图

┌─────────────────────────────────────────────────────────┐
│                      主线程                              │
│  - 接收新连接                                           │
│  - 事件循环 (aeMain)                                    │
│  - 命令执行 (processCommand)                            │
│  - 将请求分配给 I/O 线程                                │
│  - 收集 I/O 线程的解析结果                              │
└──────────────┬──────────────────────┬───────────────────┘
               │                      │
               ▼                      ▼
    ┌──────────────────┐    ┌──────────────────┐
    │   I/O 线程 1     │    │   I/O 线程 2     │
    │  - 从 socket 读取 │    │  - 从 socket 读取 │
    │  - 解析 RESP 协议 │    │  - 解析 RESP 协议 │
    │  - 写回响应数据   │    │  - 写回响应数据   │
    └──────────────────┘    └──────────────────┘
               ▲                      ▲
               └──────────┬───────────┘
                          │
                    ┌──────┴──────┐
                    │  I/O 线程 N │
                    └─────────────┘

7.3 工作流程

阶段 1:请求读取(多线程读)

传统单线程方式:

epoll_wait 返回 → 主线程循环 read() + 解析 → 执行命令

多线程读方式:

epoll_wait 返回 → 主线程将就绪的客户端分配给 I/O 线程 → 
I/O 线程并行 read() + 解析 → 主线程从队列取出已解析的命令 → 执行

核心步骤:

  1. 事件循环:主线程调用 aeApiPoll 获取就绪的客户端 fd。
  2. 分发:主线程将就绪的客户端轮询分配给各个 I/O 线程(存入 io_threads[id].clients_pending)。
  3. 唤醒:主线程设置标志,唤醒 I/O 线程。
  4. 并行读取:每个 I/O 线程循环处理自己队列中的客户端:
    • 调用 read() 从 socket 读取数据。
    • 解析 RESP 协议,将命令存入 client->querybuf。
    • 标记命令已解析完成。
  5. 收集:主线程等待所有 I/O 线程完成,然后从每个客户端取出命令执行。
  6. 执行:主线程串行执行所有命令(这一步仍然是单线程)。

阶段 2:响应写入(多线程写)

执行完命令后,响应数据被写入客户端的输出缓冲区。发送响应时:

传统单线程方式:

主线程直接 write() 或注册写事件后主线程发送

多线程写方式:

主线程执行命令 → 响应入队 → 将客户端分配给 I/O 线程 → 
I/O 线程并行 write() 发送数据

核心步骤:

  1. 命令执行:主线程执行命令,将响应写入 client->buf 或 client->reply。
  2. 入队:将客户端加入 server.clients_pending_write 队列。
  3. 分发:在 beforeSleep() 中,主线程将该队列的客户端分配给 I/O 线程。
  4. 并行写入:I/O 线程调用 write() 将数据发送给客户端。
  5. 清理:发送完成后,如果还有数据未发完,重新注册写事件(交给主线程或下次 I/O 线程)。

7.4 配置与启用

Redis 6.0 默认关闭多线程 I/O,需要手动配置。 什么时候应该开启?

  • 当前 Redis QPS > 5 万,且 CPU 使用率接近单核 100%。
  • 网络 I/O 占比高(用 perf top 看到 read/write 系统调用占用高)。
  • value 较大(>1KB)或 Pipeline 批量请求多。

开启步骤:

  1. 修改 redis.conf

    # redis.conf
    
    # 1. 开启多线程读(默认 no)
    io-threads-do-reads yes
    
    # 2. 设置 I/O 线程数量(默认 4)
    io-threads 4
    
    # 3. 相关优化:避免线程频繁创建销毁
    # (无需额外配置,Redis 启动时创建线程池)
    
    • io-threads-do-reads 设为 yes 后才开启读取多线程;否则仅写入多线程。
    • 线程数并非越大越好,通常设为 4-8 即可,或者为:CPU核心数 / 2,最多不超过 8-10。
  2. 重启 Redis

    redis-server redis.conf
    
  3. 验证

    redis-cli INFO server | grep io_threads
    # 输出:io_threads_active:1
    
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐