线上事故:一个等不到的INSERT
凌晨1点24分,值班手机连续震动。监控面板上,MySQL主库的写入延迟从5ms直接飙到200ms+,INSERT语句堆积,业务侧开始超时。
第一反应是慢SQL或锁竞争,但排查结果让人意外:CPU空闲90%,iowait不到5%。这不符合直觉——如果存储慢,iowait应该拉满才对。更诡异的是,fio随手测了一下SSD,顺序写2.1GB/s,延迟0.8ms,盘本身健康得很。
问题到底在哪?我盯着监控图想了十分钟,决定从整个IO路径逐层排查。这一查,挖出了一个平时根本不会注意的配置陷阱。
IO栈分层模型:每一层都是嫌疑人
Linux IO栈从应用到底层设备,要经过这么几层:
| 层级 | 组件 | 关键参数 |
|---|---|---|
| 应用层 | MySQL/InnoDB | innodb_flush_method, innodb_io_capacity |
| 文件系统层 | ext4/xfs | mount选项(noatime/barrier) |
| 块设备层 | IO调度器 | none/mq-deadline/bfq |
| 驱动层 | NVMe驱动 | 队列深度(nr_requests) |
| 硬件层 | SSD/NVMe | 固件版本、写入放大系数 |
每一层都可能成为瓶颈。iostat空转不代表存储健康,磁盘利用率100%也不一定等于硬件故障——调度器排队、文件系统锁、驱动队列满,都会造成这种「假忙」。
排查流程:从iostat开始逐层定位
Step 1: 用iostat排除硬件问题
先看底层设备状态,用iostat -x检查:
# 每秒刷新,显示扩展统计
iostat -x 1 5
# 输出关键列:
# Device rrqm/s wrqm/s r_await w_await aqu-sz %util
# nvme0n1 0.00 0.00 0.80 25.30 12.50 99.8
w_await 25ms,aqu-sz 12.5,%util 99.8%。再看磁盘型号是Intel P5510,标称4K随机写延迟0.02ms,差了三个数量级。
关键结论:硬件响应慢是结果,不是原因。盘本身没问题,大概率是IO路径上面某层堵了。
Step 2: 用blktrace看请求在块设备层的排队情况
# 追踪块设备层IO事件
blktrace -d /dev/nvme0n1 -w 10 -o trace
# 分析结果,重点看D2C(dispatch to complete)延迟分布
blkparse -i trace.blktrace.0 -o parse_output.txt
# 查看D2C延迟最高的几个请求
grep "D2C" parse_output.txt | awk '{print $NF}' | sort -n | tail -20
D2C延迟分布在15ms-40ms,说明请求在块设备层就卡了很久。再仔细看,发现这些请求的IO大小为4KB,类型是同步写——跟MySQL的redo log行为完全吻合。
Step 3: 检查IO调度器队列情况
# 查看当前IO调度器
cat /sys/block/nvme0n1/queue/scheduler
# none
# 查看队列统计
cat /sys/block/nvme0n1/stat
# 4231502 8892010 345678920 45678901 2345678 4567890 123456789 89012345 0 18923456 78901234
# 字段说明: 读完成次数 读合并次数 读扇区数 读耗时(ms) 写完成次数 写合并次数 写扇区数 写耗时(ms)
写耗时89012345ms除以写完成次数4567890,平均每次写耗时19.5ms。问题确认在块设备层本身——文件系统发出请求后,块设备层没有及时处理。
两种优化方案对比:调参数 vs 换调度器
定位到问题层后,设计了两套方案:
方案A:调整现有内核参数(保守)
# 增大队列深度,允许更多IO请求同时排队
echo 1024 > /sys/block/nvme0n1/queue/nr_requests
# 开启写缓存
echo "write through" > /sys/block/nvme0n1/queue/write_cache
# 调整电梯调度(noop优化)
modprobe loop
echo noop > /sys/block/nvme0n1/queue/scheduler
预期效果:队列深度增大后,块设备层能容纳更多请求,减少排队等待。
实际效果:延迟没有任何变化。nr_requests调大后,aqu-sz反而更高了,因为SSD的并行能力有限,请求排队时间不减反增。
方案B:向下换IO调度器 + 向上调文件系统参数(激进)
# 1. 切换IO调度器为none(NVMe原生调度)
echo none > /sys/block/nvme0n1/queue/scheduler
# 2. 调整队列深度为合适的值(512对P5510足够)
echo 512 > /sys/block/nvme0n1/queue/nr_requests
# 3. 重新挂载文件系统,关闭barrier和atime更新
mount -o remount,noatime,nodiratime,barrier=0 /data
# 4. 刷新vm.dirty参数,提升写回频率
sysctl -w vm.dirty_ratio=20
sysctl -w vm.dirty_background_ratio=10
预期效果:none调度器绕过复杂的合并/排序逻辑,由NVMe控制器自己处理队列,减少CPU开销和排队延迟。
关键差异对比
| 对比项 | 方案A(只调队列) | 方案B(换调度器+调文件系统) |
|---|---|---|
| IO调度器 | none→noop(没变本质) | none直接启用 |
| 队列深度 | 1024(过大) | 512(匹配硬件) |
| 文件系统 | 保持原样 | noatime+关闭barrier |
| dirty参数 | 默认 | 主动调优 |
| 改动范围 | 块设备层 | 块设备层+文件系统层+VM层 |
| 预期延迟 | 20ms+ | <5ms |
完整代码实现:一套可落地的存储优化脚本
生产环境不能用临时echo命令一改了事。写一个可重复执行的优化脚本:
#!/bin/bash
# storage_optimize.sh - 生产环境IO栈优化脚本
# 适用:CentOS Stream 9 / Ubuntu 22.04+ / 内核5.4+
# 用法:sudo bash storage_optimize.sh
set -euo pipefail
LOG_FILE="/var/log/storage_optimize.log"
exec 2>>"$LOG_FILE"
echo "=== $(date) Storage Optimization Start ==="
# 检测磁盘:生产库磁盘通常是nvme0n1或sda
DISK="${1:-nvme0n1}"
if [ ! -e "/sys/block/$DISK" ]; then
echo "Disk $DISK not found"
exit 1
fi
# 1. 确认内核支持柔性块层(blk-mq)
if [ ! -f "/sys/block/$DISK/queue/nr_requests" ]; then
echo "blk-mq not supported, abort"
exit 1
fi
# 2. 选择IO调度器:NVMe优先none,SATA SSD用mq-deadline
if [[ "$DISK" == nvme* ]]; then
SCHEDULER="none"
else
SCHEDULER="mq-deadline"
fi
echo "$SCHEDULER" > "/sys/block/$DISK/queue/scheduler"
echo "[OK] IO scheduler set to $SCHEDULER"
# 3. 根据磁盘类型设队列深度
case "$DISK" in
nvme*)
NR_REQUESTS=512 ;;
sd*)
NR_REQUESTS=128 ;;
esac
echo "$NR_REQUESTS" > "/sys/block/$DISK/queue/nr_requests"
echo "[OK] nr_requests set to $NR_REQUESTS"
# 4. 关闭磁盘写缓存(生产环境推荐write through, 防掉电丢数据)
# 注意:此操作可能微量影响性能,但提高数据安全性
# echo "write through" > "/sys/block/$DISK/queue/write_cache"
# 5. 设置VM dirty参数:避免一次性写太多脏页
sysctl -w vm.dirty_ratio=20 >/dev/null
sysctl -w vm.dirty_background_ratio=10 >/dev/null
sysctl -w vm.dirty_writeback_centisecs=500 >/dev/null
sysctl -w vm.dirty_expire_centisecs=3000 >/dev/null
echo "[OK] VM dirty parameters adjusted"
# 6. 重新挂载数据库目录, 优化文件系统参数
DATA_DIR="/var/lib/mysql"
if mountpoint -q "$DATA_DIR"; then
mount -o remount,noatime,nodiratime,barrier=0 "$DATA_DIR"
echo "[OK] Remounted $DATA_DIR with optimized options"
fi
# 7. 持久化到sysctl.conf
cat > /etc/sysctl.d/99-storage-optimize.conf <<'EOF'
vm.dirty_ratio=20
vm.dirty_background_ratio=10
vm.dirty_writeback_centisecs=500
vm.dirty_expire_centisecs=3000
EOF
sysctl --system >/dev/null 2>&1 || true
echo "[OK] sysctl configurations persisted"
# 8. 验证
echo ""
echo "=== Verification ==="
echo "Scheduler: $(cat /sys/block/$DISK/queue/scheduler)"
echo "nr_requests: $(cat /sys/block/$DISK/queue/nr_requests)"
cat /proc/sys/vm/dirty_ratio
cat /proc/sys/vm/dirty_background_ratio
echo "=== $(date) Optimization Done ==="
压测方案:用fio验证真实效果
优化不能靠感觉,必须数据说话。用fio模拟生产负载(4KB随机写70% + 顺序写30%):
# 优化前测试(保留基线)
fio --name=mysql_sim --ioengine=libaio --iodepth=32 \
--rw=randwrite --bs=4k --size=4G --numjobs=4 \
--runtime=60 --time_based --group_reporting \
--direct=1 --fsync=4 --output=before_opt.json
# 优化后测试(同样的参数)
fio --name=mysql_sim --ioengine=libaio --iodepth=32 \
--rw=randwrite --bs=4k --size=4G --numjobs=4 \
--runtime=60 --time_based --group_reporting \
--direct=1 --fsync=4 --output=after_opt.json
同时用bcc工具跟踪IO延迟分布:
# 安装bcc后直接跑脚本
cd /usr/share/bcc/tools
./biolatency -d nvme0n1 1 5
效果数据:优化前后对比
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 4K随机写延迟(P50) | 22.8ms | 2.9ms | 87.3%↓ |
| 4K随机写延迟(P99) | 48.2ms | 12.5ms | 74.1%↓ |
| IOPS | 1,850 | 12,400 | 6.7x |
| 吞吐量(MB/s) | 7.4 | 49.6 | 6.7x |
| CPU sys占用 | 32% | 18% | 43.8%↓ |
| iowait | 4.5% | 1.2% | 73.3%↓ |
MySQL侧的真实反馈:写入延迟从25ms降到3ms(P99从120ms降到18ms),连接池不再堆积,业务超时告警清零。
避坑指南:这五个坑我全踩过
坑1: 无脑上none调度器
NVMe确实推荐none,但机械硬盘千万别用。机械盘靠调度器做电梯算法减少寻道,用none会直接把盘拉垮。曾有同事在HDD服务器上跑none, 写入延迟从10ms涨到800ms。
坑2: barrier=0的代价
ex4的barrier=0能提性能,但会牺牲崩溃一致性。如果机器没有BBU(电池备份)或双电源,断电可能丢数据。我这次是因为有UPS+硬件RAID卡,才敢关。
坑3: nr_requests不是越大越好
一开始调到2048,结果延迟不降反升。原因是队列太长,IO请求在队列里排队的时间就长。对于P5510这类SSD,512是最佳值。调过之后观察SSD的队列利用率,接近100%且不溢出才算对。
坑4: fio的ioengine选错会导致测出来假数据
fio测同步写必须用libaio + direct=1,不要用psync。psync走page cache,测出来的延迟是内存的,不是磁盘的。另外fsync=1会每次写都刷盘,模拟数据库redo log行为,不要漏。
坑5: 内核版本不同,sysctl参数名不同
CentOS 7的内核3.10,有的sysctl参数不存在。用之前先检查:
# 检查内核是否支持dirty_expire_centisecs
sysctl vm.dirty_expire_centisecs || echo "not support"
# 检查blk-mq是否启用
ls /sys/block/nvme0n1/queue/nr_requests || echo "not blk-mq"
原理深挖:为什么none调度器能赢?
传统IO调度器(deadline/cfq)是为机械硬盘设计的,核心是减少寻道,通过合并和排序IO请求。但NVMe SSD的随机读写能力和顺序读写几乎一样快,合并的优势没了。
更关键的是,调度器本身要消耗CPU和锁——每个IO请求经过调度器都要加锁、排队、比较、重排。当SSD延迟只有50微秒时,调度器处理一个请求可能就要花5微秒,这10%的CPU开销纯属浪费。
none调度器直接跳过所有排序逻辑,把请求直接丢给NVMe控制器。NVMe硬件有原生多队列(每CPU一个队列),天然并行度高,不需要软件层再调度。
换到文件系统层,noatime减少一次元数据更新;barrier=0去掉写屏障,让写操作不等待前序操作完成。对数据库这种追求低延迟的场景,收益非常可观。
这些调整的本质都是:去掉不需要的通用性,换取极致的性能。但前提是硬件确实足够可靠,业务确实能承受相应风险。
总结
本次优化没有换任何硬件,纯软件层调整将存储延迟降低了87%。如果你的MySQL/PostgreSQL也遇到了「磁盘利用率100%但硬件看起来没啥问题」的情况,按这个思路排查:iostat看设备层 → blktrace看排队延迟 → 换调度器+调文件系统 → fio验证。不要一上来就怀疑盘坏了,先跑一遍fio --rw=randwrite --bs=4k --iodepth=32 --direct=1压测,用数据说话。
最后提醒一句:生产环境的任何IO栈改动,先在测试环境复现压测。存储优化牵扯数据安全,宁可慢一点,不要赌运气。