香港服务器运行CentOS 7时,如何配置epoll与SO_REUSEPORT,提升MMORPG类游戏的长连接并发?

香港葵涌的机房里的监控墙上那条TCP 连接建立耗时的曲线,在压测的最后 10 分钟突然上扬,端口 backlog 占用也开始贴着天花板。上一轮版本我们还只用单进程 accept + 线程池,accept 惊群和连接分配不均问题被我们用一堆土法子压着。但这次玩家预约量翻倍,再也糊弄不过去了。
我把计划摊在工作台上:改成多进程 + SO_REUSEPORT + epoll(ET/ONESHOT),配套把 CentOS 7 的网络栈真正掰开揉碎地调一遍。做完一轮小流量试运行后,再冲一次大压测。做得成,今晚就能睡;做不好,天亮之前别想走。
硬件与系统画像(香港节点)
| 项目 | 配置 |
|---|---|
| 机型 | 2U 单路/双路标准机(我这台是双路) |
| CPU | 2 × Intel Xeon Silver 4210(10C/20T,2.2–3.2GHz) |
| 内存 | 128 GB DDR4 |
| 存储 | 系统盘 SATA SSD 480 GB;日志盘 NVMe 1 TB |
| 网卡 | 2 × 10GbE(Intel X710),万兆接入 |
| 操作系统 | CentOS Linux release 7.9.2009(3.10 内核) |
| GLIBC / 工具链 | gcc 7(DevToolset),glibc 2.17 |
| 业务类型 | MMORPG 长连接(TCP),自研二进制协议,网关转发到后端逻辑服 |
拓扑:Internet → (Anycast LB) → [网关服(本文主角)] → 逻辑服/地图服
目标:单机在线 10 万稳定、握手峰值 4 万 QPS、消息 P99 延迟 < 12 ms
基线与问题定位(改造前)
压测 30 分钟基线数据(单机,单进程 + 线程池):
| 指标 | 数值(Before) |
|---|---|
| 建连吞吐 | ~18k QPS(峰值抖动) |
| 稳态在线 | ~55k |
| accept 失败率 | 0.8%(ECONNABORTED/EMFILE 间歇出现) |
| listen backlog 占用 | 90%+ 频繁贴顶 |
| CPU | sys ~35%,softirq ~12%,user ~28%,steal ~0 |
| P99 延迟(64B 小包) | 22–28 ms |
内核丢包(netstat -s) |
偶发 TCP 挤压/重传上升 |
| 主要报警 | 队列拥塞、逻辑服入向突发放大、线程池上下文切换高 |
症状根因(典型):
- 单 listen socket + 多线程 accept,惊群;
- 连接分配不均,部分工作线程压力过高;
- backlog/accept 队列配置偏小;
- ulimit 与 systemd 限制导致 EMFILE;
- keepalive/NAT 空闲回收不匹配,连接尸体积累。
总体方案
多进程(与 NIC/RSS 队列数对齐):每个进程独立 socket() + bind() + listen(),全部设置 SO_REUSEPORT,由内核在同端口上的多个监听套接字间做哈希/均衡分配,避免 accept 惊群与锁竞争。
每进程 epoll(ET + ONESHOT):边沿触发减少无谓唤醒,ONESHOT 防止并发读写导致的竞态;用户层做 read/write 自旋和小缓冲池。
系统调优:FD、backlog、syn backlog、somaxconn、netdev_max_backlog、rmem/wmem、tcp_rmem/tcp_wmem、tcp_tw_reuse 等;结合 NIC 队列、RPS/RFS、IRQ 亲和与 CPU 绑核。
连接生存:TCP_NODELAY + 应用层心跳(15–20s),同时合理的 tcp_keepalive_*;针对港区部分运营商 NAT 空闲策略做冗余。
部署:systemd unit + LimitNOFILE、TasksMax、CPUAffinity;滚动灰度 + 指标门槛。
CentOS 7 内核与系统参数调优
以下参数在 /etc/sysctl.d/99-game.conf,改完 sysctl --system;注释里写的是“为什么”。
# 1) 提升全局队列容量与收包能力
net.core.somaxconn = 65535 # listen 的上限(应用 listen(backlog) 仍需配合)
net.core.netdev_max_backlog = 250000 # NIC 收包软队列上限,突发流量不掉队
net.core.rmem_max = 268435456 # 256MB
net.core.wmem_max = 268435456
# 2) TCP 层细项
net.ipv4.tcp_max_syn_backlog = 262144 # 半连接队列,抗瞬时建连峰值
net.ipv4.tcp_fin_timeout = 10 # 快速回收 FIN-WAIT-2,长连场景保守调低
net.ipv4.tcp_tw_reuse = 1 # TIME-WAIT 重用(仅主动发起端显著,网关也有上游主动连接)
net.ipv4.tcp_syncookies = 1 # 防 SYN flood(有些 LB 层也会挡,仍建议开)
net.ipv4.tcp_timestamps = 1 # RTT 估计更稳,代价是每包 12B 选项
net.ipv4.tcp_slow_start_after_idle = 0 # 长连接恢复阶段更平滑
net.ipv4.ip_local_port_range = 10000 65535 # 本机向后端主动连的端口范围
# 3) keepalive:长连接配合 NAT
net.ipv4.tcp_keepalive_time = 600 # 10 分钟,仅作兜底;主心跳还是应用层 15–20s
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 5
# 4) 拒绝溢出时行为
net.ipv4.tcp_abort_on_overflow = 0 # 溢出丢弃而非 RST,更友好
文件描述符与进程限制
/etc/security/limits.d/99-game.conf:
* soft nofile 1048576
* hard nofile 1048576
* soft nproc 512000
* hard nproc 512000
systemd Unit 内部(后面给完整示例):LimitNOFILE=1048576 TasksMax=infinity
NIC/RSS/RPS
将 X710 的队列数设为与 CPU/进程数匹配(示例:8 队列):
ethtool -l eth0 # 看队列能力
ethtool -L eth0 combined 8 # 调整为 8 队列
增大环形缓冲:
ethtool -G eth0 rx 4096 tx 4096
将中断亲和绑核(避免与 User 进程同核抢):
irqbalance 开启基础均衡,再手动用 echo <mask> > /proc/irq/<irq>/smp_affinity 将 NIC 中断绑在 0–7 号核,应用绑在 8–19 号核(示例)。
SO_REUSEPORT + epoll(ET/ONESHOT)实现范式
1)多进程监听与负载均衡
思路:启动 N 个进程(一般等于 NIC 队列数或物理核数的 1/2~1 倍),每个进程都在同一个 ip:port 上 bind() + listen(),必须在 bind() 前对 监听套接字设置 SO_REUSEPORT。内核会把新连接分散到不同进程的监听队列里,天生避免 accept 锁竞争与惊群。
细节:同组 SO_REUSEPORT 的套接字,套接字选项要一致(例如 SO_KEEPALIVE、TCP_DEFER_ACCEPT 等),否则加入组会失败或行为异常。
2)epoll 事件模型
监听 fd:EPOLLIN(水平触发即可);
客户端 fd:EPOLLIN | EPOLLET | EPOLLONESHOT。
ET 减少重复唤醒;
ONESHOT 防止同一 fd 被多个线程同时处理;处理完后 epoll_ctl(EPOLL_CTL_MOD) 重新上枪。
3)关键套接字选项
监听 fd:SO_REUSEPORT、SO_REUSEADDR、TCP_DEFER_ACCEPT(旧内核行为有限,但在 3.10 也能减少空 accept)、SO_KEEPALIVE;
客户端 fd:TCP_NODELAY、SO_KEEPALIVE、TCP_QUICKACK(按需)、O_NONBLOCK。
4)核心示例(可编译运行的骨架)
文件:gameserver_epoll_reuseport.c(CentOS 7,gcc 7+)
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/tcp.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/resource.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#define MAX_EVENTS 4096
#define LISTEN_BACKLOG 65535
#define WORKERS 8 // 按需调整:与队列/核数匹配
#define BUF_SIZE 4096
static int set_nonblock(int fd){
int flags = fcntl(fd, F_GETFL, 0);
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
static void set_common_opts(int fd){
int one = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &one, sizeof(one));
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
}
static int create_listen(const char* ip, uint16_t port){
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) { perror("socket"); exit(1); }
int one = 1;
// 关键:bind 之前
if (setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)) < 0) {
perror("setsockopt SO_REUSEPORT");
exit(1);
}
set_common_opts(fd);
struct sockaddr_in addr; memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
inet_pton(AF_INET, ip, &addr.sin_addr);
if (bind(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind"); exit(1);
}
if (listen(fd, LISTEN_BACKLOG) < 0) {
perror("listen"); exit(1);
}
set_nonblock(fd);
return fd;
}
static void handle_client(int ep, int cfd){
char buf[BUF_SIZE];
for(;;){
ssize_t n = read(cfd, buf, sizeof(buf));
if (n > 0){
// 这里做协议解析/转发,示例回显
ssize_t w = 0;
while (w < n){
ssize_t m = write(cfd, buf + w, n - w);
if (m > 0) w += m;
else if (m < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) break;
else { close(cfd); return; }
}
} else if (n == 0){
// 对端关闭
close(cfd); return;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
close(cfd); return;
}
}
// 处理完成,重新上枪(ONESHOT)
struct epoll_event ev = { .events = EPOLLIN | EPOLLET | EPOLLONESHOT, .data.fd = cfd };
epoll_ctl(ep, EPOLL_CTL_MOD, cfd, &ev);
}
static void run_worker(const char* ip, uint16_t port, int wid){
// 每个进程各自 listen(SO_REUSEPORT 让内核分流)
int lfd = create_listen(ip, port);
int ep = epoll_create1(EPOLL_CLOEXEC);
if (ep < 0) { perror("epoll_create1"); exit(1); }
struct epoll_event ev = { .events = EPOLLIN, .data.fd = lfd };
epoll_ctl(ep, EPOLL_CTL_ADD, lfd, &ev);
struct epoll_event events[MAX_EVENTS];
printf("[W%02d] listening on %s:%u (fd=%d)\n", wid, ip, port, lfd);
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;
if (fd == lfd){
// ET/水平均可,这里每次把 accept 队列清空
for(;;){
struct sockaddr_in caddr; socklen_t clen = sizeof(caddr);
int cfd = accept4(lfd, (struct sockaddr*)&caddr, &clen, SOCK_NONBLOCK);
if (cfd < 0){
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
else { perror("accept4"); break; }
}
set_common_opts(cfd);
struct epoll_event cev = { .events = EPOLLIN | EPOLLET | EPOLLONESHOT, .data.fd = cfd };
epoll_ctl(ep, EPOLL_CTL_ADD, cfd, &cev);
}
} else {
handle_client(ep, fd);
}
}
}
close(ep); close(lfd);
}
int main(int argc, char** argv){
const char* ip = (argc > 1 ? argv[1] : "0.0.0.0");
uint16_t port = (argc > 2 ? (uint16_t)atoi(argv[2]) : 19000);
int workers = (argc > 3 ? atoi(argv[3]) : WORKERS);
struct rlimit rl = { .rlim_cur = 1048576, .rlim_max = 1048576 };
setrlimit(RLIMIT_NOFILE, &rl);
signal(SIGPIPE, SIG_IGN);
for (int i = 0; i < workers; ++i){
pid_t pid = fork();
if (pid == 0){
// 子进程:可选绑核
cpu_set_t set; CPU_ZERO(&set);
CPU_SET(i % sysconf(_SC_NPROCESSORS_ONLN), &set);
sched_setaffinity(0, sizeof(set), &set);
run_worker(ip, port, i);
exit(0);
}
}
// 父进程:等待子进程(简化处理)
while (wait(NULL) > 0);
return 0;
}
编译(CentOS 7):
gcc -O2 -pipe -march=native -D_GNU_SOURCE -pthread gameserver_epoll_reuseport.c -o gameserver
systemd 部署与资源绑定
/etc/systemd/system/gameserver.service
[Unit]
Description=MMORPG Gateway (epoll + SO_REUSEPORT)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=game
Group=game
ExecStart=/opt/game/bin/gameserver 0.0.0.0 19000 8
Restart=always
RestartSec=2
# 资源与限制
LimitNOFILE=1048576
TasksMax=infinity
# 绑核(与 IRQ 分离),示例绑第 8-15 号核
CPUAffinity=8 9 10 11 12 13 14 15
# 环境变量(按需)
Environment=GOGC=off
[Install]
WantedBy=multi-user.target
启动:
systemctl daemon-reload
systemctl enable --now gameserver
压测方法与观测点
压测工具
我们用自研 TCP 压测器(多机多进程),模拟握手洪峰与真实心跳/小包;你也可用 Tsung/Moongen/自写 Go/C 压测器。
关键观测
- ss -lptn 'sport = :19000':看 Send-Q/Recv-Q、拥塞;
- netstat -s / cat /proc/net/netstat:TCP 统计;
- sar -n TCP,DEV 1:TCP 活动与网卡收发;
- top/perf/pidstat -w:上下文切换、软中断;
- 应用自埋点:握手耗时、在线数、消息 P50/P90/P99、丢包/重传率。
结果(改造后)
30 分钟压测(多进程 + SO_REUSEPORT + epoll ET/ONESHOT + 系统调优):
| 指标 | Before | After |
|---|---|---|
| 建连吞吐(峰值) | ~18k QPS | ~41k QPS |
| 稳态在线 | ~55k | ~112k |
| accept 失败率 | 0.8% | < 0.05% |
| backlog 占用 | 90%+ | < 60%(峰时 < 80%) |
| P99 延迟(64B) | 22–28 ms | 8–11 ms |
| CPU(user/sys/softirq) | 28/35/12% | 34/24/9%(锁争用下降) |
| kernel 重传/丢包 | 偶发上升 | 显著下降 |
肉眼可见:监听分流均衡、线程/进程间负载更均匀,尾延迟收敛,突发握手时不再“顶天”。
坑与解决
SO_REUSEPORT 组内套接字选项不一致
现象:某些进程几乎无连接;
解决:确保所有监听 socket 在 bind() 前设置相同选项(SO_REUSEPORT/SO_REUSEADDR/SO_KEEPALIVE/TCP_DEFER_ACCEPT 等)。我最后把“创建监听 + 设置选项”封装在同一函数里,防止遗漏。
systemd 限制没放开,出现 EMFILE
现象:压测 10 分钟后 sporadic EMFILE;
解决:LimitNOFILE=1048576 + 进程内 setrlimit() 双保险;同时监控 lsof | wc -l。
accept 惊群残留(误把 SO_REUSEPORT 只设在客户端 fd)
现象:依旧有 accept 高抖动;
解决:必须在监听 fd 上设,且在 bind() 之前。
EPOLLET 下未“读空”导致饿死
现象:偶发链接卡顿、事件不再触发;
解决:read() 循环直到 EAGAIN,并在 ONESHOT 语义下显式 rearm。
NAT 空闲回收
现象:部分用户 2–5 分钟静默后断开;
解决:应用层心跳每 15–20 秒一次,小包;同时把 tcp_keepalive_time=600 作兜底,不依赖内核 keepalive。
NIC 中断与用户进程抢核
现象:softirq 飙升、尾延迟抖;
解决:IRQ 绑到低号核,CPUAffinity 把工作进程绑到高号核;RPS 开启但别过激(过多反而增加 cache miss)。
listen backlog 数值误解
现象:listen(fd, 65535) 但实际队列仍小;
解决:确认 net.core.somaxconn 已提升;同时关注 tcp_max_syn_backlog,半连接队列别忽略。
Conntrack 压力(如果经过本机 iptables/nat)
现象:nf_conntrack: table full,丢包;
解决:增大 nf_conntrack_max 或尽量旁路本机 NAT;长连接场景下,建议减少无谓的表项。
工程化小补丁
日志切分:stdout 走 journald,但关键日志(握手耗时、踢线)落盘到 NVMe,logrotate 按小时切;
健康检查:新增 /healthz TCP 探测端口(独立小监听,低频),避免用业务端口做探活;
优雅重启:父进程接 SIGHUP,平滑拉起新 worker,旧 worker 收到 SIGTERM 停止接新连、等待存量耗尽。
FAQ(我常被问到)
Q1:为什么不用多线程而选择多进程?
A:在 CentOS 7 + 3.10 内核环境,多进程 + SO_REUSEPORT 能天然绕开 accept 锁与调度开销,内核直接把新连接分发到不同监听队列里;崩溃隔离也更好。线程当然能做,但要更小心锁、NUMA 与可预测性。
Q2:EPOLLONESHOT 一定要用吗?
A:高并发小包场景下,它能避免同一 fd 被多个工作单元并发处理的竞态;配合“处理完 rearm”,可控又稳定。
Q3:TCP_NODELAY 会不会放大包量?
A:是的,但 MMORPG 小包本来就要求低尾延迟;我们在协议层做了微批(例如把逻辑帧内多条广播在 1–2ms 内聚合),总体更划算。
Q4:Linux 3.10 的 SO_REUSEPORT 是否公平?
A:相对够用。极端热点流存在倾斜时,可考虑按 CPU 绑进程、结合 SO_INCOMING_CPU(有限支持)或上游 LB 做稳定分配。更高版本内核还能用 BPF 选择器做更细腻路由,CentOS 7 就别折腾了。
可复用清单(上线前 5 分钟自检)
ulimit -n ≥ 1,048,576;systemd LimitNOFILE 与进程内 setrlimit() 到位
somaxconn / tcp_max_syn_backlog / netdev_max_backlog 已生效
监听 fd 在 bind() 前设置 SO_REUSEPORT;监听组内选项一致
epoll 客户端 fd 使用 ET + ONESHOT,处理完 rearm;读写循环到 EAGAIN
NIC 队列与工作进程数匹配;IRQ 与进程 CPU 亲和分离
应用层心跳 15–20s;内核 keepalive 仅兜底
日志切分与磁盘 IO 检查;健康检查端口独立
灰度发布:新旧 worker 并行,平滑接管
曲线回落与门外的天光
再次发起压测时,屏幕上的建连曲线先是稳稳攀升,之后平滑地贴在 4.1 万 QPS 的平台上——没有之前那种锯齿。我和同事对了几组统计:尾延迟压下去了、accept 队列也不 “红” 了。凌晨四点,机房外的天空发白,我把风衣拉上拉链,顺手把这一版配置打了个 tag。
这行的乐趣,大概就在于可解释的稳定:当你能把“为什么快”和“为什么稳”都讲清楚——从 SO_REUSEPORT 的连接分配,到 epoll 的事件边沿,再到每一处内核参数的取舍——你就知道,接下来玩家的嘈杂、节日的洪峰、甚至突如其来的热点,都只是多几条曲线而已,而不是危机。
如果你也正要在香港的机柜里把一台 CentOS 7 网关推向极限,不妨照着上面这张“手术单”来一遍。万一夜深人静还卡住了——把 NIC 队列数、somaxconn、tcp_max_syn_backlog 再看看,别忘了 SO_REUSEPORT 要设在监听 fd 上。剩下的,就交给风扇和图表去歌唱吧。