NVMe SSD 时代,数据库 IO 瓶颈往往不在硬件,而在内核 IO 栈的锁与同步机制。排查表明,ext4 的 jbd2 日志线程在提交事务时触发的全盘 Flush/FUA 屏障,会导致底层 blk-mq 硬件队列短暂挂起,引发 MySQL fsync 延迟飙升及 D 状态风暴。本文通过关闭易失性缓存、切换 none 调度器并压平脏页水位,将 99 线延迟从 250ms 压回 2ms 以内。
故障现场:明明 IOPS 富裕,MySQL 却大面积卡顿
近期在处理一个高并发支付核心库的性能问题时,遇到一个极其典型的 IO 栈陷阱。
环境为 CentOS 8 Stream,内核 5.15.0,文件系统 ext4,底层挂载企业级 NVMe SSD,MySQL 版本 8.0.32。
症状表现:
压测期间,数据库 QPS 偶尔从 25k 骤降到 500,Load Average 瞬间飙升到 150+。
通过 iostat -x 1 观察,发现磁盘 %util 只有 15% 左右,IOPS 甚至不到 2万(这块 NVMe 标称 50万 IOPS),但是 w_await(写等待时间)却经常出现 200ms 以上的尖刺。
抓取现场 D 状态进程栈(echo w > /proc/sysrq-trigger 或查阅 dmesg),满屏都是 MySQL 的 page cleaner 线程和业务写入线程卡在内核态:
[<0>] blk_execute_rq+0x5c/0x100
[<0>] blkdev_issue_flush+0x6d/0xb0
[<0>] ext4_sync_file+0x187/0x360 [ext4]
[<0>] vfs_fsync_range+0x49/0x80
[<0>] do_fsync+0x3d/0x70
同时,内核的 ext4 日志提交线程 jbd2 也处于阻塞状态:
[<0>] blk_execute_rq+0x5c/0x100
[<0>] jbd2_journal_commit_transaction+0x18e5/0x1f00 [jbd2]
[<0>] kjournald2+0xb5/0x260 [jbd2]
为什么极速的 NVMe 也会被 jbd2 拖垮?
很多开发甚至运维都有一个误区:只要换上 NVMe,IO 问题就迎刃而解。但这忽视了 Linux IO 栈的屏障(Barrier)机制。
默认情况下,ext4 采用 data=ordered 日志模式。为了保证崩溃一致性(Crash Consistency),jbd2 内核线程每隔几秒或在满足一定条件时,会将内存中的元数据和数据写入磁盘的 Journal 区。
为确保这些写入真正落盘而不是停留在 SSD 的 DRAM 缓存中,jbd2 会向下层 block layer 发送带有 REQ_OP_FLUSH 或 REQ_FUA(Force Unit Access)标志的 BIO。
当这个 Flush 请求到达 blk-mq(多队列块层)时,灾难开始了。
-
块层会暂停当前块设备所有常规 IO 的下发,必须等待 Flush 完成。
-
NVMe 控制器收到 Flush 指令后,需要将其内部易失性缓存(Volatile Write Cache, VWC)中的脏数据全部刷入 NAND Flash。
-
如果此时系统中有大量碎片化的脏页堆积,或者 SSD 的 GC(垃圾回收)任务正好触发,这个 Flush 操作可能耗时几十甚至上百毫秒。
-
在这上百毫秒内,整个
/dev/nvme0n1队列被“冻结”。MySQL 的 Redo log 发起的fsync()全部阻塞,直接导致 TPS 暴跌,连接数堆积。
深度排查:bpftrace 抓取幽灵延迟
为了拿到实锤数据,我们直接用 bpftrace 追踪 jbd2_journal_commit_transaction 的耗时分布:
bpftrace -e '
kprobe:jbd2_journal_commit_transaction {
@start[tid] = nsecs;
}
kretprobe:jbd2_journal_commit_transaction /@start[tid]/ {
@ns = hist((nsecs - @start[tid]) / 1000000);
delete(@start[tid]);
}
'
运行 5 分钟后的输出直方图(单位:毫秒):
@ns:
[2, 4) 12 |@@ |
[4, 8) 45 |@@@@@@@@ |
[8, 16) 89 |@@@@@@@@@@@@@@@@ |
[16, 32) 156 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ |
[32, 64) 201 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ |
[64, 128) 54 |@@@@@@@@@ |
[128, 256) 12 |@@ |
[256, 512) 3 | |
可以看到,大量 jbd2 提交耗时超过了 32ms,极端情况甚至达到了 256ms 以上,这在 NVMe 盘上是绝对不可接受的。
核心调优与防御性配置
面对这种内核级的锁竞争与队列挂起,一味调整 MySQL 的 innodb_io_capacity 是徒劳的。必须从 Linux IO 栈自底向上进行防御性加固。
1. 禁用物理盘易失性缓存(绕过 Flush 屏障)
前提条件: 你的 NVMe 必须是企业级 SSD,自带掉电保护电容(PLP – Power Loss Protection),或者服务器挂接了可靠的 BBU(电池备份单元)。
既然硬件保证了掉电不丢数据,我们就不需要内核一遍遍地发 Flush 指令。
老版本的内核可以通过 mount 参数 nobarrier 来禁用屏障,但在 4.19+ 及 5.x 内核中,ext4 的 nobarrier 参数实际上已被废弃并忽略。现代内核的做法是直接关闭底层块设备的 write cache 特性,内核探测到设备没有缓存,就不会再下发 REQ_OP_FLUSH。
对于 NVMe,使用 nvme-cli 直接修改控制器特性:
# 查看当前 VWC 状态
nvme get-feature /dev/nvme0 -f 0x6
# 禁用 Volatile Write Cache
nvme set-feature /dev/nvme0 -f 0x6 -v 0x0
# 通知内核重新校验设备
echo 1 > /sys/block/nvme0n1/device/rescan
执行后,再次用 bpftrace 观察,jbd2 提交延迟会断崖式下降到 1-2ms 内。
2. 剥离无用的软件调度器(blk-mq 优化)
很多系统默认给 NVMe 分配了 mq-deadline 或 bfq 调度器。对于具备极高并发处理能力且没有磁头寻道开销的 NVMe 来说,软件层的电梯算法纯属多此一举,还会引入额外的 spinlock 竞争。
立即切到 none 调度器:
# 实时生效
echo none > /sys/block/nvme0n1/queue/scheduler
# 持久化(通过 udev 规则,确保重启不丢)
cat << 'EOF' > /etc/udev/rules.d/60-io-scheduler.rules
ACTION=="add|change", KERNEL=="nvme[0-9]*", ATTR{queue/scheduler}="none"
EOF
udevadm control --reload-rules && udevadm trigger
3. 启用 ext4 快速提交(Fast Commit)
如果是较新的内核( >= 5.10 )和 e2fsprogs,强烈建议开启 ext4 的 fast_commit 特性。它通过精简元数据日志的格式,大幅减少了 jbd2 提交时的写入量。
# 需在 unmount 状态下执行
tune2fs -O fast_commit /dev/nvme0n1p1
挂载后,可通过 dumpe2fs 确认是否生效。配合 MySQL 这样大量执行 fsync 的应用,fast_commit 可以带来 15%~20% 的吞吐提升。
4. 压平 VM 脏页水位
不要让系统的脏页堆积到触发全局同步刷盘的地步,这会加剧 IO 队列深度突刺。
修改 /etc/sysctl.conf:
# 后台异步刷盘阈值压低到 5%(默认 10%)
vm.dirty_background_ratio = 5
# 进程同步阻塞刷盘阈值压低到 10%(默认 20%)
vm.dirty_ratio = 10
# 缩短脏页老化时间到 5秒(默认 30秒)
vm.dirty_expire_centisecs = 500
执行 sysctl -p 生效。通过让内核高频、小批量地刷脏,避免 jbd2 或后台 flusher 线程一次性将底层 IO 队列打满。
常见问题
Q1:如果是 XFS 文件系统,也会有类似 jbd2 的日志阻塞问题吗?
XFS 同样有日志提交流程(xfsaild 线程)。但 XFS 采用延迟日志(Delayed Logging)机制,且支持多 Allocation Group (AG) 并发,锁竞争粒度比 ext4 小得多。在极高并发的数据库场景,XFS 的表现通常更平稳,这也是为什么绝大多数现代 DB 推荐使用 XFS 的原因。不过,如果硬件 VWC 未关闭,XFS 发出的 Flush 依然会导致 NVMe 队列阻塞。
Q2:数据库改用 io_uring 能绕过这个底层 Flush 瓶颈吗?
不能。io_uring 解决的是系统调用开销(User to Kernel 的 SQE/CQE 环形队列)和线程阻塞问题。但当请求进入到块层(Block Layer),如果发生 Flush 屏障,底层硬件队列 hctx 依然会暂停。即使内核 worker 线程不阻塞用户态应用,IO 仍会积压在环形队列中,最终表现为请求超时。
Q3:云上的块存储(如 AWS EBS / 阿里云 ESSD)需要禁用 Write Cache 吗?
云盘通常由后端的分布式存储集群(如 Ceph/盘古)保障多副本和持久化。对于 Guest OS 而言,很多云平台会在虚拟化层忽略前端发来的 REQ_FLUSH,因为只要写入成功即代表落盘。但这取决于具体云厂商的实现。实践中,建议查阅云厂商文档,若支持,依然建议在 OS 层面直接把块设备的调度器改为 none,避免宿主机和虚拟机两层排队。