LVS 与 epoll
四层负载 LVS(内核 IPVS)与用户态事件模型 epoll,是高并发接入层的两块基石。本文讲清 LVS 三种转发模式(尤其 DR 为什么最快)、调度算法、Keepalived 高可用,以及 I/O 多路复用三代演进(select → poll → epoll)为什么 epoll 碾压前两代、LT/ET 与惊群的取舍,再进一步到 io_uring 的完成通知模型,最后说明 LVS 与七层 Nginx 如何分层配合。
一句话结论
LVS 四层只改 MAC 廉价均分连接,epoll 注册等待分离只返就绪 fd,二者撑起接入层高并发。
场景问题
一个千万级并发的接入层要同时解决两件事:
- 横向扩展:单机扛不住,需要把流量均匀分给一组后端(Real Server),且这一分发本身不能成为瓶颈或单点。
- 单机高并发:每台机器要用尽可能少的线程管住尽可能多的连接(C10K → C10M),不能"一连接一线程"。
前者是 LVS(负载均衡)要解的,后者是 epoll(I/O 多路复用)要解的。二者常配合出现:LVS 在四层把连接均分给多台 Nginx/后端,每台后端内部用 epoll 单线程管住海量连接。
打个比方(epoll 为何碾压 select/poll):
select/poll像个没登记本的门房——每次有人问"我的快递到了吗",他都得挨个把整栋楼所有房间的门敲一遍确认(每次遍历全部 fd),住户一多(几万连接)就活活累死,还是 O(n)。epoll是聪明门房:进楼时先在登记本上把每个人登记一次(epoll_ctl注册),之后谁的快递到了、谁的门铃响了,门房只把"有响动的那几间"名单直接递给你(epoll_wait只返就绪 fd)——你按名单去取即可,跟总人数无关,近似 O(1)。类比失效边界:这个"只报有响动的"在 ET(边缘触发)模式下更省,但也更容易踩坑——门房只在"从没货变成有货"那一瞬间按一次铃,你要是没趁这次把快递一口气全取空(没循环读到EAGAIN),剩下的它可不会再提醒你,数据就这么漏在那儿了。所以 ET 必须配非阻塞 fd + 循环读到EAGAIN为止;图省事可以用 LT(水平触发),只要还有货它就一直按铃。
实现方案
LVS 三种转发模式
LVS 工作在内核的 IPVS 模块,只做四层(IP + 端口)转发,不解析应用层。三种模式:
| 模式 | 数据路径 | 改写 | 性能 | 约束 |
|---|---|---|---|---|
| NAT | 请求、响应都过 Director | 改目的/源 IP(DNAT/SNAT) | 最低(响应也压 Director) | RS 网关须指向 Director |
| DR(直接路由) | 请求过 Director,响应直连客户端 | 只改目的 MAC | 最高 | RS 与 Director 同二层网段,RS 的 lo 绑 VIP |
| TUN(IP 隧道) | 请求过 Director(IPIP 封装),响应直连客户端 | IPIP 封装 | 高 | RS 需支持隧道,可跨网段 |
DR 为什么性能最高:
- Director 只处理入向请求包,且只改二层 MAC 地址(把目的 MAC 改成选中的 RS),不改 IP、不做连接跟踪级重写。
- 响应包由 RS 直接发回客户端(源 IP 仍是 VIP),完全不经过 Director。而真实流量绝大部分是响应(下行远大于上行),DR 让 Director 只承担了最小的那部分流量,因此吞吐最高。
- 代价:RS 必须在
lo上绑 VIP 并抑制 ARP(arp_ignore=1、arp_announce=2),保证只有 Director 应答 VIP 的 ARP。
# Director 上配置 DR 模式(ipvsadm)
ipvsadm -A -t 10.0.0.1:80 -s wlc # 添加虚拟服务 VIP:80,调度算法 wlc
ipvsadm -a -t 10.0.0.1:80 -r 10.0.0.11 -g -w 3 # -g=DR(gateway), 权重3
ipvsadm -a -t 10.0.0.1:80 -r 10.0.0.12 -g -w 1
ipvsadm -Ln # 查看连接与规则
# 每台 Real Server 上(DR 必需)
ip addr add 10.0.0.1/32 dev lo # lo 绑 VIP
sysctl -w net.ipv4.conf.all.arp_ignore=1 # 不响应非本接口目标的 ARP
sysctl -w net.ipv4.conf.all.arp_announce=2 # 通告时用真实接口地址
调度算法
- rr(轮询)/ wrr(加权轮询):静态均分,忽略实际负载。
- lc(最少连接)/ wlc(加权最少连接):按当前活动连接数分,能力强的机器权重高。wlc 是通用默认最优选择。
- sh(源地址哈希):同源 IP 固定打到同一 RS,用于会话保持。
Keepalived + LVS 高可用(VRRP)
Director 是单点,用 Keepalived 跑 VRRP 做主备:主备通过组播/单播互发心跳竞选,VIP 漂在 MASTER 上;主挂了备接管 VIP(发免费 ARP 更新交换机 MAC 表)。Keepalived 同时对 RS 做健康检查,摘除故障后端。
select → poll → epoll 演进
三代 I/O 多路复用都在解同一个问题:一个线程同时等一批 fd,谁就绪了处理谁。区别在"怎么告诉内核你关心哪些 fd""内核怎么把就绪结果还给你"。
第一代 select(1983,BSD):
fd_set rset;
for (;;) {
FD_ZERO(&rset);
for (i = 0; i < n; i++) FD_SET(fds[i], &rset); // 每次都要重建集合
int r = select(maxfd + 1, &rset, NULL, NULL, &tv); // 传全量, 内核就地改写 rset
for (i = 0; i <= maxfd; i++)
if (FD_ISSET(fds[i], &rset)) handle(fds[i]); // 返回后还要 O(n) 扫一遍找就绪
}
- fd 集合是定长 bitmap,
FD_SETSIZE通常 1024 硬上限——想监听第 1025 个 fd 直接踩坏栈内存。 - 传参是"值-结果"(value-result):内核把入参集合原地改写成就绪集合,所以每次调用都得重新
FD_SET重建整个集合。 - 三次 O(n):拷全量集合进内核、内核线性遍历全部 fd、返回后用户再
FD_ISSET线性扫一遍找出就绪的。 - 只有读/写/异常三个集合;
timeout在部分实现里也被改写。
第二代 poll(1986,System V):把 bitmap 换成数组,拆开输入输出。
struct pollfd pfds[N]; // { fd; events(关心的); revents(内核填的) }
for (i = 0; i < N; i++){ pfds[i].fd = fds[i]; pfds[i].events = POLLIN; }
for (;;) {
int r = poll(pfds, N, -1); // 传全量数组
for (i = 0; i < N; i++)
if (pfds[i].revents & POLLIN) handle(pfds[i].fd); // 仍要 O(n) 扫 revents
}
- 去掉
FD_SETSIZE硬上限(只受RLIMIT_NOFILE限制)。 events(输入,我关心啥)与revents(输出,内核填就绪)分离,不用每次重建集合。- 但没解决根本问题:每次调用仍把整个 pollfd 数组全量拷进内核 + 内核全量遍历 + 用户全量扫 revents,依旧三处 O(n)。
第三代 epoll(2002,Linux 2.5.44):把"注册"和"等待"彻底分离——这是质变。
| 维度 | select | poll | epoll |
|---|---|---|---|
| fd 上限 | FD_SETSIZE≈1024 | 无(受 RLIMIT_NOFILE) | 无 |
| 每次调用拷贝 | 全量 bitmap | 全量数组 | 不拷(fd 常驻内核红黑树) |
| 就绪查找 | O(n) 遍历 | O(n) 遍历 | O(1) 摘就绪链表 |
| 返回内容 | 改写后的全集,需自己扫 | revents 全集,需自己扫 | 只返就绪 fd |
| 触发模式 | 仅 LT | 仅 LT | LT / ET 可选 |
| 数据结构 | 定长 bitmap | 变长数组 | 红黑树 + 就绪链表 |
| 复杂度随连接数 | 线性恶化 | 线性恶化 | 与总数无关 |
一句话:select/poll 是"每次把整本花名册递进内核,让内核挨个点名";epoll 是"入职登记一次,之后内核只把有事的人名单主动塞给你"。 连接越多,差距越悬殊——10 万连接里通常只有几百个活跃,select/poll 每次却要扫满 10 万。
epoll 事件模型
epoll 是 Linux 的 I/O 多路复用机制,内核维护两个结构:红黑树(存所有被监听的 fd,epoll_ctl 增删改,O(log n))+ 就绪链表(rdllist,就绪 fd 的双向链表,epoll_wait 直接摘取,O(1))。fd 就绪时由内核回调把它挂到就绪链表,无需遍历全部 fd。
int epfd = epoll_create1(0);
struct epoll_event ev, events[MAX];
ev.events = EPOLLIN | EPOLLET; // 监听可读 + 边沿触发(ET)
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); // 注册到红黑树
for (;;) {
int n = epoll_wait(epfd, events, MAX, -1); // O(1) 取就绪, 只返回就绪 fd
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
if (fd == listen_fd) {
// ET 下必须循环 accept 直到 EAGAIN,否则漏连接
for (;;) {
int c = accept4(listen_fd, NULL, NULL, SOCK_NONBLOCK);
if (c < 0) { if (errno == EAGAIN) break; else break; }
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = c;
epoll_ctl(epfd, EPOLL_CTL_ADD, c, &ev);
}
} else if (events[i].events & EPOLLIN) {
// ET 下必须把内核缓冲一次读干净(读到 EAGAIN)
for (;;) {
ssize_t r = read(fd, buf, sizeof buf);
if (r > 0) handle(buf, r);
else if (r == 0){ close(fd); break; } // 对端关闭
else { if (errno == EAGAIN) break; else { close(fd); break; } }
}
}
}
}
LT(水平触发)vs ET(边沿触发):
- LT:只要缓冲区还有数据/可写,每次
epoll_wait都会返回该 fd。编程简单、不易丢事件,但可能重复唤醒。 - ET:仅在状态从无到有变化时通知一次,必须循环读/写到
EAGAIN把缓冲处理干净,否则事件丢失。唤醒次数少、效率高,但编程复杂,且 fd 必须为非阻塞。
惊群与 EPOLLEXCLUSIVE:多个进程/线程用各自 epoll 监听同一 listen fd,一个连接到来会唤醒所有等待者,只有一个 accept 成功,其余空转——即惊群。内核提供 EPOLLEXCLUSIVE 标志,让内核每次只唤醒一个等待者,缓解惊群(Nginx 也可用 SO_REUSEPORT 让内核直接把连接哈希分发到各 worker 的独立监听队列,从根上消除惊群)。
epoll 内核细节(面试深挖点)
- 红黑树里存的是
epitem:每次epoll_ctl(ADD)建一个epitem(key 为(fd, file 指针)),挂进struct eventpoll的红黑树。同一个 fd 重复 ADD 返回EEXIST。 - 就绪靠回调,不靠轮询:注册时 epoll 把一个回调
ep_poll_callback挂进目标 fd 的等待队列。当网卡收包、协议栈把数据放进 socket 缓冲区时,内核唤醒该等待队列,回调就把对应epitem链入就绪链表 rdllist。epoll_wait只是去 rdllist 摘节点——所以是 O(1)、与总 fd 数无关。 epoll_wait空则睡:rdllist 为空时调用者挂到eventpoll自己的等待队列上睡眠,被回调唤醒或超时才返回,不空转 CPU。- 常见误区:epoll 不用 mmap 共享 fd 表。网上"epoll 靠 mmap 把 fd 映射到用户态省拷贝"是错的——省拷贝来自"注册一次常驻内核",跟 mmap 无关(真正大量用 mmap 共享环形队列的是下面的 io_uring)。
EPOLLONESHOT:多线程共用一个 epoll 时,一个 fd 可能被多个线程同时取到导致数据竞争。加EPOLLONESHOT让 fd 触发一次后自动从活跃集合禁用,处理完须epoll_ctl(MOD)重新武装(re-arm),保证同一时刻只有一个线程处理该 fd。- 闭环陷阱:fd
close()会自动从 epoll 移除,但如果该 fd 被dup/fork复制过,底层file仍在,epoll 可能继续报告它——多进程场景要显式EPOLL_CTL_DEL。
io_uring:从"就绪通知"到"完成通知"
epoll 再快也只解决了"哪个 fd 可以读写了"(readiness / 反应堆 Reactor 模型),真正的 read/write 系统调用还得你自己做。这留下两个 epoll 治不了的痛点:
- 每次 I/O 至少两次系统调用:
epoll_wait拿到就绪,再read/write。百万 IOPS 下 syscall 的用户/内核态切换本身就成了瓶颈(Meltdown/Spectre 补丁后 syscall 更贵)。 - 普通文件无法真异步:磁盘文件在 epoll 里永远"就绪",可真正
read时若数据不在 page cache 仍会阻塞。epoll 对磁盘 I/O 基本无能为力(这也是为什么早年只能用线程池 + 阻塞读,或残缺的 Linux AIO)。
io_uring(Linux 5.1,2019) 换成完成通知 / 前摄器 Proactor 模型:你告诉内核"帮我把这块数据读进来",内核干完再通知你"读好了"。核心是两个用户态与内核态共享(mmap)的环形队列:
- SQE(Submission Queue Entry):一条待办 I/O 请求(opcode + fd + buf + offset)。SQ/CQ 是共享内存,提交/收割结果不用拷贝、不用 syscall 就能读写队列。
- 批量提交:一次
io_uring_enter可提交几十上百个 SQE,把 N 次 syscall 压成 1 次。 SQPOLL模式:内核起一个轮询线程盯着 SQ,应用只管往 SQ 塞 SQE,稳态下一次 syscall 都不用(真正的零 syscall 快路径)。- 啥都能异步:不止 socket,
read/write/accept/connect/send/recv/openat/fsync/splice甚至statx都能进 SQ,磁盘 I/O 终于真异步——补上了 epoll + Linux AIO 一直缺的那块。 - 进阶优化:
register_buffers/register_files预注册缓冲区和 fd,省掉每次的引用计数与地址翻译;IORING_OP_*链式请求(read 完直接 write)。
epoll vs io_uring 一句话对比:
| 维度 | epoll | io_uring |
|---|---|---|
| 模型 | 就绪通知 / Reactor | 完成通知 / Proactor |
| 谁做 I/O | 应用自己 read/write | 内核替你做完再通知 |
| 每次 I/O syscall | ≥2(wait + read/write) | 可批量摊薄,SQPOLL 下趋近 0 |
| 磁盘文件异步 | ❌(永远"就绪",read 仍阻塞) | ✅ 真异步 |
| 内核门槛 | 老内核都有 | 5.1+,成熟需 5.6/5.11+ |
| 复杂度/风险 | 成熟稳定 | 复杂;曾因 CVE 被部分发行版默认关闭 |
io_uring 不是"epoll 的替代品"这么简单:它更像把 epoll 的"等就绪 + 自己收发"和磁盘 AIO 统一进一套异步完成框架。高性能存储/代理(如新版 Nginx、数据库、seastar 系)在往它迁移,但绝大多数网络服务 epoll 依旧够用且更简单。
为什么这么做
- 为什么四层比七层快:LVS/IPVS 只看 IP + 端口做转发决策,不解析 HTTP、不建立到后端的独立连接、不做内容缓冲,在内核里查连接表即可转发;七层网关要完整解析应用层、维护双向连接、可能缓冲请求体,CPU 与内存开销高一个量级。
- 为什么 epoll 是 O(1) 通知:
select/poll每次调用都要把全量 fd 集合从用户态拷进内核、内核线性遍历所有 fd 找就绪的、再拷回用户态——复杂度 O(n) 且反复拷贝。epoll 把"注册"和"等待"分离:fd 一次性注册进红黑树常驻内核,就绪由回调挂入就绪链表,epoll_wait只返回就绪的那些,与总连接数无关。 - 为什么 io_uring 更进一步:epoll 只报"就绪",收发仍要自己发 syscall;io_uring 用共享环形队列 + 完成通知,把提交/收割做成内存操作,批量提交摊薄 syscall,
SQPOLL下稳态零 syscall,且让磁盘文件也能真异步——治的是 epoll 治不了的"syscall 密度"和"文件 I/O 阻塞"。 - 为什么 DR 让响应绕开 Director:真实业务下行流量远大于上行,只要 Director 不碰响应包,它的负载就只与"新连接建立速率"相关,而非总带宽,扩展性天花板极高。
为什么别的选择不行
- 为什么不用 select/poll 扛高并发:
select有FD_SETSIZE(通常 1024)硬上限;poll无上限但仍 O(n) 全量拷贝+遍历。10 万连接下每次epoll_wait/poll都遍历 10 万 fd,绝大多数空闲,CPU 全耗在无效扫描上。epoll 消除了"拷贝全集"和"遍历全集"两个 O(n)。 - 为什么不用 NAT 模式做大流量:NAT 下响应包也要回 Director 做 SNAT,Director 成为全双工带宽瓶颈;DR 只承担上行请求,吞吐高得多。
- 为什么不直接用七层网关做第一层:七层单机吞吐有限、成本高,无法承接超大流量入口;用 LVS 四层先把海量连接廉价均分到一排七层网关,才能兼顾规模与能力。
- 为什么不用一连接一线程:线程栈(默认 MB 级)+ 上下文切换成本使得万级线程就把内存与调度器压垮;epoll 单线程事件循环用一个线程管十万连接,内存与切换开销恒定。
- 为什么不无脑上 io_uring:接口复杂、SQ/CQ 生命周期与内存序易错,依赖新内核(5.6 前坑多),且历史上多次 CVE 让 Docker/发行版默认禁用它。纯网络服务用 epoll 已经把 syscall 摊得很薄,收益有限;io_uring 的甜点在高 IOPS 磁盘 I/O、混合网络+存储、超高连接建立速率这类 epoll 摸不到的场景。
沉淀结论
- LVS 三模式选型:跨网段用 TUN、同网段极致性能用 DR、简单场景用 NAT。DR 快在"只改 MAC + 响应直连客户端"。
- 调度默认 wlc;需要会话保持用 sh;纯均分用 rr/wrr。
- 高可用靠 Keepalived + VRRP:主备竞选 VIP,故障发免费 ARP 秒级切换,并做 RS 健康检查。
- epoll 胜出的两句话:注册与等待分离(红黑树常驻 + 就绪链表 O(1)),只返回就绪 fd(消除 select/poll 的全量拷贝与遍历)。
- 三代演进:select(bitmap,1024 上限,全量拷+扫)→ poll(数组去上限,仍全量)→ epoll(注册一次 + 回调就绪 + 只返就绪)。
- io_uring 再进一步:从"就绪通知(Reactor)"到"完成通知(Proactor)",共享环形队列 + 批量提交 + SQPOLL 零 syscall + 磁盘真异步;代价是复杂与新内核依赖。
- LT vs ET:LT 简单稳妥,ET 高效但必须非阻塞 + 读写到 EAGAIN;惊群用
EPOLLEXCLUSIVE或SO_REUSEPORT解。 - 分层配合:LVS(四层,均分连接) → Nginx(七层,内容路由/TLS/缓存) → 应用后端,各取所长。
记忆口诀
LVS 三模式:NAT 都过网关最慢 / DR 只改MAC响应直连最快 / TUN 隧道封装可跨段
三代多路复用:select定长bitmap千上限全量拷 / poll数组去上限仍全量 / epoll注册一次回调就绪只返就绪
epoll 两句话:注册等待分离(红黑树+就绪链表) / 只返就绪fd(消灭全量拷贝遍历)
epoll↔io_uring:epoll报就绪你自己收发(Reactor) / io_uring内核干完再通知(Proactor)共享环+批量+SQPOLL零syscall
LT vs ET:LT非空就通知可分次读简单 / ET从无到有一次必须读到EAGAIN且非阻塞
惊群解法:EPOLLEXCLUSIVE只唤醒一个 / SO_REUSEPORT内核哈希分发到各worker
内容来源
综合整理。主要参考方向:Linux Virtual Server 官方文档(IPVS、NAT/DR/TUN 模式与 ipvsadm)、ipvsadm(8) 手册、Keepalived 官方文档与 VRRP(RFC 3768/5798)、select(2)/poll(2)/epoll(7)/epoll_ctl(2)/epoll_wait(2) man page 与内核 fs/eventpoll.c 实现(红黑树 + rdllist + ep_poll_callback)、C10K/C10M 问题综述、Nginx SO_REUSEPORT/EPOLLEXCLUSIVE 惊群处理文档、io_uring 论文 Efficient IO with io_uring(Jens Axboe)与 io_uring_setup(2)/io_uring_enter(2) man page、liburing。
相关专题:本篇是 epoll/IO 多路复用(LT/ET、惊群)的详解归属;零拷贝(sendfile/splice/mmap)详见 零拷贝,TCP 握手/拥塞/流控详见 TCP 网络。LVS DR 在游戏端到端低延迟链路中的定位见 全栈极限低延迟。
自测:合上资料能说清楚吗?
DR 模式为什么比 NAT 快?它对 Real Server 有什么特殊要求?
参考答案
DR 下 Director 只改目的 MAC转发入向请求,响应包由 RS 直连客户端不经 Director;而下行流量远大于上行,Director 只承担最小的上行部分,吞吐最高。要求:RS 在 lo 绑 VIP 并设 arp_ignore=1、arp_announce=2 抑制 ARP,且与 Director 同二层网段。
epoll 相比 select/poll 快在哪两个关键点?
参考答案
一是注册与等待分离:fd 一次性注册进红黑树常驻内核,就绪时由回调挂入就绪链表,epoll_wait 直接摘取 O(1)。二是只返回就绪 fd:消除了 select/poll 每次调用把全量 fd 拷进内核并线性遍历 O(n) 的两处开销,性能与总连接数无关。
select、poll、epoll 三代分别解决了什么、又留下什么?
参考答案
select:fd_set 定长 bitmap,FD_SETSIZE≈1024 硬上限;值-结果传参每次要重建集合;全量拷进内核 + 全量遍历 + 返回后 FD_ISSET 全量扫,三处 O(n)。poll:改用 pollfd 数组去掉 1024 上限、events/revents 分离免重建,但仍是全量拷 + 全量遍历。epoll:epoll_ctl 注册一次常驻红黑树、就绪由回调挂入就绪链表、epoll_wait 只返就绪 fd,彻底消除随连接数增长的线性开销,并支持 LT/ET。
io_uring 和 epoll 的本质区别是什么?它补了 epoll 的哪些短板?
参考答案
epoll 是就绪通知(Reactor)——只告诉你 fd 可读写,read/write 还得应用自己发 syscall;io_uring 是完成通知(Proactor)——你把 I/O 请求写进共享内存的提交队列 SQ(SQE),内核干完把结果写进完成队列 CQ(CQE)。它补了 epoll 两个短板:① syscall 密度——SQ/CQ 是共享内存、可批量提交,SQPOLL 模式下稳态零 syscall;② 磁盘文件 I/O——文件在 epoll 里永远"就绪"但 read 仍可能阻塞,io_uring 让文件读写也能真异步。代价是接口复杂、依赖新内核(5.6+ 才成熟)、历史上有 CVE 被默认禁用。
LT 和 ET 有什么区别?ET 模式编程要注意什么?
参考答案
LT(水平触发):缓冲区非空/可写就每次 epoll_wait 都通知,可分次读,编程简单。ET(边沿触发):仅状态从无到有变化时通知一次,必须循环读写到 EAGAIN 把缓冲处理干净否则丢事件,且 fd 必须为非阻塞。ET 唤醒次数少、效率高但易出错。
多进程监听同一 listen fd 的惊群问题是什么?有哪两种解法?
参考答案
惊群:一个连接到来唤醒所有等待者,只有一个 accept 成功,其余空转浪费 CPU。解法一 EPOLLEXCLUSIVE:内核每次只唤醒一个等待者。解法二 SO_REUSEPORT:内核把连接哈希分发到各 worker 的独立监听队列,从根上消除惊群且负载更均衡。
为什么用 LVS 四层做第一层、Nginx 七层做第二层,而不直接用七层网关接入?
参考答案
七层网关要完整解析应用层、维护双向连接、缓冲请求体,单机吞吐有限、成本高,无法承接超大流量入口。LVS 四层只看 IP+端口在内核查表转发,能廉价把海量连接均分到一排 Nginx;再由七层做内容路由/TLS/缓存。分层各取所长,兼顾规模与能力。