Redis 单线程与事件循环 · I/O 多路复用深潜

作者:杨帅      发布日期:2026.08.07

Redis · 核心引擎

单线程与事件循环

一个线程,如何同时服务成千上万个连接

很多人第一次听说"Redis 是单线程"都会困惑:一个线程怎么扛住每秒十万级请求?答案是 I/O 多路复用 + 事件循环:让内核只在 socket 就绪时才唤醒 Redis,一个线程就能盯住上万连接,逐个快速处理。

▲ 事件循环:等就绪 → 派发处理 → 时间事件 → 循环ae event loop
客户端连接(file descriptors) fd 7 · conn fd 8 · conn fd 9 · conn fd 10 · conn fd 11 · conn aeApiPoll epoll_wait ① 等就绪 ② 派发处理(单线程,逐个到底) 读命令read + parse 执行命令execute 回写响应write reply ③ processTimeEvents · serverCron ↻ 循环
就绪:socket 有数据可读/可写 空闲:内核不打扰,零轮询开销 执行:单线程逐个跑到底
01 · 反直觉

单线程为什么更快

直觉说"多线程=更快",但那是CPU 密集型任务的规律。Redis 是内存密集型:数据都在内存里,单个操作大多是 O(1) 或 O(log n),瓶颈通常在网络和内存带宽,而不在 CPU 算力。

在这种前提下,单线程反而甩掉了三样开销:没有锁竞争没有线程上下文切换没有并发数据结构的复杂度。而且它白送一个礼物——命令天然原子:每条命令都会执行到底才轮到下一条,不需要任何加锁。

打个比方

一个动作麻利的单人快餐窗口,接单、出餐一气呵成,从不为"和同事抢冰箱"而停顿。再多雇几个人挤在同一个窗口、同一个冰箱前,反而互相碰撞、还要排班协调——对这种"每单都很快"的场景,一个人的流水线可能更高效。

02 · 循环内部

一次事件循环做了什么

Redis 有个自己的事件库 ae(async events),它在编译时挑选平台上最好的多路复用后端:Linux 用 epoll、BSD/macOS 用 kqueue、其它退回 select。整个主循环,简化后就这么几行:

while (!server.stop) { // ① 等待:内核只在有 socket 就绪时才唤醒(超时取自最近的时间事件) n = aeApiPoll(loop, timeout); // epoll_wait / kqueue / select // ② 派发:逐个处理就绪的连接 for (fd in ready[0..n]) { if (readable) readQueryFromClient(fd); // 读命令 + 解析 + 执行 if (writable) sendReplyToClient(fd); // 回写响应 } // ③ 时间事件:serverCron —— 过期采样、驱逐、渐进式 rehash、统计… processTimeEvents(loop); }

关键在第 ①步的 aeApiPoll:它不是傻等某一个连接,也不是忙轮询所有连接,而是把成千上万个 fd 一次性交给内核盯着,只有就绪的才会返回。于是一个线程的"注意力"永远只花在真正有活儿的连接上——这就是它能扛住海量并发的根本。

03 · I/O 多路复用

一个线程盯住上万连接

如果不用多路复用,服务器只有两条笨路:要么一次只服务一个连接(串行,别人干等),要么每个连接开一个线程(连接一多,线程和上下文切换的成本就压垮系统)。多路复用是第三条路。

✗ 每连接一线程

thread-per-connection

上万连接 = 上万线程,大量线程阻塞在 I/O 上等待,内存和上下文切换开销巨大——"等待"本身并不是计算,却占着一个线程。

✓ I/O 多路复用

epoll / kqueue / select

一个线程用一次系统调用监视所有 fd,内核只在就绪时通知。没数据的连接完全不占 CPU,避免了轮询空耗。

ae 把这些平台相关的 API 包成统一抽象:底层是 epoll 还是 kqueue,上层的事件循环代码完全无感。这也是 Redis 能在 Linux/macOS/BSD 上都跑得飞快的原因。

04 · 单线程的代价

慢命令的阻塞代价

单线程最锋利的一面,也是它最脆弱的一面:同一时刻只有一条命令在跑。一旦某条命令很慢,它执行期间,其它所有客户端都在干等

▲ 快命令一闪而过 · 慢命令(O(n))占住线程,后面全堵head-of-line blocking
单线程执行槽(一次一条) GET k · 0.02ms KEYS * · O(n) 很慢 等待队列 ⏳ 客户端 A ⏳ 客户端 B ⏳ 客户端 C 等 45ms ⏳ 客户端 D 等 45ms ← 慢命令执行期间,全场停摆 →

典型的"阻塞元凶":KEYS *(在千万级键上做 O(n) 扫描)、LRANGE list 0 -1(取超长列表)、SMEMBERS 巨大集合、无 LIMITSORT、以及跑很久的 Lua 脚本。生产上用游标式SCAN 替代 KEYS——每次只扫一小段,把长任务切成许多短任务,不再一口气霸占线程。

05 · 一个常见误解

完整的线程模型

"单线程"指的是命令执行这条主线。但在它周围,Redis 一直有一些辅助线程和子进程在干活——理解这点,才算真正看懂它的模型。

▲ Redis 6 线程化 I/O:读写并行,执行仍单线程io-threads
sockets conn conn conn I/O 线程 1 I/O 线程 2 I/O 线程 3 读 socket + 解析协议 + 回写(并行) 主线程 执行命令 · 仍单线程 串行执行 → 保证原子性 → 回写
Redis 6.0

线程化 I/O

可选开启多个 I/O 线程,负责从 socket 读写和解析协议;命令执行仍单线程。高并发/大数据量下,读写系统调用不再成为瓶颈。io-threads N

后台线程 bio

异步收尾

一些重活交给后台线程:UNLINK 惰性释放大对象、AOF 的 fsync、关闭文件描述符——避免它们卡住主线程。

子进程 fork

快照与重写

RDB 快照、AOF 重写通过 fork 子进程完成,利用写时复制,不阻塞主线程处理命令。

所以准确的说法是:命令执行是单线程的(因此原子、无锁),而网络 I/O、持久化、大对象回收等则借助额外的线程或进程。Redis 7.0 起还能用集群把数据和负载横向分到多个进程上,每个进程内部依然单线程。

06 · 实践要点

面向单线程的实践建议

命令保持"短平快"优先 O(1)/O(log n)。避免在大数据结构上做 O(n) 操作,否则会卡住整个实例。
用 SCAN 取代 KEYS游标式遍历,每次只扫一小段,把长任务切碎,不霸占线程。HSCAN/SSCAN/ZSCAN 同理。
用 pipeline 摊薄往返瓶颈常在网络往返(RTT),而非 CPU。批量命令用管道一次发出,吞吐可翻数倍。
盯住 slowlog用 SLOWLOG 找出慢命令,它们是单线程模型下最直接的延迟来源。
网络可能先于 CPU 触顶单实例可轻松打满 1Gbps 网卡;对高吞吐场景,10GbE 网卡是常规配置。
高并发连接再开 io-threads连接数大、读写系统调用占比高时,才开启线程化 I/O;小负载开了反而没收益。
收尾 · 一句话

为什么这套模型足够快

Redis 的快,不来自并行,而来自消除开销:I/O 多路复用让一个线程盯住上万连接,事件循环只在就绪时干活,命令串行执行因而无锁、原子。代价是慢命令会阻塞全场——所以把命令写得又短又快,才是顺着它的脾气。

epoll 等就绪 → 单线程执行到底 → 时间事件 → 循环 · 命令执行单线程(原子),I/O 与持久化另有线程/进程