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

把跨境网游延迟从 85ms 打到 30ms:在香港机房用网卡多队列 + 拥塞控制把“抖动怪”擒住了(完整实操与避坑清单)

发布人:Minchunlin 发布时间:2025-09-27 09:40 阅读量:247


那天是凌晨 2:17,我还在香港荃湾机房值夜班,广州、深圳一带玩家在我们的新服连不上几分钟就“瞬移”“回档”,语音频道里全是叹气。mtr 一扫:广州电信->香港机房,RTT 在 80~95ms 之间抖,上下波动 20ms,丢包 1% 左右。典型的跨境晚高峰+服务器侧排队延迟叠加。

我心里有数——线路我们已经上了 CN2 优质线路,真正能立刻改变玩家体感的,是服务器侧队列与拥塞控制:把内核、网卡队列、队列学科(qdisc)、中断亲和、RPS/XPS/RFS、GRO/LRO、队列长度、合并策略一层层顺下来,才是真正的“低延迟落地”。

下面是我那一夜的完整操作手记。内容偏硬核,但每一刀都是真实机房里砍出来的。

场景与目标

  • 业务:大型多人在线游戏(UDP 为主,部分 TCP 控制/登录通道)。
  • 地域:玩家主要在华南(广深佛、珠三角),服务器在香港。
  • 目标:晚高峰下把平均 RTT 压到 ≈30ms,同时把99p 抖动控制在 ≤ 8ms,基本无玩家体感卡顿。
  • 约束:不改业务逻辑,不引入 CDN/加速器;只在香港服务器侧做可回滚的系统级与网卡层优化。

硬件与系统基线

服务器与网卡(我的这台)

  • 机型:单路 2U(AMD EPYC 7543P / Intel Xeon Gold 6248R 二选一我都做过,这次是 EPYC)
  • 内存:128GB,NUMA=1(单路更好做亲和)
  • 网卡:25GbE,Intel X710-DA2(i40e 驱动) 与 Mellanox ConnectX-4 Lx 我都压过延迟,这次用 X710-DA2。
  • 存储:NVMe 系统盘 + NVMe 数据盘
  • 交换机:TOR 25G 上行汇聚,机房到运营商边界是 100G。
  • 线路:BGP 多线里含 CN2/CMI 优质段(这块是资源条件,不做展开)。

操作系统与内核

  • OS:CentOS 7(稳定为主,客户统一要求)
  • 内核:CentOS 7 自带 3.10 对 BBR、fq_codel 等支持不完美,我统一升到 ELRepo kernel-ml(建议 ≥ 5.4 LTS)。
  • 驱动:i40e/ixgbe 建议用 ELRepo kmod 或官方驱动,对低延迟与多队列更稳。
  • 关键点:跨境 RTT 30ms 的天花板由“物理距离 + 运营商路由”决定,但服务器侧排队控制经常能砍掉 20~40ms 的可变抖动,玩家体感差别巨大。

部署步骤(一步步照做,可回滚)

内核升级(CentOS 7)

# 安装 ELRepo
yum install -y https://www.elrepo.org/elrepo-release-7.el7.elrepo.noarch.rpm

# 安装主线内核(kernel-ml)
yum --enablerepo=elrepo-kernel install -y kernel-ml

# 设置默认启动到新内核
grub2-set-default 0
grub2-mkconfig -o /boot/grub2/grub.cfg   # 传统 BIOS
# UEFI 机器用:grub2-mkconfig -o /boot/efi/EFI/centos/grub.cfg

reboot

1)网卡与 BIOS/固件基线

BIOS:Performance 模式,禁用深度 C-State(或者把 C1E/Package C-State 限制在较浅等级),开 Turbo,内存定在 Maximum Performance。

网卡固件:保持与驱动匹配的稳定版本(X710 经常遇到旧固件导致队列异常/掉包)。

中断截获:确认 iommu=pt,避免奇怪的 DMA 抖动。

2)多队列、Ring Buffer、合并策略(ethtool)

# 查看硬件队列与通道
ethtool -l eth0

# 设置为与核心数/NUMA亲和的队列数(示例:16 队列)
ethtool -L eth0 combined 16

# 调整 ring buffer(避免溢出但不过大),典型:
ethtool -g eth0
ethtool -G eth0 rx 1024 tx 1024

# 关闭 LRO(UDP 强烈建议关闭),保留 GRO 先观察
ethtool -K eth0 lro off
ethtool -K eth0 gro on gso on tso on

# 中断合并(coalesce):先给低延迟基线
ethtool -C eth0 rx-usecs 5 tx-usecs 5
# 如吞吐上去后仍抖,可把 rx-usecs 适当下探至 2~4(更低延迟,CPU 换抖)

坑 1:有些云宿主会把 ethtool 设置写不进去(虚机/受管 vSwitch)。遇到写保护就别纠缠,转到上层 qdisc + 应用层并行度去消化。

3)IRQ 亲和、RPS/XPS、irqbalance

原则:接收/发送队列与 CPU 亲和,尽量同 NUMA;UDP 游戏包小、频繁,缓存命中比绝对吞吐更重要。

做法:

暂时停 irqbalance(它会打散亲和,低延迟场景不友好)。

systemctl stop irqbalance
systemctl disable irqbalance

用 Intel 驱动自带的 set_irq_affinity.sh 或手动写 cpumask,把 eth0-TxRx-<n> 中断分布到 0~15 号核(示例)。

# 快速示例:把 eth0 的 16 个中断编号查出来,轮询绑到 0-15 核
grep eth0 /proc/interrupts | awk '{print $1}' | sed 's/://g' | \
nl -v0 | while read idx irq; do
    cpu=$((idx % 16))
    mask=$(printf "%x" $((1<<cpu)))
    printf "irq %s -> cpu %d (mask 0x%s)\n" "$irq" "$cpu" "$mask"
    echo $mask > /proc/irq/$irq/smp_affinity
done

RPS/XPS:只在核心不足或硬件 RSS 不理想时用。多数 10/25G 网卡的硬件 RSS 足够好,RPS 反而可能带来缓存跨核抖动。

若必须用:/sys/class/net/eth0/queues/rx-*/rps_cpus 和 tx-*/xps_cpus 写入同核 cpumask,保持对齐。

坑 2:RPS/RFS“看起来很美”,但在 UDP 小包高 QPS 下经常适得其反。先信任硬件 RSS,只在确凿瓶颈下再开。

4)内核网络栈参数(sysctl)

准备一个独立文件 /etc/sysctl.d/99-game-lowlatency.conf:

# 队列/缓冲与 backlog
net.core.netdev_max_backlog = 4096
net.core.somaxconn = 4096
net.core.rmem_default = 262144
net.core.rmem_max = 134217728
net.core.wmem_default = 262144
net.core.wmem_max = 134217728
net.ipv4.udp_rmem_min = 16384
net.ipv4.udp_wmem_min = 16384

# 针对 UDP 的短队列 + 低排队延迟(配合 fq_codel/fq)
net.core.default_qdisc = fq_codel

# TCP 通道(登录/控制面)用 BBR/FQ,避免长尾
net.ipv4.tcp_congestion_control = bbr
net.ipv4.tcp_fastopen = 3
net.ipv4.tcp_timestamps = 1
net.ipv4.tcp_sack = 1
net.ipv4.tcp_low_latency = 1
net.ipv4.tcp_rmem = 4096 131072 134217728
net.ipv4.tcp_wmem = 4096 131072 134217728

# TIME-WAIT 回收&保护
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 10

# 连接跟踪预算(若使用 nf_conntrack)
net.netfilter.nf_conntrack_max = 262144

应用:

sysctl --system

解释:

  • UDP 没有内建拥塞控制,把默认 qdisc 设为 fq_codel 能有效抑制 bufferbloat,缩短排队时间。
  • TCP 通道改用 BBR+FQ,避免大文件/补丁下载把队列“撑爆”。
  • rmem/wmem 把上限打开,配合应用 SO_RCVBUF/SO_SNDBUF 自适应。

5)队列学科(qdisc)精准下刀(ingress + egress)

对出口(egress)与入口(ingress)都做控队列,ingress 需引 IFB:

modprobe ifb
ip link add ifb0 type ifb
ip link set ifb0 up

# 把 ingress 挂到 ifb0
tc qdisc add dev eth0 handle ffff: ingress
tc filter add dev eth0 parent ffff: protocol all u32 match u32 0 0 \
  action mirred egress redirect dev ifb0

# 出口(root)用 fq_codel,适合混合 UDP/TCP 的低延迟场景
tc qdisc replace dev eth0 root fq_codel limit 1000 target 5ms interval 100ms flows 2048 quantum 1514

# 入口(到 ifb0 的 root)也用 fq_codel,抑制突发
tc qdisc replace dev ifb0 root fq_codel limit 1000 target 5ms interval 100ms flows 2048 quantum 1514

可选:如果你想给游戏 UDP 端口更高优先级,可以做个简单的 DSCP 标记 + 分类(示例 UDP 端口 20000-20100):

# 标记包(iptables mangle 或 nftables)
iptables -t mangle -A PREROUTING -p udp --dport 20000:20100 -j DSCP --set-dscp-class EF

# 在 egress 用 tc 分类(示意)
tc qdisc replace dev eth0 root handle 1: prio bands 3
tc qdisc add dev eth0 parent 1:1 fq_codel
tc qdisc add dev eth0 parent 1:2 fq_codel
tc qdisc add dev eth0 parent 1:3 fq_codel
tc filter add dev eth0 parent 1: protocol ip u32 \
  match ip dscp 0x2e 0xff flowid 1:1   # EF 到最高优先级

坑 3:Cake 很香,但在 CentOS 7 + 生产内核上可用性一般(需要 backport 或自编译)。我在这个项目里稳用 fq_codel,效果已足够好。

6)应用层协作(关键但常被忽略)

SO_REUSEPORT + 多 worker:让 UDP 端口由 N 个进程共享,天然做了 XPS/RSS 的“应用层多队列”。

  • CPU 亲和:把每个 worker 绑到与其队列同核。
  • 缓冲:为 UDP socket 设置合适的 SO_RCVBUF/SO_SNDBUF(如 4~16MB),配合上面的 rmem_max/wmem_max。
  • 事件模型:epoll + 较小 batch,减少应用层排队。

日志:把热路径日志降级或异步,否则 IO 抖动反噬延迟。

实战数据(节选)

基线 VS 优化后(晚高峰 20:00-23:00,广州电信 -> 香港)

指标 优化前(均值/99p) 优化后(均值/99p) 备注
RTT(ms) 86 / 118 31 / 38 mtr 对边界网关 IP,采样 5 分钟
丢包率 0.8% 0.1% 以 ICMP/MTR 为准,业务侧更低
服务器排队延迟(ms) 8.5 / 21 1.7 / 4.3 flent rrul + eBPF kprobe 估计
抖动(Jitter, ms) 12.3 4.9 RTP 风格统计
CPU 占用(%) 34 41 关了 irqbalance、缩短 coalesce 后上升可接受
NIC Drops(/5min) 1,200 70 ethtool -S

注:RTT 直接到 30± 的关键是“排队时延 + 抖动”被强行压扁;跨境物理距离仍在,但玩家体感从“糯”变“脆”。

观测与回归

链路:mtr -rwzc 200 <GW/IP>,高频短窗。

服务器网卡:ethtool -S eth0 盯 rx_missed_errors、rx_no_buffer_count、tx_timeout。

队列学科:tc -s qdisc show dev eth0 / ifb0 看 backlog 与 drops。

内核态:nstat、ss -s。

系统:sar -n DEV 1、mpstat -P ALL 1、perf top 热点。

业务:玩家端打点(上行/下行 RTT、重传、丢包),灰度放量观察 48h。

完整可回滚脚本(精简版)

放在 /opt/netlowlatency/bootstrap.sh,按需修改队列/端口范围。

#!/usr/bin/env bash
set -euo pipefail

NIC=${NIC:-eth0}
QUEUES=${QUEUES:-16}

echo "[1] ethtool channels/rings/feature"
ethtool -L $NIC combined $QUEUES || true
ethtool -G $NIC rx 1024 tx 1024 || true
ethtool -K $NIC lro off gro on gso on tso on || true
ethtool -C $NIC rx-usecs 5 tx-usecs 5 || true

echo "[2] stop irqbalance & pin irqs"
systemctl stop irqbalance || true
systemctl disable irqbalance || true

IRQS=$(grep $NIC /proc/interrupts | awk '{print $1}' | sed 's/://g')
idx=0
for irq in $IRQS; do
  cpu=$((idx % QUEUES))
  mask=$(printf "%x" $((1<<cpu)))
  echo $mask > /proc/irq/$irq/smp_affinity
  echo "irq $irq -> cpu $cpu (mask 0x$mask)"
  idx=$((idx+1))
done

echo "[3] sysctl"
cat >/etc/sysctl.d/99-game-lowlatency.conf <<'EOF'
net.core.netdev_max_backlog = 4096
net.core.somaxconn = 4096
net.core.rmem_default = 262144
net.core.rmem_max = 134217728
net.core.wmem_default = 262144
net.core.wmem_max = 134217728
net.ipv4.udp_rmem_min = 16384
net.ipv4.udp_wmem_min = 16384
net.core.default_qdisc = fq_codel
net.ipv4.tcp_congestion_control = bbr
net.ipv4.tcp_fastopen = 3
net.ipv4.tcp_timestamps = 1
net.ipv4.tcp_sack = 1
net.ipv4.tcp_low_latency = 1
net.ipv4.tcp_rmem = 4096 131072 134217728
net.ipv4.tcp_wmem = 4096 131072 134217728
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 10
net.netfilter.nf_conntrack_max = 262144
EOF
sysctl --system

echo "[4] qdisc (egress/ingress)"
modprobe ifb || true
ip link add ifb0 type ifb || true
ip link set ifb0 up || true

tc qdisc replace dev $NIC root fq_codel limit 1000 target 5ms interval 100ms flows 2048 quantum 1514 || true

tc qdisc add dev $NIC handle ffff: ingress 2>/dev/null || true
tc filter add dev $NIC parent ffff: protocol all u32 match u32 0 0 \
  action mirred egress redirect dev ifb0 2>/dev/null || true

tc qdisc replace dev ifb0 root fq_codel limit 1000 target 5ms interval 100ms flows 2048 quantum 1514 || true

echo "[5] DSCP for game UDP port (optional)"
# iptables -t mangle -A PREROUTING -p udp --dport 20000:20100 -j DSCP --set-dscp-class EF || true

echo "done."

回滚很简单:

tc qdisc del dev eth0 root;tc qdisc del dev eth0 ingress;ip link del ifb0

删掉 /etc/sysctl.d/99-game-lowlatency.conf 并 sysctl --system

重新启用 irqbalance;ethtool 相关设置恢复默认或重启。

典型坑位与现场解法

BBR 不生效 / 默认还是 cubic

原因:老内核或 default_qdisc 没配 fq/fq_codel。

解法:升级到 4.9+(我用 5.4+),sysctl 明确 tcp_congestion_control=bbr,TCP 通道用 ss -ti 验证 cwnd/BBR。

GRO/LRO 导致抓包“看不到小包”

现象:tcpdump 抓到的包被合并。

解法:抓包网口临时 ethtool -K <nic> gro off lro off,抓完再开回;业务网口保持 LRO=off,GRO 视延迟/吞吐取舍。

RPS/RFS 开了反而抖

原因:跨核 cache miss;队列/核映射不稳定。

解法:先关,充分利用硬件 RSS,多 worker + SO_REUSEPORT 做“应用层多队列”。

coalesce 设太大

现象:吞吐漂亮,延迟肉眼可见地“黏”。

解法:把 rx-usecs 往 2~5 收,CPU 多了点但 RTT 立降。

irqbalance 又悄悄启动了

现象:亲和被打散,延迟曲线“锯齿化”。

解法:彻底 disable,并在维护手册里钉死。

内核 3.10 上 cake/fq_codel 参数不全

解法:不要恋战老内核。在 CentOS 7 上用 ELRepo kernel-ml,稳定又省心。

虚拟化/云宿主禁用了 ethtool / tc

解法:和提供方沟通开白名单;开不了就转移到“应用多进程 + SO_REUSEPORT + 业务层限速/排队”,做到“穷则变、变则通”。

验收 Checklist(上线/灰度)

  •  新内核已生效,驱动/固件版本记录在案。
  •  irqbalance 停止且不自启;IRQ 与队列亲和正确。
  •  ethtool 的队列、ring、coalesce、GRO/LRO 状态确认。
  •  sysctl 应用无误,default_qdisc=fq_codel、tcp_congestion_control=bbr。
  •  tc 的 root/ingress/ifb 拓扑与参数对齐,tc -s 无明显 drop/backlog 异常。
  •  应用层 worker 与 CPU 亲和、SO_REUSEPORT 生效。
  •  观测面:mtr、ethtool -S、nstat、业务打点仪表盘有对比基线。
  •  5% 玩家灰度 24h,99p 抖动 ≤ 8ms,无新增退服/报错。
  •  回滚方案可随时一键执行。

FAQ:为什么我们能摸到 30ms?

物理与路由给了一个“瓶口”(华南<->香港),但服务器侧排队经常是体感凶手。

队列学科(fq_codel)+ BBR 把“好钢用在刀刃上”:UDP 小包不被大流量淹没,TCP 下载不再把网卡/内核队列堵成一锅粥。

多队列 + 亲和 让包尽量在“同核-同 NUMA”里直来直去,减少跨核抖动。

合适的 coalesce、GRO 在“CPU 与延迟”之间找平衡点。
最终我们把可控的排队时间从 8~20ms 打到 2~5ms,跨境 RTT 就自然降到 ≈30ms,并且“稳”。

凌晨 4:03,我合上机柜门

凌晨 4:03,最后一轮 mtr 收尾,仪表盘上 RTT 曲线贴着 31ms 一条直线,抖动像被熨斗烫平。玩家频道里只剩下“今晚不错”“不卡了”的碎语。我合上机柜门,手心还有螺丝刀的温度。
这活儿没有魔法,只有一刀一刀把队列的“时间债”还清。下次你再听到“跨境必高延迟”这种定论,想想那晚 30ms 的香港机房吧。

附:我常用的观测命令清单

# 链路健康
mtr -rwzc 200 <gw_or_peer_ip>

# 网卡统计
ethtool -S eth0 | egrep 'rx|tx|drop|err|miss|buffer'

# 队列学科与流量
tc -s qdisc show dev eth0
tc -s qdisc show dev ifb0

# 内核栈
nstat -az
ss -s

# 系统与 CPU
sar -n DEV 1
mpstat -P ALL  1
目录结构
全文