上一篇 下一篇 分享链接 返回 返回顶部

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

发布人:Minchunlin 发布时间:2025-09-10 17:03 阅读量:641


凌晨 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

  1. 多进程 + SO_REUSEPORT:在 3.10 内核上没有 EPOLLEXCLUSIVE,为了避免 thundering herd,直接用 N 个监听进程(N≈物理核),每个进程一个 accept4();内核做五元组分流,不抢锁。
  2. 每进程一个 epoll,ET(边沿触发)+ 非阻塞 + EPOLLONESHOT:事件交给工作线程处理后手动重置兴趣列表,保证同一 fd 只被一个线程处理。
  3. 应用层批处理:对小包频繁的 MMO 心跳、状态同步,应用层合包(如 1ms/2ms 小批)+ writev,而非完全依赖 TCP 的 Nagle。
  4. 定时器:timerfd + 最小堆/分层时间轮做心跳与超时,避免使用 per-connection 定时器的昂贵维护。
  5. 零拷贝:业务是小包,不追求 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 不可怕,可怕的是没用对。祝你线上稳如磐石

目录结构
全文