香港服务器运行 CentOS 7 时,我是如何把 TCP_NODELAY 玩到极致,让实时对战“指令秒到”的

那天是周五凌晨两点,我在香港机房 5 楼的工作间盯着战队训练服上滚动的延迟曲线,心里“咯噔”一下:技能键从按下到服务端确认,P99 已经飙到 85 ms,而平时我们能稳在 35 ms 左右。周末有公开内测,延迟再这样抖,我们就要“出圈”——以最糟的方式。
我打开一台前端网关的 strace,看到关键端口上从未设置 TCP_NODELAY。这下有谱了:Nagle 把我们的“微小指令包”攒成大包,延迟被硬生生堆了上去。下面就是那一夜到天亮,我在 CentOS 7 + 香港裸金属 上一步步把 TCP_NODELAY 和全链路低延迟栈打磨到能“直接上场”的全过程。
环境与目标
目标:优化“实时对战类游戏”指令往返延迟(特别是小包),让 P50 ≤ 20 ms、P99 ≤ 40 ms(港到华南/华东跨境),并且在高并发(≥ 5k 并发连接/节点)下稳定。
现场环境(实配)
| 项 | 规格 |
|---|---|
| 机房 | 香港葵涌区,双线(HGC/PCCW)接入 |
| 服务器 | 1U 裸金属,Intel Xeon Silver 4210R(10C/20T)×2 |
| 内存 | 64 GB DDR4 |
| 存储 | NVMe(Samsung PM983)1 TB ×2(RAID1) |
| 网卡 | Intel X710 10 GbE(驱动 i40e),独占上联 |
| 系统 | CentOS 7.9(3.10 内核),tuned 启用 |
| 业务 | 自研 TCP 协议(指令小包 64–512 B),网关(Nginx stream)→ 游戏服(Go/Netty/C++ 混部) |
说明:如果你运行在云上或虚拟化,后面 NIC/IRQ 的一部分操作需要改为在宿主或云厂商控制台里等效处理。
1. 先校准:用数据确认“问题就是 Nagle”
1.1 快速看包型(现场)
# 抓 10 秒关键端口(例如 4001)的小包占比
tcpdump -i eth0 'tcp port 4001' -w cap.pcap -G 10 -W 1
tshark -r cap.pcap -q -z io,stat,1,"MIN(frame.len) COUNT(frame.len) AVG(frame.len)"
若大多为 < 200 B 的请求/响应小包,但首包/回包间隔异常大,多半是 Nagle(未开 TCP_NODELAY)+ ACK 行为叠加导致。
1.2 直接跟踪应用是否设置了 TCP_NODELAY
# 盯住网关/游戏服进程(替换 pid)
strace -f -e trace=setsockopt -p <pid>
# 期待出现:setsockopt(fd, SOL_TCP, TCP_NODELAY, [1], 4) = 0
看到没有调用或返回 -1,就能坐实应用侧没设,或被上游代理“吞了”。
2. 应用层:必须显式开启 TCP_NODELAY
核心原则:TCP_NODELAY 是应用层开关;操作系统无法“全局强制”替应用设置。你的每一段“持久 TCP 连接”代码,都应该在 accept/连接建立后立刻开启它。
2.1 C/C++(游戏逻辑服/网关常见)
#include <netinet/tcp.h>
#include <sys/socket.h>
void set_nodelay(int fd) {
int one = 1;
if (setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)) < 0) {
perror("setsockopt TCP_NODELAY");
}
// 建议:交互型短消息流可以开 QUICKACK,减少延迟
#ifdef TCP_QUICKACK
if (setsockopt(fd, IPPROTO_TCP, TCP_QUICKACK, &one, sizeof(one)) < 0) {
perror("setsockopt TCP_QUICKACK");
}
#endif
}
2.2 Go(我们的一条逻辑服链路)
ln, _ := net.Listen("tcp", ":4000")
for {
conn, _ := ln.Accept()
if tcp, ok := conn.(*net.TCPConn); ok {
tcp.SetNoDelay(true) // 关键
tcp.SetKeepAlive(true)
tcp.SetKeepAlivePeriod(30 * time.Second)
tcp.SetReadBuffer(64*1024)
tcp.SetWriteBuffer(64*1024)
}
go handle(conn)
}
2.3 Java / Netty(匹配我们网关)
ServerBootstrap b = new ServerBootstrap();
b.childOption(ChannelOption.TCP_NODELAY, true)
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childOption(ChannelOption.SO_REUSEADDR, true);
2.4 Node.js(用于运维小服务/机器人)
server.on('connection', (socket) => {
socket.setNoDelay(true);
});
2.5 Python(调试/工具)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
检查点:代码上线后,再用 strace 抽查,或在 C/C++ 里 getsockopt(TCP_NODELAY) 打日志确认。
3. 代理/边界:Nginx stream 必开 tcp_nodelay + TFO
我们前面有一层 Nginx 作为 TCP 四层转发(避免直接暴露游戏服,顺便做健康检查/限流)。
/etc/nginx/nginx.conf(摘)
stream {
upstream game {
server 127.0.0.1:4000 max_fails=3 fail_timeout=3s;
}
server {
listen 0.0.0.0:4001 fastopen=4096; # TCP Fast Open,减少握手
proxy_connect_timeout 1s;
proxy_timeout 30s;
proxy_pass game;
tcp_nodelay on; # 关键:Nginx 侧也开
proxy_responses 1; # 单往返型小包场景
}
}
提示:TFO 需要内核与应用支持(下一节会开启内核开关)。如果你用的是 HAProxy,默认对 HTTP 会 NODELAY,但 TCP 模式需确认版本与配置;现场不确定时,用 strace 看最靠谱。
4. 操作系统:CentOS 7 的低延迟网络基线
新建 /etc/sysctl.d/99-game-lowlatency.conf:
# 更偏“交互延迟”,不极限追吞吐
net.core.somaxconn = 1024
net.core.netdev_max_backlog = 250000
net.ipv4.tcp_max_syn_backlog = 8192
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_tw_reuse = 1
# 小包+交互,有帮助;代价是某些场景吞吐略降
net.ipv4.tcp_low_latency = 1
# TCP Fast Open:客户端+服务端
net.ipv4.tcp_fastopen = 3
# 拥塞算法维持 cubic(3.10 默认),稳定保守
net.ipv4.tcp_congestion_control = cubic
# 缓冲区:别设过大,避免排队延迟
net.core.rmem_max = 8388608
net.core.wmem_max = 8388608
net.ipv4.tcp_rmem = 4096 87380 4194304
net.ipv4.tcp_wmem = 4096 65536 4194304
# 默认队列:fq_codel,抑制队列抖动(3.10 可用)
net.core.default_qdisc = fq_codel
# 端口范围足够
net.ipv4.ip_local_port_range = 10000 65535
# 交互型建议:避免闲置后慢启动
net.ipv4.tcp_slow_start_after_idle = 0
生效:
sysctl --system
4.1 tuned 选择低延迟档
yum install -y tuned
systemctl enable --now tuned
tuned-adm profile latency-performance
tuned-adm active
4.2 NIC 与中断亲和(X710 / i40e)
关闭大聚合(对小包延迟有利):
ethtool -K eth0 gro off lro off gso off tso off
环形缓冲略放大(防止突发丢包):
ethtool -g eth0
ethtool -G eth0 rx 4096 tx 4096
多队列中断绑定 CPU(避免抖动):
# 查看队列
ls -1 /sys/class/net/eth0/queues/ | cat
# 绑定示例:rx-0/tx-0 → CPU0,rx-1/tx-1 → CPU1 ...(NUMA 就近)
echo 1 > /proc/irq/<irq_of_rx0>/smp_affinity
echo 2 > /proc/irq/<irq_of_rx1>/smp_affinity
# ... 按位掩码分配
经验:在我们场景关闭 irqbalance 后手工绑核,P99 抖动更小;若你没有时间盯这块,保留 irqbalance 也能过关。
5. 启用 TCP Fast Open(配合上游 TTFB)
对于频繁新连的移动端玩家,TFO 能显著减少首包握手延迟。
内核开:上面 sysctl 已设 net.ipv4.tcp_fastopen=3
Nginx listen ... fastopen=4096; 已配置
自研服务端(C/C++ 示例):
#ifdef TCP_FASTOPEN
int qlen = 4096;
if (setsockopt(listen_fd, IPPROTO_TCP, TCP_FASTOPEN, &qlen, sizeof(qlen)) < 0) {
perror("setsockopt TCP_FASTOPEN");
}
#endif
注意:iOS/Android 客户端是否支持 TFO 取决于系统栈与你用的 SDK;未支持时不影响。
6. 部署脚本(可复制落地)
6.1 一键系统基线(bash)
cat >/root/game_low_latency.sh <<'EOF'
#!/usr/bin/env bash
set -e
yum install -y epel-release tuned tcpdump perf htop numactl ethtool
systemctl enable --now tuned
tuned-adm profile latency-performance
cat >/etc/sysctl.d/99-game-lowlatency.conf <<'EOC'
# (同上 sysctl 配置,略)
EOC
sysctl --system
# NIC 调优
ethtool -K eth0 gro off lro off gso off tso off || true
ethtool -G eth0 rx 4096 tx 4096 || true
systemctl restart nginx || true
echo "[OK] Baseline applied."
EOF
bash /root/game_low_latency.sh
6.2 Nginx stream 模块安装(若你用编译版略过)
yum install -y nginx
# 确认 nginx --version 输出包含 "with stream"
7. 验证:延迟对比测试(实测模板)
我们用 tcpkali 做小包回显延迟基准(把游戏服开个 echo 路径/或临时 echo 服)。
# 客户端在内地机器(跨境)或香港另一台
tcpkali <hk_eip>:4001 -c 1000 -r 10 -T 30s --latency-marker "PING\n"
变更前/后的对比(真实一晚的数据缩编)
| 指标 | 变更前(未开 NODELAY) | 变更后(全链路开启) |
|---|---|---|
| P50 RTT | 28 ms | 17 ms |
| P90 RTT | 54 ms | 28 ms |
| P99 RTT | 85 ms | 39 ms |
| 丢包(tcpkali 估计) | 0.35% | 0.09% |
观感:玩家的“搓招”终于不再黏手,语音里“出刀怎么慢半拍”的抱怨消失了。
8. 生产细节 & 经验表(值与理由)
| 模块 | 项 | 建议值 | 理由/备注 |
|---|---|---|---|
| 应用 | TCP_NODELAY |
ON | 小包实时交互必须;每条连接启用 |
| 应用 | TCP_QUICKACK |
ON | 请求-响应型受益,内核会动态调整 |
| Nginx | tcp_nodelay |
on | 代理层也要确保不攒包 |
| Nginx | fastopen |
4096 | 新连密集场景减少首包延迟 |
| sysctl | tcp_low_latency |
1 | 倾向低延迟而非最大吞吐 |
| sysctl | default_qdisc |
fq_codel |
抑制队列尾延迟与抖动 |
| NIC | GRO/LRO/TSO/GSO |
off | 减少小包聚合延迟(吞吐换延迟) |
| tuned | profile | latency-performance |
统一 CPU governor/功耗/中断策略 |
| 监控 | P50/P90/P99 | 仪表盘必配 | 玩家体感的是尾延迟 |
| 回滚 | 开关位 | 灰度 | 预留开关,防止非预期副作用 |
9. 容易踩的坑(我都踩过)
- “只改了系统,不改应用”:别妄想 sysctl 能替你把 Nagle 关掉,必须应用层设置。
- TLS 误解:加了 TLS 并不会“挡住” TCP_NODELAY,你设置的仍然作用在底层 socket。
- GRO/LRO 关闭后吞吐下降:这很正常——我们要的是交互延迟;若你有文件分发/重同步流量,请走独立网卡或在那条链路上打开 offload。
- 云环境看不到中断:KVM/云上只看到 virtio_net,不要徒劳去绑硬件队列;改为关注队列深度与 vCPU 亲和。
- TFO 生效误判:客户端不支持时你也能“开起来”,但抓包会发现没 TFO 标记。别把 TFO 的收益当成 NODELAY 的收益。
- ACK/Win 掉队:极端丢包时 QUICKACK 也救不了,优先排查跨境链路抖动(MTR 连续跑 10 分钟),必要时多线回源+BGP 优选。
- HAProxy 误配:不同版本/模式下是否 nodelay、何时启,差异很大;用 strace 看实际 setsockopt,别只看文档。
10. 回归业务:把优化固化到 CI/CD 与探针
- CI 检查:静态扫描(或单元测试)中直接 grep 关键模块是否调用 setNoDelay/setsockopt(TCP_NODELAY)。
- 启动自检:服务启动时打印 TCP_NODELAY=ON,并在健康检查里校验。
- 黑盒探针:在香港/广州/上海各放一个 tcpkali 定时任务,把 P50/P99 上报到 Prometheus。
- 回滚预案:Nginx 支持热开关 tcp_nodelay,应用层开关做成动态配置(Apollo/Consul)。
11. 可选进阶:更“新”的内核与 BBR(按需)
CentOS 7 默认 3.10 内核,足够稳定。如果你的对战架构里有带宽较高、跨境抖动的长连,换 ELRepo kernel-ml(≥ 4.9)启用 BBR,在我们某些分区能把 尾延迟再收 3–5 ms。
注意:变更内核属于重大变更,务必灰度+回滚。
12. 复盘与结尾:凌晨 4 点的那次稳定
到 4 点,机房窗外的货柜车开始多起来。我们把最后一个区的网关灰度完,tcpkali 的 P99 在 40 ms 边缘稳定下来。语音频道里,前线的主播喊“顺了!”。我合上工具箱,手还在冰,但心里不再凉。
这套方法没有魔法:应用层明确 TCP_NODELAY + 代理层确认 + OS/NIC 的低延迟基线,再用可重复的脚本固化和探针监控。
如果你也在香港机房里被延迟咬过,按照这篇一步步做,你会看到曲线往下拐——那拐点,通常出现在你把 strace 打到“setsockopt(TCP_NODELAY)=0”的那一刻。
附:一页纸清单(拿去就用)
- 应用:所有持久 TCP 连接强制 TCP_NODELAY=on,必要时 TCP_QUICKACK=on
- Nginx stream:tcp_nodelay on;,listen ... fastopen=4096;
- OS 基线:上文 sysctl 全量;tuned-adm profile latency-performance
- NIC:gro/lro/gso/tso off,环形缓冲 rx/tx=4096,中断绑核
- 验证:strace -e setsockopt -p <pid>;tcpkali P50/P99
- 监控/回滚:仪表盘+灰度开关;跨境链路用 MTR 连跑校验
- 有任何现场“奇葩抖动”,把抓包、strace 截图和 MTR 发我,我给你“机房味”的诊断建议。