先说明一下,我前后读了两遍 Redis 的ae.c、networking.c和server.c,对着线上一次延迟事故把执行顺序摸了个底朝天。你问的“文件事件、时间事件、事件调度与执行”不是三个独立概念,而是一条完整的事件循环链路。把这条链路理顺了,你不仅能看懂 Redis 为什么单线程还能扛住高并发,还能自己定位那些让人挠头的延迟抖动问题。
不管你是刚把 Redis 当缓存用、正准备通读源码,还是正在排查线上卡顿,这篇文章都会按源码执行顺序拆给你看。重点放在调度器aeProcessEvents怎么决策、文件事件和时间事件怎么切、以及“文件事件优先”到底会带来什么坑。
1. 事件驱动到底解决什么问题
1.1 Redis 为什么绕不开单线程事件循环
很多人一开始都以为 Redis 的“快”来自纯内存存储,但内存数据库不少,凭什么 Redis 能把 QPS 拉到十万甚至几十万?核心其实在这套事件驱动模型上。
传统阻塞式 I/O 模型里,每个客户端连接占用一个线程,线程阻塞在read的系统调用上等人发数据。连接一多,线程切换就是一场灾难。Redis 的思路完全相反:一个线程跑一个死循环,把所有 socket 的读写都交给操作系统多路复用接口去监听,有事件就处理,没事件就阻塞等待,CPU 空转成本几乎为零。
这样做还有一个隐性收益:单线程意味着共享数据结构不需要加锁。Redis 里所有键值操作都是串行的,不需要处理并发冲突,这在多核时代反而成了优势。所以 Redis 的“快”不是没有代价,而是它把瓶颈从 CPU 计算转移到了 I/O 等待上,而事件循环正是处理 I/O 等待的最优解。
1.2 两条事件主线:文件事件和时间事件的分工
前面这个死循环里,Redis 把事件明确分成两类,它们的职责完全不同。
文件事件(File Event)负责处理客户端 socket 上的读写,比如新连接进来了、客户端发了一条SET命令、要往客户端回写结果,这些都属于文件事件。你可以把文件事件理解成“前台接待员”,所有外部请求都得通过它进入 Redis。
时间事件(Time Event)负责处理内部周期性任务,比如键过期、RDB 持久化触发、AOF 重写检查、客户端超时断开、集群心跳、统计信息更新。它们不需要外部触发,而是按照预定时间自动执行。类比一下,时间事件就是“内部保洁员”,定时巡逻,该清理清理,该记录记录。
调度器aeProcessEvents就是那个排班的人。一个死循环里,它先看看有没有文件事件要处理,再看看有没有时间事件到期,两边穿插执行,形成 Redis 的核心运转机制。
2. 文件事件:把网络 I/O 管起来
2.1 底层怎么监听 socket:I/O 多路复用抽象层
文件事件不是一个凭空来的概念,它建立在操作系统提供的多路复用接口之上。Redis 在ae.c里抽象了一组统一的接口:aeApiCreate、aeApiAddEvent、aeApiDelEvent、aeApiPoll、aeApiResize。
具体用哪个底层实现,编译时根据平台自动选择。Linux 上基本就是epoll,macOS/BSD 走kqueue,其他平台回退到select或poll。这种抽象和 Nginx 的做法类似,上层完全不用关心用的是哪种接口,代码路径完全一致。
Redis 为每个被监听的 socket 维护一个事件状态,核心就是两个标志位:AE_READABLE(可读)和AE_WRITABLE(可写)。当操作系统报告某个 fd 可读或可写时,事件循环就会调用对应的处理器(Processor)。注册一个文件事件的入口是aeCreateFileEvent,它把事件写入内部就绪表,并绑定上下文指针和回调函数。
注意:Redis 用的是水平触发(Level Triggered)模式,不是边缘触发。也就是说只要 socket 上还有数据没读完,每次
epoll_wait都会返回这个事件。这样实现简单,不会因为漏读边缘事件而丢数据。代价是如果你读得不干净,事件会反复触发,所以 Redis 在文件事件处理器里也是尽可能把当前缓冲区的数据读完。
2.2 一个连接从接入到关闭的三种处理器
文件事件处理器不是一尘不变的,它随着连接所处阶段不同而动态切换。一个客户端的完整生命周期里,主要涉及下面三种处理器。
首先是连接应答处理器acceptTcpHandler。Redis 服务器启动时,对监听 socket 注册了AE_READABLE事件,回调就是它。当新客户端发起 TCP 连接时,事件触发,它执行accept接收连接,然后为这个新客户端创建client结构,同时在新 socket 上注册命令请求处理器。这一步是入口,没有它后面的命令都进不来。
接着是命令请求处理器readQueryFromClient。连接建立后,客户端发的命令都通过它读取。它会从 socket 读数据到输入缓冲区,解析出命令参数,执行命令,然后把响应写入输出缓冲区。注意,这里不是直接把数据发回客户端,而是先写到缓冲区里,真正发送由下一个处理器负责。
最后是命令回复处理器sendReplyToClient。当输出缓冲区有数据要写且 socket 可写时触发,它把缓冲区内容write到客户端。如果客户端断开,Redis 会释放client结构、关闭 fd 并取消事件注册,连接生命周期到此结束。
这三种处理器串起来就是完整的文件事件链路:接入、读请求、回响应。你如果自己写过高性能网络库,会发现这套流程几乎是事件驱动服务器的标配。
2.3 写事件什么时候注册:输出缓冲区才需要关注
有一个容易被忽略的细节:命令请求处理器执行完命令后,并不会立刻给 socket 注册AE_WRITABLE事件,只有输出缓冲区有内容时才会注册。这么做是为了尽量减少无谓的epoll事件切换。
Redis 在networking.c里写回复时,先在用户态调用write尝试直接发送。如果数据量小、内核发送缓冲区有空余,一次write就发出去了,根本不需要注册写事件。只有当一次写不完、输出缓冲区还有残留时,才把AE_WRITABLE挂上去,等 socket 可写了继续发。这一手“能省则省”的优化,在高并发小包场景下尤为重要。
3. 时间事件:周期任务的调度
3.1 定时事件和周期事件,其实是一个东西
时间事件在 Redis 里统一用aeTimeEvent结构表示,核心字段包括事件 id、下一次执行的时间when、处理函数timeProc和上下文数据。
它有两种表现形式。如果timeProc返回AE_NOMORE(也就是 -1),那么这个事件执行一次后就被删除,这是定时事件。如果返回的是某个毫秒数,Redis 会用当前时间加上这个返回值更新when,让事件继续排在后面,这是周期事件。
所有时间事件都放在一个无序链表里。这里有个看似不合理的点:既然是查找下一个执行时间,为什么不用最小堆或红黑树?原因很实际:Redis 里的时间事件数量极少,正常情况下就一个serverCron,加上客户端超时检查等零星几个。数量小的时候,每次遍历链表的开销完全可以忽略,而用无序链表可以让插入时间事件的时间复杂度为 O(1)。为这点规模引入一个复杂的优先队列反而得不偿失。
3.2 serverCron 到底在忙什么
Redis 最核心的时间事件就是serverCron,它通常在服务器启动时创建,第一次执行间隔甚至只有 1 毫秒,之后每 100 毫秒执行一次,对应配置hz=10。
serverCron的任务非常杂,我简单列几个:过期键的主动清理、更新服务器各类统计指标、判断是否要触发 RDB 持久化或 AOF 重写、关闭超时客户端、为慢查询日志采样、集群模式下的心跳发送、主从复制中的超时检测与重连尝试。
实际上serverCron内部还会通过run_with_period宏对任务做二次分级。比如某些任务不需要每个 tick 都执行,可以 5 秒一次,run_with_period(5000)就会只在满足条件的 tick 里执行。这样hz控制基础周期,宏控制低频率任务,整体 CPU 消耗保持可控。
如果你把
hz调高,比如调到 100,serverCron的执行频率会变成每 10 毫秒一次,对过期清理和超时检测更敏感,但 CPU 开销也会增加。反之调低能省 CPU,但可能让空闲连接超时判定变慢、过期键短时间积累变多。线上调参要权衡。
3.3 时间事件的时间粒度与 Redis 的“时间缓存”
服务器对时间事件执行时间的判断依赖当前时间,但 Redis 不会每次判断都调用系统函数,而是在事件循环里维护一个缓存的时间戳,最大缓存时间是 1 毫秒。也就是说,事件循环里拿到的mstime()可能比真实时间慢 1 毫秒。
这个设计看起来很小,但影响面很大。Redis 的高频执行路径上到处都要取当前时间,如果没有缓存,每次取时间都是一次系统调用,在高 QPS 下会放大成不小的开销。1 毫秒的误差对绝大多数逻辑都没影响,所以这个取舍非常划算。
有一点需要理解:时间事件并不意味着毫秒级精确。受hz=10限制,serverCron的实际执行间隔是 100 毫秒左右,一旦某个周期内文件事件处理久了,时间事件还会进一步延后。所以 Redis 内部所有依赖时间事件的逻辑都在设计上留了余量,没有谁会指望它精确到个位毫秒。
4. 事件调度与执行:核心循环
4.1 aeMain:一个死循环的自我修养
aeMain是整个事件循环的总入口,结构比想象中简单,核心就是一个while循环:
void aeMain(aeEventLoop *eventLoop) { eventLoop->stop = 0; while (!eventLoop->stop) { aeProcessEvents(eventLoop, AE_ALL_EVENTS | AE_CALL_BEFORE_SLEEP | AE_CALL_AFTER_SLEEP); } }条件里加上了AE_CALL_BEFORE_SLEEP和AE_CALL_AFTER_SLEEP,分别代表在进入阻塞等待前和从阻塞等待返回后要回调处理。beforeSleep通常用来处理延迟写回复、刷盘内容;afterSleep则做事件循环巡检。只要没有显式调用aeStop,这个循环就会一直跑下去。
4.2 aeProcessEvents:调度器如何决策下一步
所有调度的秘密都在aeProcessEvents里。它的核心逻辑大概分四步。
第一步,如果启用了文件事件处理,先找出最近要执行的时间事件,计算tvp:跟当前时间的差值。如果最近事件在 50 毫秒后到期,那tvp就是 50 毫秒;如果没有时间事件需要关注,tvp就是 NULL,意思是可以一直阻塞等待文件事件。
第二步,调用beforeSleep。这是事件循环每次转入等待前的最后一道工序,比如处理完上一次回复写入后剩余的数据、更新客户端状态等。
第三步,调用aeApiPoll(tvp)。这里是真正的阻塞点。传进去的tvp决定了epoll_wait最多阻塞多久。有文件事件要等、但时间事件也快到期时,最多等tvp这么久,时间一到即使没有文件事件就绪也会返回,好让时间事件有机会执行。如果只有时间事件需要处理,tvp就是 0,aeApiPoll不阻塞,立刻返回并转去处理时间事件。
第四步,返回后先执行afterSleep,然后处理所有就绪的文件事件,最后处理所有已到期的时间事件。这一步的顺序是重点,下面单独拆解。
4.3 为什么文件事件优先,时间事件如何保住“不被饿死”
看aeProcessEvents的源码你会发现,Redis 把文件事件的处理排在了时间事件前面。这不是随手一写,而是刻意的优先级设计:对外部客户端的响应应当优先于内部周期任务。
想象一个场景:某个 tick 刚开始,客户端发来了一大堆命令,同时serverCron也正好到期。调度器会先把所有就绪的文件事件处理完,把命令响应尽可能发出去,然后才检查时间事件。这个逻辑符合直觉:用户的请求延迟更敏感,内部维护任务晚 几十毫秒执行没什么关系。
但这就带来一个隐患:如果文件事件源源不断,时间事件会不会一直被拖后?Redis 的解决方案很巧妙。它不会在文件事件处理到一半插进来执行时间事件,而是在处理完文件事件后,立刻重新获取时间并遍历时间事件链表。只要时间事件已经到期,马上执行。所以最多被拖一个文件事件处理周期的时长,不会无限延迟。因此叫“相对优先”,而不是“绝对抢占”。
这种设计对我排查问题的启发很大:如果某段时间线上请求量极大,时间事件延后是可接受的;但如果某个大 key 删除操作在文件事件处理器里卡了 1 秒,那么所有时间事件都会跟着延后 1 秒,过期清理、心跳检测全都被拖延。所以排查延迟问题,最终往往要回到“有没有慢命令拖了事件循环”上。
4.4 一次事件循环的完整推演
为了方便理解,我构造一个最小示例。假设hz=10,serverCron上一次执行后,下次到期时间在 t+100ms,当前 t=0。事件循环按下面这样走。
第一次循环,没有文件事件就绪,aeApiPoll会带着 100 毫秒的超时阻塞。5 毫秒后客户端发来一条GET命令,epoll_wait立刻返回,Redis 读入命令、执行、写回响应,整个过程可能只用 0.2 毫秒。处理完,调度器检查时间事件,发现还剩大约 95 毫秒才到期,于是再次进入阻塞等待。这个循环重复,直到时间逼近 t+100ms。
如果 t+100ms 那一刻正好有文件事件就绪,调度器会先处理完文件事件,再执行serverCron。假设文件事件处理花了 15 毫秒,那么serverCron实际执行发生在 t+115ms 左右。下一次到期时间从真实执行时间往后推 100 毫秒,不是按理论周期硬凑。
这个例子里有个值得品味的细节:Redis 的周期任务允许“顺延”但不会“补跳”。如果某次serverCron执行延迟了,它不会为了追平理论节奏而连跳两次,而是顺延下一次。这种宽松的调度策略保证了整体实现足够简单。
5. 常见问题与排查技巧实录
5.1 事件循环“卡住”了怎么定位
如果你观察到的现象是:Redis 客户端偶尔延迟几百毫秒甚至秒级,但 CPU 不高,大概率不是负载压力,而是某个操作在事件循环里长时间阻塞。
最直接的工具是redis-cli --latency,它会周期性发送PING并统计往返延迟。如果能看到明显的尖刺,再配合SLOWLOG查看是否有慢命令。SLOWLOG只能记下执行超过阈值的命令,如果阻塞来源是serverCron内部的耗时操作(比如 RDB 全量保存),SLOWLOG就看不到,需要结合INFO persistence和耗时统计去判断。
我自己踩过一个坑:当时线上 Redis 延迟偶发跳到 200 毫秒,排查了半天,最后发现是某条命令不带NOMIGRATE之类的控制参数直接删了一个包含百万字段的大 Hash,导致整个事件循环停顿。解决手段是把这个操作拆成HSCAN分批删除,延迟尖刺立刻消失。
5.2 serverCron 执行过久怎么察觉
serverCron是时间事件的常驻任务,它内部如果耗时长,会表现为“所有命令周期性地变慢”。比如每 100 毫秒抖一次,那基本就是serverCron里的某个环节出问题了。
常见原因有三个。第一,hz被调得过高,比如为了优化过期清理把hz调成 500,serverCron每秒执行 500 次,CPU 成本飙升。第二,RDB 持久化或 AOF 重写触发了fork(),如果内存占用大,fork本身会阻塞主进程一小段时间。第三,键过期数量突然很大,serverCron里的过期清理每次被迫扫描大量 key。
检查手段也很直接:用INFO stats查看instantaneous_ops_per_sec是否周期性波动,再用INFO commandstats看慢命令分布。如果怀疑是fork阻塞,看INFO latest_fork_usec里的微秒值就能确认。
5.3 文件事件优先带来的两个隐蔽问题
第一个问题是大批量命令处理时的时间事件延迟。假设你用了大量Pipeline一次塞进来几千条命令,Redis 会连续处理完它们,可能几十毫秒就过去了,期间时间事件得不到执行。这通常没什么大问题,但如果此时恰好有主从心跳或过期键要处理,就会略被拖延。
第二个问题是读事件优先于写事件的处理顺序。源码里同一轮就绪事件中,Redis 会先调用可读处理器,再调用可写处理器。原因很朴素:命令请求是需要先进来的,先读后写才能保证语义正确。如果你在写监控脚本时发现某个 fd 同时可读可写,它的读处理器一定比写处理器先执行。这个细节写扩展模块或者做网络层二次开发时特别有用。
5.4 排查速查表
| 现象 | 可能原因 | 验证方式 | 解决手段 |
|---|---|---|---|
| 偶发高延迟尖刺 | 大 key 阻塞事件循环 | SLOWLOG+redis-cli --latency | 拆分删除、异步删除或限制命令复杂度 |
| 周期性延迟波动 | serverCron 内耗时操作 | INFO分项排查 + 时间点对比 | 恢复默认hz、错峰持久化、调整过期扫描策略 |
| 命令处理正常但整体延迟升高 | 写事件注册过多或输出缓冲堆积 | INFO clients看输出缓冲占用 | 调整client-output-buffer-limit,排查慢消费客户端 |
| 过期键命中率下降 | 时间事件延后,清理不及时 | 观察expired_keys与延迟时段 | 检查文件事件是否长期处于高压力状态 |
| 主从复制延迟变大 | 心跳时间事件被推迟 | INFO replication看 lag | 避免在serverCron密集节点执行高耗时操作 |
6. 几个我能直接用的实践建议
最后分享几点我从源码追踪和线上排障里沉淀下来的个人体会。
如果想系统读懂这套机制,建议不要只看ae.c,要结合networking.c里的事件注册逻辑一起看。文件事件处理器不是孤立的,它的注册、切换、注销散落在连接生命周期各个角落。把acceptTcpHandler、readQueryFromClient、sendReplyToClient三条路径串一遍,整棵事件树的脉络就清晰了。
排查延迟问题时,先不要急着优化配置。先回答一个问题:当前延迟是文件事件造成的还是时间事件造成的?如果是前者,找慢命令和大 key;如果是后者,找serverCron里的异常项。很多人一上来就调hz或者改过期策略,结果把问题带偏了。
另外,Redis 的事件循环设计对“精确性”有着非常明确的取舍,它可以容忍时间事件延后、容忍时间缓存误差,但绝不允许事件处理内部逻辑复杂化。如果你在自己的 C 扩展或业务代码里模拟类似事件循环,也尽量保持这个原则:调度器只管分发,业务逻辑不要写阻塞操作。
我最初也以为单线程模型很简单,真正把aeProcessEvents的执行顺序读进去才发现,每一条优先级决策背后都是对响应时延和实现复杂度的权衡。搞明白这套机制之后,再回头看很多调优方案,思路清楚多了。