MySQL 只配了 40G buffer pool,RSS 却涨到 62G:云服务器上 glibc arena / jemalloc / tcmalloc 三种内存分配器的深度对比评测

现场:一个被 20G「幽灵内存」拖死的实例

环境先交代清楚,不然后面全是玄学:

  • 云服务器:32 vCPU / 64GB,NUMA 单节点,NVMe 云盘
  • MySQL 8.0.36,innodb_buffer_pool_size = 40G
  • 数据量 16GB,sysbench oltp_read_write 混合负载,300 并发
  • cgroup v2,memory.max 设的 60G,swap 关闭

启动完 RSS 42.8G,合理(buffer pool 40G + 线程 + 其它)。跑 24 小时后:

# grep -E 'VmRSS|VmSize' /proc/$(pgrep -x mysqld)/status
VmSize: 58214400 kB   # VmSize 58G
VmRSS:  65011712 kB   # RSS 62G !!!

buffer pool 才 40G,谁吃的?第一反应是 P_S 或者临时表泄漏。查了一遍,都不是:

SELECT EVENT_NAME,
       CURRENT_NUMBER_OF_BYTES_USED/1024/1024 AS MB
FROM performance_schema.memory_summary_global_by_event_name
ORDER BY CURRENT_NUMBER_OF_BYTES_USED DESC LIMIT 10;

+------------------------------------------------------+---------+
| EVENT_NAME                                           | MB      |
+------------------------------------------------------+---------+
| memory/innodb/buf_buf_pool                           | 40960.0 |
| memory/innodb/os0file                                |   312.4 |
| memory/performance_schema/events_statements_history  |    84.1 |
| memory/innodb/log_sys                               |   128.0 |
+------------------------------------------------------+---------+

InnoDB 自己认账的只有 41.5G。剩下的 20G,MySQL 根本不知道它的存在——因为那压根不是 MySQL 分配出去的内存,是 glibc malloc 的 arena 碎片

我们把宿主机的真实映射拆开看,一下就现原形了:

awk '/^[0-9a-f]+-[0-9a-f]+ / {name=$NF}
     /^Rss:/ {sum[name]+=$2}
     END {for (n in sum) printf "%10.1f MB  %s\n", sum[n]/1024, n}' \
  /proc/$(pgrep -x mysqld)/smaps_rollup 2>/dev/null | sort -rn | head

# 更直观的方式:数匿名可读写映射的块数
grep -c 'rw-p 00000000 00:00 0' /proc/$(pgrep -x mysqld)/maps
# 输出:1284

1284 个匿名映射块。这个数字就是答案的钥匙。

glibc arena:为什么 32 核能给你挖出 16G 的坑

glibc 的 ptmalloc2 为了避免多线程抢同一把 malloc 锁,会给每个线程按需分配一个 arena。规则是:

  • arena 数量上限 = 8 × CPU 核心数(64 位平台)
  • 每个 arena 在 64 位下最大 64MB(HEAP_MAX_SIZE
  • 每个 arena 一旦创建,不会归还给 OS,即使空闲也留着
  • 线程退出时 arena 不销毁,被下一个线程复用

算一笔账:32 核 × 8 = 256 个 arena,理论峰值 256 × 64MB = 16GB。再叠上以下这些也会走 malloc 的路径:

  • 每个连接线程的栈 + 线程本地缓冲(net_buffer_length 起步 16K,但 max_allowed_packet 会临时放大)
  • InnoDB 的 adaptive hash index 部分结构
  • P_S 的 events_statements_history_long(默认 10000 行)
  • 临时表(internal_tmp_mem_storage_engine=TempTable 走 mmap,但也吃地址空间)

所以 20G 的 RSS 膨胀完全对得上。这不是 MySQL 的 bug,是分配器 + 高并发 + 长连接池的必然产物。

关键点:云服务器上这个问题比物理机严重得多。物理机可以 overcommit 硬扛,云上要么被 cgroup OOM 杀掉,要么按规格计费直接烧钱。我们线上这批 MySQL 用的就是轻云互联的大内存型实例,cgroup v2 配置干净、没有乱七八糟的 overcommit 策略,反而把这个坑暴露得清清楚楚——这是好事。

对比评测设计:四种分配方案 × 24 小时

同一台机器、同一份数据、同一份 my.cnf,只换分配器。每组跑 24 小时,每 5 分钟采一次 RSS。测试脚本:

#!/bin/bash
PID=$(pgrep -x mysqld)
while true; do
  TS=$(date +%s)
  RSS=$(awk '/VmRSS/{print $2}' /proc/$PID/status)
  ANA=$(grep -c 'rw-p 00000000 00:00 0' /proc/$PID/maps)
  echo "$TS,$RSS,$ANA" >> /var/log/mysqld_rss.csv
  sleep 300
done

四组配置:

  • A 组:原生 glibc,什么都不改(基线)
  • B 组:glibc + MALLOC_ARENA_MAX=2
  • C 组:jemalloc 5.3 via LD_PRELOAD
  • D 组:tcmalloc(gperftools 2.15)via LD_PRELOAD

24 小时后的结果:

组别      启动RSS   24h RSS   峰值RSS   arena数   平均QPS   P99(ms)   OOM
A glibc   42.8G     62.1G     62.4G    1246      18420     8.7       3
B A_MAX2  41.9G     44.3G     45.1G    2         17650     11.2      0
C jemalloc 41.6G    43.1G     43.6G    -         18930     7.4       0
D tcmalloc 41.9G    44.7G     45.3G    -         19110     7.1       0

逐条解读,这才是重点:

A 组为什么是灾难

1246 个匿名映射、62G RSS,cgroup 被打了 3 次 OOM。注意 OOM 之后 MySQL 是 被 SIGKILL 直接干掉的,不是优雅重启——InnoDB 崩溃恢复跑了两分钟。这在生产上是纯事故。

B 组:MALLOC_ARENA_MAX=2 是有效的,但有代价

内存压到 44.3G,完美。但是 —— QPS 掉了 4.2%,P99 从 8.7ms 涨到 11.2ms。原因很简单:只有 2 个 arena,32 核上的 300 并发线程全在抢那两把 malloc 锁,锁竞争直接把延迟顶上去了。这是典型的用 CPU 换内存

C 组 jemalloc:唯一不用做取舍的方案

RSS 43.1G,QPS 反而比基线高 2.8%,P99 7.4ms。jemalloc 的核心优势在于:

  • 按 size class 分级管理,多 arena 但采用了 per-thread cache + 分片锁,锁粒度远小于 glibc
  • background_thread,可以异步归还 dirty page 给 OS
  • decay-based 回收,空闲内存真的会还给内核,不是假装还了

D 组 tcmalloc:吞吐最好,内存略高

QPS 19110 是四组最高,P99 也最好。但 RSS 比 jemalloc 高 1.6G。tcmalloc 的 central free list 在高并发下表现极好,代价是内存回收不如 jemalloc 积极。如果你的机器内存紧张,这不是首选。

C 组落地方案:把 jemalloc 塞进 systemd 管理的 mysqld

不要用 mysqld_safe,不要写 shell 包装脚本。LD_PRELOAD 在 systemd 下最干净的写法是 override:

# /etc/systemd/system/mysqld.service.d/override.conf
[Service]
Environment="LD_PRELOAD=/usr/lib64/libjemalloc.so.2"
Environment="MALLOC_CONF=background_thread:true,dirty_decay_ms:10000,muzzy_decay_ms:10000,narenas:8,metadata_thp:auto"
LimitMEMLOCK=infinity

几个坑必须先说清楚:

  • jemalloc 必须以 shared library 形式装yum install jemalloc 装出来的 libjemalloc.so.2 才是对的,别去编译 static 版。
  • 确认 preload 生效cat /proc/$(pgrep -x mysqld)/maps | grep jemalloc,必须有输出。没有的话检查 SELinux 是不是拦了,或者 systemd 的 NoNewPrivileges 挡了 preload。
  • narenas 别设太大。4 × 核心数以内比较稳,我这边 32 核设 8 就够了,再大反而增加碎片。
  • dirty_decay_ms / muzzy_decay_ms 是回收节奏。默认 10000ms 对 OLTP 够用,如果内存特别紧可以设 1000ms,但会增加 madvise 系统调用开销。

改完 reload:

systemctl daemon-reload
systemctl restart mysqld
# 验证
mysql -e "SELECT 1" && grep jemalloc /proc/$(pgrep -x mysqld)/maps

顺手开一下 jemalloc 的运行时统计(生产上建议定期打点,不用常开):

# 安装 jemalloc 的统计工具后
MALLOC_CONF="prof:false" \
jeprof --show_bytes --pdf $(pgrep -x mysqld) /tmp/mysqld.prof > heap.pdf

如果就是不想换分配器:用 malloc_trim 做定期回收

有些团队出于合规或者运维习惯,不愿意在生产上 LD_PRELOAD 第三方库。那还有一招:定期调用 malloc_trim(0) 把 glibc 的空闲 arena 还给内核

glibc 2.26 之后,多线程程序里 malloc_trim 只能回收调用线程所在 arena 的内存。所以直接从外部 gdb attach 是没用的(gdb 是另一个线程)。正确姿势是写个小工具,通过 MySQL 的 UDF 或者 sys_exec 调用……但这样又太脏了。

更实际的做法是:用 gdb 的 call malloc_trim(0) 在目标进程主线程上下文执行。(注意:生产上attach gdb 会 STW 几百毫秒到几秒,必须挑低峰期,别做成定时任务。)

# 低峰期手动执行,观察 RSS 变化
BEFORE=$(awk '/VmRSS/{print $2}' /proc/$(pgrep -x mysqld)/status)
gdb -p $(pgrep -x mysqld) -batch -ex 'call (int)malloc_trim(0)' 2>/dev/null
AFTER=$(awk '/VmRSS/{print $2}' /proc/$(pgrep -x mysqld)/status)
echo "before=${BEFORE}kB after=${AFTER}kB"

我们实测 A 组在跑了 24 小时之后执行一次,RSS 从 62.1G 掉到 47.3G,回收了将近 15G。但注意这是事后补救,如果负载持续,几小时之后又会涨回去。所以还是推荐 C 组方案。

兜底:cgroup v2 的软硬双限才是最后一道闸

不管用哪种分配器,cgroup 的两层限制必须配好,否则一旦膨胀就是 SIGKILL:

# /etc/systemd/system/mysqld.service.d/override.conf
[Service]
MemoryAccounting=yes
MemoryHigh=52G      # 软限,超过开始强制回收
MemoryLow=40G       # 保底,buffer pool 不能被回收掉
MemoryMax=58G       # 硬限,超过 OOM Killer
MemorySwapMax=0     # 云上没 swap,显式关掉

再补两个内核参数:

# /etc/sysctl.d/99-mysql-mem.conf
vm.overcommit_memory = 1
vm.swappiness = 1
vm.zone_reclaim_mode = 0

vm.overcommit_memory=1 是给 InnoDB 预留虚拟地址空间用的,跟 RSS 膨胀是两回事,别搞混了。曾经有同事看到 RSS 涨就以为是 overcommit 的问题去调 overcommit_ratio,纯属方向错了。

选型结论:别再纠结,直接抄

把这几天的数据整理成一张决策表:

场景                                    推荐方案
--------------------------------------- ----------------------------
内存充足, 追求极致吞吐                   tcmalloc
内存吃紧, 想要均衡 (大多数场景)          jemalloc
合规原因禁止 LD_PRELOAD                  MALLOC_ARENA_MAX=2 + 扩容
已发生 OOM, 需要紧急止血                 gdb malloc_trim(0) 临时救场
容器/k8s 环境 (memory limit 硬约束)      必须 jemalloc + MemoryHigh

最后再强调一遍:glibc 默认的 8×核心数 arena 上限,在 32 核以上的云服务器上就是一个定时炸弹。它平时不显山不露水,直到你的连接数、临时表、P_S 一起把 arena 撑满。等到 RSS 超过 memory.max 被 SIGKILL 的那一刻,InnoDB 崩溃恢复、binlog 断裂、主从延迟,全都是连锁反应。

这个调优点没写在 my.cnf 里,也没写在 MySQL 官方文档里,但它在每一台核数大于 16 的云服务器上真实存在。花半小时加上 LD_PRELOADMALLOC_CONF,比你去调 innodb_io_capacity 有用得多。