云服务器跑 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 也只是在赌。