如何在香港服务器的 CentOS 7.9 中,用 epoll 把大型 MMORPG 的长连接性能“拧到顶”

凌晨 3:10,我在香港葵涌机房的走道里,被一串“连接抖动”的短信震醒。手游的新大区刚开,在线数顶上来后,玩家大厅开始小批量掉线——不是全线崩,是那种让你“又急又拿不准”的毛病:心跳延迟偶发飙高、重连窗口变窄、服务端 CPU 局部打满。
我把手里的咖啡放在冷气出风口上,笔电连上跳线架,第一眼看到 SYN backlog 偶发溢出,ss -s 显示 time-wait、estab 曲线异常锯齿,业务线程时不时被唤醒一大波空事件——典型的“事件循环没用对”。我们要把 epoll 重新打磨到位,否则撑不过周末的自然流量峰。
下面是那天夜里,我从底到上,全链路把长连接性能拧到顶的完整过程。
基础环境与目标
机房与硬件(单台网关服 / 网关分片的一致配置)
| 类别 | 参数 |
|---|---|
| 位置 | 香港葵涌,BGP 多线(CN2/GIA/本地递延路由混布) |
| 服务器 | 2× Intel Xeon Silver 4210R(10C20T)/ 或 1× AMD EPYC 7302P(16C) |
| 内存 | 128 GB DDR4 |
| 系统盘 | 2× NVMe (RAID1),用于日志和本地队列 |
| 网卡 | Intel X710 10GbE,RSS 开启 |
| OS | CentOS Linux release 7.9 (3.10.0-1160.*) |
| glibc / gcc | glibc 2.17;gcc 使用 devtoolset-9(GCC 9) |
| 业务类型 | MMORPG 长连接(自研二进制协议,TCP) |
注:CentOS 7.9 自带内核 3.10 系列,不带 BBR,但 SO_REUSEPORT、TCP_DEFER_ACCEPT、TCP_FASTOPEN(需要内核与 sysctl 同时开启)可用。
性能目标(单台)
- 8 万长连接稳态(轻负载)/ 5~6 万中高负载(带大厅广播/组队/匹配)
- p99 业务“帧”延迟 < 35 ms(港区内用户)
- CPU 总体 < 60%;内存 socket 缓冲占用 < 60 GB
- SYN backlog 无溢出,time_wait 有序收敛
优化前基线(节选)
| 指标 | 数值(问题时段) |
|---|---|
| 并发连接(estab) | ~30k |
| p99 心跳 RTT | 120~160 ms(抖) |
| CPU | 局部核 85%(系统态抖动明显) |
| 丢连接 | 0.3% 峰值(5 分钟窗口) |
| 现象 | SYN backlog 偶发溢出、time_wait 尖峰、worker 醒来全是空读 |
架构与设计取舍:为什么是 epoll + 多进程 REUSEPORT
- 多进程 + SO_REUSEPORT:在 3.10 内核上没有 EPOLLEXCLUSIVE,为了避免 thundering herd,直接用 N 个监听进程(N≈物理核),每个进程一个 accept4();内核做五元组分流,不抢锁。
- 每进程一个 epoll,ET(边沿触发)+ 非阻塞 + EPOLLONESHOT:事件交给工作线程处理后手动重置兴趣列表,保证同一 fd 只被一个线程处理。
- 应用层批处理:对小包频繁的 MMO 心跳、状态同步,应用层合包(如 1ms/2ms 小批)+ writev,而非完全依赖 TCP 的 Nagle。
- 定时器:timerfd + 最小堆/分层时间轮做心跳与超时,避免使用 per-connection 定时器的昂贵维护。
- 零拷贝:业务是小包,不追求 sendfile;更关注 收发环节的“读到 EAGAIN 为止” 与写队列回压。
系统调优(CentOS 7.9)
文件句柄与服务限制
# /etc/security/limits.d/game.conf
* soft nofile 1048576
* hard nofile 1048576
* soft nproc 65536
* hard nproc 65536
# systemd 服务单元节选
[Service]
LimitNOFILE=1048576
LimitNPROC=65536
sysctl 网络栈(实测可落地值)
# /etc/sysctl.d/99-game.conf
# 队列与积压
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 250000
# TCP 缓冲自动调优(上限控制,避免内存被吃爆)
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
# SYN / 握手
net.ipv4.tcp_max_syn_backlog = 262144
net.ipv4.tcp_synack_retries = 3
net.ipv4.tcp_syncookies = 1
# TIME-WAIT 管理(避免“回收”导致 NAT 客户端异常)
net.ipv4.tcp_tw_reuse = 1
# 千万不要启用 tcp_tw_recycle(已被移除/会害死 NAT 客户端)
# 快速打开(内核与应用都需开启)
net.ipv4.tcp_fastopen = 3
# keepalive(配合移动网 NAT 空闲回收)
net.ipv4.tcp_keepalive_time = 120
net.ipv4.tcp_keepalive_intvl = 15
net.ipv4.tcp_keepalive_probes = 4
# 端口与 PMTU
net.ipv4.ip_local_port_range = 10000 65535
net.ipv4.tcp_mtu_probing = 1
说明:香港到内地/东南亚用户跨运营商路由多、NAT 层多,保持较短的 keepalive 可更快发现“静默断链”。tcp_mtu_probing=1 能救回部分碎片化/ICMP 被阻拦导致的低速问题。
网卡与中断
# 查看与设置 RSS 队列数(示例:8)
ethtool -l eth0
ethtool -L eth0 combined 8
# 关闭大包分段会降低 CPU,但 MMO 小包场景更关注延迟,可按需保留
ethtool -K eth0 gro on gso on tso on
# 将 RX 队列中断绑核(避免抖动,可结合 irqbalance)
grep eth0 /proc/interrupts
# 然后对每个中断号写 /proc/irq/IRQNUM/smp_affinity_list
echo 0-7 > /proc/irq/XX/smp_affinity_list
监听与 Socket 选项
关键在监听 socket与新连接 socket的配置。
int s = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
int v = 1;
setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &v, sizeof(v));
setsockopt(s, SOL_SOCKET, SO_REUSEPORT, &v, sizeof(v)); // 多进程分流关键
int qlen = 1; // 秒
setsockopt(s, IPPROTO_TCP, TCP_DEFER_ACCEPT, &qlen, sizeof(qlen)); // 直到有数据才唤醒 accept
int q = 1;
setsockopt(s, IPPROTO_TCP, TCP_FASTOPEN, &q, sizeof(q)); // 配合 sysctl 的 tcp_fastopen
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons(9000);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(s, (struct sockaddr*)&addr, sizeof(addr));
// backlog 够大 + somaxconn 已提高
listen(s, 65535);
对已建立连接:
int fd = accept4(lsn, &cliaddr, &len, SOCK_NONBLOCK | SOCK_CLOEXEC);
int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)); // 禁 Nagle,应用层自己合包
int keepalive = 1;
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive));
int ka_time = 120, ka_intvl = 15, ka_probes = 4;
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &ka_time, sizeof(ka_time));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &ka_intvl, sizeof(ka_intvl));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &ka_probes, sizeof(ka_probes));
/* 可按需调缓冲,通常让 autotune 工作即可:
int snd = 256*1024, rcv = 256*1024;
setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &snd, sizeof(snd));
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &rcv, sizeof(rcv));
*/
epoll 服务骨架(可直接跑的“骨头”)
下面是一个可编译的极简骨架,用的是 ET + EPOLLONESHOT,writev 合并小包,timerfd 做心跳/超时。你可以据此替换协议编解码与业务回调。
说明:为篇幅可读性,省去日志与错误处理细枝末节,但核心控制流与“读到 EAGAIN 为止”的要点都在。
// build: gcc -O3 -pthread -Wall ep_game.c -o ep_game
#define _GNU_SOURCE
#include <sys/epoll.h>
#include <sys/timerfd.h>
#include <sys/eventfd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/uio.h>
#define MAX_EVENTS 4096
#define READ_BUF (64*1024)
static int add_ep(int ep, int fd, uint32_t ev) {
struct epoll_event e = {.events = ev, .data.fd = fd};
return epoll_ctl(ep, EPOLL_CTL_ADD, fd, &e);
}
static int mod_ep(int ep, int fd, uint32_t ev) {
struct epoll_event e = {.events = ev, .data.fd = fd};
return epoll_ctl(ep, EPOLL_CTL_MOD, fd, &e);
}
static int set_nonblock(int fd){
int f = fcntl(fd, F_GETFL, 0);
return fcntl(fd, F_SETFL, f | O_NONBLOCK);
}
struct conn {
int fd;
char rbuf[READ_BUF];
size_t rlen;
// 简化:写队列只做一个小缓存与 writev 向量
char wsmall[READ_BUF];
size_t wlen;
};
static void on_close(int ep, struct conn* c){
if(c->fd>0) close(c->fd);
c->fd = -1;
}
static int on_read(int ep, struct conn* c){
for(;;){
ssize_t n = read(c->fd, c->rbuf + c->rlen, sizeof(c->rbuf) - c->rlen);
if(n > 0){
c->rlen += n;
// 业务协议:假设 [len(2B)|payload] 简单帧
size_t off = 0;
while(c->rlen - off >= 2){
unsigned short plen = (unsigned char)c->rbuf[off] << 8 |
(unsigned char)c->rbuf[off+1];
if(c->rlen - off < 2 + plen) break;
// 这里调用业务处理 payload: c->rbuf+off+2, plen
// 业务回包写入 c->wsmall(示例,真实应有队列/环形缓冲)
const char pong[] = {0, 1, 0x7f}; // 假装 1 字节 payload: 0x7f
if(sizeof(pong) + c->wlen <= sizeof(c->wsmall)){
memcpy(c->wsmall + c->wlen, pong, sizeof(pong));
c->wlen += sizeof(pong);
}
off += 2 + plen;
}
if(off){
memmove(c->rbuf, c->rbuf + off, c->rlen - off);
c->rlen -= off;
}
} else if(n == 0){
return -1; // 对端关闭
} else {
if(errno == EAGAIN || errno == EWOULDBLOCK) break;
if(errno == EINTR) continue;
return -1; // 其他错误
}
}
// 若有可写数据,注册写事件
uint32_t ev = EPOLLIN | EPOLLET | EPOLLONESHOT;
if(c->wlen) ev |= EPOLLOUT;
mod_ep(ep, c->fd, ev);
return 0;
}
static int on_write(int ep, struct conn* c){
while(c->wlen){
struct iovec iov[1] = {{.iov_base = c->wsmall, .iov_len = c->wlen}};
ssize_t n = writev(c->fd, iov, 1);
if(n > 0){
if((size_t)n < c->wlen){
memmove(c->wsmall, c->wsmall + n, c->wlen - n);
}
c->wlen -= n;
} else {
if(errno == EAGAIN || errno == EWOULDBLOCK) break;
if(errno == EINTR) continue;
return -1;
}
}
uint32_t ev = EPOLLIN | EPOLLET | EPOLLONESHOT;
if(c->wlen) ev |= EPOLLOUT;
mod_ep(ep, c->fd, ev);
return 0;
}
int main(){
// 监听
int lsn = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
int v = 1;
setsockopt(lsn, SOL_SOCKET, SO_REUSEADDR, &v, sizeof(v));
setsockopt(lsn, SOL_SOCKET, SO_REUSEPORT, &v, sizeof(v));
int qlen = 1; setsockopt(lsn, IPPROTO_TCP, TCP_DEFER_ACCEPT, &qlen, sizeof(qlen));
int q = 1; setsockopt(lsn, IPPROTO_TCP, TCP_FASTOPEN, &q, sizeof(q));
struct sockaddr_in addr = {.sin_family=AF_INET, .sin_port=htons(9000), .sin_addr={htonl(INADDR_ANY)}};
bind(lsn, (struct sockaddr*)&addr, sizeof(addr));
listen(lsn, 65535);
int ep = epoll_create1(EPOLL_CLOEXEC);
add_ep(ep, lsn, EPOLLIN | EPOLLET); // 监听不需要 ONESHOT
// 简陋连接表(生产可换 slab/哈希)
struct conn* conns = calloc(200000, sizeof(struct conn));
struct epoll_event evs[MAX_EVENTS];
while(1){
int n = epoll_wait(ep, evs, MAX_EVENTS, 1000);
for(int i=0;i<n;i++){
int fd = evs[i].data.fd;
uint32_t ev = evs[i].events;
if(fd == lsn){
// accept 直到 EAGAIN(ET 必须)
for(;;){
struct sockaddr_in cli; socklen_t len = sizeof(cli);
int cfd = accept4(lsn, (struct sockaddr*)&cli, &len, SOCK_NONBLOCK|SOCK_CLOEXEC);
if(cfd < 0){
if(errno==EAGAIN || errno==EWOULDBLOCK) break;
if(errno==EINTR) continue;
break;
}
int one=1;
setsockopt(cfd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
setsockopt(cfd, SOL_SOCKET, SO_KEEPALIVE, &one, sizeof(one));
conns[cfd].fd = cfd;
conns[cfd].rlen = conns[cfd].wlen = 0;
add_ep(ep, cfd, EPOLLIN | EPOLLET | EPOLLONESHOT);
}
}else{
struct conn* c = &conns[fd];
if(ev & (EPOLLHUP|EPOLLERR)) { on_close(ep, c); continue; }
if((ev & EPOLLIN) && on_read(ep, c) < 0){ on_close(ep, c); continue; }
if((ev & EPOLLOUT) && on_write(ep, c) < 0){ on_close(ep, c); continue; }
}
}
}
return 0;
}
关键点
- ET 下读写必须循环到 EAGAIN。
- 用 EPOLLONESHOT 避免同一 fd 被多线程并发处理;处理完成后 mod_ep() 重新武装事件。
- 粘包/拆包:示例里用 2 字节长度前缀,实际 MMO 协议请替换为你的帧格式与编解码。
- 写队列要能承压:生产上请用无锁环形缓冲 + writev,并在发送满时把 fd 从 EPOLLOUT 中摘掉,待缓冲回落再重挂。
进程模型与 CPU 亲和
master 拉起 N 个 worker(N≈物理核/NUMA node 维度),每个 worker:
- 自己 bind()+listen() 同一端口,SO_REUSEPORT=1。
- 设置亲和:taskset -c 0-7 ./gateway_worker。
- 每个 worker 一个独立 epoll,避免跨核共享。
NUMA 机器(如 EPYC):尽量绑核 + 本地内存;可用 numactl --cpunodebind=0 --membind=0。
部署步骤(CentOS 7.9)
# 构建链升级
yum install -y centos-release-scl
yum install -y devtoolset-9-gcc devtoolset-9-gcc-c++ make
scl enable devtoolset-9 bash # 进入带 GCC9 的 shell
# 常用工具
yum install -y epel-release
yum install -y htop iotop perf iftop numactl ethtool jq
# 编译
gcc -O3 -pipe -fno-omit-frame-pointer -march=native ep_game.c -o ep_game -pthread
systemd 单元(节选):
[Unit]
Description=MMO Gateway Worker
After=network-online.target
[Service]
ExecStart=/usr/local/game/ep_game
Restart=always
RestartSec=1
LimitNOFILE=1048576
# 绑核示例(8 个实例用 template 单元更优)
CPUAffinity=0 1 2 3 4 5 6 7
[Install]
WantedBy=multi-user.target
压测与观测
工具与方法
- 连接压测:tcpcopy / 自研连接枪 + 城域/跨境双点发起(模拟 NAT、移动网抖动)。
- 系统观测:ss -s、/proc/net/sockstat、sar -n TCP,DEV 1、top/htop、perf top。
- 协议观测:业务层埋点(心跳 RTT、丢包重传指标),ngrep/tcpdump 抽样。
优化后数据(同一时段、同样业务流)
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 并发连接(estab) | ~30k | ~82k(轻负载)/ ~58k(中高负载) |
| p99 心跳 RTT | 120~160 ms | 22~28 ms |
| CPU(总/热点核) | 55% / 85% 抖 | 38% / 62% 稳 |
| 丢连接(5 分钟窗) | 0.3% | < 0.05% |
SYN backlog 溢出 |
偶发 | 无 |
time_wait 尖峰 |
有 | 平滑 |
现场踩过的坑 & 解法
ET 忘了“读到 EAGAIN”
表现:事件风暴 + 空醒。
解决:严格 while(read(...)) + EAGAIN break;写同理。
tcp_tw_recycle 的幻觉
有人建议“开回收更少 TIME_WAIT”。实际这是历史坑,对 NAT 客户端致命,且已在新内核移除。
取舍:只开 tcp_tw_reuse=1,让主动关闭端重用;辅以负载均衡层做四层会话保持。
EPOLLRDHUP 没处理
移动网切到 4G/5G 或 Wi-Fi,半关闭状态没及时 close,造成僵尸连接。
解法:监听并处理 EPOLLRDHUP,优先读 0 / 关闭。
EPOLL_CTL_MOD 丢了 EPOLLONESHOT
开发同学在 mod_ep() 时少带 EPOLLONESHOT,导致同一 fd 被重复调度。
规范:封装 mod_ep(),统一带旗标。
写队列回压没有摘 EPOLLOUT
导致空转。
解法:只有在 write 返回 EAGAIN 时挂 EPOLLOUT;缓冲可写再重挂读。
TCP_DEFER_ACCEPT 设太大
新区高并发登录时,握手后迟迟不 accept,玩家体感“卡登录”。
经验:1~2 秒比较稳,过大适得其反。
TFO(TCP Fast Open)误期望
有助减少往返,但并非所有链路/中间设备都乖。
策略:灰度开启、观测 SYN data 的失败率;失败回落传统握手。
RSS/中断未绑核
导致每逢尖峰就cache 竞争。
解法:ethtool 配 RSS 队列,irq 绑核;worker 与 RX 队列核位对齐。
和业务结合:小包合并与心跳策略
- 应用层合包:12 ms 的微批,writev 把多个 2080B 的小包凑成 1~2KB;延迟可控、QPS 更稳。
- 心跳:服务器 30s、客户端 30~60s,不同网络条件可动态协商;服务端 2 个心跳周期未收到即踢。
- 重连窗口:建议 3~5 分钟;结合网关分区路由表(一致性哈希)确保重连落回原进程,降低状态迁移成本。
负载均衡与拓扑(简述)
- 入口:Anycast/BGP or DNS + L4(如 HAProxy/Tengine stream)
- 策略:四层一致性哈希(五元组/用户 ID),避免会话漂移。
- conntrack:L4 尽量关闭 conntrack,减少无谓消耗。
观测指标(建议至少这些)
- 系统:CPU(核级)、runq、软中断、上下文切换、网络吞吐、丢包。
- TCP:SYN backlog、retrans、estab/timewait、orphan。
- 业务:在线数、p50/p99 RTT、断开原因码、重连成功率、写队列长度分布。
- epoll:事件数/循环耗时、空事件比、EAGAIN 命中率。
凌晨 5 点,p99 心跳线从 120ms 掉到 25ms 之后,就再也没上去。走到窗边能看到维港那条淡淡的亮线,空调风还是冷,但心里热了:这套在 CentOS 7.9 上的 epoll + REUSEPORT 长连接方案,能扛住我们这款 MMO 的上线潮水——并且是可复制的。
后来有同事问我,“要不要换新内核上 BBR?”我说当然可以,但在你把事件模型、队列回压、RSS/中断、sysctl 这些底座垫实之前,换内核只是锦上添花。那一晚,我更想记录下“把老内核榨干”这件事本身——因为它能让你在资源有限、时间有限时,仍然把系统打磨得足够漂亮。
清单汇总(可对照落地)
sysctl(建议初始值)
| 键 | 值 |
|---|---|
| net.core.somaxconn | 65535 |
| net.core.netdev_max_backlog | 250000 |
| net.ipv4.tcp_max_syn_backlog | 262144 |
| net.ipv4.tcp_fastopen | 3 |
| net.ipv4.tcp_tw_reuse | 1 |
| net.ipv4.tcp_keepalive_time/intvl/probes | 120 / 15 / 4 |
| net.ipv4.tcp_rmem | 4096 87380 16777216 |
| net.ipv4.tcp_wmem | 4096 65536 16777216 |
| net.core.rmem_max / wmem_max | 16777216 / 16777216 |
| net.ipv4.ip_local_port_range | 10000 65535 |
| net.ipv4.tcp_mtu_probing | 1 |
监听/连接选项
- 监听:SO_REUSEADDR、SO_REUSEPORT、TCP_DEFER_ACCEPT=1、TCP_FASTOPEN=1
- 连接:TCP_NODELAY=1、SO_KEEPALIVE=1(配合 keepalive sysctl)
- 读写:ET+非阻塞,读/写到 EAGAIN,EPOLLONESHOT 配合 mod_ep() 复武装
运行时要点
- 多进程 SO_REUSEPORT,每进程单 epoll,绑核
- RSS/中断与 worker 对齐
- 写队列回压:只在 EAGAIN 时挂 EPOLLOUT
- 应用层小包合并 + writev
- 定时器用 timerfd + 时间轮/小根堆
如果你要把这套方案搬到你的网关服或长连接大厅,先用我的 sysctl、socket 选项、骨架代码跑一版压测,把“读到 EAGAIN”与“回压摘挂”这些关键手势做顺,再去替换协议与业务回调。等你看到 p99 线往下掉、CPU 峰值不再乱跳的时候,你会明白——epoll 不可怕,可怕的是没用对。祝你线上稳如磐石