1. 先搞明白一件事:Redis 的并发瓶颈到底在哪
1.1 一次命令请求走完的完整链路
很多人第一次听说“Redis 单线程却扛得住几万 QPS”的时候,第一反应都是:怎么可能?再听说是靠 I/O 多路复用,就开始背八股:“多路复用就是多个连接复用一个线程”……然后就没有然后了。面试题能答上来,但真要你解释为什么它能解决高并发、为什么不用多线程、epoll 比 select 强在哪,很多人就卡住了。
我们先把一次 Redis 命令的完整链路拆开看,这是理解 IO 多路复用的基础。
假设客户端 C 给 Redis 发了一条SET name jack,这条命令从发出到收到响应,中间经历了什么?
- 客户端把命令写到 TCP 连接对应的 socket 发送缓冲区。
- 数据被内核打包成网络包,经过网卡发出去。
- Redis 所在的机器网卡收到数据,内核把它放进这个 TCP 连接的接收缓冲区。
- Redis 进程需要知道“我的这个 socket 上,有数据可以读了”。
- Redis 把数据从内核缓冲区读到自己进程的内存里,解析成命令。
- Redis 执行
SET,把数据写进内存。 - Redis 把响应写回 socket 发送缓冲区。
- 内核把响应数据发回给客户端,客户端从自己的内核缓冲区读到数据。
这 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 给你标记出来。但这个方案有两个硬伤:
- fd 数量上限。
FD_SETSIZE通常默认 1024,也就是说一个select最多只能监听 1024 个文件描述符。几万连接直接超纲。 - 重复拷贝和线性扫描。每次调用
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做这几件事:
- 从 socket 中读取数据到输入缓冲区
querybuf。 - 解析其中的命令,进入命令队列。
- 如果缓冲区还有数据,继续解析,直到协议不完整为止。
- 解析出的命令交给
processCommand执行,执行结果写入输出缓冲区,然后注册写事件。 - 写事件触发时,
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 强大的并发底座,但真正决定它稳不稳定的,永远是执行路径上有没有“意外长跑”的命令。多路复用解决的是等的问题,解决不了干活的问题——这句话我放在最后再强调一遍,你迟早会用到。