前几篇聊了不少Redis的数据结构和命令实现,评论区一直有人催:什么时候讲讲事件驱动?为什么Redis单线程还能跑这么快?这期“漫话Redis源码之二十九”干脆把这块硬骨头啃了,咱们直接翻开ae.c这个核心文件,看看Redis那颗不停跳动的心脏到底长什么样。
如果你写过一点C,或者对Nginx、Netty的高并发模型感兴趣,这篇文章就是给你准备的。你不需要把ae.c每一行都背下来,但看完你至少能回答三个问题:Redis的事件循环是怎么转起来的?文件事件和时间事件怎么协同?这套设计对我们自己写网络程序有什么可以抄作业的地方?
我尽量用“大排档老板点单”这种生活类比把这事儿讲明白。老板一个人站在档口,手里攥着一沓订单,哪桌先喊“买单”就先处理哪桌,没订单的时候就擦擦桌子等下一波客人——这就是典型的事件驱动。Redis做的事情,本质上就是这件事。
1. 先聊聊这期要啃哪块硬骨头
1.1 单线程高并发的秘密:不是算得快,是等得漂亮
很多初学者第一次听说Redis单线程能扛十万级QPS,第一反应都是“不可能”。我们印象里的高并发,不都得开一堆线程池、来一个请求分配一个线程吗?但Redis偏偏反着来,它只用主线程干所有事,而且干得比绝大多数多线程模型都漂亮。
关键点在于:Redis的大量时间不是花在“计算”上,而是花在“等待”上。网络请求过来,内核把数据准备在缓冲区里,应用层很多时候都在等这个“准备”的过程;等数据到了,真正解析协议、执行命令、写回响应的计算量反而没多少。传统的一个连接一个线程模型,把宝贵的CPU时间浪费在线程切换和阻塞等待上,而Redis用一个事件循环把所有“等待”集中起来,统一交给操作系统去盯着,谁有动静就处理谁。
这就是ae.c存在的意义。它把底层不同平台的IO多路复用接口(Linux的epoll、macOS的kqueue、老系统的select)封装成一套统一的接口,对外只暴露“注册事件”“轮询事件”“触发回调”三个动作。
1.2 进入源码前先记住三个结构体
翻开ae.h,你会看到三个核心结构,后面的所有逻辑都围着它们转:
aeEventLoop:整个事件循环的“大总管”,它保存了所有文件事件、时间事件、以及底层多路复用器的状态。aeFileEvent:文件事件,记录某个文件描述符(fd)上关心什么事件(可读、可写),以及对应的处理函数。aeTimeEvent:时间事件,记录某个定时任务什么时候该跑、跑完之后下一次什么时候再跑。
你可以这么理解:aeEventLoop是一张餐厅的排班表,aeFileEvent是“哪个客人招手了叫哪个服务员去”,aeTimeEvent是“哪个菜炖到点了该揭锅了”。后面所有源码阅读,都是围绕这三者展开的。
2. 文件事件:怎么把“有新连接”变成一声招呼
2.1 注册文件的底层逻辑
Redis启动时,会创建一个socket fd并监听端口,然后调用aeCreateFileEvent把这个fd注册到事件循环里,注册的事件类型是AE_READABLE,回调函数是acceptTcpHandler。
int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask, aeFileProc *proc, void *clientData) { aeFileEvent *fe = &eventLoop->events[fd]; ... fe->mask |= mask; if (mask & AE_READABLE) fe->rfileProc = proc; if (mask & AE_WRITABLE) fe->wfileProc = proc; fe->clientData = clientData; aeApiAddEvent(eventLoop, fd, mask); return AE_OK; }这段代码有几个细节值得注意。eventLoop->events是一个以fd为索引的数组,所以它直接通过下标找到对应的事件项;mask是一个位掩码,通过|累加,表示这个fd上同时关心读和写事件。
你可能问:为什么不用链表或者哈希表,非要用数组?因为文件描述符在大多数系统里都是一个小整数,从0开始递增,数组下标直接映射fd,时间复杂度O(1),没有比这更快的查找方式了。代价是会浪费一点内存,但一个进程的fd数量通常只有几千到几万,这点内存完全可接受。
2.2 事件循环中怎么分发文件事件
事件驱动的核心就在aeProcessEvents里。每次循环开始,它调用底层的aeApiPoll阻塞等事件,返回就绪的fd数量,然后逐个处理:
numevents = aeApiPoll(eventLoop, tvp); for (j = 0; j < numevents; j++) { aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd]; int mask = eventLoop->fired[j].mask; if (!(fe->mask & mask)) continue; if (fe->mask & mask & AE_READABLE) { fe->rfileProc(eventLoop, fd, fe->clientData, mask); } if (fe->mask & mask & AE_WRITABLE) { fe->wfileProc(eventLoop, fd, fe->clientData, mask); } }fired数组保存的是“这次轮询中有动静的fd”,它跟events数组不同。你可以理解成:events是排班表,fired是“今天实际被叫到号的客人名单”。这里有个很关键的细节——Redis用fe->mask & mask判断程序员关心的事件和内核报告的事件是否匹配,如果只关心读事件,但内核报告了写事件,这一轮就直接跳过,避免误调用。
2.3 为什么内核事件表要自己维护
我在读这段源码时曾经有个疑问:epoll自己维护着一个内核事件表,为什么Redis还要在用户态维护一套events数组?这不是重复吗?
答案是:内核事件表只能告诉你“这个fd有动静了”,但没告诉你“这个fd对应的是谁的连接、该怎么处理”。events数组里存的信息(回调函数、clientData)是内核表给不了的。两套表分工明确,一个管通知,一个管业务。
还有一个容易踩的坑:aeApiPoll返回的事件中可能包含EPOLLERR和EPOLLHUP,这类异常事件如果不处理,连接断开的客户端就没人管了。Redis的做法是在aeApiPoll之后,如果掩码里有AE_READABLE或者AE_WRITABLE才回调,而EPOLLERR会自动带上EPOLLIN,所以读回调里会触发read,读到0字节或者错误码,就知道连接挂了,从而走freeClient流程。
3. 时间事件:不是定时器,是闹钟扫一圈
3.1 惰性归并的设计:时间事件为什么要用无序链表
Redis里的时间事件主要用来做周期性任务,比如serverCron每秒执行若干次,检查过期key、触发持久化、更新统计信息等。它的注册方式是通过aeCreateTimeEvent,数据结构是一个无序链表:
typedef struct aeTimeEvent { long long id; long when_sec; long when_ms; aeTimeProc *timeProc; aeEventFinalizerProc *finalizerProc; void *clientData; struct aeTimeEvent *next; } aeTimeEvent;你可能马上会吐槽:无序链表查找下一个最近的时间事件不是要O(n)吗?为什么不用最小堆?
Redis在这里做了一个非常务实的取舍。时间事件的数量通常很少,固定就那么几个:一个serverCron,再加上一些临时任务。链表插入是O(1),查找虽然要遍历,但n只有几十个,遍历一次微秒级都不到。如果用最小堆,插入要O(logn),调整堆还要额外维护,对Redis的场景来说是杀鸡用牛刀。这种“根据实际规模选数据结构”的思路,在Redis源码里反复出现。之前聊ziplist时也说过,元素少的时候,连续内存的数组比链表和哈希表都高效。
3.2 serverCron这个“大管家”到底在干嘛
serverCron是Redis里最出名的周期任务,它像大管家的每日巡查,每隔固定时间就绕一圈,把该干的事都干了。我简单列一下它管的范围:
- 检查并清理过期key
- 更新各类统计信息(内存、连接数、命令执行次数)
- 判断是否要触发RDB快照或AOF重写
- 处理主从重连、集群心跳
- 动态调整哈希表的rehash进度
这里有一个实用技巧:serverCron的调用频率受配置项hz控制,Redis默认是10,也就是每秒执行10次。如果你把hz调成100,serverCron的执行频率会更高,过期key清理和rehash都会更及时,但CPU占用也会上升;反过来调低,延迟变高但省CPU。生产环境里我一般建议在CPU有余力时适度调到20~30,性能敏感场景优先考虑调整别的参数。
4. aeMain里的一日:一个完整的事件周期拆解
4.1 beforeSleep和afterSleep:Redis的“前后摇”
把时间尺拉大,看Redis的主循环入口aeMain:
void aeMain(aeEventLoop *eventLoop) { eventLoop->stop = 0; while (!eventLoop->stop) { if (eventLoop->beforesleep != NULL) eventLoop->beforesleep(eventLoop); aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_BEFORE_SLEEP|AE_CALL_AFTER_SLEEP); } }beforesleep和aftersleep是两个容易被忽略但非常关键的钩子。名字看着像“睡觉前”和“睡觉后”,其实指的是“进入epoll_wait之前”和“从epoll_wait返回之后”。
beforesleep在Redis里干了一件特别重要的事:把写缓冲区的数据真正发送给客户端。Redis处理完命令后,并不是立刻写socket,而是先把响应挂在客户端的输出缓冲区里,统一在下一轮事件循环的beforesleep阶段刷出去。
为什么要攒一波再刷?这就好比餐厅出菜,厨师炒完一盘端一盘,还是盛满托盘一次性端出去?一次性端出去,服务员跑的次数少,整体效率高。Redis正是通过这种批量刷缓冲区的策略,减少了系统调用次数,从而提升吞吐量。
aftersleep则做一些收尾工作,比如更新缓存的时间戳、处理收到的信号。这两个钩子的存在,说明Redis的事件循环不是简单“等事件、分事件”,而是把很多周期性、批量性的工作巧妙地微调在循环两端。
4.2 aeProcessEvents的执行流拆解
aeProcessEvents是循环体里最核心的函数。它的完整流程可以拆成五步:
- 根据参数决定是否阻塞,以及阻塞多久。
- 如果有关心的文件事件,调用
aeApiPoll等待。 - 处理所有就绪的文件事件,执行对应的读写回调。
- 处理时间事件,看哪些定时任务到期了。
- 返回本轮处理的事件总数。
其中第二步的超时时间计算,是整个循环能否“既高效又及时”的关键,值得单独拿出来说。
4.3 epoll_wait的超时时间是怎么动态算出来的
我读第一遍的时候以为,epoll_wait要么是永不超时,要么是固定超时100毫秒。但实际上Redis的计算逻辑很巧妙:
if (flags & AE_DONT_WAIT) { tv.tv_sec = tv.tv_usec = 0; } else if (flags & AE_TIME_EVENTS) { shortest = aeSearchNearestTimer(eventLoop); if (shortest) { long now_sec, now_ms; aeCurrentTime(&now_sec, &now_ms); long long ms = (shortest->when_sec - now_sec)*1000 + shortest->when_ms - now_ms; if (ms > 100) { tv.tv_sec = 0; tv.tv_usec = 100000; /* 最多等100ms */ } else if (ms > 0) { tv.tv_sec = ms/1000; tv.tv_usec = (ms%1000)*1000; } else { tv.tv_sec = 0; tv.tv_usec = 0; /* 最近时间事件已经到期,不等待 */ } } else { tv.tv_sec = 0; tv.tv_usec = 100000; } } else { tvp = NULL; /* 永久阻塞 */ }这段逻辑翻译成大白话就是:如果最近的时间事件在100毫秒以内,那我就等到那个时间点;如果最近的时间事件在100毫秒以外,我最多等100毫秒就醒来一趟,防止有突发情况没人响应;如果时间事件已经到期了,那我一刻都不等,立刻去处理。
为什么上限是100毫秒?这是“延迟”和“心跳”之间的平衡。时间事件的最短执行周期默认是10ms(hz=10),如果epoll_wait每10ms醒一次,CPU会吃不消;但如果一等等到天荒地老,后面的定时任务就全乱套了。取100ms这个值,保证了即使某个任务不紧急,Redis也不会睡死过去,最多一百毫秒就会醒来看一次情况。
5. 从源码到实战:这些设计能直接抄作业
5.1 自研网络服务怎么复用ae这套事件机制
读完ae.c最直接的好处是,你可以把这套东西“抄”到自己的网络程序里。很多轻量级中间件就是这么干的:把ae.c、ae.h抠出来,放在工程里,注册几个文件事件钩子,一个单线程高并发服务就搭起来了。
比如我曾经在某个轻服务里处理WebSocket心跳,做法是:
- 为每个客户端fd注册
AE_READABLE,回调里读取数据并解析; - 同时注册一个周期性的时间事件,比如每30秒扫描一次所有连接,超时没心跳的就主动断开。
这中间有个极易踩的坑:回调函数里千万别做耗时操作。你可能会觉得“我就解析一个JSON,能有多慢?”但事件循环是单线程的,一个回调卡了,后面所有fd都得排队等着。一旦某个客户端的请求里带了一个超大JSON,解析花了上百毫秒,全服务的延迟立刻飙升。
5.2 调整hz参数时要考虑什么
hz这个参数前面提过,但实际调优时还要再展开一点。它影响的不仅是serverCron的执行频率,还会连带影响:
- 过期key的清理节奏,调大了能在内存里少堆积过期数据
- 主动rehash的推进速度,调大后大哈希表缩容更快
- 持久化触发条件的检查频率,主从复制里的心跳发送频率也受影响
我见过一个把hz调到500的极端例子,结果CPU占用翻了一倍,性能反而下降。调优这事,核心逻辑是hz越高,Redis花在周期任务上的CPU占比越高。一般场景10就够了,对延迟和过期清理比较敏感,可以慢慢往上试,每次翻倍观察,别一步调到位。
5.3 连接接入慢?先查backlog和accept逻辑
线上经常遇到一个问题:并发一大,新连接接不进来,程序也没报错,就是卡着。这时候要看的不是ae.c,而是acceptTcpHandler背后的listenbacklog 设置。
TCP有三次握手,内核维护两个队列:半连接队列和全连接队列。accept从全连接队列里取连接,如果 backlog 设置太小,全连接队列满了,新连接直接被内核丢弃。Redis的tcp-backlog默认是511,对应就是listen的第二个参数。
如果你用的是Nginx反代到Redis,或者客户端瞬时创建大量连接,建议把系统的somaxconn和tcp-backlog一起调大,否则即使Redis内部处理得再快,连接也进不了门。这算是一个跟事件循环无关、但直接影响事件循环性能的经典坑。
6. 读这段源码最容易踩的5个坑
6.1 文件事件回调里的“死循环陷阱”
aeProcessEvents里处理文件事件时,会一次性把numevents全部处理完,再回到aeMain开头。如果某个读回调里没有读到数据就返回,而fd又一直是可读状态,下一轮epoll_wait会立刻再次返回,回调又被执行,形成了一个变相的死循环,CPU直接被占满。
Redis本身处理了这种情况:它的读回调和命令执行之间是有状态管理的,读不到完整请求就挂起等下一次。但你自己写代码时,如果回调里发现没数据,千万不要直接return后还保留注册状态,一定要根据实际情况判断是否临时关闭读事件。
6.2 时间事件返回AE_NOMORE与纳秒级精度的误解
时间事件的返回类型有两个约定:返回AE_NOMORE表示执行完就把这个事件删掉;返回一个毫秒数,表示下一次执行的间隔。很多人以为Redis的时间事件能精确到纳秒级调度,其实它用的是秒+毫秒结构when_sec+when_ms,精度远没到纳秒。
对绝大多数服务器业务来说,毫秒级已经足够用了。如果你真想搞微秒级定时,那要换一整套实现方案,比如用epoll的timerfd或者DPDK这类用户态IO方案,这不是Redis的设计目标。
6.3 IO线程为什么默认关闭:正确理解Redis 6.0的线程化
Redis 6.0引入了IO多线程,默认配置是io-threads 1,也就是关闭状态。很多人以为有了IO线程,Redis就变成多线程了,其实不然。线程化的只是“读取数据”和“写回数据”这两个IO环节,命令的真正执行还是单线程。为什么?因为一旦命令并发执行,所有共享数据结构的访问都得加锁,Redis的简单高效就全毁了。
实际优化手段是:如果一台机器核数很多,网络IO占用高,可以把io-threads调成4或8,但io-threads-do-reads默认是关闭的,需要显式开启。我建议先加IO线程看CPU的软中断占用有没有降下来,如果网络读写不是瓶颈,调这个参数没意义。
6.4 从epoll切到kqueue和select的兼容设计
ae.c在不同平台下会有不同的实现文件,Linux下走ae_epoll.c,macOS下走ae_kqueue.c,最差的情况会回退到ae_select.c。这种“平台相关代码拆分 + 统一接口导出”的思路,是所有可移植网络库的标准写法。
我自己在某个跨平台项目里仿写过这套设计,最大的体会是:接口层一定要简洁,只暴露create、resize、poll、add、del这些原语,具体实现藏在下面。测试的时候也要注意,同一套逻辑在epoll和select上表现很不一样,select有fd数量上限,还得自己遍历所有fd,性能远不如epoll。写兼容层时一定要做个性能回归测试,不能只在Linux上验证完就上线。
6.5 读源码时建议加打印的“断点”位置
如果你打算自己调试一遍ae.c,我给你几个建议的观察点,省得一头扎进去出不来:
aeCreateFileEvent:看注册事件时mask和回调对不对。aeApiPoll返回后:打印numevents和每个fd,这一步能看到系统报告的就绪列表。timeProc回调里:打印当前时间事件还剩多少个,观察链表是否被正确增删。aeMain的循环计数:加一个计数器,看每秒能循环多少轮。
这些观察点配合实操,比我写一万字解说都更直观。
7. 个人体会与扩展建议
每次有人问我“读Redis源码从哪里开始”,我都不建议立刻去抠某个具体命令的实现,而是建议先读ae.c。原因很简单,它是Redis所有行为的容器。命令执行、网络处理、持久化、主从复制,全都挂在这个事件循环上,理解了这个容器,再看server.c、networking.c会轻松得多。
我自己的阅读经验是:不要试图一次把每个宏定义、每个函数细节都吃透。第一遍只抓主干,看事件循环怎么转;第二遍再聚焦某一个具体事件从注册到触发的完整链路;第三遍可以思考“如果让我来设计,我会怎么做”,带着问题去对比源码里的取舍。
把ae.c读完了,下一步比较有意思的是networking.c,那里面能看到一个客户端连接从accept到request解析到command执行再到response写回的全过程,配合这期讲的事件循环,Redis的网络模型就算彻底打通了。