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_PRELOAD 和 MALLOC_CONF,比你去调 innodb_io_capacity 有用得多。