☰
Redis高并发之IO多路复用:从select到epoll的演进与源码解析
2026/10/16 7:07:50 网站建设 项目流程

1. 先搞明白一件事:Redis 的并发瓶颈到底在哪

1.1 一次命令请求走完的完整链路

很多人第一次听说“Redis 单线程却扛得住几万 QPS”的时候,第一反应都是:怎么可能?再听说是靠 I/O 多路复用,就开始背八股:“多路复用就是多个连接复用一个线程”……然后就没有然后了。面试题能答上来,但真要你解释为什么它能解决高并发、为什么不用多线程、epoll 比 select 强在哪,很多人就卡住了。

我们先把一次 Redis 命令的完整链路拆开看,这是理解 IO 多路复用的基础。

假设客户端 C 给 Redis 发了一条SET name jack,这条命令从发出到收到响应,中间经历了什么?

  1. 客户端把命令写到 TCP 连接对应的 socket 发送缓冲区。
  2. 数据被内核打包成网络包,经过网卡发出去。
  3. Redis 所在的机器网卡收到数据,内核把它放进这个 TCP 连接的接收缓冲区。
  4. Redis 进程需要知道“我的这个 socket 上,有数据可以读了”。
  5. Redis 把数据从内核缓冲区读到自己进程的内存里,解析成命令。
  6. Redis 执行SET,把数据写进内存。
  7. Redis 把响应写回 socket 发送缓冲区。
  8. 内核把响应数据发回给客户端,客户端从自己的内核缓冲区读到数据。

这 8 步里,真正属于“Redis 干活”的其实是第 5、6、7 步,而且这几步都是纯内存操作,速度以纳秒计。真正的耗时大头在第 4 步——怎么高效地知道“有连接可读了”。

1.2 阻塞模型在“万人同时点单”面前有多惨

现在思考一个问题:如果不用 IO 多路复用,最笨的方案是什么?

每个连接来一个线程,线程在自己的连接上阻塞等待数据。来了数据就处理,没数据就挂在那。这听起来很合理,一对一嘛。

但想想你只有一个厨师,却有五万个客人在点单。厨师面前摆着五万个窗口,每个窗口前都站着一个服务员(线程),服务员什么都不干,就等着客人念出菜名(数据到达)。五万个服务员同时站在那等,光调度、切换这些服务员就已经把厨房搞得一塌糊涂了。

这就是阻塞模型的致命伤:线程的数量必须等于连接的数量。而线程是很贵的东西,一个线程线程栈默认要 8MB 虚拟内存,五万个线程意味着 400GB 虚拟内存的消耗,再加上线程切换,CPU 会彻底烧在上下文切换上,而不是干正事。

有人会说,那用少量线程,每个线程循环去问“我的连接有没有数据?”——这就是非阻塞轮询,俗称 busy loop。CPU 会被白占,同样不可行。

所以 Redis 需要的模型是:我有很多连接,但我只有一个处理核心(单线程),我希望只在某个连接真的可用时才去处理它,而不是挨个轮询全部连接。这就是事件驱动模型的出发点,也是 IO 多路复用的核心目标。

1.3 从一次请求反推 Redis 的设计目标

理解了链路和痛点之后,Redis 的设计目标就非常明确了:

  • 并发目标:同时维护几万、几十万个客户端连接。
  • 资源目标:不能为每个连接分配一个线程,线程数的增长必须远小于连接数的增长。
  • 延迟目标:空闲连接不能白白消耗 CPU,系统得“闲着”,直到有事发生。
  • 执行目标:命令执行本身必须快,因为单线程能处理的命令总量是固定的,每秒能执行的命令总数约等于单条命令耗时的倒数。

IO 多路复用解决的是前三个目标。第四个目标靠内存和高效数据结构来解决。两者缺一不可,但很多人只盯着“单线程”这个标签,把功劳都归给内存快,忽略了 Redis 在“等待网络 IO”这件事上的成功设计。

2. 为什么不靠多线程:单线程模型背后的权衡

2.1 纯内存操作的特殊性:加锁成本可能高过收益

搞清楚 IO 多路复用是什么之后,紧接着的一个疑惑就是:那为什么 Redis 不干脆把命令执行也做成多线程?多线程 + 多路复用不就更好吗?

答案要从 Redis 的负载特征说起。Redis 的所有操作几乎都是纯内存操作:取一个字符串、改一个哈希、算一个分数、做一次原子自增。这些操作的延迟大约在几十纳秒到几微秒之间。

对比一下:一次磁盘随机 IO 要花大约 10 毫秒,一次网络往返大约要 0.1 到 1 毫秒。Redis 的单个操作比磁盘快了好几个数量级。这种情况下,你引入多线程想并行执行,第一个要面对的就是锁竞争。

比如INCR count,这是一个读、加、写三步操作,多线程并发执行必须保证原子性,那就要给这个 key 加锁。加锁本身有原子指令成本,锁等待会产生线程切换,竞争激烈的时候,可能十有八九的线程都在排队等锁。

在操作本来就微秒级完成的情况下,锁的开销占比是非常高的。就好比你开一家只卖热干面的店,一碗面三秒钟就能出餐,你为了“提升效率”请了几十个工人,但是面粉、芝麻酱都得大家抢着用,结果大家都在排队等酱,出餐速度反而更慢。

2.2 “Redis 不是完全单线程”这个事实要摆清楚

我这里必须澄清一个长期流传的说法。很多人说“Redis 是单线程的”,这句话不准确。准确说法是:Redis 命令执行的核心路径是单线程的,但网络 IO 的读写和持久化等模块并不全是单线程。

Redis 6.0 引入了多线程 IO(通过io-threads配置控制),默认io-threads 4其实是关闭的。它的作用是在高并发网络读写场景下,把“从 socket 读取数据并解析”和“把响应写回 socket”的工作分给多个线程做,缓解主线程在处理网络数据拷贝时的 CPU 开销。但注意,命令的真正执行仍然在主线程,而且是单线程有序执行的。

这个设计很容易被误解成“Redis 改成多线程了,可以随便并行执行命令了”。其实不是,多线程 IO 就像是餐厅多雇了几个服务员帮忙传菜,但掌勺的厨师依然只有一个,菜品还是按顺序做。引入多线程 IO,是为了解决网络数据吞吐的瓶颈,而不是为了并行执行命令。

那为什么命令执行不并行?除了锁的问题,还有一个重要原因:单线程让所有命令天然串行,省掉了大量复杂的数据结构并发访问控制。Redis 的数据结构如跳表、字典、压缩列表,都可以不考虑并发安全问题,开发复杂度大大降低,而且微观上 CPU 缓存命中率更高——数据、指令、栈都在同一个核心上,热数据基本不换缓存,这对微秒级操作非常友好。

理解了这些,才能明白后面要讲的多路复用:**Redis 不是用多路复用替代多线程来“并行处理”,而是用多路复用让单线程高效地管理大量连接的“等待”状态。**等待不占 CPU,让单线程把时间都花在真正执行命令上。

3. select、poll、epoll:从“挨个问”到“只看结果”

3.1 阻塞与非阻塞:最底层的那一档差距

讲多路复用之前,必须把这三种模式理清楚,不然看 Redis 源码里的ae_select.c、ae_poll.c、ae_epoll.c会一头雾水。

最底层的模式是阻塞 IO。你调read(sockfd, buf, len),如果 socket 上没有数据,这个函数调用会一直挂起,直到数据到达或者对端关闭。这个模式没法多路复用,一个线程只能等一个连接。

稍微进阶一点是非阻塞模式。把 socket 设为非阻塞后,read没有数据时立刻返回EAGAIN错误,而不是挂起。这样单个线程可以轮询所有连接:先问第一个有没有数据,没有就跳过,问第二个……问题是,几万个连接里绝大多数时刻都是没数据的,这种轮询让你在“问”这件事上浪费大量 CPU。同时你也无法立刻知道连接上有数据,最多是轮询转一圈的延迟内发现。

非阻塞轮询确实也算某种意义上的“复用”,但效率惨不忍睹。多路复用和它的本质区别在于:多路复用是由内核帮你盯着这批连接,哪个有事件,返回时就告诉你哪个,而不是你自己挨个问。

3.2 select 和 poll 的“重复劳动”

select是第一代多路复用方案。把你要监听的 socket 数组传给内核,内核帮你检查,有事件的 socket 给你标记出来。但这个方案有两个硬伤:

  1. fd 数量上限。FD_SETSIZE通常默认 1024,也就是说一个select最多只能监听 1024 个文件描述符。几万连接直接超纲。
  2. 重复拷贝和线性扫描。每次调用select需要把整个 fd 集合从用户态拷贝到内核态,内核要线性扫描所有 fd 来检查事件,调用完再拷贝回用户态。你有没有事件,都得等内核整个跑一遍。

poll把 1024 的上限去掉了,用链表存储 fd,但每次调用依然要全量拷贝、全量扫描。连接数一多,这个“轮询所有 fd”的成本非常可观。

你可以这样理解 select 和 poll:老师想知道班里谁作业写完了,挨个点名问一遍,问完一批再把没写完的划掉,下一次又从头问一遍。学生越多,提问成本越高。

3.3 epoll 的三段式与就绪链表

epoll的出现彻底改变了思路。它不是一个函数,是一套 API 的组合:

  • epoll_create:创建一张事件表。
  • epoll_ctl:向事件表里注册、修改、删除你要关心的 fd 和事件类型。
  • epoll_wait:等待事件发生,返回有事件发生的 fd 列表。

三个函数一拆,本质变化就出来了:注册和等待分离。fd 集合不是每次调用时临时拷贝进去,而是通过epoll_ctl长期驻留在内核里,不需要反复拷贝。内核用红黑树维护这张事件表,增删查都是 O(log n)。

更重要的是,epoll 在内核维护了一个就绪链表。当某个 fd 上有事件到来时,内核通过回调机制,把这个 fd 直接挂到就绪链表上。epoll_wait返回时,你只需要遍历这个就绪链表——链表里有多少个 fd,就有多少个事件,有事件才通知,没有就睡在那里。

用老师收作业来类比:select/poll 是老师一个个问“你作业写完没有”;epoll 是每个学生写完作业后主动交到讲台上,老师只需要看讲台上堆了几本。交作业的人少,老师一眼就数完,完全不用管大多数人是否没写完。

这也解释了为什么 epoll 在高并发下几乎是 Linux 的事实标准:事件驱动、只返回就绪事件、O(1) 的就绪查询,让 CPU 开销和连接数基本无关。

3.4 水平触发和边缘触发:Redis 为什么选 LT

epoll 还有一个容易忽略的细节:触发模式。水平触发(LT)模式下,只要 fd 上还有没读完的数据,每次epoll_wait都会返回它;边缘触发(ET)模式下,fd 从“无事件”变成“有事件”时只通知一次,你有本事就读完,读不完数据就留着,但不会反复通知你。

听起来 ET 更高效,为什么会错过通知?因为 ET 只在状态变化时通知一次,如果你只读了一部分数据,剩余数据不会触发新通知,必须等下一次新数据到来才再通知。想不丢数据,就得把 fd 设为非阻塞,然后循环read到EAGAIN,把缓冲区读到空为止,还要小心没读完导致的数据滞留。

Redis 源码里ae_epoll.c注册事件的时候,只关注EPOLLIN和EPOLLOUT,没有设置EPOLLET,也就是说它用的是水平触发。原因也很务实:

  • LT 实现简单,不容易出“丢事件”的 bug。
  • Redis 的事件循环本质是“来多少处理多少”,每次处理命令时会把缓冲区里已有数据尽量读完,LT 多带来的少许性能损失,远不及 ET 读空循环的复杂性和出错概率。
  • 既然是网络事件驱动,命令处理本来就是一两个回合的事,不像流式大文件传输那样需要连续大量读。

不过别因此觉得 ET 没用,像 Nginx 这种以高吞吐转发见长的中间件,就喜欢 ET 来减少无效唤醒。工具没有绝对好坏,关键看场景。

4. 拆开源码事件循环:Redis 是怎么转起来的

4.1 事件驱动架构的骨架:aeEventLoop 与两类事件

理论铺垫得差不多了,现在看看 Redis 源码里这套机制长什么样。Redis 的事件驱动代码在ae.c和对应平台文件ae_epoll.c/ae_select.c/ae_kqueue.c中,核心是一个aeEventLoop结构体。

它的设计思路非常清晰,所有事件分两类:

  • 文件事件:网络连接上的读写事件,对应你常说的高并发网络 IO。
  • 时间事件:定时任务,比如serverCron(每 100ms 跑一次的周期任务,处理过期 key、更新统计信息、检查持久化状态等)。

事件循环每转一圈,做两件事:先查最近的时间事件还有多久到期,把这个剩余时间作为epoll_wait的超时时间;然后等网络事件。这样网络事件和定时任务都能及时被处理,谁也不饿死。

4.2 简化版 aeMain:几行代码看懂机制

不用背源码,我用一个简化版伪代码就能把这个机制讲清楚:

// 事件循环主体(极度简化版) void aeMain(aeEventLoop *eventLoop) { while (!eventLoop->stop) { // 1. 计算最近要执行的定时任务还有多久 int64_t usUntilTimer = findNextTimer(eventLoop); // 2. 等网络事件,最多等 usUntilTimer 微秒 int numevents = aeApiPoll(eventLoop, usUntilTimer); // 3. 逐个处理有事件的文件描述符 for (int i = 0; i < numevents; i++) { aeFileEvent *fe = eventLoop->events + fired[i].fd; if (fe->mask & fired[i].mask & AE_READABLE) { fe->rfileProc(eventLoop, fired[i].fd, ...); } if (fe->mask & fired[i].mask & AE_WRITABLE) { fe->wfileProc(eventLoop, fired[i].fd, ...); } } // 4. 执行到期的定时任务 processTimeEvents(eventLoop); } }

这段伪代码对应了真实aeMain的工作逻辑。aeApiPoll在 Linux 上就是对epoll_wait的一层封装;rfileProc对应的是读事件回调,对于主线程来说,真正的入口是readQueryFromClient,它负责从 socket 读取数据、解析协议,然后调用命令执行函数。

整个过程就是:一个线程,一边等网络事件,一边按超时时间兼顾定时任务,事件来了就处理,没有就睡。Redis 每秒十万 QPS 的秘密就在这个循环里。

4.3 网络读写回调:从内核缓冲区到命令执行

再往下看一个细节:读事件来了之后 Redis 干什么?readQueryFromClient做这几件事:

  1. 从 socket 中读取数据到输入缓冲区querybuf。
  2. 解析其中的命令,进入命令队列。
  3. 如果缓冲区还有数据,继续解析,直到协议不完整为止。
  4. 解析出的命令交给processCommand执行,执行结果写入输出缓冲区,然后注册写事件。
  5. 写事件触发时,writeToClient把输出缓冲区的数据送回客户端。

这里有个容易被忽略但很重要的点:读事件处理时会循环读 socket,直到把所有可读数据都读完。配合 epoll 水平触发,Redis 不需要担心 ET 模式下那种“读一半绑定悄悄丢失”的问题。这种“根除问题”的意识,值得写业务代码时也保留——尽量一次把事件处理干净,而不是把状态留给后续碰运气。

Redis 还提供了aeSetBeforeSleepProc/aeSetAfterSleepProc这类钩子函数,在睡眠前后做一些批量操作,比如把多个客户端待写响应统一处理、后台处理一些累积任务。整个框架的设计哲学是:所有可能阻塞的操作都尽量挪出主路径。

5. 多路复用并不是万能药:这些坑我替你们踩过

5.1 big key 与“慢命令”:IO 不卡了,执行却卡了

多路复用解决的是“等数据”的问题,不是“执行数据”的问题。这句话我在排障时反复跟同事强调。

假设某个 key 是一个巨大的 list,里面有上百万个元素,有人执行了一条LRANGE 0 -1。这条命令要把整条列表序列化后写回客户端——注意,这个序列化过程在主线程里执行,可能耗时几十甚至上百毫秒。在这段时间内,事件循环卡住了,所有其他客户端、所有其他命令都得排队等着。

于是你会看到一种神奇的现象:Redis 的 CPU 占用并不高,但所有请求的延迟都暴涨。查slowlog也没发现多少慢命令——因为问题可能不是慢查询列表里记录的那些,而是某条大 key 命令把线程一次占死了。

遇到这种问题,排查思路要转换:别只盯着slowlog,先看客户端连接数是否暴涨、有没有KEYS *或HGETALL这种全量命令、有没有 close 不掉的大结果集写事件。

5.2 连接数上来之后:文件描述符上限才是隐形门槛

有一次我调 Redis 的高并发参数,maxclients设成了五万,结果压测到三万左右,新连接就开始大量报错。查了半天,不是 Redis 的锅,是 Linux 系统的文件描述符上限才开到了 1024。

epoll本身再高效,它也绕不开一个现实:每个 TCP 连接在 Linux 里就是一个文件描述符。ulimit -n不够时,accept会失败,Redis 日志里会打“max number of clients reached”之类的错误。

这里有个很多人不知道的连锁反应:文件描述符不够时,不只是新连接进不来,就连主线程、持久化子进程 fork 出来的新 fd 也可能分配失败。所以排查 Redis 连接问题,第一步不是调maxclients,而是看系统级限制:

# 查看当前用户进程的 fd 上限 ulimit -n # 查看 Redis 进程当前打开的 fd 数量 ls /proc/$(pgrep redis-server)/fd | wc -l

如果打开数逼近上限,先调系统参数,再调maxclients,顺序不要搞反了。

5.3 io-threads 打开前的冷静期

Redis 6.0 之后,很多优化教程上来就让你设io-threads 8,好像线程越多越厉害。我实际测下来,在小规格机器上并不会带来收益,甚至可能造成 CPU 不均衡。

多线程 IO 的原理是把网络读写的部分工作拆给多个线程:主线程把收到的连接请求分配出去,IO 线程负责读取和解析数据,然后把解析好的命令交回主线程执行。如果你的瓶颈不在网络读写这一层——比如命令都很小、QPS 中的每个请求只有几十字节——多线程 IO 省下的那点系统调用时间,远比不上线程间传递数据的开销。

我建议这样判断:先压测一轮,观察主线程 CPU 是否已经接近饱和。如果主线程 CPU 都不到 80%,别折腾io-threads。如果真要开,从io-threads 2起步,压测对比之后再慢慢加。另外注意,io-threads-do-reads默认是no,只开这个才能把读操作也分流,开之前想清楚你的场景是读重还是写重。

5.4 排查工具:从现象反推事件循环状态

最后分享一个很实用的排查技巧。当 Redis 出现“连接正常但命令延迟很高”的情况,可以用strace看一下进程是不是长时间停在epoll_wait或者某条命令的执行处:

# 跟踪 redis-server 进程的系统调用 strace -f -p $(pgrep redis-server) -e trace=read,write,epoll_wait

如果大量时间花在epoll_wait,说明网络事件稀少,Redis 很闲,问题多半在客户端或网络链路。如果epoll_wait唤醒次数很少但 CPU 占用很高,说明唤醒之后执行了耗时命令,这时候优先查 slowlog 和 big key。

还有一个更高级的观察点:redis-cli info stats里的total_commands_processed,结合latency monitor的延迟分布,能帮你判断延迟是集中在某几条命令还是均匀分布。均匀分布通常是网络问题,集中在某几条命令上则是命令执行阻塞。

这些经验不亲自踩一遍很难有体感。IO 多路复用给了 Redis 强大的并发底座,但真正决定它稳不稳定的,永远是执行路径上有没有“意外长跑”的命令。多路复用解决的是等的问题,解决不了干活的问题——这句话我放在最后再强调一遍,你迟早会用到。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询