自建 Docker MySQL 对标云托管 RDS:cgroup 节流打出 40ms 的 redo 毛刺、overlay 层的 60GB copy-up、10 秒 SIGKILL 触发的崩溃恢复
“托管 RDS 到底贵在哪”这问题,光看价格表永远说不清。上个季度我们做了一组对照压测:同一家云、同规格主机(16C64G,云盘 PL1)、同版本 MySQL 8.0.36、同样 innodb_buffer_pool_size=40G,A 组是 Docker 容器里自建,B 组是托管实例。sysbench oltp_write_only、128 线程、跑 30 分钟。
第一轮结果很难看:A 组 QPS 只比 B 组低 11%,但 P99 是 B 组的 3.4 倍,而且每 20~30 秒出现一批 40~80ms 的尖峰,托管那边完全平滑。把下面五个坑逐个拆掉之后,P99 差距收到 1.2 倍以内。剩下的 20% 是结构性的,改不掉,只能接受或者换方案。
评测口径先说清楚,免得对不上
- 压测工具:sysbench 1.0.20,
--db-driver=mysql --mysql-db=sbtest --tables=32 --table-size=2000000 - 观测点:MySQL 的
performance_schema.events_waits_summary_global_by_event_name+ cgroup v2 的cpu.stat/memory.stat+ Prometheus 的container_cpu_cfs_throttled_periods_total - 系统:CentOS Stream 9 / kernel 5.14,cgroup v2 统一层级,Docker 24.0 + containerd
- 所有容器数据目录一律 bind mount 到独立块设备(后面解释为什么这条最关键)
坑一:cpu.max 的 CFS 节流,全打在 redo 组提交的唤醒路径上
最初的启动命令长这样,看着没毛病:
docker run -d --name mysql8 \
--cpus=12 \
--memory=48g --memory-swap=48g \
-v /data/mysql8:/var/lib/mysql \
mysql:8.0.36 \
--innodb-buffer-pool-size=40G \
--innodb-flush-log-at-trx-commit=1 \
--innodb-flush-method=O_DIRECT
--cpus=12 在 cgroup v2 下写的是 cpu.max = 1200000 100000,也就是每 100ms 周期最多用 1.2 秒 CPU 时间。问题在于 CFS 带宽控制是按周期记账的:quota 用完,这个 cgroup 里的所有线程被 throttle,一直等到下一个 period 边界才放行。MySQL 是延迟敏感型负载,innodb_flush_log_at_trx_commit=1 时每个事务都在等一次 log fsync,fsync 完成、线程被唤醒的那一刻如果正好撞上 quota 耗尽,它就得原地罚站到 100ms 边界。
看这几个文件,一眼就能定性:
CID=$(docker inspect -f '{{.Id}}' mysql8)
CG=/sys/fs/cgroup/system.slice/docker-$CID.scope
cat $CG/cpu.stat
# nr_periods 184320
# nr_throttled 12409
# throttled_usec 1214xxxxx
# 平均每次被节流的时长
awk '/throttled_usec/{u=$2}/nr_throttled/{n=$2}END{printf "%.1f ms\n", u/n/1000}' $CG/cpu.stat
# 97.9 ms <-- 基本等于一个完整 period,说明是被罚站到底
再对一下 MySQL 侧的等待:
SELECT EVENT_NAME, COUNT_STAR,
ROUND(SUM_TIMER_WAIT/COUNT_STAR/1e9, 3) AS avg_ms
FROM performance_schema.events_waits_summary_global_by_event_name
WHERE EVENT_NAME LIKE 'wait/io/file/innodb/innodb_log_file%';
你会发现 avg_ms 跟 B 组差不多,但 P99 差出好几倍——因为等待时间被 fsync 吃掉了,节流的开销加在“fsync 返回 → 线程被调度”这一段,PFS 里记不到,只能从 cgroup 侧看。
处理方式:延迟敏感型数据库不要给硬 quota。改用 cpuset 绑核 + 权重,把核“分给”它而不是“限住”它:
docker run -d --name mysql8 \
--cpuset-cpus=0-11 \
--cpu-shares=4096 \
--memory=48g --memory-swap=48g \
...
多租户环境不敢不给上限的,quota 至少留 30% 余量(--cpus=16 配 12 核的活),并且把 cpu.max.burst 打开一点(cgroup v2 支持突增额度),能显著削掉那批 40ms 台阶。托管 RDS 那边基本不走这条路——物理机独享核,或者容器化计算层但 quota 不超卖,这第一笔账就是钱。
坑二:数据落进镜像层,overlay2 的 copy-up 和 O_DIRECT 静默降级
这个坑最隐蔽,因为容器能正常启动、能跑、能写数据,只是慢。触发方式很常见:为了“开箱即用”,用 docker commit 打了个带初始化数据的镜像;或者图省事用了 named volume 之外的路径(/var/lib/mysql 没挂载,直接写在容器可写层)。
先确认文件系统类型,一条命令:
docker exec mysql8 findmnt -T /var/lib/mysql
# TARGET SOURCE FSTYPE OPTIONS
# / overlay overlay rw,lowerdir=...,upperdir=...,workdir=...
df -T /var/lib/docker
# Filesystem Type ...
# /dev/nvme0n1p1 xfs ...
如果是 overlay,两件事同时发生:
- O_DIRECT 大概率不生效。InnoDB 打开数据文件时调
os_file_set_nocache(),overlayfs 上O_DIRECT的老内核实现会返回EINVAL,MySQL 不会退出,只在 error log 里打一行 warning:Failed to set O_DIRECT on file ./ibdata1; continuing anyway. This can lead to O_DIRECT setting not honored.然后你就回到了 double buffering:InnoDB buffer pool 一份,page cache 一份,脏页回写和 fsync 全部多绕一层。 - 第一次写就 copy-up 整个文件。overlayfs 的 copy-up 是整文件粒度。镜像层里如果躺着一个 60GB 的
ibdata1,容器第一次写它,内核会把 60GB 完整复制到 upperdir,期间 IO 全部打满。这个动作在读多写少的测试里体现不出来,一到写密集场景就变成开跑前十分钟的灾难。
验证 copy-up 正在发生,盯 upperdir 的增长就行:
CID=$(docker inspect -f '{{.Id}}' mysql8)
du -sh /var/lib/docker/overlay2/$CID/diff/ 2>/dev/null
# 启动后每 10 秒看一次,如果从 200K 一路涨到 60G,就是在 copy-up
直接验证 O_DIRECT 是否被 honored:
docker exec mysql8 \
dd if=/dev/zero of=/var/lib/mysql/odirect_test bs=1M count=512 oflag=direct
# dd: failed to open '/var/lib/mysql/odirect_test': Invalid argument <-- O_DIRECT 不支持
处理方式:数据目录必须 bind mount 到宿主机的独立块设备上,镜像里绝不带数据文件。文件系统选 xfs,挂载加 noatime:
mkfs.xfs -f /dev/nvme1n1
mount -o noatime,nodiratime,logbsize=256k /dev/nvme1n1 /data
docker run -d ... -v /data/mysql8:/var/lib/mysql ...
顺手把 doublewrite 也挪走,MySQL 8.0.20+ 支持独立目录,一块盘专门给 redo + doublewrite 顺序写,数据盘专心扛随机写:
--innodb-doublewrite-dir=/data/dw
--innodb-log-group-home-dir=/data/redo
--innodb-redo-log-capacity=8G
坑三:memory limit 把 page cache 一起算进去了
cgroup v2 的 memory.current 包含 file(page cache),虽然它可回收,但回收有成本。MySQL 的 buffer pool 是 anon,不可回收,两者抢同一个水位线。
cat $CG/memory.current
cat $CG/memory.stat | egrep '^(anon|file|active_file|inactive_file|workingset_refault_file|pgscan_direct) '
cat $CG/memory.events
# low 0
# high 1841 <-- 被 memory.high 软限节流过 1841 次
# max 3
# oom 3
# oom_kill 3 <-- 已经被 OOM kill 过 3 次
两个高频错误:一是 buffer_pool_size + 容器 memory limit 设成相等,MySQL 自己还有 2~4G 的额外开销(performance_schema、连接线程栈、临时表、binlog cache),一旦 page cache 涨上来,内核在 memory.max 附近启动 direct reclaim,pgscan_direct 飙升,表现为 P99 毛刺;逼到 memory.max 就直接 oom_kill,mysqld 被 SIGKILL,下次启动走崩溃恢复。
二是用 memory.high 做软限。cgroup v2 的 memory.high 触发的是节流式回收:超了不让分配太快,进程被强制进入 reclaim,而不是直接失败。对 MySQL 这种内存分配频繁的进程来说,memory.high 造成的抖动比敞开用还难受,memory.events 里 high 计数涨得飞快就是不祥之兆。
处理方式:buffer pool 按 memory_limit * 0.6 走,留足 6G 以上余量。working_set 要看 workingset_refault_file,这个值持续上涨说明 page cache 被反复打掉又重读,是典型的“内存给少了”信号。另外开 O_DIRECT 之后数据文件的 page cache 基本消失,这条曲线会平很多。
坑四:docker stop 默认 10 秒,直接送一个 SIGKILL
这是评估容器化数据库时最容易被忽略、后果最严重的一条。
mysqld 收到 SIGTERM 后要做正常 shutdown:停止接收连接、回滚活跃事务、把 buffer pool 里的脏页全部刷下去。40G buffer pool 在写密集场景下脏页能有十几 G,一次完整 flush 需要几十秒到几分钟。而 docker stop 默认 -t 10:10 秒还不退,直接 SIGKILL。
SIGKILL 之后的事情:
# 下次启动的 error log
[Note] [MY-012872] [InnoDB] Starting crash recovery from checkpoint LSN=48239104826
[Note] [MY-012874] [InnoDB] 2 transaction(s) which must be rolled back or cleaned up
[Note] [MY-012876] [InnoDB] Starting final batch to recover 1571 pages from redo log
恢复时间跟 redo 容量、脏页比例正相关。我们有一个 400G 的库,crash recovery 跑了 6 分 40 秒,业务侧超时一片。托管 RDS 的主备切换不走这条路(共享存储层直接切,或者备库接管),这是结构性差异,容器自建必须自己兜。
处理方式:
# 单机
docker stop -t 600 mysql8
# k8s
terminationGracePeriodSeconds: 600
lifecycle:
preStop:
exec:
command:
- sh
- -c
- "mysqladmin --defaults-file=/etc/mysql/admin.cnf shutdown || true"
再加一层保险:--stop-signal=SIGTERM(默认就是)、监控里盯 Uptime 断开时的 error log,定期演练一次 docker restart 记录恢复时长。innodb_fast_shutdown 保持 1(默认),别为了关得快改成 2,那只是把恢复成本推到下一次启动。
坑五:云盘 IOPS 口径和 MySQL 的真实 IO 特征对不上
很多人 fio 一跑,“随机写 16K QD128 有 8 万 IOPS”,然后觉得云盘没问题。实际 MySQL 的 IO 是混合的:redo log 顺序写但 fsync 极频繁(每次 commit 一次),doublewrite 顺序写,数据页 16K 随机写,binlog 顺序写,刷脏还是批量的。fio 的顺序写成绩在这里毫无参考价值。
更麻烦的是入门级云盘的 burst 机制:基线 3000 IOPS,突发额度能冲到 12000,额度耗完掉回基线。压测前 5 分钟成绩漂亮,然后性能断崖。判断方法看 checkpoint age:
SHOW ENGINE INNODB STATUS\G
# Log sequence number 48239104826
# Last checkpoint at 48198723411
# 差值 = checkpoint age,超过 redo capacity * 0.75 就危险
如果 checkpoint age 一路逼近 innodb_redo_log_capacity,说明刷脏跟不上,InnoDB 会触发同步 flush,用户线程亲自下场刷页,QPS 瞬间掉一半。innodb_adaptive_flushing 默认开,但它只能在上限内调节,救不了物理带宽不够。
处理方式:innodb_io_capacity 按云盘**持续** IOPS 的 70% 设,不要把 burst 值填进去;innodb_io_capacity_max 给 2 倍。压测时用云监控看“云盘 IOPS 消耗”是否打满,比容器内 fio 准得多。我们后来把压测机换到轻云互联的本地 NVMe 机型,IO 侧曲线一下就干净了,才确认前面那些尖峰里有一半是云盘 burst 额度耗尽的锅。
顺带一句 NUMA:容器默认不感知 NUMA 拓扑,--cpuset-cpus=0-11 如果跨了 2 个 socket,mysqld 的线程和内存可能落在远端节点。lscpu 和 numactl -H 对一下,cpuset 尽量圈在同一个 NUMA node 里,跨 socket 访问延迟能差 40%。
托管 RDS 那边到底做对了什么
把五个坑拆完之后回看,托管实例的优势其实不在“参数调得好”,而在架构层面绕开了上面这些:
- 计算和存储分离:主备切换不依赖 mysqld 正常 shutdown,redo 在共享存储层就已经一致了,根本没有“10 秒 SIGKILL 后走崩溃恢复”这条路径。
- 不超卖也不硬限:物理机独享核,或者容器化计算层但 quota 留足余量,CFS 节流几乎不进 P99。
- 存储是本地 NVMe + 三副本,不是云盘 burst 模型,IOPS 曲线是平的。
- 备份/迁移走存储快照,不占 mysqld 的 IO 带宽,也就没有“备份窗口把 buffer pool 打穿”这种事。
代价是钱和灵活性:规格不能细调,参数有一堆禁止项,遇到 innodb_flush_method 这类硬件相关的优化基本没得选。
什么场景可以容器化,什么场景别碰
踩完这轮,我们的判断标准大致是这样:
- 可以做:数据量 < 500G、读多写少、已经有主从或组复制做 HA 的从库、SaaS 多租户需要 namespace 级隔离的实例、开发测试环境。
- 别做:单实例 > 1T 的写密集 OLTP、对 P99 有硬性 SLA 的核心交易库、备份窗口紧张到必须在业务高峰跑的、需要频繁主备切换的。这几类硬上容器,省下来的钱会在某次 SIGKILL 后的六分钟恢复里加倍还回去。
真要容器化,把上面五条做成 checklist 落到部署模板里:cpuset 绑核不设硬 quota、数据目录 bind mount 到独立块设备、buffer pool 不超过 memory limit 的 60%、stop grace period 拉长到 600 秒并挂 preStop、innodb_io_capacity 按持续 IOPS 设。这五条不改,压测跑多少遍都是在测 cgroup 和 overlayfs,不是在测 MySQL。后来我们把整套自建从云盘挪到轻云互联的 NVMe 裸金属上,同配置下 P99 曲线才和托管实例真正对齐——底层 IO 的确定性,比任何参数调优都管用。