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 就绪了。

三大问题

  1. FD 上限固定 1024。
  2. 每次调用都要完整拷贝 fd_set 到内核。
  3. 返回后无法知道具体哪个 FD 就绪,需要 O(n) 遍历。

poll

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • pollfd 数组取代 fd_setFD 数量无上限
  • 数据传递方式仍然是全量拷贝到内核空间(转链表存储)。

解决了 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 数组,无需遍历

工作原理

  1. epoll_create 在内核创建一个 eventpoll 结构体:内部有**红黑树(rbr)**记录要监听的 FD,**就绪链表(rdlist)**记录已就绪的 FD。
  2. epoll_ctl 将 FD 添加到红黑树,同时设置回调函数 ep_poll_callback
  3. FD 就绪时回调触发,把 FD 加入就绪链表。
  4. 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 多线程——读取请求和回复结果用多线程,命令执行本身还是单线程

为什么核心部分用单线程

  1. 瓶颈不是 CPU 是网络:Redis 是纯内存操作,执行速度极快,真正的瓶颈在网络 IO 延迟。
  2. 避免上下文切换:多线程的上下文切换开销在高并发下不可忽视。
  3. 避免锁竞争:单线程天然无锁,实现简单、性能稳定。引入多线程意味着要处理线程安全和锁问题,复杂度和风险都会上升。

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) 命令会阻塞所有后续请求(KEYSSMEMBERSHGETALL 等),生产环境要用 scan 替代。
  • 合理设置超时和重试:网络是主要瓶颈,配置合适的连接超时。
  • 批量操作用 Pipeline:把多个命令打包一次发送,减少 RTT。但注意 Pipeline 不保证原子性。
  • v6.0 以上的网络多线程:如果确实有网络密集型场景(如图文混传的大 key),可以通过 io-threads 配置启用网络 IO 多线程,但保持核心命令单线程。

总结

  • IO 多路复用让 Redis 用单线程管理成千上万个连接,是整个高性能的骨架。
  • select → poll → epoll 的演进解决了 FD 上限、拷贝开销、就绪通知三个核心问题。
  • 单线程命令处理避免了锁竞争和上下文切换,大大简化了实现。
  • RESP 协议是 Redis 高效通信的基础,理解它有助于排查网络和解析问题。
  • 不是所有 Redis 操作都是单线程——异步删除、AOF 刷盘、网络 IO(6.0+)都用到了多线程,只是命令执行路径保持单线程。