在高性能数据库领域,Redis 凭借其卓越的读写速度成为缓存和数据库优化场景中的首选方案。然而,支撑其高性能的底层网络模型,尤其是对 epoll 事件通知模式的选择,却常常被开发者误解。本文将从数据库架构师的视角,深入剖析 epoll 的 LT 与 ET 两种模式,并结合源码揭示 Redis 的真实选择及其背后的设计哲学,帮助你更好地理解数据库优化与网络编程的底层逻辑。
一、两种通知哲学:从数据库视角看事件驱动
在之前的文章中,我们探讨过 epoll 如何通过红黑树与就绪链表的高效内核数据结构,成为 Redis 高性能网络模型的基石。然而,epoll 的真正强大之处,在于它提供了两种截然不同的事件通知模式:水平触发(LT) 与 边缘触发(ET)。
这两种模式代表了两种截然不同的设计哲学:
- LT 模式(Level Triggered):如同一位尽职的管家,只要任务未完成,便会持续提醒你。
- ET 模式(Edge Triggered):更像一位高冷的信使,仅在状态发生变化的瞬间通知一次,之后便不再过问。
那么,作为顶级开源项目的 Redis,究竟在这两种模式中如何抉择?为什么?本文将为你揭开谜底。
核心价值:
理解 ET/LT 的区别,是掌握高性能网络编程的关键。而 Redis 的选择,更是为我们提供了一个绝佳的工业级实践范例!
二、LT 与 ET 核心原理:一场关于状态与事件的博弈
2.1 水平触发(LT):宽容且安全的默认选择
工作模式:这是 epoll 的默认模式。只要文件描述符(fd)处于“就绪状态”(例如 socket 接收缓冲区中有数据可读),每次调用 epoll_wait 都会返回该 fd 的事件。
行为特点:
- 宽容:即使程序在收到通知后未一次性读完所有数据,下次调用
epoll_wait时,它依然会告知你“这个 fd 仍有数据可读”。 - 安全:不易遗漏事件,编程模型相对简单,尤其适合像 MySQL 或 MongoDB 这类需要稳定处理海量连接的服务端。
✅ 类比:如同一个电灯开关,只要灯亮着(就绪状态),你去检查(epoll_wait),就会发现它是亮的。
2.2 边缘触发(ET):高效但严苛的极致之选
工作模式:需要在 epoll_event 的 events 字段中显式设置 EPOLLET 标志位才能启用。
行为特点:
- 高效:仅在 fd 状态发生“变化”的瞬间(如从“不可读”变为“可读”)通知一次。
- 严格:一旦通知过,即使缓冲区还有未读完的数据,后续的
epoll_wait调用也不会再返回该 fd 的事件,直到下一次有新数据到达。 - 高要求:必须配合 非阻塞 I/O 使用。否则,如果一次
read调用未读完数据并阻塞,你将永远收不到下一次通知,导致连接“饿死”。⚠️
✅ 类比:就像一个门铃,只有在有人按下的那一刻才会响一次。如果没听到,它不会一直响。
2.3 经典场景对比:10KB 数据的读取之旅
假设客户端向服务器发送了 10KB 数据,而服务器每次 read 最多只能读取 2KB:
LT 模式:
- 第一次
epoll_wait返回,通知 fd 可读。 - 服务器
read2KB,缓冲区还剩 8KB。 - 第二次
epoll_wait立刻返回,再次通知 fd 可读(因为状态仍是“就绪”)。 - 重复此过程,直到 10KB 数据全部读完。总共触发 5 次通知。
ET 模式:
- 第一次
epoll_wait返回,通知 fd 可读(因有新数据到达,状态变化)。 - 服务器
read2KB,缓冲区还剩 8KB。 - 第二次
epoll_wait会阻塞!因为它认为你已经处理了这次“状态变化”事件。除非客户端发来新数据,否则服务器永远不会知道缓冲区里还有旧数据。✅ 关键结论:在 ET 模式下,必须在一个事件通知周期内,循环 直到返回 或 错误(表示数据已读完),才能确保不丢失数据。
三、Redis 的决策:为何坚定地选择 LT?
现在,让我们回答最核心的问题:Redis 到底使用的是 ET 还是 LT?
尽管网上有很多文章声称 Redis 使用了更高效的 ET 模式,但事实并非如此。通过查阅 Redis 源码(ae_epoll.c),我们可以找到确凿的证据。
3.1 源码分析:ae_epoll.c
在 Redis 的 ae_epoll.c 文件中,aeApiAddEvent 函数负责向 epoll 实例注册或修改事件:
// ae_epoll.c
static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) {
// ...
struct epoll_event ee = {0}; /* avoid valgrind warning */
/* If the fd was already monitored for some event, we need a MOD
* operation. Otherwise we need an ADD operation. */
int op = eventLoop->events[fd].mask == AE_NONE ?
EPOLL_CTL_ADD : EPOLL_CTL_MOD;
ee.events = 0;
// 注意这里!只设置了 EPOLLIN 和 EPOLLOUT
if (mask & AE_READABLE) ee.events |= EPOLLIN;
if (mask & AE_WRITABLE) ee.events |= EPOLLOUT;
ee.data.fd = fd;
if (epoll_ctl(state->epfd, op, fd, &ee) == -1) return -1;
return 0;
}
关键点:代码中完全没有设置 EPOLLET 标志位!这意味着 Redis 使用的是 epoll 的默认模式——LT(水平触发)。
3.2 设计智慧:为什么 Redis 坚持 LT?
这是一个非常值得深思的设计决策,主要基于以下几点考量:
- 简化逻辑,保证正确性:Redis 的核心是单线程事件循环。在 LT 模式下,即使某次事件处理函数(如
readQueryFromClient)未能一次性读完所有请求数据,epoll_wait在下一次循环中依然会通知该 fd 可读。这极大地降低了编程复杂度,避免了因数据未读完而导致的连接挂起等隐蔽 Bug。 - I/O 模型天然适配:Redis 的网络协议(RESP)是基于完整命令的。当一个客户端连接就绪时,事件处理器会尝试一次性读取并解析完整的命令。在绝大多数情况下,一个 TCP 包就能包含完整命令,因此一次
read调用就足以处理完毕。即使遇到命令分片,LT 模式也能优雅地兜底。 - 性能瓶颈不在通知次数:对于 Redis 这种内存数据库,真正的性能瓶颈通常在于内存操作、命令执行和网络带宽,而非
epoll被多调用几次。LT 模式带来的少量额外通知开销,在整体性能图谱中几乎可以忽略不计。保证系统稳定性和代码简洁性更为重要。 - 跨后端行为一致性:Redis 的事件驱动框架 (
aeEventLoop) 是一个抽象层,需要在select、poll、epoll、kqueue等不同后端上提供一致行为。而select和poll本质上就是 LT 模式。为保证跨平台一致性,epoll后端也采用 LT 模式是最自然的选择。总结:Redis 的选择体现了其一贯的设计哲学——在保证极致性能的同时,绝不牺牲代码的简洁性和系统的健壮性。LT 模式完美地契合了这一哲学。
四、ET 模式的正确打开方式:Nginx 的极致追求
虽然 Redis 没有选择 ET,但这并不意味着 ET 模式没有价值。对于 Nginx 这样需要处理海量静态文件、对每一个系统调用都锱铢必较的服务器来说,ET 模式减少的通知次数能带来可观的性能提升,尤其在数据库优化和反向代理场景中。
4.1 ET 模式下的标准读取循环
如果要在自己的项目中使用 ET 模式,必须严格遵循以下模板:
void handle_read_event(int client_fd) {
char buffer[4096];
ssize_t n;
// 必须循环读取,直到 EAGAIN
while ((n = read(client_fd, buffer, sizeof(buffer))) > 0) {
// 处理读取到的数据
process_data(buffer, n);
}
if (n == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 数据已读完,这是正常情况
printf("Data fully read.\n");
} else {
// 真正的错误
perror("read error");
close(client_fd);
}
} else if (n == 0) {
// 对端关闭连接
printf("Client disconnected.\n");
close(client_fd);
}
}
4.2 必须设置非阻塞 I/O
在将 fd 加入 epoll 之前,必须将其设置为非阻塞模式,这是使用 ET 模式的硬性前提:
int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) return -1;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
// 在 accept 之后
int client_fd = accept(server_fd, ...);
set_nonblocking(client_fd); // 关键步骤!
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 启用 ET 模式
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
五、总结与思考
通过本文的深入剖析,我们可以清晰地看到:Redis 在 LT 与 ET 之间坚定地选择了 LT 模式,这并非技术上的保守,而是基于单线程架构、协议特性和跨平台一致性考量的最优解。对于大多数数据库和中间件而言,LT 模式提供的安全性与简洁性远比节省几次系统调用更有价值。
理解这两种模式的区别,有助于我们在面对不同场景时做出更明智的技术选型:如果你追求极致的性能且能驾驭复杂性,ET 模式是利器;如果你更看重稳定性和代码可维护性,LT 模式是基石。
感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!
readEAGAINEWOULDBLOCK
浙公网安备 33010602011771号