拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Redis内核解析:从零拆解ae事件驱动框架与事件循环

Redis内核解析:从零拆解ae事件驱动框架与事件循环 聊到Redis的内核解析很多人第一反应是跳表、压缩列表、字典这些数据结构但真正让Redis在单线程模型下还能扛住十万级QPS的其实是藏在ae.c/ae.h里那套自研的事件驱动框架。这套框架跑在每次命令处理之前和之后是Redis一切网络IO、定时任务、后台刷盘动作的中枢调度器。想真正读懂Redis光知道五种数据类型的特性是不够的必须把这套事件循环的来龙去脉吃透。这篇文章会从设计动机讲起把aeEventLoop、文件事件、时间事件、底层多路复用封装、aeProcessEvents主循环逐一拆开最后落到真实业务场景和线上问题排查上。适合对Redis有一定使用经验、开始往源码和内核方向深入的开发者也适合做中间件维护、性能排查的运维同学参考。1. Redis为什么要自己“造”一套事件驱动框架1.1 单线程模型背后的无名功臣Redis在6.0之前严格意义上只有一个主线程在干活接收连接、读取请求、解析命令、执行命令、写回响应、处理定时任务全部串在这个线程里。这个线程不是简单的while循环里同步等请求而是一个标准的事件循环把所有关心的fd注册成“文件事件”比如新连接、可读、可写把所有需要周期性执行的任务注册成“时间事件”比如serverCron每100ms跑一次用一个多路复用调用epoll/kqueue/poll/select之一阻塞等待一旦有fd就绪或者时间事件到期立刻分发到对应的回调函数。这套机制就是Redis源码里ae模块的核心。它决定了Redis的延迟上限、连接处理能力、定时任务精度也决定了线上很多诡异现象的根源。你把maxclients调到上万把pipeline开到几百最终压力全部会汇聚到这一段事件循环代码上。1.2 为什么Redis不直接用libevent或libev很多人会问Nginx用libevent衍生思路很多开源软件也直接用libeventRedis为什么不直接用现成的事件库答案其实藏在Redis的使用方式里。Redis对事件框架的需求极度精简注册事件、修改事件、删除事件、阻塞等待、回调分发一共就五个接口。libevent虽然功能强大但同时带来了buffer管理、定时器堆、多线程同步、跨平台适配等一大堆Redis根本用不上的组件。而且libevent的抽象层级更深每次事件分发要经过的事件结构体和回调链更长对Redis这种追求极致延迟的应用来说每多一层间接跳转都会变成实实在在的CPU开销。更重要的是Reactor模式下的“谁唤醒谁处理”语义。Redis需要在整个生命周期里精确控制每次阻塞之前和唤醒之后做什么比如在阻塞前把AOF缓冲刷下去、把积压的写事件分配给IO线程。自研框架意味着这些钩子可以按需设计挂多少、什么时候挂完全自己说了算而不是被第三方库的生命周期绑住手脚。另外还有一点很容易被忽略Redis源码里ae模块只有一千多行维护成本极低。对一个以稳定和简单为最高目标的系统来说“自己写的、看得懂的”往往比“功能全的、但出问题要追到第三库源码里”更靠谱。这也是Redis作者一贯的工程态度能用简单方案解决的绝不引入重型依赖。2. 核心数据结构事件循环、文件事件、时间事件2.1 所有事件的中枢aeEventLoopaeEventLoop是整个事件驱动框架的根对象Redis在initServer阶段就会创建它之后主线程的一切工作都在这个结构体周围转。简化后的定义大概是这个样子typedef struct aeEventLoop { int maxfd; // 当前已注册的最大fd int setsize; // 事件表大小决定fd上限 long long timeEventNextId; // 时间事件自增ID aeFileEvent *events; // 文件事件表按fd下标存取 aeFiredEvent *fired; // 就绪事件表epoll_wait返回后填充 aeTimeEvent *timeEventHead; // 时间事件链表头 int stop; // 置1后退出主循环 void *apidata; // 多路复用层的私有数据 aeBeforeSleepProc *beforesleep; // 阻塞等待前调用 aeBeforeSleepProc *aftersleep; // 阻塞返回后调用 int flags; } aeEventLoop;setsize不是随意设的它在server.c里由maxclients CONFIG_MIN_RESERVED_FDS决定默认会是10000多一点。events数组就是按fd做下标的fd是多少就对应数组第几个位置所以fd不能超过setsize这就是为什么maxclients配置得太大时Redis会直接拒绝启动或运行时报错。apidata是让每个多路复用实现自己塞私有数据的入口epoll实现里它指向一个包含epfd和epoll_event数组的结构体select实现里它指向fd_set集合。通过这个void指针ae核心代码完全不依赖任何具体的系统调用底层的差异被完全隔离了。2.2 文件事件一张按fd索引的“登记表”文件事件在ae.h里的定义很朴素typedef struct aeFileEvent { int mask; // AE_READABLE / AE_WRITABLE 的组合 aeFileProc *rfileProc; // 可读回调 aeFileProc *wfileProc; // 可写回调 void *clientData; // 关联数据通常是client指针 } aeFileEvent;每个fd只能拥有一份文件事件但mask可以同时包含读和写。注册时调用aeCreateFileEvent(fd, mask, proc, clientData)删除时调用aeDeleteFileEvent(fd, mask)。删除操作的逻辑会在数组里把对应mask位清掉清完后如果没有残留事件还会顺手把maxfd缩回去保证多路复用层不会傻等一个已经不存在的最大值。另外还有一个配套结构aeFiredEvent它表示“这次轮询就绪了哪些fd”。epoll_wait返回后底层实现会把就绪的fd和mask填进eventLoop-fired数组aeProcessEvents再根据这里的fd去events数组里找回调。这里的设计思路是把“底层轮询结果”和“事件注册表”分开底层只需要往fired里写结果上层只需要关心分发。2.3 时间事件按毫秒“上闹钟”的调度器时间事件不是轮询的而是在每次事件循环开始前算好“最近一个时间事件什么时候到期”把到期时间传给epoll_wait做超时参数这样既不会白白忙等也不会错过定时任务。typedef struct aeTimeEvent { long long id; // 唯一ID long long when; // 到期时间戳毫秒精度 aeTimeProc *timeProc; // 到期后执行的函数 aeEventFinalizerProc *finalizerProc;// 删除时回调 void *clientData; struct aeTimeEvent *prev, *next; // 双向链表 int refcount; // 保护计数防止回调中删自己 } aeTimeEvent;所有时间事件串成一个双向链表新的时间事件会插入链表头部。到期执行的时间处理函数aeProcessTimeEvents会遍历整个链表找出when已经到了的事件逐一触发。如果timeProc返回值是AE_NOMORE则该事件自动删除否则Redis会把返回值当作“下次触发间隔毫秒数”重新计算when继续挂到链表里。Redis里最典型的时间事件就是serverCron。它通过aeCreateTimeEvent(1000/server.hz, serverCron, NULL, NULL)注册默认hz为10也就是每100ms执行一次。过期key清理、客户端超时检查、持久化触发、内存统计、主从心跳这些后台逻辑全部挂在serverCron下。2.4 钩子函数阻塞前后的“两张王牌”beforesleep和aftersleep这两个钩子是ae框架设计的点睛之笔。名字容易让人误会beforesleep不是在用户态睡眠而是指“进入epoll_wait阻塞等待之前”。Redis会在主循环里按照“调beforesleep - 多路复用阻塞 - 调aftersleep - 分发事件”的顺序运转。beforesleep里Redis会紧凑地做一堆收尾工作处理积压的待写客户端、把AOF缓冲刷进磁盘、处理阻塞客户端超时、调用模块钩子。它的意义在于每次线程快要“睡过去”之前把所有能做的善后工作清干净这样一旦进入阻塞就能安心等新事件不会因为积压工作导致下一次唤醒后处理不过来。aftersleep则负责唤醒后的善后比如更新事件循环状态、处理多路复用返回的异常等。掌握这两个钩子的位置对理解Redis为什么能保持低延迟非常关键。3. 多路复用封装层一套接口五个后端3.1 aeApi层到底做了什么抽象ae核心代码只调用多路复用层提供的五个函数aeApiCreate、aeApiFree、aeApiAddEvent、aeApiDelEvent、aeApiPoll。编译时通过宏选择具体实现ae.c里有一段经典的条件编译#ifdef HAVE_EVPORT #include ae_evport.c #else #ifdef HAVE_EPOLL #include ae_epoll.c #else #ifdef HAVE_KQUEUE #include ae_kqueue.c #else #ifdef HAVE_POLL #include ae_poll.c #else #include ae_select.c #endif #endif #endif #endif优先级从高到低是evport、epoll、kqueue、poll、select。这个顺序不是随便排的是作者根据各平台性能和扩展性精心选择的。Linux下走到epollmacOS/BSD下走到kqueueSolaris下走到evport实在不行才退化到poll和select。对于开发者来说这意味着同一份ae代码可以在各种系统上编译运行完全不用改业务逻辑。3.2 epoll实现的关键细节Linux上我们主要面对ae_epoll.c核心数据结构非常简单typedef struct aeApiState { int epfd; struct epoll_event *events; // 就绪事件数组容量等于setsize } aeApiState;创建事件循环时epoll_create拿到epfd之后每个文件事件的注册和修改都通过epoll_ctl操作。这里有一个特别容易踩坑的细节同一个fd如果之前已经用EPOLL_CTL_ADD加入过第二次想改事件掩码时必须用EPOLL_CTL_MOD否则会返回EEXIST。Redis的处理是int op eventLoop-events[fd].mask AE_NONE ? EPOLL_CTL_ADD : EPOLL_CTL_MOD; mask | eventLoop-events[fd].mask; if (epoll_ctl(state-epfd, op, fd, ee) -1) { if (op EPOLL_CTL_ADD errno EEXIST) { op EPOLL_CTL_MOD; // 重试一次 } }这段代码非常值得学习。它先把新掩码和旧掩码合并确保修改事件时不会覆盖掉原本注册的另一类事件遇到EEXIST又自动降级成MOD重试兜住了并发场景下事件注册的竞态。Redis的事件掩码和epoll事件的映射关系也很直白AE_READABLE映射EPOLLINAE_WRITABLE映射EPOLLOUT。epoll_wait返回后底层实现会把每个就绪事件的fd和mask写入eventLoop-fired数组由上层统一分发。注意这里的分发不会按fd排序所以上层拿到的是一个无序的就绪列表必须逐个处理。3.3 为什么select被放在最后如果系统没有任何高级多路复用机制Redis会退化到select实现。select最明显的限制是fd_set的长度由FD_SETSIZE决定Linux上通常是1024。也就是说如果maxclients超过大概1000select后端根本跑不起来。Redis在ae_select.c的aeApiCreate里会对setsize做检查超过FD_SETSIZE就直接报错。这也解释了为什么在生产环境部署Redis时如果系统内核不支持epoll或者容器环境把epoll禁用了客户端连接数稍微上来就会莫名其妙连不上。遇到这种情况第一反应不应该是怀疑maxclients配置而是先确认cat /proc/version和当前Redis实际使用的多路复用后端。虽然这种场景今天已经很少见但在某些裁剪过的嵌入式内核或老系统上仍然会发生。4. aeProcessEvents主循环逐行拆解4.1 主循环的调用位置与参数含义Redis启动时main函数调用initServer完成各种初始化后直接进入aeMainvoid aeMain(aeEventLoop *eventLoop) { eventLoop-stop 0; while (!eventLoop-stop) { aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_BEFORE_SLEEP|AE_CALL_AFTER_SLEEP); } }aeProcessEvents是事件循环的心脏它接收两个参数eventLoop和flags。flags里比较重要的几个值分别是AE_ALL_EVENTS同时处理文件事件和时间事件AE_FILE_EVENTS只处理文件事件AE_TIME_EVENTS只处理时间事件AE_DONT_WAIT文件事件没有就绪时也不阻塞立刻返回AE_CALL_BEFORE_SLEEP / AE_CALL_AFTER_SLEEP决定是否调用两个钩子函数。实际业务中主线程基本就是AE_ALL_EVENTS全开但其他代码路径里会有精细化控制。比如进程想优雅退出时会调aeStop置stop为1事件循环走到下一轮直接结束。4.2 阻塞时间的核心算法usUntilEarliestTimeraeProcessEvents里最关键的计算发生在epoll_wait之前到底要不要阻塞、阻塞多久。如果只处理时间事件Redis会遍历时间事件链表找到最近一个到期事件的时间差换算成微秒usUntilEarliestTimer。然后把这段时间作为epoll_wait的timeoutif (usUntilTimer 0) { tv.tv_sec usUntilTimer / 1000000; tv.tv_usec usUntilTimer % 1000000; tvp tv; } else if ((flags AE_FILE_EVENTS) !(flags AE_DONT_WAIT)) { tvp NULL; // 永久阻塞 } else { tv.tv_sec 0; tv.tv_usec 0; tvp tv; // 忙轮询 }这段逻辑的聪明之处在于epoll_wait的阻塞时长永远被“最近的时间事件”限制住这样即没有新连接也没有新请求时Redis不会空转但定时任务又绝对不会被耽误。默认serverCron每100ms一个周期所以epoll_wait最长也就睡100ms左右。如果没有任何时间事件、只有文件事件tvp设为NULL意味着无限期阻塞直到某个fd就绪。如果既没有时间事件也没有文件事件则直接不进入epoll循环processed结果返回0主循环会重来一次。4.3 文件事件分发时读优先背后的门道epoll_wait之后Redis会遍历fired数组对每个就绪的fd先看可读事件再看可写事件。有一个容易被忽视的细节如果同一个fd同一轮同时有读和写事件Redis先执行读回调再执行写回调但会顺带做一次“去重保护”if (fe-mask mask AE_READABLE) { rfired 1; fe-rfileProc(eventLoop, fd, fe-clientData, mask); processed; } if (fe-mask mask AE_WRITABLE) { if (!rfired || fe-wfileProc ! fe-rfileProc) { fe-wfileProc(eventLoop, fd, fe-clientData, mask); processed; } }这个!rfired || fe-wfileProc ! fe-rfileProc条件核心目的是如果同一个fd的读回调和写回调是同一个函数读事件已经处理完就没有必要再重复调用一次写事件。这种细节一般文档里根本不会提但它避免了线上一个fd同时读写时造成的重复回调。读优先还有个实际意义如果客户端一边发请求一边等应答Redis优先读取请求可以尽快解析并执行命令减少命令在socket缓冲区里积压的时间。对Redis这种单线程模型来说请求尽早从内核缓冲区搬到用户态也能降低socket内存压力。4.4 时间事件处理的refcount保护机制processTimeEvents里有个refcount字段是Redis 7.0之后加的保护机制。它的场景是这样的时间事件回调内部可以自由地创建新时间事件、删除别人、甚至删除自己。如果回调先把自己从链表里删除然后继续执行return逻辑函数后续可能会访问已经被释放的内存导致崩溃。refcount的解法是在处理某个时间事件前先对refcount加一回调执行完再减一如果事件已经被标记删除并且refcount归零才真正释放。这套思路和引用计数垃圾回收如出一辙用在单线程事件框架里显得优雅且轻量。对应到实际教训就是如果你们自己的代码里基于老版本Redis源码扩展了时间事件一定要小心“在回调里删除自身”这个操作这个坑在Redis 7.0修复之前是真实会崩的。5. 事件驱动如何串起Redis的真实业务5.1 从accept到readQueryFromClient的链路Redis启动时会为监听socket注册一个AE_READABLE事件回调是acceptTcpHandler。一旦有新连接到达epoll_wait返回aeProcessEvents找到监听fd的rfileProc调用acceptTcpHandler。在这个回调里Redis不是只accept一次就完事而是用while循环把当前内核backlog里排队的连接尽量全部接收掉直到出现EAGAIN为止。每接受一个连接就会调用acceptCommonHandler创建client对象然后为这个新fd注册AE_READABLE事件回调是readQueryFromClient。至此这个连接的后续请求就开始由事件循环接管任何请求到达都会唤醒epoll并进入readQueryFromClient。注意这里用的是非阻塞socket。Redis在anet.c里创建socket后都会设置O_NONBLOCK保证任何一次read/write都不至于把主线程卡死。非阻塞事件循环才是单线程能支撑高并发的组合拳。5.2 命令执行后写回响应和写事件的注册请求数据到达后readQueryFromClient把内容读进客户端的query buffer然后processInputBuffer逐条解析命令调用call执行命令最后通过addReply系列函数把结果写入客户端的输出缓冲区。这个“写入输出缓冲区”的动作并不等于立刻发到socket。Redis的写回策略是先用writeToClient尝试直接写如果socket发送缓冲区很大一次就能把数据写完那就不需要注册写事件。如果数据量太大没写完剩余数据还攒在客户端的reply链表里Redis会通过aeCreateFileEvent给这个fd注册AE_WRITABLE事件回调是sendReplyToClient等socket可写时继续发送。之前我在一个聊天室项目里看到过一种现象偶尔有个连接会卡几十毫秒才收到响应。排查后发现问题不是命令慢而是输出缓冲区积压后写事件注册又被误删了导致数据一直躺在链表里等不到发送时机。所以记住一点当客户端出现大规模批量读取时写事件的生命周期管理往往是延迟毛刺的源头。5.3 serverCron与时间事件落地serverCron是Redis最核心的时间事件它每100ms被事件循环触发一次做掉所有“按周期该做的事情”。粗略列举就有清理过期key主动过期采样、检测客户端空闲超时、触发RDB/AOF持久化、更新内存峰值统计、向主从节点发送心跳、检查repl backlog大小等。也就是说很多你在Redis配置文件里设置的参数最终都是靠这个时间事件驱动执行的。比如maxmemory策略下的逐出、hz参数的调整都会影响serverCron的执行频率。这个事件如果因为某种原因迟迟得不到调度影响面会非常大。时间事件还承担blocked客户端的超时管理。比如执行BRPOP时客户端会进入阻塞状态Redis把阻塞信息记在客户端上然后由beforeSleep里的handleBlockedClientsTimeout统一做超时检查。没有这个机制阻塞命令就可能出现“永远不返回”的bug。5.4 beforeSleep每次“入睡”前的扫尾局前面说过的beforeSleep钩子在实际Redis业务里承担的工作相当密集。我简化后的调用序列是调用模块系统的beforeSleep钩子处理排队的命令和输入缓冲把待写客户端分发给多路IO线程flushAppendOnlyFile刷AOF缓冲检查阻塞客户端超时。最后一条尤其重要AOF的刷盘动作放在epoll_wait之前意味着即使接下来系统进入长时间空闲这段AOF缓冲也已经落盘了把数据丢失窗口压缩到最小。把写IO分发给IO线程也放在这里意味着每次循环最多分发一次不会在中间重复调度。这也是为什么Redis中beforeSleep函数虽然看起来不起眼却是性能调优时的高频关注点。如果线上出现AOF刷盘慢拖累主线程的现象比如配置了always策略且磁盘性能差你会立刻在beforeSleep里看到耗时飙升。5.5 与多线程IO的分工边界Redis 6.0引入的io-threads并不改变事件驱动的主体架构。主线程依然承担accept、命令解析、命令执行、事件分发等核心工作IO线程只负责做两类系统调用从socket里读数据到缓冲区、把缓冲区数据写到socket。流程上主线程会在beforesleep里把待写的客户端列表分给IO线程用原子变量io_threads_pending做同步每个IO线程处理完一批fd后递减计数。读线程通过io-threads-do-reads配置开启默认不开因为解析命令本身在主线程读线程只是提前把字节搬进用户态缓冲区对纯SET/GET场景收益有限。一个容易误导人的点io-threads不是“越多越好”。官方建议也就4到8个开太多反而会因为线程调度和共享缓存争抢导致性能下降。另外命令的真正执行永远只有一个线程所以Redis本质上依然是基于事件驱动的单线程执行模型IO线程只是给它加了个“搬运工”。6. 常见问题与性能排查实录6.1 单核CPU被打满先看忙轮询还是真流量线上遇到单个Redis进程CPU打到100%常见原因有几种连接风暴、大key慢命令、过期key集中清理还有一个不太容易想到的是“忙轮询”。忙轮询的典型特征是在perf top里看到aeProcessEvents的比例极高但实际QPS很低。排查时可以先用strace观察系统调用频率strace -c -p redis_pid如果看到epoll_wait每秒调用次数异常高而且timeout都几乎为0说明事件循环在频繁空转。这时候要检查是不是有开发者注册了高频时间事件或者某些客户端疯狂尝试连一个不存在的端口导致accept事件不断触发。也可以临时用CONFIG SET latency-monitor-threshold 100打开延迟监控然后看LATENCY LATEST里是不是有大量command或eventloop事件。6.2 延迟毛刺epoll超时和事件风暴的双重影响epoll_wait的最大超时时间由最近的时间事件决定默认被serverCron限制在100ms以内。如果server.hz调得很大比如调到100最近的定时器可能只有10msepoll_wait每10ms就醒一次。这在CPU和延迟上都有代价所以hz不是越大越好。还有一种场景是同一时刻大量客户端同时超时重连形成了“惊群效应”。虽然Redis自己不会多进程accept但底层如果有多个线程在同一个fd上调用epoll_ctl或epoll_wait也会产生不必要的唤醒。遇到这类事件风暴可以看INFO stats里的total_connections_received是不是突然飙升配合ss查看连接状态确认。6.3 maxclients和setsize的隐性绑定把maxclients调大不是随便改个配置就行它直接影响aeEventLoop里的setsize而setsize又是事件表数组的长度。如果maxclients设置得超过实际系统允许的fd数Redis在一开始创建事件循环时就可能失败。另外事件表数组是通过realloc动态调整的调整发生在aeResizeSetSize里。这个函数会清空多路复用层的一部分缓存如果调用时机不对可能导致某次epoll_ctl操作丢失。Redis源码里已经处理了大部分边界但作为使用者还是要记住不要频繁在运行期调大maxclients最好在启动配置里一次定准。6.4 slowlog和LATENCY工具组合定位事件驱动框架本身不会慢慢的是挂在事件上的回调。排查线上延迟我通常按这个顺序走先看slowlog有没有慢命令再看LATENCY LATEST有没有command、fork、aof-write等异常类别如果没有用redis-cli --latency -i 1测客户端到服务端往返延迟再用perf top看内核和用户态热点分布。有一回我们遇到一个周期性抖动slowlog全空LATENCY里全是command类别且每次都是一两毫秒。后来发现是某客户端每隔一段时间发一个超大的MSET命令本身执行不慢但输入缓冲区和输出缓冲区的拷贝把主线程拖住了。这种延迟在事件循环的视角看就是某个文件事件回调占用了太长时间其他fd全部在epoll里排队等着。6.5 压测Redis时观察事件循环的三个指标日常压测Redis除了看QPS和P99延迟我还习惯额外盯三个东西INFO stats里的instantaneous_ops_per_sec是否平稳如果有规律性锯齿多半和时间事件serverCron、过期清理有关INFO clients里的客户端数量变化与total_net_input_bytes是否匹配排查是否存在半连接或空连接用perf top看hotspot是aeProcessEvents还是epoll_wait如果是epoll_wait占比极高说明Redis在“等人”如果是其他回调说明命令处理是瓶颈。这套方法帮我在不少项目里快速定位过问题。核心思路其实很简单把Redis当成一个事件循环来观察而不是当成一个黑盒数据库来猜。我个人在实际操作中还有个体会读ae源码时不要让宏定义拦住你先把ae_epoll.c一个文件吃透再横向对比ae_select.c就能很快理解整套设计的边界。后续如果你们自己基于Redis做网关、做缓存框架、甚至写一个mini事件循环这套ae的思路都可以直接迁移。它没有高深的理论但几乎每一行都是工程权衡的产物值得反复咀嚼。
返回列表