云服务器跑 MySQL 别只改 my.cnf:systemd cgroup 里还藏着 5 道隐形的墙

上周帮一个朋友看事故:4C8G 的云主机,MySQL 8.0.32,`my.cnf` 里 `innodb_buffer_pool_size=4G`,配置看着人畜无害。业务跑到晚上 9 点高峰突然全站 502,SSH 上去一看 mysqld 进程没了,`error.log` 最后一行停在三小时前的正常输出,连个 segfault 都没有。

这不是 MySQL 崩了,是被**外面**的东西勒死的。云主机和物理机最大的区别是:你的进程头上压着 systemd unit、cgroup v2、云盘 IO 模型三层"看不见的手"。下面这 5 个坑,我在生产环境里每一个都真实踩过。

坑 1:你的 open_files_limit 根本没生效,systemd 替你砍了

新手最爱干的事:`my.cnf` 里写 `open_files_limit = 65535`,重启,然后 `SHOW VARIABLES` 一看还是 10000。

mysql> SHOW GLOBAL VARIABLES LIKE 'open_files_limit';
+------------------+-------+
| Variable_name    | Value |
+------------------+-------+
| open_files_limit | 10000 |
+------------------+-------+

原因:MySQL 8.0 之后不再走 `mysqld_safe`,直接被 systemd 拉起。MySQL 官方发行的 `mysqld.service` 里明明白白写着 LimitNOFILE = 10000,my.cnf 的请求再大也会被这个软上限截断。真正的生效值取三者最小值:

  • my.cnf 的 open_files_limit
  • systemd unit 的 LimitNOFILE(或 shell 的 ulimit -n
  • 内核 fs.file-max

别信 `SHOW VARIABLES`,直接问内核:

pid=$(pgrep -x mysqld | head -1)
grep -i 'open files' /proc/$pid/limits
# Max open files            10000                10000                files

修复姿势(不要直接改 `/usr/lib/systemd/system/mysqld.service`,升级会被覆盖):

mkdir -p /etc/systemd/system/mysqld.service.d
cat > /etc/systemd/system/mysqld.service.d/limits.conf <<'EOF'
[Service]
LimitNOFILE=1048576
LimitMEMLOCK=infinity
EOF

systemctl daemon-reload
systemctl restart mysqld

副作用提醒:`table_open_cache` 会自动跟随 `open_files_limit` 调整,之前被压到 2000 的缓存扩到 8 万后,`Innodb_buffer_pool` 之外的元数据内存占用会涨,改完先观察一天。

坑 2:cgroup 内存墙比物理内存先到,OOM 杀你连日志都不留

回到开头那个事故。真正的凶手是这行:

dmesg -T | grep -i -E 'oom|killed process'
# [Tue Sep 10 21:14:03 2024] Memory cgroup out of memory: Killed process 1187 (mysqld) total-vm:5243180kB

云主机交付时,很多镜像模板会给你预设 MemoryMax,或者你自己为了"防止 MySQL 吃爆整机"手动设过。MySQL 实际内存远不止 buffer pool:

mysql> SELECT
    @@innodb_buffer_pool_size/1024/1024/1024 AS bp_gb,
    (@@max_connections * (
        @@sort_buffer_size + @@read_buffer_size + @@read_rnd_buffer_size +
        @@join_buffer_size + @@binlog_cache_size + @@thread_stack
    ))/1024/1024/1024 AS worst_case_gb;

`max_connections=2000` 加上默认的几种 per-connection buffer,最坏情况能再吃掉 3~4G。再加 `performance_schema=ON` 起步 1G 左右,`tmpdir` 落在大查询上又可能几百 M —— 你算的 4G,实际峰值 9G。

查看 cgroup v2 的真实账本:

systemctl show mysqld -p MemoryCurrent -p MemoryPeak -p MemoryMax -p MemoryHigh
cat /sys/fs/cgroup/system.slice/mysqld.service/memory.events
# low 0
# high 0
# max 12
# oom 3
# oom_kill 3   <-- 这个非 0,就是它在杀你

经验值:单实例 MySQL,`innodb_buffer_pool_size` 不要超过 **cgroup MemoryMax 的 60%**,剩下的留给会话内存、PFS 和 OS page cache。如果是给 MySQL 单独划 slice:

systemctl set-property mysqld MemoryHigh=48G MemoryMax=56G MemorySwapMax=0

注意 MemoryHigh 是软限流(会触发回收、拖慢但不杀),MemoryMax 是硬墙。两个都设,前者给你留出告警窗口。

坑 3:/tmp 是 tmpfs,临时表直接烧内存

这条和上一条是连体婴。云主机镜像里 `/tmp` 默认挂 tmpfs 的概率不低:

df -hT /tmp
# Filesystem     Type    Size  Used Avail Use% Mounted on
# tmpfs          tmpfs   16G     0   16G   0% /tmp

然后 MySQL 的 tmpdir 默认就是 `/tmp`。一条 5 表 JOIN 或者 `ALTER TABLE ... ALGORITHM=COPY` 产生的内部临时表,`Innodb_temp_data_file_path` 是落盘的,但 `MEMORY` 引擎的临时表、`filesort` 溢出文件全在 tmpfs 里,直接算进你的 cgroup 内存账。

修复:给 MySQL 单独一个真实磁盘路径,并且顺手把 tmpfs 的尺寸限制住。

mkdir -p /var/lib/mysql-tmp
chown mysql:mysql /var/lib/mysql-tmp
chmod 750 /var/lib/mysql-tmp

# my.cnf
[mysqld]
tmpdir = /var/lib/mysql-tmp

同时确认它不在 tmpfs 上:findmnt -T /var/lib/mysql-tmp -o TARGET,FSTYPE,SIZE。如果云盘分了数据盘,就指到数据盘。

坑 4:vm.swappiness 让 buffer pool 被换出,性能悄无声息塌一半

这套组合拳很阴:cgroup 内存紧张 → 内核不急着 OOM,先把匿名页换出去 → buffer pool 里全是冷页 → 每次查询都在等 swap in。`iostat` 上一片祥和,因为 IO 全在 swap 分区。

vmstat 1 5
# procs -----------memory---------- ---swap-- -----io----
#  r  b   swpd   free   buff  cache   si   so    bi    bo
#  0  2  812344  51200    120 320144  892  644   920   1120
#                                  ^^^  ^^^  si/so 非 0 就是灾难

永久关掉:

cat > /etc/sysctl.d/99-mysql.conf <<'EOF'
vm.swappiness = 1
vm.dirty_ratio = 15
vm.dirty_background_ratio = 5
EOF
sysctl --system

# cgroup v2 层再加一道锁
echo 0 > /sys/fs/cgroup/system.slice/mysqld.service/memory.swap.max

dirty_ratio 那两个值也别忽略。云盘的 fsync 延迟比本地 NVMe 高一个数量级,脏页攒到 20% 才回写,一次 writeback 风暴能让 P99 直接飙到秒级。降到 15/5 是云环境里比较稳的甜点值。

坑 5:突发性能实例 + 默认 fsync = QPS 半夜腰斩

这条是"新手避坑"里最容易被销售话术带偏的。共享型/突发性能实例(t 系列那一挂)有 CPU 积分池,积分耗尽后 CPU 被限到基线的百分之几,MySQL 这种吃 CPU 的负载立刻现原形。

检测方法很土但管用 —— 定时跑基准看算力是否恒定:

for i in 1 2 3; do
  sysbench cpu --cpu-max-prime=30000 --threads=2 --time=10 run | grep events
  sleep 5
done

如果三次 events/sec 相差超过 30%,基本就是被限流了。同时看 steal:

mpstat -P ALL 1 5 | grep -v Average
# 看 %steal 列,持续 > 5% 说明宿主机超卖严重

顺带说个冷门的:`innodb_flush_method` 在云盘上默认是 `fsync`,意味着双重缓冲(OS page cache + buffer pool),内存白白浪费一份。云盘场景建议直接开 `O_DIRECT`,再把 8.0.26+ 的 `innodb_use_fdatasync` 打开,让 redo log 刷盘走 fdatasync 而不是完整的 fsync,省掉 inode 元数据同步那一刀:

[mysqld]
innodb_flush_method = O_DIRECT
innodb_use_fdatasync = ON
innodb_flush_neighbors = 0

`innodb_flush_neighbors=0` 也是云 SSD 的必调项,默认值 1 是给机械盘做顺序预读的,在 NVMe/云盘上纯属添乱。

一键巡检脚本,新机上线先跑一遍

#!/bin/bash
SVC=mysqld
echo "=== systemd 软限制 ==="
systemctl show $SVC -p LimitNOFILE -p LimitMEMLOCK -p MemoryMax -p MemoryHigh -p MemoryCurrent -p MemoryPeak
echo
echo "=== 内核实际看到的限制 ==="
pid=$(pgrep -x $SVC | head -1)
grep -Ei 'open files|max memory|processes' /proc/$pid/limits
echo
echo "=== cgroup OOM 计数器(oom_kill 非 0 要警惕)==="
cat /sys/fs/cgroup/system.slice/$SVC.service/memory.events 2>/dev/null
echo
echo "=== swap 配置 ==="
sysctl vm.swappiness vm.dirty_ratio vm.dirty_background_ratio
echo
echo "=== tmpdir 落在哪个文件系统 ==="
TMPDIR_MYSQL=$(mysql -N -e "SELECT @@tmpdir" 2>/dev/null || echo /tmp)
findmnt -T "$TMPDIR_MYSQL" -o TARGET,SOURCE,FSTYPE,SIZE
echo
echo "=== 最近 OOM 记录 ==="
dmesg -T | grep -i 'killed process' | tail -5
echo
echo "=== CPU steal ==="
mpstat 1 3 | tail -3

我自己的习惯是每开一台新实例,第一件事就是跑这个脚本。轻云互联那边交付的机器默认就是 cgroup v2 unified 层级,`mysqld` 挂在独立 slice 下,路径固定是 /sys/fs/cgroup/system.slice/mysqld.service/,不用先去猜是 v1 还是 v2,省了不少事;但 `LimitNOFILE` 和 `MemoryMax` 还是得按业务自己核算,云厂商不会替你算这笔账。

最后一句:MySQL 调优不是改 `my.cnf` 里那几个 G 字结尾的数字,而是先搞清楚**你的进程在这个云主机上到底被允许用多少资源**。my.cnf 写的是"我想要多少",systemd + cgroup 写的是"我允许你用多少",两者不对齐,调优就是自嗨。