2026年7月12日 · 7 分钟阅读
Redis 网络模型:IO 多路复用、epoll 与单线程之争
从 Linux 内核 IO 模型出发,深入 select/poll/epoll 的演进和差异,剖析 Redis 为什么单线程还这么快,RESP 协议的二进制编码细节。
面试 Redis 时最常听到的一句话是”单线程 + 纯内存 + IO 多路复用”。但大部分人只记住了结论,并不知道 IO 多路复用具体是什么,select 和 epoll 到底差在哪,以及 Redis 真的是全程单线程吗。
这之前先理解一个基本模型:Linux 的用户态和内核态划分。
用户态 vs 内核态
为了避免用户程序导致内核崩溃,操作系统的寻址空间分为两部分:
- 用户空间(Ring 3):只能执行受限命令,不能直接调用系统资源,必须通过内核接口访问。
- 内核空间(Ring 0):可以执行特权命令,调用一切系统资源。
读写文件或网络数据时,数据流动的全路径是:
设备 → 内核缓冲区 → 用户缓冲区
这意味着任何 IO 操作至少有一次从内核空间到用户空间的数据拷贝。
Linux 的五种 IO 模型
阻塞 IO
用户进程调用 recvfrom,如果内核数据还没准备好,进程挂起等待。数据准备好并拷贝到用户空间后,进程恢复运行。
优点:简单。缺点:线程被浪费在等待上。
非阻塞 IO
用户进程调用 recvfrom,如果数据没准备好,立即返回(EWOULDBLOCK)。进程需要不断轮询,CPU 空转严重。
优点:不阻塞。缺点:轮询浪费 CPU。
IO 多路复用
阻塞 IO 和非阻塞 IO 的问题可以抽象为:
服务端处理多个客户端 socket 请求时,如果正在处理的 socket 还没就绪,线程就被阻塞了,其他所有客户端都要等待。
这就是 C10K 问题的本质。
IO 多路复用就是用单个线程同时监听多个文件描述符(FD),当某个 FD 可读/可写时得到通知。它有三种主要实现:select → poll → epoll。
信号驱动 IO
建立 SIGIO 信号回调,内核数据就绪时发信号通知进程。但大量 IO 操作时信号队列可能溢出,且用户态与内核态频繁信号交互性能不佳。
异步 IO(AIO)
整个 IO 过程(包括数据从内核拷贝到用户空间)都不会阻塞进程。内核做完一切之后才通知用户进程。这是真正的异步。
select / poll / epoll 的演进
select
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
fd_set是一个 1024 位的位图,每个 bit 代表一个 FD。- 调用 select 时,整个 fd_set 要从用户空间拷贝到内核空间。
- 内核遍历所有 FD,判断是否就绪。
- 返回后,用户进程需要再次遍历 fd_set 才能知道哪些 FD 就绪了。
三大问题:
- FD 上限固定 1024。
- 每次调用都要完整拷贝 fd_set 到内核。
- 返回后无法知道具体哪个 FD 就绪,需要 O(n) 遍历。
poll
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- 用
pollfd数组取代fd_set,FD 数量无上限。 - 数据传递方式仍然是全量拷贝到内核空间(转链表存储)。
解决了 select 的 1024 上限问题,但遍历所有 FD 的痛点依然存在。监听的 FD 越多,每次遍历消耗就越大。
epoll(最终形态)
int epoll_create(int size); // 创建 epoll 实例
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 注册/修改/删除 FD
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); // 等待就绪事件
epoll 用三个操作彻底解决了 select 和 poll 的问题:
| 问题 | 解决方案 |
|---|---|
| FD上限 | epoll 无上限,用红黑树管理 FD,增删改查 O(log n) |
| 重复拷贝 | FD 只通过 epoll_ctl 添加一次,无需重复拷贝 |
| 就绪通知 | 内核直接将就绪 FD 写入用户空间的 events 数组,无需遍历 |
工作原理
epoll_create在内核创建一个eventpoll结构体:内部有**红黑树(rbr)**记录要监听的 FD,**就绪链表(rdlist)**记录已就绪的 FD。epoll_ctl将 FD 添加到红黑树,同时设置回调函数ep_poll_callback。- FD 就绪时回调触发,把 FD 加入就绪链表。
epoll_wait检查就绪链表,不为空则返回就绪事件。
LT 和 ET 模式
| 模式 | 行为 | 场景 |
|---|---|---|
| Level Triggered(LT) | FD 有数据可读时,重复通知直到数据读完 | epoll 默认模式,简单安全 |
| Edge Triggered(ET) | FD 有数据可读时,只通知一次,不管是否读完 | 需要配合非阻塞 IO 循环读取,避免漏数据 |
ET 模式效率更高,但实现更复杂。Redis 在事件处理上结合了 LT 和 ET 的优势。
Redis 的线程模型
是单线程还是多线程?
分场景看:
- 核心命令处理:单线程。所有命令的执行在一个线程中串行完成。
- 整个 Redis 进程:多线程。后台有:
v4.0引入异步删除(unlink)、AOF 写等后台线程。v6.0引入网络 IO 多线程——读取请求和回复结果用多线程,命令执行本身还是单线程。
为什么核心部分用单线程
- 瓶颈不是 CPU 是网络:Redis 是纯内存操作,执行速度极快,真正的瓶颈在网络 IO 延迟。
- 避免上下文切换:多线程的上下文切换开销在高并发下不可忽视。
- 避免锁竞争:单线程天然无锁,实现简单、性能稳定。引入多线程意味着要处理线程安全和锁问题,复杂度和风险都会上升。
IO 多路复用 + 事件驱动
Redis 把不同平台的 IO 复用封装成统一的事件 API(AE 抽象层):
epoll (Linux) | kqueue (macOS/FreeBSD) | evport (Solaris) | select (兜底)
aeEventLoop 是核心事件循环,内部维护两个事件队列:文件事件(IO)和时间事件(定时任务)。主循环一次处理一个命令,读取、解析、执行、写回全部串行。
这也是为什么单个 Redis 命令总是原子执行——没有并发,也就没有竞态条件。
RESP 协议
客户端和服务端通信的格式规范。
RESP 通过首字节区分数据类型:
| 类型 | 首字节 | 示例 |
|---|---|---|
| 单行字符串 | + | +OK\r\n |
| 错误 | - | -Error message\r\n |
| 数值 | : | :10\r\n |
| 多行字符串(二进制安全) | $ | $5\r\nhello\r\n |
| 数组 | * | *3\r\n$3\r\nSET\r\n... |
这样一段请求 SET name 极客时间 的 RESP 表示:
*3\r\n
$3\r\n
SET\r\n
$4\r\n
name\r\n
$12\r\n
极客时间\r\n
RESP 协议在 Redis 2.0 成为标准(RESP2),6.0 升级到 RESP3 增加了更多数据类型。
实战建议
- 避免执行慢命令:单线程模型下,一个 O(n) 命令会阻塞所有后续请求(
KEYS、SMEMBERS、HGETALL等),生产环境要用scan替代。 - 合理设置超时和重试:网络是主要瓶颈,配置合适的连接超时。
- 批量操作用 Pipeline:把多个命令打包一次发送,减少 RTT。但注意 Pipeline 不保证原子性。
- v6.0 以上的网络多线程:如果确实有网络密集型场景(如图文混传的大 key),可以通过
io-threads配置启用网络 IO 多线程,但保持核心命令单线程。
总结
- IO 多路复用让 Redis 用单线程管理成千上万个连接,是整个高性能的骨架。
- select → poll → epoll 的演进解决了 FD 上限、拷贝开销、就绪通知三个核心问题。
- 单线程命令处理避免了锁竞争和上下文切换,大大简化了实现。
- RESP 协议是 Redis 高效通信的基础,理解它有助于排查网络和解析问题。
- 不是所有 Redis 操作都是单线程——异步删除、AOF 刷盘、网络 IO(6.0+)都用到了多线程,只是命令执行路径保持单线程。