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

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

发布人:Minchunlin 发布时间:2025-09-12 09:39 阅读量:852


凌晨 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(落地就用)

  1.  核心参数(sysctl/ulimit)已生效
  2.  网卡队列、GRO/LRO、RSS/RPS/XPS 配置与线程绑定一致
  3.  SO_REUSEPORT 多监听 + 每核 Reactor 工作正常
  4.  心跳抖动策略、握手限速开关可配
  5.  观测面到位:ss -s、/proc/net/sockstat、ethtool -S、nstat、mpstat -P ALL
  6.  压测覆盖:建链洪峰、稳态长连、断线重连、广播风暴
  7.  回滚预案:内核、网卡、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 左右;
  • 压测是否覆盖“握手洪峰 + 稳态长连 + 广播风暴”。
目录结构
全文