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

游戏状态服“内存总是吃满”?我在香港机房用 DDR4/DDR5 与 NUMA 把它打回原形

发布人:Minchunlin 发布时间:2025-09-27 09:53 阅读量:187


那天夜里,短信告警把我叫醒:“状态服内存 95%+ 持续 20 分钟,玩家登陆出现抖动。”
我 VPN 进了香港机房的跳板,free -h 一看心里“咯噔”——内存几乎被吃光。但经验告诉我,Linux 的“内存满”不一定是坏事:大量的 buff/cache + NUMA 远程内存 + THP 抖动 + 分配器碎片,能把指标和真实体验“拉开一条鸿沟”。
这篇文章,就是把那次彻夜折腾的过程,写成一份可复用的部署与优化手册:从硬件(DDR4/DDR5、内存通道数)到 OS(CentOS 7)、再到进程级(numactl、jemalloc、HugePage、亲和性),一步步把“看似吃满”变成“稳态健康”。

1. 现场环境清单(香港常见机型 + 我这次的两台样机)

为了有“现场感”,先把我手头两类机器贴出来(都是 HK 机房的常见库存):

机型 CPU / 代系 内存代际 每路内存通道 典型内存速率(单通道理论) 插条示例 备注
A(DDR4 方案) Intel Xeon Gold 6130(1S 或 2S) DDR4 6 ch/路 DDR4-2666 ≈ 21.3 GB/s 8×32GB RDIMM 老练稳,机房多;注意双路跨 UPI 远程内存
B(DDR5 方案) Intel Xeon(Sapphire Rapids)/ AMD EPYC 9004 DDR5 Intel 8 ch / AMD 12 ch DDR5-4800 ≈ 38.4 GB/s 8×32GB RDIMM(Intel)/ 12×32GB(AMD) DDR5 吞吐和并发友好,NUMA 细分更激进

粗略带宽心算:

  • DDR4-2666:约 21.3 GB/s/通道;单路 6 通道≈128 GB/s
  • DDR5-4800:约 38.4 GB/s/通道;Intel 8 通道≈307 GB/s;AMD 12 通道≈460 GB/s

真实值受 DIMM 插条数量、DPC、BIOS 限频、内存时序等影响。

我们的目标:在状态服这类“读多、写频繁、小对象生命周期复杂”的负载下,让本地 NUMA 节点优先、减少远程访问、降低碎片/抖动、把“看似吃满”变成预期可控。

2. 症状与误判:为什么“内存满”不等于出事?

  • Linux 会尽可能把内存用作页缓存(buff/cache)。
  • NUMA 自动平衡可能把热页迁移到远程节点,引发延迟飙升但总内存还“看似安全”。
  • THP(透明大页)在“always”时,密集缺页与内存压缩会抖。
  • 分配器碎片(glibc malloc)导致 RSS 居高不下、释放不及时。
  • 跨 NUMA 的 IRQ/NIC 中断,会让网络线程频繁打远程内存。

判定要点:别只看 free -h,要看 numa 维度、缺页/压缩/回收、远程访问。

3. 上线前硬件/BIOS 准备(DDR4/DDR5 的差异心法)

DIMM 布局

  • DDR4/DDR5 都要尽量插满每通道的第一条,优先 1DPC 保速率;跨通道对称插条,避免单通道成为瓶颈。
  • 双路(2S)机器:每路插条对称,避免某路/某 NUMA 节点容量或带宽偏科。

BIOS 关键项(不同厂商措辞略有差异)

NUMA / Memory Interleaving:关闭全局内存交错(保持 NUMA 拆分清晰);若确实要“全局平均”,可在应用层用 interleave控制。

SNC(Sub-NUMA Clustering)/ NPS(AMD):

  • 不了解进程绑核/绑内存?先关闭(或用 NPS=1)。
  • 能做到“每实例绑定到一个 NUMA 子域”?可开启 SNC2/NPS=2/4,换更小延迟,但要严格绑核/绑内存。

UPI/Infinity Fabric 频率:尽量“Auto 高速档”。

虚拟化/内存保护(如 PAT/Memory RAS):保持默认稳定即可。

4. 作业系统:CentOS 7 基线(落地就这几步)

假设干净的 CentOS 7 环境,root 执行

# 1) 基础工具
yum install -y epel-release
yum install -y numactl numactl-devel hwloc lshw lscpu dmidecode tuned tuned-utils \
               net-tools irqbalance perf numactl numactl-devel numactl-libs \
               jemalloc jemalloc-devel

# 2) tuned 低延迟基线
tuned-adm profile latency-performance
systemctl enable --now tuned

# 3) irqbalance 开启(先开着,后面再定向亲和)
systemctl enable --now irqbalance

# 4) 关闭自动 NUMA 平衡(运行时立即生效)
echo 0 > /proc/sys/kernel/numa_balancing
echo "kernel.numa_balancing = 0" > /etc/sysctl.d/99-numa.conf

# 5) 内存回收策略(减少跨节点回收)
cat <<'EOF' > /etc/sysctl.d/99-memory.conf
vm.zone_reclaim_mode = 0
vm.swappiness = 1
vm.dirty_background_ratio = 5
vm.dirty_ratio = 20
EOF
sysctl --system

# 6) 透明大页改为 madvise(推荐)
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled
echo madvise > /sys/kernel/mm/transparent_hugepage/defrag
cat <<'EOF' > /etc/rc.d/rc.local
#!/bin/bash
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled
echo madvise > /sys/kernel/mm/transparent_hugepage/defrag
EOF
chmod +x /etc/rc.d/rc.local

若想彻底固化到内核参数(下次重启生效):
编辑 /etc/default/grub 的 GRUB_CMDLINE_LINUX 追加:
numa_balancing=disable transparent_hugepage=madvise
然后 grub2-mkconfig -o /boot/grub2/grub.cfg 并重启。

5. NUMA 拓扑与网络/磁盘亲和性排查

# 看拓扑/核/缓存
lscpu -e
numactl --hardware
hwloc-ls --top
# 远程内存比率
numastat
# NIC 归属哪个 NUMA 节点
for d in /sys/class/net/*/device/numa_node; do echo -n "$d -> "; cat $d; done
# 中断和队列
grep -i eth0 /proc/interrupts
ethtool -l eth0   # 查看队列

准则:把 NIC/Rx-Tx 队列的 IRQ 固定到同 NUMA 节点的 CPU,应用线程也落在同节点,减少远程访存。示例脚本(以 eth0、NUMA 节点 0 为例):

# 根据 /proc/interrupts 找到 eth0 相关 IRQ,绑到 CPU 0-7
for irq in $(grep eth0 /proc/interrupts | awk -F: '{print $1}'); do
  echo 0-7 > /proc/irq/$irq/smp_affinity_list
done

生产建议:把游戏状态服实例、对应的 NIC 队列 IRQ、Redis/缓存实例放到同一 NUMA 节点,保证热数据本地化。

6. 进程级策略:分片 or 交错?三种可落地的运行形态

方案 A:每节点一实例(强绑定) —— 我最常用

单机多实例;每实例只用一个 NUMA 节点的 CPU 和内存。

优点:本地化极致,尾延迟小。

缺点:实例间负载不均需调度;跨实例通信复杂些。

# 以 NUMA 节点 0 为例,绑核 + 绑内存
numactl --cpunodebind=0 --membind=0 \
  env LD_PRELOAD=/usr/lib64/libjemalloc.so.2 \
  MALLOC_CONF="background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000" \
  ./gameserver --shard-id=0 --config=...

方案 B:单实例 + 内存交错(interleave)

应用不易拆分,且跨节点访问比较平均。

优点:实现简单。

缺点:不是热点友好,极端热点时仍可能远程开销大。

numactl --interleave=all \
  env LD_PRELOAD=/usr/lib64/libjemalloc.so.2 \
  ./gameserver --config=...

方案 C:SNC/NPS 子域 + 微分片(DDR5 机器常用)

BIOS 开启 SNC2/NPS=2/4;每子域跑一个小实例。

优点:更短的 L3/本地内存路径;吞吐/延迟漂亮。

缺点:部署与调度复杂,需严格绑核/绑内存/绑 IRQ。

7. 分配器与大页:用 jemalloc + THP(madvise) 稳住碎片与抖动

为什么不用 glibc malloc? 状态服的小对象/跨线程生命周期复杂,glibc 在高并发下更容易碎片化,RSS 掉不下来。

我的默认组合:jemalloc 5.x + transparent_hugepage=madvise。

实操要点

LD_PRELOAD jemalloc,并设置 MALLOC_CONF:

  • background_thread:true:后台回收更温和
  • dirty_decay_ms/muzzy_decay_ms:加速脏/朦胧页回收,降低 RSS 尖峰

代码中(若可改)对大对象 madvise(MADV_HUGEPAGE)(C/C++);Go 则观察 GOGC 与 arena 行为(见下一节)。

export LD_PRELOAD=/usr/lib64/libjemalloc.so.2
export MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000,abort_conf:false
./gameserver ...

可选:1G HugeTLB(需要显式申请)

对极大对象或环形缓冲,你可以预留 1G 大页,让 TLB 命中更稳:

# 预留 8 个 1G 大页(重启后生效)
# /etc/default/grub 追加:
# default_hugepagesz=1G hugepagesz=1G hugepages=8
grub2-mkconfig -o /boot/grub2/grub.cfg
reboot

# 运行时挂载
mkdir -p /mnt/huge
mount -t hugetlbfs nodev /mnt/huge

# 应用里用 mmap/SHM 显式申请;或用 libhugetlbfs

8. Go / C++ 侧的几条“救命线”

Go(常见状态服栈)

从 Go 1.19+ 起,GC 与大对象行为更稳,但GOGC 仍然影响延迟尾巴。

实践值:GOGC=100~200 试范围;大对象池化;避免频繁 []byte 重分配;压热点 map。

若你能在关键缓冲上使用 unix.Madvise(addr, MADV_HUGEPAGE)(cgo),配合 THP=madvise 很香。

# 进程环境
export GOGC=150
export GOMEMLIMIT=0   # 视进程上限决定是否打开
taskset -c 0-15 numactl --cpunodebind=0 --membind=0 ./gameserver-go ...

C/C++

  • 小对象统一用 arena/池化(tcmalloc/jemalloc 都可,但我是 jemalloc 派)。
  • 大对象路径 madvise(MADV_HUGEPAGE),分配对齐到 2M,减少 THP 抖动。
  • 读多写少结构尽量无锁/低锁,减少跨 NUMA 的 cache bounce。

9. 一次真实变更的“前后对比”样例(节选)

机器:Intel DDR4(6 通道/单路),CentOS 7,状态服单机 QPS≈8.5w,峰值在线 2.3w。

形态:由“单实例 + 内核自动 NUMA 平衡 + THP=always”切到“每 NUMA 节点一实例 + THP=madvise + jemalloc”。

指标 变更前 变更后 备注
P99 逻辑帧延迟 42 ms 27 ms 抖动显著收敛
远程内存占比(numastat other%) 18% 3% 绑定 & IRQ 亲和生效
每秒缺页(major/minor) 240 / 2.8w 30 / 1.6w THP 改为 madvise、jemalloc 回收更稳
RSS 峰值 76 GB 61 GB 碎片降低,冷热区分明
CPU softirq 占比 9% 4% NIC 队列与应用线程同节点

上面是我真实落地的一组量级(精简过业务细节),你在 DDR5 + SNC 的机器上,P99 还能再往下砍一刀。

10. systemd 与多实例编排(CentOS 7 友好做法)

CentOS 7 的 systemd 没有新式 NUMAPolicy=,我更喜欢模板 Unit + numactl:

/etc/systemd/system/gameserver@.service

[Unit]
Description=Game State Server shard %i
After=network.target

[Service]
Type=simple
Environment=LD_PRELOAD=/usr/lib64/libjemalloc.so.2
Environment=MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000
ExecStart=/usr/bin/numactl --cpunodebind=%I --membind=%I \
  /opt/gameserver/bin/gameserver --shard-id=%I --config=/opt/gameserver/conf/server.yml
Restart=always
RestartSec=2

# CPU 亲和(可选,和 numactl 互补)
CPUAffinity=0-15

[Install]
WantedBy=multi-user.target

启动两个分片(对应 NUMA 节点 0/1):

systemctl enable --now gameserver@0
systemctl enable --now gameserver@1

11. 日志与可观测性(没有数据,优化都是玄学)

numa 维度:numastat -p <pid>、/proc/<pid>/numa_maps

THP:cat /sys/kernel/mm/transparent_hugepage/enabled、/proc/meminfo | grep -i huge

缺页/回收:vmstat 1、sar -B 1、perf stat -a

RSS/碎片(jemalloc):启动时加 MALLOC_CONF=prof:true,prof_active:true,定时统计(生产要评估开销)。

Prometheus:node_exporter 的 numastat、meminfo;应用暴露 alloc/GC 指标(Go 自带)。

12. DDR4 vs DDR5:选型与“踩坑清单”

选型建议

  • 以延迟稳定为王:DDR5 在并发与总带宽上领先,SNC/NPS 细粒度让本地化更强。
  • 预算紧/老业务:DDR4 依然能打;关键是把 NUMA 做对、分片落对。

常见坑

  • BIOS 开了内存交错,全局看似均匀,但你无法按节点做容量/带宽规划;建议关掉交错,用应用层掌控。
  • SNC 开了但没绑核/绑内存/绑 IRQ:延迟反而更糟。
  • THP=always 导致缺页和碎片回收抖动;madvise 一般更稳。
  • 自动 NUMA 平衡在高 churn 的状态服里迁页过度,P99 抖动明显;建议关闭,用 numactl 显式指定。
  • glibc malloc 在高并发小对象下 RSS 居高不下;上 jemalloc/tcmalloc。
  • NIC 队列跨节点:网络栈在 Node0,应用在 Node1,来回“打远程”,P99 不稳。
  • 双路机器跨 Socket 访问:把 Redis/状态缓存和计算实例分到同一 Socket/节点。

13. 一键体检小脚本(上线前跑一下)

/usr/local/bin/numa_doctor.sh

#!/bin/bash
set -e
echo "==[CPU/NUMA]=="
lscpu | egrep 'Socket|NUMA node|Thread|Core'
numactl --hardware

echo "==[NIC to NUMA]=="
for n in /sys/class/net/*/device/numa_node; do
  nic=$(echo $n | awk -F'/' '{print $(NF-2)}')
  echo -n "$nic -> "; cat $n
done

echo "==[THP]=="
grep -H . /sys/kernel/mm/transparent_hugepage/enabled
grep -H . /sys/kernel/mm/transparent_hugepage/defrag

echo "==[Kernel/VM]=="
sysctl vm.zone_reclaim_mode
sysctl vm.swappiness
sysctl kernel.numa_balancing

echo "==[IRQ for eth0]=="
grep eth0 /proc/interrupts || true

把输出截图/保存,作为“变更前基线”;变更后再跑一次对比。

14. 回滚与灰度策略(别让优化成为事故)

灰度单位:按NUMA 节点/实例为粒度;先 A/B 两台机器。

可逆性:

  • kernel.numa_balancing 可运行时改回 1;
  • THP echo always > .../enabled 即可;
  • LD_PRELOAD 下掉回到 glibc;
  • systemd 模板一键切回“单实例”。
  • 观测窗口:至少覆盖一波晚高峰 + 一次版本热更新。

15. 天亮 6:03,我把“满内存”的锅还了回去

凌晨 6 点,我看着 P99 曲线从“锯齿”变成“平缓小波”,numastat 上远程内存只剩个位数,玩家的投诉在群里消失了。
内存从来不是“用多少”的问题,而是“在哪儿、怎么用”的问题。
当你把NUMA 拆清、把线程/IRQ/数据放到同一个拓扑岛,再用 jemalloc+THP(madvise) 收拾碎片和抖动,“内存吃满”就不再是事故的预告,而是系统健康地把资源吃干榨尽。

速查清单(可以直接照抄)

  • BIOS:关全局交错;不会绑就别开 SNC/NPS。
  • OS:kernel.numa_balancing=0;THP=madvise;vm.zone_reclaim_mode=0;tuned-adm profile latency-performance。
  • 进程:每 NUMA 节点一实例(首选);numactl --cpunodebind --membind;LD_PRELOAD jemalloc。
  • 网络:NIC 队列 IRQ 绑到实例同节点;应用线程亲和同节点。
  • 观测:numastat -p、vmstat/sar -B、/proc/<pid>/numa_maps、Prometheus。
  • 回滚:开关 numa_balancing、THP 模式、LD_PRELOAD、systemd 模板。

如果你正被“内存吃满”折磨,照着上面的基线→分片→绑核/内存→jemalloc/THP→观测流程,先把可控性拿回来。
香港机房的延迟预算紧、晚高峰拥塞重,DDR5 + SNC + 分片能让你把稳态拉到一个新台阶;DDR4 机器也能靠拓扑纪律打出漂亮数据。

目录结构
全文