如何在香港服务器的CentOS 7中调优epoll与SO_REUSEPORT,提高百万级并发连接的处理效率?

那天是周五凌晨 1 点,香港荃湾机房的空调比我手里的拿铁还冷。业务侧突然把一组促销活动提前了 12 小时,外加海外流量回流,北向链路的会话瞬间拉到 80 万并发,入口 Nginx 层的 CPU 抖成“心电图”。监控里不断冒红:listen queue overflow、SYNs to LISTEN sockets dropped、accept4: Too many open files。我一边把手机设成勿扰,一边让远端同事把下一棒流量控住 10 分钟。接下来这篇文章,基本就是我那一夜从“背水一战”到“图线回绿”的完整过程:如何在 CentOS 7 上把 epoll 和 SO_REUSEPORT 调到位,再配合内核网络栈、IRQ、RPS/XPS 等系统级优化,把 百万级并发“稳稳地接住”。
机房与测试基线
硬件/系统环境(实测节点)
| 组件 | 参数 |
|---|---|
| 机型 | 2U 单路/双路通用服务器(香港机房常见白牌) |
| CPU | 2 × Intel Xeon Silver 4214R(24C48T 总计),开启 HT |
| 内存 | 128 GB DDR4-2933 |
| 磁盘 | 2 × NVMe(系统/日志分离) |
| 网卡 | Intel X710 10GbE ×2(bonding=active-backup),驱动 i40e |
| 系统 | CentOS 7.9(3.10 内核系,厂商回溯补丁) |
| 角色 | L4/L7 网关 + 自研长连接接入服务(TCP/HTTP1.1 keepalive) |
说明:CentOS 7 的内核虽老,但 SO_REUSEPORT 与 epoll 的关键能力都可用,且大量发行版对关键补丁做了回溯,稳定度高,适合“守擂”。
目标与约束
- 连接规模:≥ 1,000,000 并发(混合空闲/低速流)。
- 入口延迟:p99 ≤ 12ms(同城内网链路)。
- 丢包/排队:入口无明显 SYN backlog 丢弃,netdev_backlog 低水位。
- 资源边界:单机 CPU < 70%,内存留有 20% buffer,I/O 不瓶颈。
基线(未优化/仅默认 epoll)
| 指标 | 数值(压力 60 万并发) |
|---|---|
| accept 队列溢出 | 间歇出现 |
| p99 响应 | 28–35ms |
| 丢包 | 网卡 RX Miss/Drop 偶发升高 |
| CPU | softirq 飙升,系统态占比 35%+ |
| FD 限制 | 经常触发 Too many open files |
总体路线图(拿来就能用)
- 系统资源上限:fd、进程、ulimit、systemd 限额拉满且持久化。
- 内核网络栈:somaxconn / tcp_max_syn_backlog / netdev_max_backlog / TCP 缓冲/内存曲线调到与业务匹配。
- 中断与队列:正确的 IRQ 绑核、RPS/RFS 与 XPS,充分利用多队列网卡。
- epoll + SO_REUSEPORT 架构:每核一 listener(同端口)、每核一 epoll,避免 accept 惊群。
- 应用层落地:C 端示例代码(关键路径)、Nginx 的 reuseport 配置。
- 压测与可观测:tcpkali/wrk 方法、监控项和常见坑的现场处置。
Step 1:把“天花板”撬高(FD/进程/limits)
/etc/security/limits.d/99-nofile.conf
* soft nofile 1048576
* hard nofile 1048576
* soft nproc 65536
* hard nproc 65536
systemd 服务单元(示例)
/etc/systemd/system/gateway.service
[Service]
LimitNOFILE=1048576
LimitNPROC=65536
TasksMax=infinity
内核级 FD 上限
echo 2000000 > /proc/sys/fs/file-max
# 持久化:
cat >> /etc/sysctl.d/99-fd.conf <<'EOF'
fs.file-max = 2000000
EOF
sysctl -p /etc/sysctl.d/99-fd.conf
实战提示:先用 lsof -p <pid> | wc -l、cat /proc/sys/fs/file-nr 验证上限确实生效;systemd 的 LimitNOFILE 如果忘了写,进程里还是会炸。
Step 2:内核网络栈调优(Listen、Backlog、TCP 记忆体)
/etc/sysctl.d/99-net.conf(示例基线)
# 1) Listen 与排队
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 250000
net.ipv4.tcp_max_syn_backlog = 262144
net.ipv4.tcp_synack_retries = 3
# 2) 缓冲与窗口(按 10GbE、长连偏多负载取中值)
net.core.rmem_default = 262144
net.core.wmem_default = 262144
net.core.rmem_max = 134217728
net.core.wmem_max = 134217728
net.ipv4.tcp_rmem = 4096 87380 67108864
net.ipv4.tcp_wmem = 4096 65536 67108864
# 3) 行为开关(视业务取舍)
net.ipv4.tcp_tw_reuse = 1 # 仅客户端有效,服务端谨慎
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_timestamps = 1 # 需要 PAWS 保守就开,极限性能可关=0
net.ipv4.tcp_sack = 1
net.ipv4.tcp_syncookies = 1 # 在突发 SYN 洪峰时保命
# 4) Fast Open(若内核/内网链路可靠且客户端支持)
net.ipv4.tcp_fastopen = 3 # 0/1/2/3,CentOS 7 某些版本需核实内核回溯
sysctl -p /etc/sysctl.d/99-net.conf
经验法则:Backlog 三角 = listen(backlog) 实参 ≤ net.core.somaxconn,且上游四层(如 LVS/ELB)的 tcp_max_syn_backlog 也要匹配,否则 “你这边接得住,他那边先丢了”。
Step 3:把包“喂”到对的 CPU(IRQ/RPS/XPS)
1)查看队列与中断号
ethtool -l eth0 # 查看 RX/TX 队列数
grep eth0 /proc/interrupts
2)合理绑核(NUMA 及缓存友好)
给每个 RX 队列分配固定 CPU,避免在 LLC 之间乱跑:
# 示例:把中断号 123 绑到 CPU 2
echo 0x4 > /proc/irq/123/smp_affinity
3)开启 RPS/RFS 与 XPS
RPS:把软中断分摊到更多 CPU;XPS:控制发包从哪个队列出去。
(路径按队列编号 N 调整)
# 每个 RX 队列允许在一组 CPU 上调度(例如 CPU 2-15)
echo fffc > /sys/class/net/eth0/queues/rx-0/rps_cpus
echo 32768 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt
# XPS 固定 TX 队列到相应 CPU(减少反复跨核)
echo 0004 > /sys/class/net/eth0/queues/tx-0/xps_cpus
4)GRO/LRO
对 L7 网关一般 保留 GRO 开启、禁用 LRO(避免乱序/大包在某些上层协议下带来负面):
ethtool -K eth0 gro on lro off tso on gso on
实战坑:开了 irqbalance 又手动绑核会互相“打架”。要么关 irqbalance,全手动;要么只做温和调整,并把不希望 irqbalance 管的中断 smp_affinity 锁死。
Step 4:应用架构——每核一 listener + 每核一 epoll
传统“单 listener + 多工作线程”在 CentOS 7 上容易出现 accept 惊群 或 accept 热点。SO_REUSEPORT 能让多个进程/线程在同端口各自拥有一个独立的监听 socket,内核根据 5 元组 hash 把新连接分发到不同的 listener 上,天然做到了无锁负载均衡。
关键策略
- 每个 CPU(或每个 NUMA 节点)一个监听 socket:SO_REUSEPORT。
- 线程/进程亲和:把工作线程 pin 到对应 CPU。
- 每线程一个 epoll 实例:事件完全本地化,减少共享结构。
- 边沿触发 (EPOLLET) + 非阻塞:减少唤醒/系统调用,但要写对循环。
- 监听 socket 用 accept4(..., SOCK_NONBLOCK | SOCK_CLOEXEC)。
- 订阅事件:EPOLLIN | EPOLLRDHUP | EPOLLET(必要时加 EPOLLONESHOT 自复位模型)。
Step 5:C 语言最小可用示例(精简关键路径)
说明:下面示例展示了 per-thread listener + epoll(ET) + SO_REUSEPORT 的关键细节,适合自研长连接接入/网关做骨架。生产请补齐日志、连接池、错误处理与超时管理。
// gcc -O2 -pthread -Wall server.c -o server
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <pthread.h>
#include <sched.h>
#include <signal.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#define MAX_EVENTS 4096
#define PORT 7000
#define BACKLOG 65535
#define BUF_SIZE 4096
static int set_nonblock(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) return -1;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
static int create_reuseport_listener(int port) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) { perror("socket"); exit(1); }
int one = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
if (setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)) < 0) {
perror("SO_REUSEPORT"); exit(1);
}
#ifdef TCP_FASTOPEN
int q = 4096;
setsockopt(fd, IPPROTO_TCP, TCP_FASTOPEN, &q, sizeof(q));
#endif
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(port);
if (bind(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind"); exit(1);
}
if (listen(fd, BACKLOG) < 0) { perror("listen"); exit(1); }
set_nonblock(fd);
return fd;
}
struct thread_arg {
int cpu;
int listen_fd;
};
static void *worker(void *arg_) {
struct thread_arg *arg = (struct thread_arg *)arg_;
cpu_set_t cpuset; CPU_ZERO(&cpuset); CPU_SET(arg->cpu, &cpuset);
pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset);
int ep = epoll_create1(EPOLL_CLOEXEC);
if (ep < 0) { perror("epoll_create1"); exit(1); }
struct epoll_event ev = {0};
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = arg->listen_fd;
if (epoll_ctl(ep, EPOLL_CTL_ADD, arg->listen_fd, &ev) < 0) {
perror("epoll_ctl listen"); exit(1);
}
struct epoll_event events[MAX_EVENTS];
char buf[BUF_SIZE];
for (;;) {
int n = epoll_wait(ep, events, MAX_EVENTS, -1);
if (n < 0) {
if (errno == EINTR) continue;
perror("epoll_wait"); break;
}
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
uint32_t e = events[i].events;
if (fd == arg->listen_fd) {
// Accept loop until EAGAIN (ET)
for (;;) {
struct sockaddr_in in; socklen_t inlen = sizeof(in);
int cfd = accept4(arg->listen_fd, (struct sockaddr*)&in, &inlen,
SOCK_NONBLOCK | SOCK_CLOEXEC);
if (cfd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
perror("accept4"); break;
}
// 注册读事件(同时监听对端关闭)
struct epoll_event cev = {0};
cev.events = EPOLLIN | EPOLLRDHUP | EPOLLET;
cev.data.fd = cfd;
epoll_ctl(ep, EPOLL_CTL_ADD, cfd, &cev);
}
} else {
if (e & (EPOLLHUP | EPOLLERR | EPOLLRDHUP)) {
close(fd); continue;
}
if (e & EPOLLIN) {
// 读到尽头(ET)
for (;;) {
ssize_t r = read(fd, buf, sizeof(buf));
if (r > 0) {
// 回显(示例):真实业务可协议解析/路由
ssize_t off = 0;
while (off < r) {
ssize_t w = write(fd, buf + off, r - off);
if (w > 0) off += w;
else if (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) break;
else { close(fd); goto next_fd; }
}
// 若未写完则转挂 EPOLLOUT;此处演示省略
} else if (r == 0) {
close(fd); break;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
close(fd); break;
}
}
}
}
next_fd: ;
}
}
return NULL;
}
int main() {
signal(SIGPIPE, SIG_IGN);
int ncpu = sysconf(_SC_NPROCESSORS_ONLN);
pthread_t *ths = calloc(ncpu, sizeof(pthread_t));
struct thread_arg *args = calloc(ncpu, sizeof(struct thread_arg));
for (int i = 0; i < ncpu; i++) {
int lfd = create_reuseport_listener(PORT);
args[i].cpu = i;
args[i].listen_fd = lfd;
pthread_create(&ths[i], NULL, worker, &args[i]);
// 小憩让 listener 建好再创建下一个,避免早期倾斜
usleep(20000);
}
for (int i = 0; i < ncpu; i++) pthread_join(ths[i], NULL);
return 0;
}
关键点复盘
- 每个线程一个独立监听 fd(SO_REUSEPORT),避免 accept 锁与惊群。
- EPOLLET + 非阻塞 + 读/accept “直到 EAGAIN”,是避免遗漏事件的“铁律”。
- 生产中通常会做 连接对象池、收发环形缓冲、协议帧切分、超时心跳 和 零拷贝优化(如 sendfile/splice)。
Step 6:Nginx 层同理(如果你是 HTTP 入口)
/etc/nginx/nginx.conf 关键片段:
worker_processes auto;
worker_rlimit_nofile 1048576;
events {
use epoll;
worker_connections 1024000;
multi_accept on;
}
http {
# 省略 server/tuning ...
server {
listen 80 reuseport backlog=65535;
# ...
}
}
listen reuseport 会让每个 worker 都有自身的监听 socket,避免单点热点。记得 worker_connections × worker_processes 要与系统 FD 上限对齐。
Step 7:压测方法与观测
工具与拓扑
- tcpkali:模拟长连接与低速收发;易推至百万连接(多发压机)。
- wrk:HTTP 场景吞吐与延迟。
- 压机:同机房 2–3 台 10GbE 服务器,分散源 IP(避免 5 元组 hash 偏斜)。
示例命令
# 1) 建 100 万空闲连接,每连接每秒 1 次小报文
tcpkali -r 1 -c 1000000 --connect-rate=50000 \
--duration=300s --latency-markers \
--binary -m "ping\n" <server-ip>:7000
# 2) HTTP 场景
wrk -t24 -c100000 -d120s --latency http://<server-ip>/
需要盯的指标
- ss -s、ss -ant state established | wc -l:连接规模。
- sar -n DEV 1、ethtool -S eth0:RX/TX、丢包、队列统计。
- cat /proc/net/netstat、/proc/net/snmp:ListenOverflows、TCPAbortOn*。
- top/perf top:softirq、ksoftirqd 是否抢戏。
- 应用日志:accept 错误、EAGAIN 频度、慢发送堆积。
Step 8:结果(我那夜的前后对比)
| 指标 | 调优前 | 调优后 |
|---|---|---|
| 并发连接能力 | ≈ 60–70 万 | ≥ 120 万(混合流) |
| p99 延迟(同城链路) | 28–35ms | 8–12ms |
| accept 溢出 | 偶发 | 基本无 |
| 丢包(RX Miss/Drop) | 偶发 | 显著下降 |
| CPU System/Softirq | 35%+/高 | < 20%/平稳 |
备注:数字取决于业务数据面工作量、报文大小和压测拓扑,但“跃迁感”非常明显。SO_REUSEPORT + per-CPU epoll 是最关键的结构性收益。
Step 9:常见坑 & 现场处置
FD 还是不够用
忘了在 systemd 服务里加 LimitNOFILE;或 Nginx worker_rlimit_nofile 没设。
观察 EMFILE/ENFILE,两类错误定位到进程/系统级别。
Backlog 看似够,还是溢出
listen(x) 的 x 实参被截断到 somaxconn。确认三角:上游四层 → 本机 tcp_max_syn_backlog → 本机 somaxconn。
SYN 洪峰时,临时打开 syncookies,压峰后再评估。
SO_REUSEPORT 负载不均
建议所有 listener 同时创建(或在创建间加入微小随机延迟),避免早期连接把 hash“啃偏”。
极端下可研究 BPF reuseport 分流(CentOS 7 部分内核带回溯,需评估稳定性)。
EPOLLET 漏事件
99% 是因为没有“读/accept 到 EAGAIN 为止”。循环写对,问题就消失。
半关闭检测请订阅 EPOLLRDHUP,否则对端优雅关不会通知你。
软中断爆、ksoftirqd 占满
重新做 IRQ 绑核、RPS/XPS;确保 RX/TX 队列映射与 CPU 拓扑匹配。
检查 GRO/LRO、netdev_max_backlog 和 rps_flow_cnt。
TIME_WAIT“海啸”
这常见于主动关闭的一端(多数是客户端)。服务端不要迷信 tcp_tw_reuse,看业务语义。
优化 Keep-Alive,减少频繁建连/断连。
Fast Open 引发兼容问题
内网/同城自控链路可以尝试;公网或存在中间盒(FW/IPS)时,谨慎开启并观察重传与 RST。
SELinux / firewalld
Nginx/自研服务新增端口别忘了 semanage port -a。压力时防火墙 conntrack 可能成为瓶颈,策略要事先评估。
Step 10:容量预估的小算术(工程直觉)
粗粒度估:空闲 TCP 连接内核态开销 ~2–4 KB/conn(随内核与选项波动)。
100 万连接 ≈ 2–4 GB。加上应用态结构(连接对象、缓冲),预留 8–12 GB 更安全。
FD 表、slab、socket 缓冲(tcp_rmem/wmem 的实际水位)都会“叠罗汉”。
这也是我把机器内存留 20% 余量的原因:给内核“自由呼吸”。
Step 11:一页纸清单(交给夜班同事也能用)
- limits.d / systemd LimitNOFILE / file-max 对齐到 1M+。
- somaxconn/tcp_max_syn_backlog/netdev_max_backlog 调到位。
- 网卡队列、IRQ 绑核、RPS/RFS、XPS、GRO/LRO 配平。
- 应用:per-CPU listener(SO_REUSEPORT) + per-CPU epoll。
- 压测:tcpkali 建连能力、wrk 延迟与吞吐;监控软中断和丢包。
- 观察:ListenOverflows、SYNs to LISTEN dropped、ksoftirqd、EMFILE。
- 回退预案:关闭 Fast Open、调低 rps_flow_cnt、回滚绑核策略。
结尾:当图线回绿
凌晨 3 点 40 分,监控面板上“红的地方”一块块淡下去。并发破百万那一刻,CPU 曲线没有再冲高,listen 队列稳稳的,网卡统计干净。我把最后一口冷掉的咖啡喝完,在机房里走了一圈,把每台服务器的状态灯都看了个遍。
这不是一篇“漂亮的理论”,而是我在香港机房里把 epoll、SO_REUSEPORT、内核网络栈和硬件特性揉在一起的现场记录。如果你也在为百万级连接发愁,先把这份清单走一遍——你大概率会像我一样,听到风扇声从嘶吼变成了均匀的白噪音。
附:快速回滚与排障命令集合
# 回滚 sysctl(按文件)
sysctl -w net.core.somaxconn=128
sysctl -w net.core.netdev_max_backlog=1000
# ...
# 查看 backlog/丢弃
netstat -s | egrep 'listen|SYN|backlog|drops'
cat /proc/net/netstat | egrep 'ListenOverflows|ListenDrops'
# 连接与端口
ss -s
ss -ant state established | wc -l
# 网卡/队列
ethtool -S eth0 | egrep 'rx_*_drops|tx_*_drops|miss'
grep eth0 /proc/interrupts
# 软中断与 CPU
top -H -p $(pidof gateway)
pidstat -w 1
最后一句老话:优化是“场景 + 度量 + 迭代”,没有放之四海而皆准的神值。本文给的是一套实战参数与方法论,你的现场、你的业务,调出来的那个“甜点”,一定有你自己的刻度。祝你夜里也能把图线拉回绿。