架构、事件循环与线程模型

→ 返回 Redis

Redis 的“单线程”特指:一条命令对内存数据的核心执行过程主要由主线程串行完成。它不等于整个进程只有一个线程:后台删除、AOF 刷盘、关闭文件、RDB/AOF 子进程,以及 Redis 6.0+ 的网络 I/O 线程都会并行工作。

Redis 服务端没有一个把普通 GET/SET 分发给多个 worker 并行修改键空间的“通用命令线程池”。实际存在的是职责分离的 I/O 线程、后台 I/O(BIO)工作线程和持久化子进程;Java 客户端的 Netty 线程池/连接池又是另一层。

客户端 Socket
    │ 连接/读/写就绪
    ▼
ae 事件循环(Linux 通常选 epoll)
    │
    ├─ 文件事件:accept、读取请求、写回响应
    ├─ 时间事件:serverCron、过期采样、统计、集群心跳
    └─ beforeSleep / afterSleep:集中处理队列与 I/O
             │
             ▼
       主线程解析并串行执行命令
             │
             ├─ 内存对象与字典
             ├─ AOF / 复制传播
             └─ 响应缓冲区

为什么核心命令使用单线程

收益含义
避免锁竞争修改字典和对象时不需要为每条命令加锁
命令天然串行单条命令执行期间不会被另一条普通命令穿插,便于保证原子性
CPU 缓存友好少了线程切换和共享状态同步
实现简单网络并发与数据执行解耦,错误边界更清晰

Redis 快的核心是内存访问 + 高效数据结构 + 非阻塞 I/O + 少量系统调用,不能简单归因于“单线程”。当命令本身为 O(N)、访问大 key、执行复杂 Lua,主线程仍会被阻塞。

常见阻塞源:

  • KEYS *、大范围 HGETALL / SMEMBERS、大集合运算;
  • 删除超大 key(优先 UNLINK,让后台线程回收对象);
  • Lua / Function 长时间运行;
  • fork 时内存页表复制,以及写时复制(COW)造成的内存和延迟抖动;
  • 磁盘慢导致 AOF fsync 或后台重写压力传导。

epoll 与 Reactor 事件循环

Redis 自己实现了轻量事件库 ae,按平台选择后端:Linux 通常是 epoll,BSD/macOS 通常是 kqueue,其他平台可退化为 poll / select。因此更准确的说法是“Redis 使用 I/O 多路复用”,而不是所有系统都固定使用 epoll。

一次读请求如何执行

  1. Redis 将监听 socket 和客户端 socket 注册为文件事件,并设置为非阻塞。
  2. 主线程调用事件轮询函数;没有就绪事件时线程可以休眠,不必逐个 socket 空转查询。
  3. epoll 返回已就绪的 fd,Redis 调用对应回调:接受连接或读取请求。
  4. 请求进入输入缓冲区,解析 RESP 协议,生成待执行命令。
  5. 主线程查询命令表、校验参数与权限,串行操作内存数据。
  6. 响应先写入客户端输出缓冲区;若一次未写完,则注册可写事件,等待下次就绪后继续发送。

这是典型的 Reactor:事件分发器报告“哪个 fd 可以读写”,真正的读取、命令执行和响应处理由 Redis 回调完成。epoll 不负责执行业务,也不会让一条 Redis 命令自动并行。

epoll 为什么适合大量连接

  • 一个线程可以等待大量 fd,无须“一连接一线程”;
  • 只返回当前就绪事件,避免每轮线性扫描全部连接;
  • socket 非阻塞,单个慢客户端不会因一次读写直接卡住整个循环;
  • 事件注册与就绪集合分离,减少无效系统调用。

Redis 通常使用水平触发语义,并在回调中尽量读完当前可读数据。应用层仍需限制客户端输入/输出缓冲区,否则慢消费者可能占用大量内存。


Redis 6.0+ I/O 多线程

I/O 多线程解决的是 socket 读写、响应发送等网络阶段在高吞吐下占用 CPU 的问题,开启 threaded reads 后也可并行处理读取阶段;命令对键空间的核心执行仍由主线程串行完成。

主线程等待就绪事件
    ├─ I/O 线程并行读取(开启 io-threads-do-reads 后)
    ├─ 主线程串行执行命令
    └─ I/O 线程并行写响应
io-threads 4
io-threads-do-reads yes

io-threads 1 等价于不启用额外 I/O worker。只配置 io-threads > 1 时,主要并行响应写出;io-threads-do-reads yes 才把读取阶段也交给 I/O 线程。具体执行阶段会随 Redis 版本演进,应以当前版本配置说明和压测为准。

一批命令的阶段屏障

简化理解如下:

主线程收集待处理客户端
       │
       ├─ 分配给 I/O threads 并行读/写网络缓冲区
       │                ▼
       └──────── 等待本批 I/O 完成
                        ▼
                 主线程串行执行命令
                        ▼
                 下一批响应并行写出

I/O 线程不是让 INCR、Lua、HGETALL 在多个 CPU 核上同时执行。慢命令仍会卡住主线程,所有 I/O worker 最终也要等待命令执行阶段。

什么时候值得开启

现象判断
单核主线程接近饱和,网络收发/协议处理占比高I/O threads 可能有效
大量小命令、高连接数、较高网络吞吐值得以 2/4 等小步压测
慢 Lua、大 key、大集合运算导致延迟加 I/O 线程基本无效,应先治理命令
单实例 QPS 不高、CPU 仍有余量保持默认通常更简单
容器 CPU quota 很小或发生 throttling线程过多可能互相争抢,尾延迟更差

线程数不是越多越好:主线程仍是串行瓶颈,I/O worker 还会增加调度、同步、缓存竞争和内存开销。不要直接照搬“CPU 核数 × 2”;从较小值开始,在相同请求大小、pipeline、连接数和持久化配置下比较吞吐与 p99/p999。

CPU 规划

  • 为主线程保留稳定 CPU 时间,不要让 I/O worker、AOF/RDB 和同机业务争抢同一小份 quota;
  • Redis Cluster 是多进程/多分片扩展,单机多个实例时需要把所有实例的主线程、I/O 线程和后台任务一起预算;
  • NUMA、容器限核和云主机 steal/throttling 都会让“宿主机有很多核”失去意义;
  • CPU affinity 是高级优化,应先解决慢命令、大 key、网络和内存瓶颈。

BIO 后台工作线程

Redis 的 bio(Background I/O)将少量可能阻塞主线程的系统操作放入后台任务队列。它是按职责划分的内部 worker,并不是普通 Redis 命令线程池。

后台职责典型触发注意点
AOF fsyncappendfsync everysec 等磁盘卡顿会形成待处理压力并影响尾延迟
异步关闭文件RDB/AOF 文件切换避免某些 close 操作阻塞主循环
Lazy FreeUNLINK、FLUSHDB ASYNC、配置的异步驱逐/过期主线程摘除引用,后台遍历并释放对象

Lazy Free 不等于删除零成本

UNLINK big-key
  ├─ 主线程:从键空间摘除,命令快速返回
  └─ BIO:递归释放对象和内存

后台释放仍消耗 CPU、内存带宽和 allocator 时间。短时间 UNLINK 大量超大 key 可能让 lazy-free 队列积压;主线程虽然不再长时间停顿,整机资源仍可能被拖慢。

相关配置会随版本变化,常见方向包括:

lazyfree-lazy-user-del yes
lazyfree-lazy-user-flush yes
lazyfree-lazy-expire yes
lazyfree-lazy-eviction yes
replica-lazy-flush yes

是否开启要结合内存峰值:对象从字典移除后,在后台真正释放前仍占用物理内存。


子进程不是线程池

执行单元典型职责与主进程关系
RDB 子进程BGSAVE 生成快照fork 后读取快照视图
AOF 重写子进程BGREWRITEAOF 生成紧凑 basefork + COW,父进程继续服务

RDB/AOF 使用 fork 后,父子进程初始共享物理内存页;父进程继续写数据时触发 COW。它们不会从队列中取普通客户端命令执行,也不属于可调大小的 worker pool。

数据集越大、页表越大,fork 停顿风险越高;子进程运行期间写入越密集,COW 额外内存越多。应监控 latest_fork_usec、rdb_last_cow_size、aof_last_cow_size 和机器可用内存。


服务端线程与客户端线程不要混淆

应用 Tomcat/虚拟线程
  → Lettuce/Redisson Netty EventLoop
  → TCP 连接(可能复用/池化)
  → Redis I/O threads
  → Redis 主线程执行命令

增加应用线程、Netty 线程或连接数,只会提高并发提交能力;它不会让单个 Redis 分片的键空间执行自动并行。上游无限并发还会造成:客户端待发送队列、连接池等待、Redis 输入/输出缓冲区和超时重试同时膨胀。

客户端线程和连接池详见 线程池与客户端连接。


监控与诊断

INFO threads          # Redis 8.0+ I/O thread 分配、read/write 统计
INFO cpu
INFO clients
INFO commandstats
INFO latencystats
LATENCY DOCTOR
SLOWLOG GET 20
指标/现象可能原因
主线程 CPU 高、网络吞吐高、无明显慢命令评估 I/O threads 或水平分片
p99 高且 slowlog 有大命令治理命令/大 key,线程数不是根因
blocked_clients 高BLPOP/XREAD 等阻塞命令或模块阻塞;检查用途是否符合预期
AOF 延迟与磁盘 await 同时升高fsync/重写 I/O 竞争
used_memory 暂不下降、lazy free 活跃后台释放尚未完成或 allocator 未归还 OS
客户端超时但 Redis CPU 不高网络、客户端 EventLoop 被阻塞、池耗尽或超时配置

面试结论

Redis 没有通用命令执行线程池:主线程通过 ae/epoll 处理事件并串行修改键空间;Redis 6.0+ I/O threads 只并行网络读写阶段,BIO 线程处理 fsync、close、lazy free,RDB/AOF 重写使用子进程。调大服务端 I/O 线程或客户端连接池都不能解决慢命令和大 key,必须结合 CPU、网络、延迟和队列指标压测。


相关