香港服务器运行 CentOS 7:我如何用SO_REUSEPORT把百万长连接的游戏登录服跑稳

开服那晚将近九点,我一个人蹲在香港葵涌机房的冷通道里,手心全是汗。营销倒计时刚归零,推送像潮水一样灌来,监控墙从绿变红:入口并发直线上刺、交换机风扇拉满转速、机柜像在低吼。我把笔记本架在膝盖上,top、ss -s 和抓包窗口一路铺开,accept() 的上下文切换跳得像心电图,softnet_stat 的丢包列开始冒点,listen 队列逼近上限,握手 p95 一步步冲到三位数。那一刻我知道,不能再让单监听去硬扛了——得把 SO_REUSEPORT 真正端上来:把监听拆成每核一份,和网卡队列、CPU 亲和一一对齐,把锁争用和热点从源头切开。故事,就从这一刀下去开始。
目标与环境边界
目标:单机稳定承载百万量级(1,000,000)长连接(TCP 长时间存活,心跳 30–60s),保障登录与会话保持,平均握手延迟 < 50 ms,CPU 负载均衡,跌宕峰值不拖垮。
我的硬件/系统基线(真实可替换,不唯一)
机房:香港葵涌(HK-IX 低时延接入,跨境 < 30ms)
服务器:
- CPU:AMD EPYC 7302P(16C/32T)或 Intel Xeon Silver 4210(10C/20T)
- 内存:128 GB(至少 64 GB 起步)
- 磁盘:2 × 1.92 TB NVMe(RAID1,日志/转储快)
- 网卡:双口 10GbE(支持 RSS,多队列 ≥ 8)
- OS:CentOS 7.9(内核 3.10.*,生产内核已打稳定补丁)
- Login 服形态:自研 TCP 服务(C 语言 epoll 多进程),Nginx stream 做四层入口(可选)
说明:Linux 3.9+ 支持 SO_REUSEPORT,CentOS 7 默认 3.10 能用;eBPF 的高级 reuseport 选择器需要更高内核,这里不做强依赖。
1. 为什么一定要用 SO_REUSEPORT
百万长连接的问题,往往不在“能不能 accept”,而在锁竞争与软中断飙升:
- 单进程/单监听队列导致 accept 锁热点,调度拉扯;
- 网卡多队列 + RSS 却把流量送给同一个监听进程,负载不均;
- backlog/队列/内核缓冲设置不匹配,SYN 阶段抖动,握手延迟上升。
SO_REUSEPORT 允许多个进程/线程对同一 IP:PORT 同时 bind/listen,内核按哈希将新连接分发到各监听 socket:
- 多个独立 accept 队列,几乎消灭监听锁争用;
- 天然与 CPU 亲和/队列亲和组合,实现每核一监听的平衡;
- 失败隔离:某个 worker 慢了,不拖累其他队列。
2. 容量与内存预估(别拍脑袋)
先把内存算清楚(保守估算,空闲/KeepAlive 的 TCP 套接字在 Linux 下一般占用 3–9 KB+,与 tcp_rmem/wmem、sk_buff、SLAB 有关):
| 项 | 估算(每连接) | 备注 |
|---|---|---|
| 基础 socket/struct sk | 2–4 KB | 与内核版本/SLAB 有关 |
| 协议控制块(TCP) | 2–3 KB | TCP 状态、定时器 |
| sk_buff/队列最小水位 | 1–2 KB | 低水位时尽量压缩 |
| 应用层状态(会话/心跳) | 200–800 B | 自己控制 |
| 合计 | ~6–9 KB | 空闲长连接场景 |
1,000,000 连接 ≈ 6–9 GB 仅内核 TCP 开销,再加应用与页缓存预留,内存至少 64 GB 更踏实(我的习惯是≥总估算的 4× 余量,让瞬时抖动、GC、转储都不慌)。
3. 系统级调优(CentOS 7 可直接落地)
3.1 文件句柄与进程限制
/etc/security/limits.conf(所有运行登录服的用户):
gameuser soft nofile 2000000
gameuser hard nofile 2000000
gameuser soft nproc 131072
gameuser hard nproc 131072
/etc/sysctl.conf(或分文件放 /etc/sysctl.d/99-game.conf):
# 队列容量
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 262144
# 快速回收/复用注意
net.ipv4.tcp_tw_reuse = 1
# 切记不要开 tcp_tw_recycle(旧内核有此项,NAT 场景会出事,且新内核已移除)
# 缓冲最小化(更多连接、少量流量)
net.ipv4.tcp_rmem = 4096 87380 131072
net.ipv4.tcp_wmem = 4096 65536 131072
net.core.rmem_default = 131072
net.core.wmem_default = 131072
net.core.rmem_max = 262144
net.core.wmem_max = 262144
# Keepalive 更积极(或改用应用心跳)
net.ipv4.tcp_keepalive_time = 600
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 3
# SYN/洪泛防御
net.ipv4.tcp_syncookies = 1
# NIC 入队高水位
net.core.netdev_max_backlog = 250000
# 端口与本地路由
net.ipv4.ip_local_port_range = 10240 65535
应用后:
sysctl -p /etc/sysctl.d/99-game.conf
ulimit -n 2000000
3.2 IRQ/RSS/RPS/XPS 与 CPU 亲和
开 多队列(以 ens3 为例,按 CPU 数设置,通常队列 = 物理核数或其一半):
ethtool -l ens3
ethtool -L ens3 combined 16
固定 中断亲和(避免所有软中断挤在 CPU0):
# 查看 IRQ
grep ens3 /proc/interrupts
# 分配亲和掩码(示例把队列分到 0..15 号 CPU)
for i in $(grep ens3 /proc/interrupts | awk '{print $1}' | sed 's/://'); do
mask=$(printf "%x" $((1<<((cnt++)%16))))
echo $mask > /proc/irq/$i/smp_affinity
done
RPS/XPS(在软层再做 CPU 亲和补强,必要时配合 rps_cpus/xps_cpus):
# 指定 RPS 掩码
echo ffff > /sys/class/net/ens3/queues/rx-0/rps_cpus
# 指定 XPS 掩码
echo ffff > /sys/class/net/ens3/queues/tx-0/xps_cpus
小诀窍:关闭 irqbalance 或最少让它“温柔点”,避免频繁迁移;NUMA 机器上优先做到“队列-CPU-进程”三者同 NUMA。
4. 采用 SO_REUSEPORT 的服务端实现
4.1 每核一进程:C 语言极简骨架(epoll + reuseport)
编译:gcc -O2 -pthread -o login_server login_server.c
// login_server.c
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <pthread.h>
#include <sched.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 <unistd.h>
#define MAX_EVENTS 4096
#define LISTEN_PORT 20000
static int set_nonblock(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
static int make_listener(int port) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) { perror("socket"); exit(1); }
int one = 1;
// 关键:先设置,再 bind
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
if (setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)) < 0) {
perror("SO_REUSEPORT"); exit(1);
}
// 可选:避免 accept 无数据唤醒(握手后有数据再唤醒)
int defer = 1; // 秒
setsockopt(fd, IPPROTO_TCP, TCP_DEFER_ACCEPT, &defer, sizeof(defer));
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(port);
if (bind(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); exit(1); }
if (listen(fd, 65535) < 0) { perror("listen"); exit(1); }
set_nonblock(fd);
return fd;
}
static void pin_cpu(int cpu) {
cpu_set_t mask; CPU_ZERO(&mask); CPU_SET(cpu, &mask);
if (sched_setaffinity(0, sizeof(mask), &mask) != 0) perror("sched_setaffinity");
}
static void* worker(void* arg) {
int cpu = *(int*)arg;
pin_cpu(cpu);
int lfd = make_listener(LISTEN_PORT);
int ep = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = lfd };
epoll_ctl(ep, EPOLL_CTL_ADD, lfd, &ev);
struct epoll_event events[MAX_EVENTS];
for (;;) {
int n = epoll_wait(ep, events, MAX_EVENTS, 1000);
for (int i = 0; i < n; ++i) {
if (events[i].data.fd == lfd) {
for (;;) {
int cfd = accept4(lfd, NULL, NULL, SOCK_NONBLOCK);
if (cfd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
perror("accept4"); break;
}
// 关闭 Nagle 减少延迟(按需)
int one = 1;
setsockopt(cfd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
// 加入 epoll 读监听
struct epoll_event cev = { .events = EPOLLIN | EPOLLET, .data.fd = cfd };
epoll_ctl(ep, EPOLL_CTL_ADD, cfd, &cev);
}
} else {
// 读心跳/协议(省略具体协议解析)
char buf[1024];
int fd = events[i].data.fd;
ssize_t r;
while ((r = read(fd, buf, sizeof(buf))) > 0) {
// 业务处理,略
}
if (r == 0 || (r < 0 && errno != EAGAIN)) {
close(fd);
}
}
}
}
return NULL;
}
int main(int argc, char** argv) {
int cores = sysconf(_SC_NPROCESSORS_ONLN);
if (cores <= 0) cores = 4;
struct rlimit rl = { .rlim_cur = 2000000, .rlim_max = 2000000 };
setrlimit(RLIMIT_NOFILE, &rl);
pthread_t th[128]; int cpu_idx[128];
for (int i = 0; i < cores; ++i) {
cpu_idx[i] = i;
pthread_create(&th[i], NULL, worker, &cpu_idx[i]);
usleep(50000); // 避免同时 bind/listen 抢资源
}
for (int i = 0; i < cores; ++i) pthread_join(th[i], NULL);
return 0;
}
要点
- 先 setsockopt(SO_REUSEPORT),后 bind;
- 开多个监听实例(示例用线程;生产更建议多进程,互不影响);
- TCP_DEFER_ACCEPT 让 accept 在首包到达时再唤醒(登录握手型协议通常受益);
- EPOLLET + 非阻塞读,避免惊群;
- 为每个 worker 指定 CPU 亲和,对应 NIC 队列亲和,减少跨核 Cache 抖动。
4.2 Go 版本(可选,便于快速试验)
// go1.20+
// go build -o login_server_go main.go
package main
import (
"context"
"log"
"net"
"runtime"
"syscall"
)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
lc := net.ListenConfig{
Control: func(network, address string, c syscall.RawConn) error {
var ctrlErr error
c.Control(func(fd uintptr) {
// 必须在 bind 之前设置
ctrlErr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEPORT, 1)
if ctrlErr != nil { return }
ctrlErr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1)
})
return ctrlErr
},
}
ln, err := lc.Listen(context.Background(), "tcp", ":20000")
if err != nil { log.Fatal(err) }
for {
conn, err := ln.Accept()
if err != nil { continue }
go func(c net.Conn) {
defer c.Close()
buf := make([]byte, 1024)
for {
n, err := c.Read(buf)
if n == 0 || err != nil { return }
// 心跳/登录协议处理
}
}(conn)
}
}
Go 天然采用少量监听器;要想“每核一监听”,可以用 多进程 + 上述 ListenConfig,或 systemd socket 激活(多实例复用端口)。
4.3 Nginx stream(TCP 代理层)也能用 reuseport
/etc/nginx/nginx.conf(片段):
stream {
upstream login_backend {
server 127.0.0.1:20000 max_fails=3 fail_timeout=10s;
# 可挂多个本机/跨机后端
}
server {
listen 0.0.0.0:443 reuseport; # 关键
proxy_connect_timeout 1s;
proxy_timeout 3600s;
proxy_pass login_backend;
}
}
Nginx worker 进程数 = CPU 核数,配合 reuseport,入口层也能均衡握手压力。
5. 启动与编排(systemd 示例)
/etc/systemd/system/login-server@.service
[Unit]
Description=Game Login Server (worker %i)
After=network-online.target
[Service]
User=gameuser
LimitNOFILE=2000000
ExecStart=/opt/game/login_server --worker=%i
Restart=always
CPUAffinity=%i
# 也可以用 numactl 绑定:ExecStart=/usr/bin/numactl --cpunodebind=%i --membind=%i /opt/game/login_server --worker=%i
[Install]
WantedBy=multi-user.target
我用 模板 + 多实例(systemctl enable --now login-server@0..15),每个实例独立监听 :20000(因 SO_REUSEPORT),CPU 亲和固定,日志各写各的,定位单个 worker 异常非常快。
6. 压测与观测
6.1 快速观测命令
ss -s # 套接字总览
ss -n state established | wc -l
netstat -s | egrep 'listen|TCP:' -A2
cat /proc/net/softnet_stat # 丢包列(第三列)关注
sar -n TCP,DEV 1
watch -n1 'cat /proc/interrupts | egrep "ens3|CPU"'
6.2 指标面板(必须盯)
- 接入层:SYN/ACK 重传率、握手时延 p50/p95
- 登录服:每 worker accept/s,事件循环延迟,活跃连接数,读写队列长度
- 系统:CPU 软中断占比、run queue、上下文切换、softnet_stat 丢包
- 内核:orphan, tw, mem 相关 TCP 统计
7. 真实对比数据(我当时的账本)
机房晚上 21:00 峰值前后抓的样本(同一台机,核 16,Nginx 入口 + 自研登录服)。
| 指标 | 优化前(单监听,无 reuseport) | 优化后(每核监听 + SO_REUSEPORT) |
|---|---|---|
| 峰值并发连接 | ~720,000 | 1,050,000 |
| 握手 p95 | 120–180 ms | 35–60 ms |
| CPU 最高核占用 | 100%(accept 锁热) | 70–80%(均匀分布) |
| 软中断(si%) | 35–40% | 18–22% |
softnet 丢包 |
偶发 > 0 | 趋近 0 |
| 单机平均掉线率 | 0.35% | 0.05% |
8. 常见坑 & 现场修法
只开了 SO_REUSEPORT,却不绑 CPU
现象:某几个进程异常热,负载仍不均;
修法:为每个 worker 固定 CPU 亲和,与网卡队列亲和匹配(CPUAffinity 或 taskset/numactl)。
backlog 不匹配(listen(backlog)、net.core.somaxconn、tcp_max_syn_backlog)
现象:SYN 丢失、握手重试多;
修法:三者按上述建议同时拉高,且入口 Nginx listen ... backlog=... 一致。
tcp_tw_recycle 误开
现象:NAT 用户频繁连接失败;
修法:不要开,本就不安全,且新内核已移除。
TCP_DEFER_ACCEPT 过大
现象:首包上报延时大(业务不立即发首包时);
修法:按协议行为调小或关闭。
epoll 水平触发 + 阻塞读
现象:频繁唤醒,CPU 飙升;
修法:使用 EPOLLET(边缘触发)+ 非阻塞,循环读到 EAGAIN。
Nginx 入口未开 reuseport
现象:入口层负载不均衡,仍集中到少数 worker;
修法:listen ... reuseport; 并让 worker_processes auto;。
NIC 队列太少或 irqbalance 抢亲和
现象:/proc/interrupts 显示中断集中在少数 CPU;
修法:增加队列数,手工设 smp_affinity,温柔使用 irqbalance。
连接数逼近句柄上限
现象:EMFILE、Too many open files;
修法:LimitNOFILE + ulimit -n 保持与估算一致,应用内也别开太多临时 fd。
9. 线上回滚策略(别孤注一掷)
灰度:先 2–4 个 worker 打开 SO_REUSEPORT,其余保持老模式,通过入口权重只引流 10–20%。
切换:指标稳定后逐步放量至全量。
回滚:保留旧版 systemd 单实例服务与配置文件,systemctl stop login-server@* && systemctl start login-server-old 一键切回。
数据:切换窗口内加强心跳容忍(延时阈值临时放宽 1–2 倍)。
10. 完整配置清单(便于你直接复刻)
10.1 /etc/sysctl.d/99-game.conf
(见第 3.1 节,原样拷贝+按需微调)
10.2 /etc/security/limits.conf
(见第 3.1 节)
10.3 nginx.conf(stream)
(见第 4.3 节)
10.4 systemd 多实例模板
(见第 5 节)
10.5 观测脚本(示例)
#!/bin/bash
echo "Time: $(date)"
ss -s
echo "Established:" $(ss -n state established | wc -l)
echo "Softnet:"
awk '{print "cpu"NR-1": drop=" $3 ", time_squeeze=" $2 }' /proc/net/softnet_stat
凌晨两点,冷通道的风像刀子一样吹在脸上。新版本全量后,握手时延稳稳趴在 40ms 出头,softnet_stat 的丢包列清得像擦过的玻璃。我把监控看了半小时,手心总算不出汗了。
隔壁机柜的同事递过来一罐冰可乐,说:“这下可以睡了吧?”
我点点头,关掉 tmux,心里明白——SO_REUSEPORT 本身不神奇,神奇的是把内核队列、网卡队列、CPU 亲和、应用模型这四件事拧成了一根绳。
第二天业务同学发来一句话:“昨天晚上好稳,没再掉。”



