云数据库容器化架构:cgroup v2 回收不了 InnoDB 的匿名内存,预算偏差如何引发 OOM-Kill 风暴与在线缩池设计
先摆结论:容器内存限制和数据库实例规格,是两个不能互相代替的东西
把 MySQL / PostgreSQL 塞进 Docker 做云数据库多租户,最常见的错误设计是:把 docker run --memory=16g 当成"这个实例是 16G 规格"。这在 Web 服务上是成立的,在数据库上是灾难性的,因为内核对两类内存的态度完全不同。
cgroup v2 的 memory controller 只能回收 file 类型的页(page cache、可回写的脏页)。而 InnoDB Buffer Pool 是通过 mmap(MAP_PRIVATE|MAP_ANONYMOUS) 拿的匿名内存,属于 anon 类型。匿名页在没有 swap 的情况下,内核没有任何手段把它们收回——它不能像 page cache 那样直接丢弃,因为那不是磁盘文件的缓存,是进程自己的堆。
所以当 memory.max 被压到低于当前 anon 用量时,内核的行为不是"温和回收 buffer pool",而是:
memory.high被触碰 → 分配路径上同步执行 direct reclaim → 空转(因为没有可回收的 file 页)→ 每次内存分配都被拖慢,QPS 断崖,但不报错;memory.max被触碰 → 回收失败 → OOM-Killer 直接 SIGKILL mysqld,不给刷 redo、不给刷 doublewrite 的机会。
这就是容器化云数据库最隐蔽的一类故障:监控上 CPU 不高、IO 不高,延迟却从 4ms 涨到 200ms,或者实例周期性地"自己重启"。
验证:一条命令看清 anon 占比
# 在容器内执行,看 mysqld 主进程的匿名内存占比
pid=$(pidof mysqld)
grep -E '^(Rss|Pss|Anonymous|AnonHugePages|Private_Dirty|Swap)' /proc/$pid/smaps_rollup
# 输出示例(16G buffer pool 实例)
Rss: 16512384 kB
Pss: 16512384 kB
Anonymous: 16184256 kB <-- 98% 是匿名,cgroup 碰不到
Private_Dirty: 15728640 kB
Swap: 0 kB
同时看宿主机侧 cgroup 的记账,一对比就露馅了:
CG=/sys/fs/cgroup/kydb/db-001
cat $CG/memory.current
cat $CG/memory.stat | grep -E '^(anon|file|pgscan_direct|pgsteal_direct|pgscan_kswapd|workingset_refault_anon) '
cat $CG/memory.events # oom / oom_kill / high / max 四个计数器
如果 memory.stat 里 anon 已经贴着 memory.max、而 file 只有几百 MB,那说明这个 cgroup 的"可回收余量"基本是零——任何一次连接风暴带来的线程栈增长,都会把实例打到 OOM。
第二层坑:容器里的 /proc/meminfo 是宿主机的
很多镜像的 entrypoint 都有一段"自动计算 buffer pool"的逻辑,读取 /proc/meminfo 的 MemTotal。Docker 默认不隔离 /proc/meminfo,除非挂载 lxcfs 或 cgroup namespace + 特定内核支持。结果就是:你申请 16G 的容器,mysqld 按 512G 的宿主机内存算了 60G 的 buffer pool,启动瞬间就被 OOM-Kill。
正确做法是干掉所有隐式推断,配置全部由编排层显式注入:
# /etc/mysql/conf.d/00-resources.cnf —— 由 entrypoint 用 envsubst 渲染
[mysqld]
innodb_buffer_pool_size = ${BP_SIZE_BYTES}
innodb_buffer_pool_instances = ${BP_INSTANCES}
innodb_buffer_pool_chunk_size = 134217728
innodb_flush_method = O_DIRECT
innodb_log_file_size = 2G
innodb_log_writer_threads = ON
innodb_buffer_pool_in_core_file = OFF
# 关键:不要依赖自动计算
innodb_dedicated_server = OFF
注意 innodb_buffer_pool_size 必须是 innodb_buffer_pool_instances × innodb_buffer_pool_chunk_size 的整数倍,否则 InnoDB 会向上取整到最近的合法值——你以为设了 12G,实际吃掉了 12.5G,这 500MB 的偏差在多实例混部时就是 OOM 的引信。
# 校验真实生效值
mysql -e "SELECT @@innodb_buffer_pool_size/1024/1024/1024 AS bp_gb,
@@innodb_buffer_pool_instances AS inst,
@@innodb_buffer_pool_chunk_size/1024/1024 AS chunk_mb;"
内存预算的正确公式
容器 cgroup 上限 ≠ 实例规格。它应该是一个"熔断阈值",算账方式如下:
memory.max = innodb_buffer_pool_size
+ innodb_log_file_size * innodb_log_files_in_group # 若 flush_method != O_DIRECT 走 page cache
+ innodb_doublewrite 缓冲(约 1~2GB,取决于 innodb_doublewrite_pages * 页大小 * 并发)
+ max_connections * (thread_stack + net_buffer_length + 排序/临时表均值)
+ performance_schema 开销(开启时 500MB~2GB)
+ 20% 安全水位
以 16C32G 实例、buffer pool 24G 为例,memory.max 应该落在 34~36G,而不是 32G。用 --memory=32g --memory-reservation=24g 这种配置在数据库上一定会出事:Docker 的 --memory-reservation 映射到 cgroup v2 的 memory.low,它只影响回收优先级,不提供任何"低于此值就不杀"的保证。
OOM-Kill 之后的第二场事故:恢复期的内存二次冲击
杀掉 mysqld 只是开始。重启后 InnoDB 要做 crash recovery,重放 redo,这期间 buffer pool 会以极快速度被重新填满(redo 重放是纯随机写 page),同时 redo 文件本身也要重新读入。如果 cgroup 上限没变,第二次 OOM 会在几分钟内到来,形成"重启风暴"。在 200 实例混部的宿主机上,这种风暴会互相踩踏,最终整机雪崩。
必须打开 group kill,避免出现"半死"状态(容器在跑但 mysqld 已死,健康检查还没超时,负载均衡还在导流量):
# 直接写 cgroup(Docker 没有暴露这个参数,需要 --cgroup-parent 定位后手动设置)
echo 1 > /sys/fs/cgroup/kydb/db-001/memory.oom.group
# 禁止 swap,否则匿名页回收路径会被激活,产生不可预测的 IO 抖动
echo 0 > /sys/fs/cgroup/kydb/db-001/memory.swap.max
顺带把 vm.max_map_count 也纳入检查。buffer pool 拆成多个 instances、加上大量连接线程栈,很容易把默认的 65530 打爆,报 Cannot allocate memory 但 free 看着还有几十 G——这个坑在容器里尤其常见,因为该参数是 namespace 级的,容器改不了宿主机。
正确的弹性架构:双层预算 + 在线缩池闸门
核心设计原则:cgroup 只做熔断,规格调节交给数据库自己。缩放实例内存时,永远不要直接改 memory.max,而是先改 innodb_buffer_pool_size,等 InnoDB 把页吐干净了,再动 cgroup 上限。
InnoDB 支持在线调整 buffer pool 大小(5.7.5+)。收缩是异步的,由后台线程按 chunk 粒度释放页面,速率与 innodb_buffer_pool_instances 相关,实测约 100~400MB/s。你可以用 Innodb_buffer_pool_pages_free 观察进度。下面是我们在生产里用的缩容脚本骨架:
#!/usr/bin/env bash
# db-resize.sh <instance_id> <target_bp_gb> <cgroup_mem_gb>
set -euo pipefail
INST=$1
TARGET_GB=$2
CG_MAX_GB=$3
CG=/sys/fs/cgroup/kydb/$INST
MYSQL=(mysql --socket=/var/run/mysqld/mysqld.sock -N -B)
CHUNK=$((128 * 1024 * 1024))
INSTANCES=$("${MYSQL[@]}" -e "SELECT @@innodb_buffer_pool_instances")
# buffer pool 必须是 instances*chunk 的整数倍
UNIT=$(( CHUNK * INSTANCES ))
TARGET_BP=$(( (TARGET_GB * 1024*1024*1024 / UNIT) * UNIT ))
[ "$TARGET_BP" -lt $((128*1024*1024)) ] && { echo "target too small"; exit 1; }
echo "[1/4] 抬升 cgroup 上限到安全区,避免收缩期临时峰值触发 OOM"
cur=$(cat $CG/memory.max)
want=$(( CG_MAX_GB * 1024*1024*1024 ))
[ "$cur" != "max" ] && [ "$cur" -lt "$want" ] && echo "$want" > $CG/memory.max
echo "[2/4] 在线收缩 buffer pool -> $TARGET_BP bytes"
"${MYSQL[@]}" -e "SET GLOBAL innodb_buffer_pool_size = $TARGET_BP;"
echo "[3/4] 等待页释放完成"
deadline=$(( $(date +%s) + 900 ))
while :; do
data=$("${MYSQL[@]}" -e "SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_pages_data'" | awk '{print $2}')
live=$(( data * 16384 ))
echo " pages_data=$data (~$(( live/1024/1024 )) MB)"
[ "$live" -le "$TARGET_BP" ] && break
[ "$(date +%s)" -gt "$deadline" ] && { echo "TIMEOUT"; exit 2; }
sleep 5
done
echo "[4/4] buffer pool 已低于目标,回收 cgroup 余量"
echo $(( want )) > $CG/memory.max
cat $CG/memory.current
反过来扩容时流程要倒过来:先抬 memory.max,再调 innodb_buffer_pool_size。顺序错了就是一次必现的 OOM。
用 memory.events 做熔断前置告警
不要等 OOM。用一个 sidecar 每 10 秒采一次 cgroup 指标,把 memory.current / memory.max 超过 0.85、或者 memory.events 里 high 计数器出现增长,作为"该缩池了"的信号:
#!/bin/bash
CG=/sys/fs/cgroup/kydb/$INST
cur=$(cat $CG/memory.current); max=$(cat $CG/memory.max)
[ "$max" = "max" ] && exit 0
pct=$(( cur * 100 / max ))
high=$(awk '/^high /{print $2}' $CG/memory.events)
oom=$(awk '/^oom_kill /{print $2}' $CG/memory.events)
echo "inst=$INST usage=${pct}% high_events=$high oom_kill=$oom"
# high 计数持续增长 = 内核已经在分配路径上做 direct reclaim,延迟即将劣化
[ "$pct" -ge 88 ] && echo "ALERT: trigger buffer pool shrink"
IO 侧的同源问题:io.max 节流会打乱 innodb_io_capacity
很多人为了多租户 IO 公平,给每个容器配 --device-write-bps。注意这个参数只对 device-mapper 驱动生效,overlay2 + 云盘场景下是完全不生效的,你会以为配了限速,其实什么都没发生。真正的限速点在 blk cgroup v2 的 io.max。
但 io.max 和 InnoDB 的刷脏节奏是冲突的:InnoDB 通过 innodb_io_capacity 估算自己能发多少 IOPS,如果被 cgroup 的令牌桶在 blk-mq 层拦住,fsync 会被强制排队,innodb_flush_log_at_trx_commit=1 的组提交批次被拉长,P99 直接劣化。
# 找到云盘在宿主机上的 major:minor
lsblk -o NAME,MAJ:MIN,ROTA,TYPE | grep disk
# 给实例配置下限保障 + 上限(上限建议设成峰值 IOPS 的 1.5 倍,而不是刚好等于峰值)
echo "8:16 rbps=209715200 wbps=524288000 riops=50000 wiops=30000" \
> /sys/fs/cgroup/kydb/db-001/io.max
# 关键:给 page cache 回写用的带宽权重,避免脏页回写把读路径挤死
echo "8:16 200" > /sys/fs/cgroup/kydb/db-001/io.weight
# 查看当前是否真的在限流(throttled 计数增长就是被拦了)
cat /sys/fs/cgroup/kydb/db-001/io.stat
如果 io.stat 里的 wbps / wiops 长期贴着上限值,说明 InnoDB 的刷脏预算被外部限制截断了,这时候要么放宽上限,要么把 innodb_io_capacity 调到和实际可用 IOPS 匹配——让数据库自己减速,比让 cgroup 硬拦要平滑得多。
落地检查清单
- 干掉所有从
/proc/meminfo推断资源的 entrypoint 逻辑,配置全部由编排层显式注入; memory.max= buffer pool + redo(未用 O_DIRECT 时)+ doublewrite + 连接开销 + 20% 水位,绝不等同于实例规格;memory.swap.max=0、memory.oom.group=1;- 缩容顺序:抬 cgroup → 改
innodb_buffer_pool_size→ 等pages_data落地 → 收 cgroup;扩容顺序相反; - 校验
innodb_buffer_pool_size是instances × chunk_size的整数倍,别信"我设了 12G"; io.max上限留 1.5 倍余量,别贴着峰值配,否则组提交会被 blk-mq 令牌桶拖死;- 告警看
memory.events的high计数器,不要等oom_kill; - 宿主机
vm.max_map_count调大到 262144 以上,多实例 + 大 buffer pool 场景必备。
这套方案我们在轻云互联的独享型裸金属上跑了 200 多个 MySQL 8.0 实例,宿主机 cgroup v2 完整可用、CPU 和 IO 阈值不会被邻居实例抢占,才有条件把内存闸门真正交给数据库自己控制——如果宿主机本身是超卖的,cgroup 的记账再准也救不了尾延迟。
最后强调一遍:容器化数据库的内存问题,本质不是"给多少内存",而是"谁来决定什么时候还内存"。内核不懂 InnoDB 的 LRU,InnoDB 也不认 cgroup 的账本,让前者做熔断、后者做调节,才是唯一稳定的架构。