云服务器跑 MySQL:O_DIRECT 反而慢 18%,把 doublewrite 挪到独立盘 QPS 直接翻倍——12 组 fio+sysbench 实测与写路径拆解
一、先钉变量:两台靶机,同一份数据
网上抄来的 my.cnf 在云上翻车,八成不是参数写错了,是你压根不知道脚下这块盘的瓶颈在带宽还是在 IOPS。云盘的 IOPS 和带宽是两条独立上限,谁先撞上,决定了你该用 fsync 还是 O_DIRECT。
测试环境,两台配置完全一致的云服务器(16C / 64G / MySQL 8.0.36 / XFS),只换数据盘:
- A 组:500G 容量型云盘,标称 baseline 5000 IOPS / 140MB/s,有 burst credit。
- B 组:本地 NVMe,单盘 3.2T,实测随机写稳定 9 万 IOPS,不打 credit 折扣。
sysbench 1.0.20,oltp_write_only,100 表 × 100 万行,64 线程,300 秒,取 60–300s 均值,连跑 3 轮取中位。每轮前 sysbench prepare 后打快照回滚,保证数据量一致。
# 开工前先把块设备的"真实"块大小摸出来,后面一半的坑从这里来 for d in vda vdb nvme0n1; do echo "$d logical=$(cat /sys/block/$d/queue/logical_block_size) \ physical=$(cat /sys/block/$d/queue/physical_block_size) \ sched=$(cat /sys/block/$d/queue/scheduler) \ nr_req=$(cat /sys/block/$d/queue/nr_requests)" done # 典型输出:logical=512 physical=4096 sched=[none] nr_req=1024
记住 physical_block_size=4096,一会儿 innodb_log_write_ahead_size 必须跟它对齐,否则每次 redo 写都是 read-modify-write。
二、第一组横评:innodb_flush_method 六档,教科书输了
# 配置 PL1云盘 本地NVMe p99(PL1) -------------------------------------------------------------------------------- ① fsync(默认) + doublewrite ON 3,820 11,200 4.1 ms ② O_DSYNC + doublewrite ON 1,910 6,700 88.0 ms ③ O_DIRECT + doublewrite ON 3,120 14,800 7.9 ms ④ O_DIRECT_NO_FSYNC + doublewrite ON 3,410 15,200 6.2 ms ⑤ fsync + doublewrite 独立盘 6,930 13,300 3.4 ms ⑥ O_DIRECT_NO_FSYNC + doublewrite 独立盘 6,150 17,600 5.1 ms
①③ 对比最扎眼:在容量型云盘上,O_DIRECT 比默认的 fsync 慢 18%,p99 差了将近一倍。在本地 NVMe 上结论直接反转,O_DIRECT_NO_FSYNC 比 fsync 快 36%。
为什么?看 w/s 和 await 就明白了
配置 w/s wkB/s wrqm/s await aqu-sz %util fsync 1,120 128,400 36,800 12.3 14.2 99.9 O_DIRECT 4,900 127,300 0 34.1 41.6 100.0
两组带宽几乎一样(都贴着 140MB/s 的天花板),但 IO 数量差了 4.4 倍:
fsync模式下,InnoDB 的 16K 脏页写进 page cache 就返回了,真正的下发交给内核 writeback 线程。相邻脏页被合并成大 IO,wrqm/s干到 3.6 万,平均单 IO 从 5KB 一路合并到 118KB。等于用 page cache 白嫖了云盘的 IOPS 配额,还多了一层 128K 的写缓冲。O_DIRECT绕开 page cache,每个脏页(哪怕是部分页)都直达设备,4.9k 的 w/s 撞上 5000 IOPS 的硬配额,aqu-sz从 14 涨到 41,排队延迟直接吃掉 group commit 的收益。
O_DSYNC 那一行不用多说,每次写都带同步语义,132ms 的 p99 在云盘上属于自杀,littlesync / nosync 是给内核开发者调试用的,生产别碰。
所以第一组结论:IOPS 受限、有 burst 的云盘 —— 用 fsync,让内核帮你合并写;本地 NVMe / 独享 IOPS 的盘 —— 用 O_DIRECT_NO_FSYNC,省掉双缓冲拷贝和 fdatasync 的元数据事务。我们后来把 B 组的压测机换成了轻云互联的本地 NVMe 云服务器,同一份 my.cnf、同样 64 线程,oltp_write_only 稳定在 17.6k TPS 不掉档,因为它的随机写 IOPS 是独享的、不走 burst credit,这一点对 redo 密集型的写入场景太关键了。
但别高兴太早,fsync 模式是有代价的:崩溃恢复时间变长。掉电时 page cache 里那批没落盘的脏页全丢,全靠 redo 重放。复现方法很土 —— sysbench prepare 之后压测 60 秒,直接 kill -9 掉 mysqld 再用 echo 1 > /proc/sys/vm/drop_caches,从 error log 里 Database was not shutdown normally 打到 ready for connections 计时。我们在 40G buffer pool 下测出 11s(O_DIRECT)vs 68s(fsync),上生产前务必按你的 redo 容量估一遍。
三、第二组横评:doublewrite 对云盘的杀伤力是本地盘的 4 倍
看表里的 ①⑤ 和 ④⑥,同一台机器只是把 doublewrite 挪到另一块盘,PL1 上 3820 → 6930(+81%),NVMe 上只有 11200 → 13300(+19%)。
原因很朴素:doublewrite 是纯粹的 2 倍写放大 —— 每个脏页先顺序写进 doublewrite buffer,再写回数据文件。在按 IOPS 计费/限流的云盘上,这等于直接砍掉你一半配额;在本地 NVMe 上 IOPS 富余,写放大只是多占点带宽,感知不强。
[mysqld] innodb_doublewrite = ON innodb_doublewrite_dir = /mnt/dw # 独立小盘挂这里,目录必须存在且为空 innodb_doublewrite_files = 8 # 每个 buffer pool instance 一对,最大 2×instances innodb_doublewrite_pages = 64 # 8.0.20+,默认跟随 innodb_write_io_threads innodb_doublewrite_batch_size = 32 # 批量刷 dw 页,减少 syscall
几个实操坑:
innodb_doublewrite_dir只有在 doublewrite 开启时才生效,MySQL 启动时不会帮你建目录,报Operating system error number 2就是没建。- 这块独立盘不需要大,2G 够用,但要顺序写能力好、延迟稳。别拿一块容量型云盘凑数,你会把瓶颈从主盘挪到副盘上。
- 别看到 81% 就想着
innodb_doublewrite=OFF。云盘厂商嘴上说的"power-loss protection",你得拿到书面承诺 + 实测,否则一个 16K 页写一半掉电,你的表就是永久性损坏。innodb_doublewrite=DETECT_ONLY(8.0.30+)可以只检测不恢复,用来评估风险,别上生产。
四、第三组横评:innodb_log_write_ahead_size 配错,redo 每次写都在 RMW
这个参数绝大多数人抄配置时直接忽略,但它在云盘上能吃掉 20% 的写吞吐。innodb_log_write_ahead_size 默认 8192,看着没问题,可一旦你或者某个"优化脚本"把它调成 512(不少老博客教人这么干,说"减少写放大"),灾难就来了:
配置 PL1云盘 blk层 R/写比 ------------------------------------------------------------ innodb_log_write_ahead_size = 8192 3,120 0.02 innodb_log_write_ahead_size = 4096 3,120 0.02 # == physical_block_size innodb_log_write_ahead_size = 512 2,410 0.97 # 每次写都配一次读
证据链用 blktrace 抓,重点看 redo 文件偏移上冒出来的 R(读)请求:
blktrace -d /dev/vdb -w 10 -o /tmp/bt
blkparse -i /tmp/bt -f "%T %C %S %N\n" | awk '$2=="R"{print}' | head -20
# 输出形如:
# 8,16 3 1 0.000000000 4104 R 7234512 + 8 ← redo 文件区域读 4K
# 8,16 3 2 0.000412000 4104 W 7234512 + 1 ← 紧接着写 512B
用 bpftrace 看 mysqld 的写请求尺寸分布更直观:
bpftrace -e '
tracepoint:syscalls:sys_exit_pwrite64 /comm == "mysqld" && args->ret > 0/ {
@sz[args->ret] = count();
}
interval:s:5 { print(@sz); clear(@sz); }'
配 8192 时输出集中在 [8192, 16k, 1M];配 512 时全是 [512],而设备物理块是 4096 —— 一次 512B 的追加写不满一个块,块层必须先把整块读上来改完再写回,RMW 直接让 IO 翻倍,而云盘的 IOPS 是按"次"算钱的。
规则一句话:innodb_log_write_ahead_size 必须等于 /sys/block/<dev>/queue/physical_block_size,云盘上就是 4096。顺带说,redo 文件的写默认走 page cache,innodb_flush_method 管不到它(只管数据文件),所以指望 O_DIRECT 顺手把 redo 的小写合并掉,是行不通的。
另外 8.0.30 之后 innodb_redo_log_capacity 默认只有 100MiB(内部拆成 32 个文件轮转)。从 5.7 或早期 8.0 升上来的实例如果没显式配这个值,你会看到 checkpoint 疯狂推进、Innodb_os_log_written 飙高。云盘版给 8G,NVMe 版给 16G 起步。
innodb_redo_log_capacity = 8G innodb_log_writer_threads = ON # 8.0.22+,无锁 redo 写线程 innodb_use_fdatasync = ON # 8.0.26+,省一次元数据事务,实测 +3~5%
五、innodb_io_capacity:把 burst credit 提前打光的两种死法
默认 innodb_io_capacity=200,在 5000 IOPS 的云盘上等于让 InnoDB 只用 4% 的能力,脏页堆积,最终被前台线程的刷脏卡住。于是有人一把调到 20000,前 40 秒 TPS 冲到 5500,然后掉到 2100 并出现 1.2 秒的周期性尖刺 —— burst credit 用完了,盘被限回 baseline,而 InnoDB 还在按 20000 的速度往下灌。
正确的算法是按 baseline 而不是按 peak 配:
innodb_io_capacity = 3500 # baseline IOPS × 0.7 innodb_io_capacity_max = 6000 # burst 上限 × 0.8,永远别贴 100% 打 innodb_max_dirty_pages_pct_lwm = 5 # 早动手,别等脏页堆起来 innodb_max_dirty_pages_pct = 60 innodb_adaptive_flushing_lwm = 10 innodb_flush_neighbors = 0 # SSD/云盘必须 0,否则刷脏把随机写合并成顺序写放大
判断有没有撞上 credit 墙,看两个数:
mysql -e "SHOW ENGINE INNODB STATUS\G" | awk '
/^Log sequence number/ {lsn=$NF}
/^Last checkpoint at/ {ckpt=$NF}
/^Pending writes/ {pw=$0}
END{printf "checkpoint_age=%.2fGB %s\n",(lsn-ckpt)/1024/1024/1024, pw}'
checkpoint_age 超过 innodb_redo_log_capacity 的 70% 就是刷脏跟不上;Pending writes: flush list 长期非 0 说明已经在硬扛。performance_schema 侧再补一眼:
SELECT VARIABLE_NAME, VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME IN ( 'Innodb_buffer_pool_pages_dirty','Innodb_dblwr_writes','Innodb_log_waits', 'Innodb_data_pending_writes','Innodb_os_log_written');
写个 2 秒采样的循环,把尖刺和当时的脏页量对齐,比盯 Grafana 快得多:
#!/bin/bash
# spike-watch.sh —— 抓云盘写延迟尖刺,只打印异常点
while sleep 2; do
read -r awkv aqu <<< "$(iostat -dx 1 2 /dev/vdb | awk '/^vdb/{n++; if(n==2) print $10, $11}')"
dirty=$(awk '/^Dirty:/{print $2}' /proc/meminfo)
logw=$(mysql -Nse "SELECT VARIABLE_VALUE FROM performance_schema.global_status
WHERE VARIABLE_NAME='Innodb_log_waits'")
awk -v a="$awkv" -v q="$aqu" -v d="$dirty" -v l="$logw" -v t="$(date +%T)" 'BEGIN{
if (a+0 > 40 || l+0 > 0)
printf "[%s] await=%.1fms aqu=%d Dirty=%dKB log_waits=%d <-- 尖刺\n", t, a, q, d, l
}'
done
六、把结论拼成两份 my.cnf
A. 容量型云盘(IOPS 受限、有 burst)
[mysqld] innodb_buffer_pool_size = 40G innodb_buffer_pool_instances = 8 innodb_flush_method = fsync # 靠 page cache 合并写,别用 O_DIRECT innodb_redo_log_capacity = 8G innodb_log_write_ahead_size = 4096 # == physical_block_size innodb_doublewrite = ON innodb_doublewrite_dir = /mnt/dw # 独立顺序写小盘 innodb_doublewrite_files = 8 innodb_io_capacity = 3500 innodb_io_capacity_max = 6000 innodb_max_dirty_pages_pct_lwm= 5 innodb_flush_neighbors = 0 innodb_use_fdatasync = ON
B. 本地 NVMe(独享 IOPS)
[mysqld] innodb_buffer_pool_size = 44G innodb_buffer_pool_instances = 8 innodb_flush_method = O_DIRECT_NO_FSYNC innodb_redo_log_capacity = 16G innodb_log_write_ahead_size = 4096 innodb_doublewrite = ON innodb_doublewrite_files = 8 innodb_io_capacity = 60000 innodb_io_capacity_max = 90000 innodb_max_dirty_pages_pct_lwm= 10 innodb_flush_neighbors = 0
七、两个别踩的坑
innodb_dedicated_server=ON在云上基本是反模式。它只会按物理内存算 buffer pool(≥4G 时给 75%),按 buffer pool 算 redo,完全不知道你的盘有多少 IOPS。64G 机器上它给你 48G buffer pool + 巨大 redo,checkpoint 压力直接压爆容量型云盘,然后你会看到入参全对、性能全错的诡异现象。- 别拿
iostat的%util判断云盘饱和。云盘是多队列远端设备,%util常年 100% 不代表打满,看await和aqu-sz的乘积估算实际并发,再和盘的 IOPS 配额对一下,比什么都准。
最后一句:MySQL 调优在云上从来不是参数的排列组合,是先搞清你那块盘的瓶颈在带宽还是 IOPS、在不在 burst。搞不清这一点,抄十份 my.cnf 也只是在赌。