在高性能数据库领域,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_eventevents 字段中显式设置 EPOLLET 标志位才能启用。

行为特点

  • 高效:仅在 fd 状态发生“变化”的瞬间(如从“不可读”变为“可读”)通知一次。
  • 严格:一旦通知过,即使缓冲区还有未读完的数据,后续的 epoll_wait 调用也不会再返回该 fd 的事件,直到下一次有新数据到达。
  • 高要求:必须配合 非阻塞 I/O 使用。否则,如果一次 read 调用未读完数据并阻塞,你将永远收不到下一次通知,导致连接“饿死”。⚠️

类比:就像一个门铃,只有在有人按下的那一刻才会响一次。如果没听到,它不会一直响。

2.3 经典场景对比:10KB 数据的读取之旅

假设客户端向服务器发送了 10KB 数据,而服务器每次 read 最多只能读取 2KB:

LT 模式

  1. 第一次 epoll_wait 返回,通知 fd 可读。
  2. 服务器 read 2KB,缓冲区还剩 8KB。
  3. 第二次 epoll_wait立刻返回,再次通知 fd 可读(因为状态仍是“就绪”)。
  4. 重复此过程,直到 10KB 数据全部读完。总共触发 5 次通知

ET 模式

  1. 第一次 epoll_wait 返回,通知 fd 可读(因有新数据到达,状态变化)。
  2. 服务器 read 2KB,缓冲区还剩 8KB。
  3. 第二次 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) 是一个抽象层,需要在 selectpollepollkqueue 等不同后端上提供一致行为。而 selectpoll 本质上就是 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