Linux IO栈深挖:一场存储性能优化实战
发布日期: 2026/08/04 阅读总量: 2

线上事故:一个等不到的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/InnoDBinnodb_flush_method, innodb_io_capacity
文件系统层ext4/xfsmount选项(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.8ms2.9ms87.3%↓
4K随机写延迟(P99)48.2ms12.5ms74.1%↓
IOPS1,85012,4006.7x
吞吐量(MB/s)7.449.66.7x
CPU sys占用32%18%43.8%↓
iowait4.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栈改动,先在测试环境复现压测。存储优化牵扯数据安全,宁可慢一点,不要赌运气。