第4章:内存性能分析:从使用率到NUMA架构

内存性能分析,说实话,是很多性能问题的“隐形杀手”。CPU跑得再快,一旦内存跟不上,整个系统就得等着。我见过太多案例,应用层代码写得花里胡哨,结果一查,内存带宽打满了,或者NUMA跨节点访问导致延迟飙升。这一章,咱们就把内存这块彻底聊透。

4.1 内存使用率:别被数字骗了

很多人看内存,就盯着free -h里的“used”和“free”。其实这远远不够。我习惯用/proc/meminfo,那里才是真相。

# 查看详细内存信息
cat /proc/meminfo

# 重点关注这几个指标
MemTotal:       16384000 kB
MemFree:         1024000 kB
MemAvailable:    8192000 kB
Buffers:          512000 kB
Cached:          6144000 kB

这里有个坑:MemFree ≠ 可用内存。Linux会把空闲内存用作文件缓存(Cached),当应用需要时,这些缓存可以快速释放。所以真正可用的,是MemAvailable这个值。

我的习惯:监控内存时,我只看 MemAvailable / MemTotal 这个比例。低于20%就要警惕了,不是内存泄漏就是缓存堆积。

我曾经遇到一个Java应用,free -m显示用了90%内存,但应用响应很慢。一查/proc/meminfo,发现Cached占了60%。原来是日志文件被反复读取,导致page cache膨胀。解决方案很简单:用sync && echo 3 > /proc/sys/vm/drop_caches清理一下,应用立刻恢复。

4.2 内存泄漏:最难抓的Bug

内存泄漏,说白了就是“借了不还”。C/C++里最常见,Java里虽然GC会回收,但对象引用没释放,照样泄漏。

我常用的排查工具:

  • valgrind:C/C++程序的“照妖镜”,能定位到具体哪一行代码泄漏了
  • jemalloc:带profiling功能,可以生成内存分配火焰图
  • perf:配合mem事件,采样内存访问模式
# 用valgrind检测内存泄漏
valgrind --leak-check=full --show-leak-kinds=all ./my_program

# 输出示例
==12345== 40 bytes in 1 blocks are definitely lost in loss record 1 of 5
==12345==    at 0x4C2B800: malloc (vg_replace_malloc.c:309)
==12345==    by 0x4005E4: main (test.c:10)

嗯,这里要注意:valgrind会让程序慢10-20倍,生产环境别用。我一般先在测试环境复现,或者用jemalloc的profiling做低开销采样。

避坑指南:我曾经在生产环境直接跑valgrind,结果服务超时,被运维骂了一顿。后来学乖了,用 jemalloc --prof 做在线分析,开销只有5%左右。

4.3 Swap:性能的“毒药”

Swap是内存不够时的“救命稻草”,但也是性能杀手。为什么?因为磁盘IO比内存慢几个数量级。一旦开始频繁swap,系统响应时间会从毫秒级变成秒级。

我判断swap是否健康的指标:

指标 健康值 危险值
si (swap in) 接近0 > 100 pages/s
so (swap out) 接近0 > 100 pages/s
swap使用率 < 10% > 50%
# 用vmstat监控swap
vmstat 1 5
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 1  0 102400 204800 51200 614400    0    0    10    20  100  200 10  5 85  0  0

如果si和so持续大于0,说明物理内存不够用了。我建议:要么加内存,要么调整vm.swappiness参数,让系统更倾向于使用内存而不是swap。

我的建议:对于数据库、缓存这类延迟敏感的应用,直接关闭swap。对于普通Web服务,设置 vm.swappiness=10,只在极端情况下才使用swap。

4.4 大页内存:TLB Miss的救星

现代CPU的TLB(页表缓存)条目有限。默认4KB的页大小,意味着要覆盖2MB内存,需要512个TLB条目。而大页(HugePages)把页大小提升到2MB甚至1GB,TLB覆盖范围瞬间扩大。

我为什么强调这个?因为数据库、虚拟化、HPC这类应用,内存访问模式是“大块连续”的,TLB Miss会严重影响性能。

# 查看当前大页配置
cat /proc/meminfo | grep Huge

# 配置2MB大页(以1024个为例)
echo 1024 > /proc/sys/vm/nr_hugepages

# 挂载hugetlbfs
mount -t hugetlbfs hugetlbfs /dev/hugepages

配置完大页后,应用需要显式使用。比如MySQL的InnoDB引擎,可以设置innodb_use_large_pages=ON。Java的JVM可以用-XX:+UseLargePages参数。

性能提升案例:我优化过一个Redis集群,开启大页后,SET/GET操作的延迟从120μs降到了45μs。原因就是TLB Miss从15%降到了0.5%。

不过要注意:大页是预分配的,如果配置太多,其他进程可能拿不到内存。我习惯先预留总内存的20%给大页,然后根据实际使用情况调整。

4.5 NUMA架构:跨节点访问的代价

NUMA(非统一内存访问)架构下,CPU访问本地内存和远端内存的速度不一样。你想想看,如果进程跑在CPU 0上,却频繁访问CPU 1的内存,延迟会高30%-50%。

我排查NUMA问题的三板斧:

  1. 查看NUMA拓扑:numactl --hardware
  2. 检查进程绑定:numastat -p <pid>
  3. 调整策略:numactl --cpunodebind=0 --membind=0 ./my_app
# 查看NUMA节点信息
numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3
node 0 size: 32768 MB
node 0 free: 16384 MB
node 1 cpus: 4 5 6 7
node 1 size: 32768 MB
node 1 free: 20480 MB
node distances:
node   0   1
  0:  10  20
  1:  20  10

看到node distances了吗?20表示跨节点访问的延迟是本地(10)的两倍。如果进程的numastat显示大量远端访问,就得考虑绑核或内存迁移了。

我曾经踩过的坑:有一次优化一个多线程服务,把线程绑到了不同CPU上,但没绑内存。结果线程A在CPU 0上运行,却分配了CPU 1的内存,性能反而下降了。后来用 numactl --membind=0 强制本地分配,吞吐量提升了40%。

4.6 内存带宽:被忽视的瓶颈

很多人只关注内存容量,却忽略了带宽。其实,内存带宽打满时,CPU会进入“等待内存”状态,利用率再高也没用。

我常用的带宽测试工具:

  • stream:经典的带宽基准测试,测Copy、Scale、Add、Triad四种模式
  • perf stat:配合mem-loads和mem-stores事件,看实际带宽
# 用stream测试内存带宽
gcc -O3 -fopenmp -DSTREAM_ARRAY_SIZE=100000000 stream.c -o stream
./stream

# 输出示例
Copy:   12500.0 MB/s
Scale:  12000.0 MB/s
Add:    13000.0 MB/s
Triad:  12800.0 MB/s

如果实际带宽接近硬件理论值(比如DDR4-3200的理论带宽约25.6 GB/s),说明内存子系统已经饱和了。这时候优化方向不是内存,而是减少内存访问次数。

我的优化思路:当内存带宽成为瓶颈时,我会优先考虑:1) 数据压缩,减少传输量;2) 缓存友好算法,提高数据局部性;3) 多线程分片,让每个线程访问独立的内存区域。

举个例子,我优化过一个图像处理管线。原始代码逐像素处理,导致大量随机内存访问。改成按16x16的块处理,利用CPU缓存行,带宽利用率从30%提升到了85%,处理速度翻了2.5倍。

小结

内存性能分析,说白了就是搞清楚三件事:容量够不够、访问快不快、分配合不合理。从使用率到NUMA,每个环节都可能成为瓶颈。我个人的经验是:先看MemAvailable,再看si/so,然后检查NUMA分布,最后用stream测带宽。按这个顺序排查,90%的内存问题都能定位到。

下一章,咱们聊聊磁盘IO。那又是一个全新的世界——IO延迟、队列深度、文件系统...嗯,到时候再细说。