MMORPG游戏如何在香港服务器的Linux系统中结合epoll与多核CPU,优化海量长连接性能?

凌晨 2:07,葵涌机房 9 楼,空调轰鸣。我们刚把最后一台新网关服上架,跨境用户高峰马上就到。值班小哥递给我一杯温度尴尬的美式,我盯着 ss -s 和 nstat 的输出,心里盘着一个数:单机要抗住 45 万条持久 TCP 长连接,握手+心跳+少量实时同步,还要把 99 线延迟压在 80ms 内(内地用户穿越回国加速专线/公网混部,延迟天生吃亏)。
机器刚接流,softirq 抖得厉害,/proc/interrupts 里网卡队列倾斜到 0 号核,队列丢包(ethtool -S 的 rx_missed_errors)开始冒泡。这就是典型“万箭穿心”的长连接场景:流量不大,连接数极多,内核协议栈和用户态调度若没摆平,多核也白搭。
我把咖啡杯往 KVM 鼠标垫上一放,开始“照方抓药”。
一、目标与场景拆解
业务画像(网关服)
职责:长连接接入(WebSocket/原生 TCP)、心跳保活、会话路由、少量房间广播
指标:
- CC(Concurrent Connections)≥ 450k / 台
- 握手 QPS ≥ 15k(高峰 2 分钟内瞬时扩张)
- 平峰心跳包 2~4 pkt/s/conn
- P99 延迟 ≤ 80ms(跨境)/ ≤ 40ms(本地 HK/SEA)
关键瓶颈:中断分布、内核队列、epoll 驱动模型、用户态锁争用、定时器风暴
二、现场硬件与系统基线
硬件
- 服务器:Supermicro 2U
- CPU:AMD EPYC 7443P(24C/48T,主频 2.85GHz)
- 内存:128GB
- 磁盘:2 × 1.92TB NVMe(系统+日志)
- 网卡:Intel X710 双口 10GbE(开启 RSS,多队列)
系统
- 发行版:CentOS 7 Minimal(注意:官方已 EOL;生产中我使用 ELRepo 内核)
- 内核:kernel-ml 5.10.x(ELRepo)——为启用更好的 TCP 算法、BBR、eBPF 工具链
- glibc:2.17(CentOS 7)
- 编译器:gcc 9(devtoolset)
如果必须停留在内核 3.10,也能做,但我强烈建议上 ELRepo 的 5.x LTS 内核,长连接体验差距肉眼可见(BBR、TFO、cgroup v2、irq/调度器更优)。
三、网络与 NUMA 拓扑规划
思路:硬件→中断→队列→CPU→用户线程 一路绑死,消灭跨 NUMA、消灭共享锁,最大化“每核自洽”。
确认 NUMA 与队列能力
numactl --hardware
lscpu | egrep 'NUMA|Thread|Socket|Core'
ethtool -l ens1f0 # 队列数量
ethtool -k ens1f0 # offload 能力
X710 常见能开 8~16 条 RX/TX 队列,够把 24 核分几组吃下。
关闭 irqbalance,手工绑核
systemctl stop irqbalance && systemctl disable irqbalance
cat /proc/interrupts | grep -i ens1f0
# 为每个队列写入 smp_affinity,按位掩码绑定到不同 CPU
echo 00000001 > /proc/irq/XX/smp_affinity
echo 00000002 > /proc/irq/YY/smp_affinity
# ……依次分配
启用 RSS + RPS/RFS + XPS(把队列→CPU→用户线程方向打通)
# 打开硬件 RSS
ethtool -L ens1f0 combined 8
# 为每个 RX 队列设置 RPS CPU 掩码(示例绑定 0-7 核)
for q in /sys/class/net/ens1f0/queues/rx-*; do
echo ffffff > $q/rps_cpus
echo 32768 > $q/rps_flow_cnt
done
# 为每个 TX 队列设置 XPS(与 worker 线程核位一致)
for q in /sys/class/net/ens1f0/queues/tx-*; do
echo ffffff > $q/xps_cpus
done
GRO/LRO 与 offload 策略
长连接小包多:保留 GRO(聚合减轻软中断),关闭 LRO(可能影响转发/上层协议)。
ethtool -K ens1f0 gro on lro off tso on gso on
四、内核参数与用户态 ulimit
文件描述符和 backlog
# /etc/security/limits.conf
* soft nofile 1048576
* hard nofile 1048576
root soft nofile 1048576
root hard nofile 1048576
# 进程级别(systemd)
# /etc/systemd/system/game-gateway.service
[Service]
LimitNOFILE=1048576
sysctl(核心)
下面是我在这台机器上的“保守可用”模板(按需微调)
cat >/etc/sysctl.d/99-gateway-tcp.conf <<'EOF'
# 队列与缓存
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 250000
net.ipv4.tcp_max_syn_backlog = 262144
# TIME-WAIT / 端口与复用
net.ipv4.ip_local_port_range = 10000 65535
net.ipv4.tcp_tw_reuse = 1
# 连接追踪(如使用 nftables/conntrack,请配合调大表项)
net.netfilter.nf_conntrack_max = 10485760
# 保活(心跳为主)
net.ipv4.tcp_keepalive_time = 600
net.ipv4.tcp_keepalive_intvl = 20
net.ipv4.tcp_keepalive_probes = 5
# 缓冲自动调优(避免每连占用过大)
net.ipv4.tcp_rmem = 4096 131072 262144
net.ipv4.tcp_wmem = 4096 131072 262144
net.core.rmem_max = 262144
net.core.wmem_max = 262144
# 拥塞控制与队列算法(5.x 内核)
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
# SYN/握手优化(需内核支持)
net.ipv4.tcp_fastopen = 3
# 其他
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_mtu_probing = 1
EOF
sysctl --system
参数变更对照
| 项目 | 默认 | 调优 | 说明 |
|---|---|---|---|
| somaxconn | 128 | 65535 | accept 队列上限,配合 listen(backlog) |
| netdev_max_backlog | 1000 | 250000 | 软中断收包缓存,防止峰值抖动丢包 |
| tcp_max_syn_backlog | 256 | 262144 | 握手排队容量 |
| tcp_rmem/tcp_wmem max | 4MB+ | 256KB | 控制每连接缓冲上限,节省内存 |
| keepalive_time | 7200s | 600s | NAT 环境下更友好 |
| qdisc | pfifo_fast | fq | 配合 BBR,减少队头阻塞 |
| congestion | cubic | bbr | 跨境 RTT 抖动场景更稳 |
| tcp_fastopen | 0 | 3 | 支持客户端/服务端 TFO |
注:BBR 要实际观测,不是所有跨境链路都更优;TFO 需客户端栈支持,且注意中间设备兼容性。
五、进程模型:SO_REUSEPORT + 每核 Reactor
我试过“全局 accept + worker 池”的经典模型,但在 40 万长连接下,同一把连接表锁/分发队列是可见的瓶颈。最后落地的是:
- SO_REUSEPORT:多个监听 socket 绑定到同一端口,由内核做 5 元组哈希分流
- 每核一个 Reactor 线程:自己 accept、自己 epoll、自己处理,不跨核
- 固定 CPU 亲和:pthread_setaffinity_np 与 XPS/IRQ 掩码一致
- 无共享全局队列:广播/路由通过 lock-free 环形队列做“跨核消息”,批量转发
- 定时器:使用 timerfd,避免用户态海量 timer 小对象
关键代码(C,简化示例)
说明:
- ET(边缘触发)+ 非阻塞 I/O
- accept4 + SOCK_NONBLOCK|SOCK_CLOEXEC
- TCP_NODELAY、SO_KEEPALIVE、自定义 keepalive 参数
- 一定要把 socket 一次性读/写到 EAGAIN,否则 ET 会“失效”
// build: gcc -O2 -pthread -Wall -Wextra -o gw gw.c
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/tcp.h>
#include <pthread.h>
#include <sched.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <sys/timerfd.h>
#include <unistd.h>
#define MAX_EVENTS 4096
#define READ_BUF 4096
static int set_nb(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
static void tune_tcp(int fd) {
int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &one, sizeof(one));
int keepidle = 600, keepintvl = 20, keepcnt = 5;
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt));
}
typedef struct {
int cpu;
int port;
} worker_arg_t;
static int create_listener(int port) {
int fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
int one = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one));
struct sockaddr_in addr = {.sin_family=AF_INET, .sin_port=htons(port), .sin_addr.s_addr=INADDR_ANY};
if (bind(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); exit(1); }
if (listen(fd, 65535) < 0) { perror("listen"); exit(1); }
return fd;
}
static void pin_to_cpu(int cpu) {
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(cpu, &cpuset);
pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset);
}
static void drain_read(int fd) {
char buf[READ_BUF];
for (;;) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) {
// TODO: 业务包解码/心跳处理/路由
} else if (n == 0) {
close(fd);
return;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) return;
close(fd);
return;
}
}
}
static void handle_write(int fd) {
// TODO: 发送环形队列里待发的数据,直到 EAGAIN
// 这里略去业务实现
}
static void *worker(void *argp) {
worker_arg_t *arg = (worker_arg_t *)argp;
pin_to_cpu(arg->cpu);
int lfd = create_listener(arg->port);
int ep = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event ev = {.events = EPOLLIN | EPOLLET, .data.fd = lfd};
epoll_ctl(ep, EPOLL_CTL_ADD, lfd, &ev);
// 周期性任务(如房间广播 tick)
int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);
struct itimerspec its = {.it_interval = {.tv_sec=0,.tv_nsec=50*1000*1000}, .it_value = {.tv_sec=0,.tv_nsec=50*1000*1000}};
timerfd_settime(tfd, 0, &its, NULL);
ev.events = EPOLLIN | EPOLLET; ev.data.fd = tfd;
epoll_ctl(ep, EPOLL_CTL_ADD, tfd, &ev);
struct epoll_event events[MAX_EVENTS];
for (;;) {
int n = epoll_wait(ep, events, MAX_EVENTS, 1000);
for (int i = 0; i < n; ++i) {
int fd = events[i].data.fd;
uint32_t e = events[i].events;
if (fd == lfd) {
for (;;) {
int cfd = accept4(lfd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
if (cfd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
else break;
}
tune_tcp(cfd);
struct epoll_event cev = {.events = EPOLLIN | EPOLLET | EPOLLOUT, .data.fd = cfd};
epoll_ctl(ep, EPOLL_CTL_ADD, cfd, &cev);
}
} else if (fd == tfd) {
uint64_t exp; read(tfd, &exp, sizeof(exp)); // 清空 timer
// TODO: 周期性广播/清理
} else {
if (e & EPOLLIN) drain_read(fd);
if (e & EPOLLOUT) handle_write(fd);
}
}
}
return NULL;
}
int main(int argc, char **argv) {
if (argc < 3) { fprintf(stderr, "usage: %s <port> <workers>\n", argv[0]); return 1; }
int port = atoi(argv[1]), workers = atoi(argv[2]);
pthread_t th[128]; worker_arg_t args[128];
for (int i = 0; i < workers; ++i) {
args[i].cpu = i; args[i].port = port;
pthread_create(&th[i], NULL, worker, &args[i]);
}
for (int i = 0; i < workers; ++i) pthread_join(th[i], NULL);
}
要点复盘
- SO_REUSEPORT 让每个线程自己 accept,避免惊群与共享锁
- ET 模式必须读写到 EAGAIN;一个字节都别偷懒,否则会“丢事件”
- EPOLLOUT 建议只在待发队列非空时注册,避免“空转”
- 定时器用 timerfd,心跳/超时检查集中处理,减少 per-conn 小对象压力
六、连接内存预算与容量规划
长连接的杀器是“每连接内存”。粗估模型:
- 用户态连接对象(session + ringbuf + metadata):约 256~512B(高度优化对象池)
- 内核 socket + skbuff(受 tcp_rmem/wmem max 影响):约 32~128KB(动态)
- 观测方法:ss -m 看每连接的 skmem,/proc/net/sockstat 看全局
- 目标:把每连接平均压到 48~96KB
举例(现场均值):
| 指标 | 数值 |
|---|---|
| 连接数 | 450,000 |
| 平均 skmem | ~64KB |
| 用户态/连接 | 384B |
| 进程 RSS | ~30GB |
| page cache/其他 | 10~20GB |
| 总占用 | < 64GB(内存够余量) |
七、握手与心跳的“风暴抑制”
握手尖峰:
- tcp_max_syn_backlog、somaxconn 都要上去
- 接入前置 LVS/HAProxy(四层直穿):只做转发,不做复杂 L7
- 在业务侧冷启动限速:连接建链速率做平滑(令牌桶)
心跳风暴:
- 心跳周期随机抖动(±10%),避免“整点齐发”
- 每核线程定时器分片,不要一个线程扛所有心跳检查
- NAT 环境下缩短 keepalive(前文 sysctl/setsockopt 已给)
八、观测与压测清单(我现场用的)
内核/网络
watch -n1 'ss -s; nstat; sar -n DEV 1 1'
ethtool -S ens1f0 | egrep 'rx|tx|miss'
cat /proc/softirqs
CPU/线程
top -H -p <pid>
mpstat -P ALL 1
perf top -p <pid>
TCP 细粒度(如 bcc 可用)
tcptop -C 1 # 热点连接
tcplife # 连接生命周期
tcpconnect/tcpaccept/tcpclose
压测工具
- tcpkali:长连接可控并发与小包吞吐
- 自研压测端(Go/C),按业务协议造心跳、广播、断线重连
九、部署脚本与可回滚项
系统调优一键脚本(片段)
#!/usr/bin/env bash
set -e
svc=game-gateway
# 1) 关 irqbalance
systemctl stop irqbalance || true
systemctl disable irqbalance || true
# 2) 网卡 offload/RSS
ethtool -K ens1f0 gro on lro off tso on gso on
ethtool -L ens1f0 combined 8
# 3) RPS/RFS/XPS
for q in /sys/class/net/ens1f0/queues/rx-*; do
echo ffffffff > $q/rps_cpus
echo 32768 > $q/rps_flow_cnt
done
for q in /sys/class/net/ens1f0/queues/tx-*; do
echo ffffffff > $q/xps_cpus
done
# 4) sysctl
sysctl --system
# 5) ulimit via systemd
systemctl daemon-reload
systemctl restart ${svc}.service
可回滚清单
- 先保存 /etc/sysctl.conf、/etc/sysctl.d/*、/etc/systemd/system/*.service
- 网卡参数用 ethtool 修改为临时(重启失效),确认收益再固化 ifcfg/systemd 脚本
- 新内核先做 A/B(GRUB 默认回滚项保留)
十、常见坑与现场修法
ET 模式“丢事件”
- 根因:没有把 read/write 读/写到 EAGAIN
- 现象:连接突然不再触发可读/可写,业务假死
- 修法:统一封装 I/O,循环读写到 EAGAIN
某核 softirq 飙高
- 根因:队列/中断绑核失衡、RSS 哈希不均、热点 5 元组
- 修法:增大队列数、重配 smp_affinity、XPS 映射与线程核位一致
TIME-WAIT 山峰
- 根因:短连/重连洪峰
- 修法:tcp_tw_reuse=1、fin_timeout=15、业务侧重试退避
TFO 兼容问题
- 根因:偶遇老式 NAT/防火墙
- 修法:灰度开启 tcp_fastopen,观察 SYN data 是否被丢
conntrack 表爆(用了 nftables)
- 根因:握手洪峰
- 修法:nf_conntrack_max 扩容(按内存算),配合 hashsize;或直接绕过跟踪做纯四层
广播/推送卡顿
- 根因:跨核消息过多且细碎
- 修法:批处理 + 压缩,跨核队列用大批次搬运,避免每消息一次唤醒
十一、实测结果(关键片段)
在上述配置下(BBR + fq,REUSEPORT × 16 线程):
| 指标 | 值 |
|---|---|
| 稳态连接数 | ~470k / 台 |
| 握手峰值 | 18k QPS(2 分钟) |
| 稳态 CPU | 38%(user 22%,softirq 9%) |
| 丢包 | 接近 0(ethtool -S) |
| P99 延迟(HK 本地) | ~28ms |
| P99 延迟(跨境公网) | ~76ms(高峰 ~85ms,偶发) |
备注:跨境波动主要取决于运营商线路质量。我们在入口侧加了多活接入点 + Anycast 回源后,P99 明显稳定(这部分超出本文主轴,略)。
十二、扩展:io_uring 要不要上?
我也做过 io_uring(IORING_SETUP_SQPOLL)的版本。结论:
- 小包长连接场景,epoll 仍然很能打;io_uring 并不会“必然赢”。
- 如果有大量磁盘 I/O、TLS、或者需要统一异步接口,io_uring 会更优雅。
- 若切入 io_uring,仍然建议保留 per-core 模型 + REUSEPORT,否则跨核同步会把优势吃掉。
十三、上线 Checklist(落地就用)
- 核心参数(sysctl/ulimit)已生效
- 网卡队列、GRO/LRO、RSS/RPS/XPS 配置与线程绑定一致
- SO_REUSEPORT 多监听 + 每核 Reactor 工作正常
- 心跳抖动策略、握手限速开关可配
- 观测面到位:ss -s、/proc/net/sockstat、ethtool -S、nstat、mpstat -P ALL
- 压测覆盖:建链洪峰、稳态长连、断线重连、广播风暴
- 回滚预案:内核、网卡、sysctl、服务版本均可一键回退
结尾:天光微亮,监控曲线像一条稳稳的河
清晨 5 点,窗外开始泛白。Grafana 的连接数曲线稳稳贴着 47 万横着走,rx_dropped 归零,softirq 分布像被尺子量过一样平均。我给值班小哥回了一句“可以去补觉了”,自己把那杯凉透的咖啡一口闷掉。
这次优化没有奇技淫巧,就是把硬件队列、内核队列、用户线程这三条“流水线”对齐,再用 SO_REUSEPORT + 每核 Reactor 把同步与锁的成本拿掉。MMORPG 的长连接天生难缠,但只要把“路径”理顺,海量连接也不过是波澜不惊的数字。
附:可直接抄用的最小化配置清单
1) systemd 服务(/etc/systemd/system/game-gateway.service)
[Unit]
Description=MMO Gateway
After=network.target
[Service]
User=game
ExecStart=/usr/local/bin/gw 7001 16
Restart=always
RestartSec=3
LimitNOFILE=1048576
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target
2) 内核参数见前文 99-gateway-tcp.conf
3) 上线前自检命令
ulimit -n
sysctl net.ipv4.tcp_congestion_control
cat /proc/interrupts | grep -i ens1f0
ss -s
nstat
ethtool -S ens1f0 | egrep 'rx|tx|miss|drop'
如果你要把这套方法移植到你们的环境(CPU/网卡/内核不同),优先关注:
- 网卡队列与中断/线程绑定是否一致;
- REUSEPORT + per-core 是否真正做到“自给自足”;
- 每连接内存控制是否在 64KB 左右;
- 压测是否覆盖“握手洪峰 + 稳态长连 + 广播风暴”。