一台运行 MySQL、ClickHouse、MongoDB、MinIO 等多套服务的 CentOS 7 虚拟机出现"磁盘读写缓慢、数据库查询卡顿"。本文完整记录了从建立观测、指标判读、双盘对照、落盘分布定位,到最终用"同文件系统重写"实现零扩容、停机 1 分钟内完成数据迁移的全过程。
关键词:CentOS 7 / VMware PVSCSI / iostat / await / LVM 落盘分布 / XFS / 无损迁移
前言:问题现象
现象描述:
- 数据库查询与写入明显变慢,体感"响应恢复不到正常水平"
- 重启部署应用时也慢,部署过程涉及大量文件拷贝
- 机器本身 CPU 空闲、负载不高,
top里看不到明显瓶颈
这个"业务慢但资源看着不忙"的组合,是典型的 I/O 路径问题特征。下面按五层框架逐层收敛。
一、环境与拓扑
| 项目 | 值 |
|---|---|
| 虚拟化平台 | VMware(systemd-detect-virt = vmware) |
| 存储控制器 | VMware PVSCSI(vmw_pvscsi,单队列,cmd_per_lun=254) |
| 操作系统 | CentOS Linux 7.9.2009 (Core) |
| 内核 | 3.10.0-1160.119.1.el7.x86_64 |
| CPU / 内存 | 12 vCPU / 28 GB |
| 运行时长 | 228 天未重启 |
磁盘拓扑(关键):
sda 300G disk
├─sda1 1G part /boot
└─sda2 209G part <- LVM PV
├─centos-root lvm / <- 同时横跨 sda2 与 sdb
├─centos-swap lvm [SWAP] <- 独占 sda2
└─centos-home lvm /home <- 独占 sda2
sdb 1T disk
└─centos-root lvm / <- root LV 的另一段
换算一下布局:
sda2= 209 G,其中 swap(4.9 G) + home(50 G) 独占,留给 root LV 的只有约 154 GrootLV 总容量约 1.2 T,由 sda2 段(约 154 G)+ sdb 段(约 1000 G)线性拼接而成- 文件系统为 XFS,已用 907 G(77%)
这个"跨两块虚拟盘拼成的根分区",是后面一切问题的放大器。
同机混部情况(单台机器上跑了 33 个 systemd 服务):
| 服务 | 累计写入(228 天) |
|---|---|
| ClickHouse | 5.19 TB |
| MySQL 8.0.40 | 4.06 TB |
| MongoDB | 581 GB |
| MinIO / KVM / 多个 Java 应用 | 若干 |
二、排查方法论:五层收敛
磁盘 I/O 慢的排查容易发散,建议按下面五层自上而下收敛,每层只回答一个问题:
| 层次 | 要回答的问题 | 主要手段 |
|---|---|---|
| 1. 业务/数据库层 | 是 DB 慢还是全盘慢?影响面多大? | 慢日志、应用耗时埋点 |
| 2. 虚拟机 OS 层 | 设备是否饱和? | iostat -x、sar -d |
| 3. 进程层 | 谁在产生 I/O? | pidstat -d、iotop、/proc/<pid>/io |
| 4. 虚拟化层 | 请求堵在控制器还是后端? | 队列深度、dmesg、多盘对照 |
| 5. 宿主机/存储层 | 是后端问题还是租户争抢? | 宿主机侧 DAVG/KAVG、VMDK 健康 |
三、第一阶段:先建立观测能力
第一个发现:机器上根本没装 sysstat。
$ iostat -V
-bash: iostat: 未找到命令
没有 iostat / pidstat / sar,意味着:
- 看不到设备级指标,只能靠
vmstat的wa猜 sar没有历史采样,问题发生时没留下任何数据,事后无法回溯
这本身就是一个严重的运维缺口。第一件事就是补上:
bash
yum -y install sysstat iotop
systemctl enable --now sysstat
# sysstat 会通过 /etc/cron.d/sysstat 每 10 分钟自动采样
# 之后可用 sar -d -p 回溯当天任意时段
经验 :任何承载数据库的服务器,
sysstat应该是最早安装的软件包之一。它的价值不在于"实时看",而在于"事后能翻账"。
同时准备一个采集脚本,把设备级、进程级、内核参数、数据库配置一次性抓全(本文附录提供)。
四、第二阶段:设备级指标判读
4.1 三指标判饱和
iostat -x 1 N 里真正需要盯的是三个(svctm 在新内核已废弃,不要用):
| 指标 | 含义 | 异常判据 | 指向 |
|---|---|---|---|
%util |
设备繁忙度 | 持续 >95% | 设备侧饱和 |
await |
单次 I/O 平均耗时(ms) | 机械盘 >20、本地 SSD >5、云盘 >10 | 延迟异常 |
avgqu-sz |
平均排队深度 | 持续 >2~4 或 > 队列深度一半 | 排队积压 |
组合判读规则(这一步决定后面往哪查):
%util |
await |
结论 |
|---|---|---|
| 高 | 高 | 设备已饱和,负载真的过大 |
| 低 | 高 | 请求堵在后端(控制器 / 存储路径) |
| 低 | 低 | 不是磁盘瓶颈,去查锁、缓冲池、网络 |
注意:
%iowait高只说明 CPU 在等 I/O,不是根因。
4.2 双盘对照法:最有说服力的证据
这台机器有个天然优势:两块虚拟盘共用同一个 PVSCSI 控制器。这提供了一个完美的对照组 ------ 同一时刻、同一控制器、同一套内核参数,两块盘的表现直接可比。
实测结果(同一 30 秒窗口内的逐秒采样):
| 时刻 | 设备 | w/s | wkB/s | avgqu-sz | await | svctm | %util |
|---|---|---|---|---|---|---|---|
| 1 | sda | 22 | 71 | 0.10 | 4.5 ms | 6.41 | 14% |
| 2 | sda | 96 | 1519 | 145.98 | 476 ms | 10.42 | 100% |
| 3 | sda | 69 | 708 | 116.85 | 1606 ms | 14.29 | 99% |
| 4 | sda | 0 | 0 | 53.07 | --- | --- | 100% |
| 5 | sda | 2 | 64 | 52.74 | 5292 ms | 500 ms | 100% |
| 6 | sda | 2 | 64 | 53.74 | 10128 ms | 500 ms | 100% |
| 7 | dm-0 | 25 | 549 | 47.19 | 30350 ms | 40.00 | 100% |
同一时刻 sdb 的表现:
| 设备 | max %util | max await | max aqu | max wkB/s |
|---|---|---|---|---|
| sda | 100.0 | 10128 ms | 146 | 1519 |
| sdb | 3.8 | 14.9 ms | 3.67 | 3235 |
决定性的一条 :某次采样中 sda 每秒只有 2 个写请求,队列里却积压 53.7 个 ,最久的等了 10.1 秒,而设备显示 100% 忙。
这不是"忙",是"卡死" ------ 请求进了设备就不返回,内核只能留在队列里反复重试。
4.3 单位利用率折算:把差距量化
更有杀伤力的对比方式,是把"每 1% 利用率能承载多少吞吐"算出来:
| 设备 | w/s | wkB/s | svctm | %util | 每 1% util 承载 |
|---|---|---|---|---|---|
| sdb | 254 | 4325 | 0.16 ms | 4.0% | 约 1081 kB/s |
| sda | 93 | 1345 | 10.71 ms | 99.6% | 约 13.5 kB/s |
同一秒、同一控制器,单位利用率下的有效吞吐相差约 80 倍。
这个方法比单纯比 await 更有说服力,因为它排除了"负载不同"的干扰。
五、第三阶段:低负载高延迟 = 后端问题
再看一组数据,答案就更明确了:
# 同一个设备、同一分钟内
sda 22 w/s 51 kB/s aqu 0.01 await 0.27 ms svctm 0.27 %util 0.60
sda 3 w/s 48 kB/s aqu 5.39 await 547 ms svctm 333.33 %util 100.00
负载几乎相同,延迟差 2000 倍;而且负载最低的那一秒最卡。
这条规律非常有用:
设备的延迟与负载不相关(甚至负相关)时,问题一定不在"量",而在"设备/后端本身"。
同期的辅助证据:
%steal = 0→ 宿主 CPU 没有超分争抢Dirty只有 11.7 MB、Writeback48 KB → 不是脏页回写限流- 平均写入 <600 kB/s、峰值 3.3 MB/s → 这个负载压不垮任何盘
于是可以排除:负载过大、内核参数、CPU 争抢、脏页限流。范围收敛到"sda 这块盘及其后端"。
另外一个强证据来自内核日志:
blk_update_request: critical target error, dev sda, sector 414198112
blk_update_request: critical target error, dev sda, sector 304861544
- 集中在固定的两个扇区区段,反复出现
- 时间跨度长达数月
critical target error是 ESXi 存储层返回的 SCSI target error,不是客户机文件系统的错误- PVSCSI 设备
timeout = 180秒 → 命中这些区域的 I/O 会重试最长 180 秒,期间同队列的其它请求全部排队
六、第四阶段:定位"谁在用慢盘"------落盘分布分析
6.1 为什么需要这一步
常规排查到这里会陷入困境:知道是 sda 有问题,但不知道哪些数据在 sda 上。
而这台机器的 root 分区横跨两块盘,同一目录下的文件,可能一个在慢盘、一个在快盘。所以问题的答案不是"哪个目录慢",而是"哪个文件的物理位置在慢盘"。
6.2 原理
LVM 线性逻辑卷的物理布局是确定的:
- 用
lvs -o seg_pe_ranges读出 LV 的段(segment)顺序,算出在 LV 逻辑空间里 PV 的切换边界 - 用
filefrag -v <文件>读出文件各 extent 的物理块号,换算成 LV 内的字节偏移 - 两者比对,得出每个 extent 落在哪个 PV
6.3 工具实现(pvmap.sh)
bash
#!/usr/bin/env bash
# pvmap.sh --- 判断文件/目录的数据实际落在哪块物理盘上(LVM 线性卷场景)
set -u
SHOW_ALL=0; SCAN=0; SCAN_N=200; SCAN_DEPTH=3
SLOW_PV="${SLOW_PV:-/dev/sda2}"
EXT_LIMIT="${FILEFRAG_LIMIT:-300}"
TARGETS=()
while [ $# -gt 0 ]; do
case "$1" in
-a|--all) SHOW_ALL=1; shift ;;
-S|--scan) SCAN=1; shift ;;
-n|--top) SCAN_N="${2:-200}"; shift 2 ;;
-d|--depth) SCAN_DEPTH="${2:-3}"; shift 2 ;;
-h|--help) sed -n '2,22p' "$0"; exit 0 ;;
*) TARGETS+=("$1"); shift ;;
esac
done
[ "${#TARGETS[@]}" -eq 0 ] && { echo "用法: bash $0 [-a] [-S] <路径> [...]"; exit 1; }
WORK=$(mktemp -d /tmp/pvmap.XXXXXX) || exit 1
trap 'rm -rf "$WORK"' EXIT
# 路径 -> LV 名(三级兜底,不依赖单一 LVM 字段)
lv_of() {
local src real name out
src=$(findmnt -no SOURCE --target "$1" 2>/dev/null) || src=""
[ -z "$src" ] && src=$(df -P -- "$1" 2>/dev/null | awk 'END{print $1}')
[ -z "$src" ] && { echo ""; return 0; }
real=$(readlink -f "$src" 2>/dev/null) || real="$src"
if command -v lvs >/dev/null 2>&1; then
out=$(lvs --noheadings -o lv_dm_path,vg_name,lv_name 2>/dev/null \
| awk -v d="$real" '$1==d {print $2"/"$3; exit}')
[ -n "$out" ] && { echo "$out"; return 0; }
fi
case "$src" in
/dev/mapper/*)
name=${src#/dev/mapper/}
if command -v lvs >/dev/null 2>&1; then
out=$(lvs --noheadings -o vg_name,lv_name 2>/dev/null \
| awk -v n="$name" '{ if ($1"-"$2 == n) {print $1"/"$2; exit} }')
[ -n "$out" ] && { echo "$out"; return 0; }
fi
echo "$name"; return 0
;;
esac
echo "$src"
}
# 生成「累积字节 -> PV」映射表
seg_map() {
lvs --noheadings --units b --nosuffix -o seg_start,seg_size,seg_pe_ranges "$1" 2>/dev/null \
| awk '{ dev=$3; sub(/:[0-9]+-[0-9]+$/, "", dev); if (dev=="") next; c+=$2; print c, dev }' \
> "$WORK/segmap"
[ -s "$WORK/segmap" ]
}
pv_at() {
awk -v b="$1" '{ last=$2 } $1+0 >= b+0 { print $2; found=1; exit }
END { if (!found && last != "") print last }' "$WORK/segmap"
}
# 文件 -> 各 extent 的物理块号
extents_of() {
filefrag -v -- "$1" 2>/dev/null | awk -v lim="$EXT_LIMIT" '
/^[[:space:]]*[0-9]+:/ && NF>=4 {
p=$4; gsub(/[^0-9]/, "", p)
if (p!="") { print p; n++; if (n>=lim) exit }
}'
}
first_extent_pv() {
local p
p=$(filefrag -v -- "$1" 2>/dev/null \
| awk '/^[[:space:]]*[0-9]+:/ && NF>=4 {print $4; exit}' | tr -dc '0-9')
[ -z "$p" ] && return 1
pv_at $(( p * $2 ))
}
pick_file() {
if [ -f "$1" ]; then printf '%s\n' "$1"; return 0; fi
[ -d "$1" ] || return 1
find "$1" -maxdepth "$SCAN_DEPTH" -type f -size +256k -printf '%s %p\n' 2>/dev/null \
| sort -rn | head -n 1 | cut -d' ' -f2-
}
scan_dir() {
local dir="$1" last_lv="" f lv bs pv sz cnt=0 nfiles top
find "$dir" -maxdepth "$SCAN_DEPTH" -type f -size +256k -printf '%s %p\n' 2>/dev/null \
| sort -rn | head -n "$SCAN_N" | cut -d' ' -f2- > "$WORK/filelist"
nfiles=$(wc -l < "$WORK/filelist" | tr -d ' ')
[ "$nfiles" -eq 0 ] && { echo " 结果 : 目录内没有大于 256KB 的文件"; return 0; }
echo " 扫描范围 : 最大的 $nfiles 个文件(>256KB,最多 $SCAN_DEPTH 层)"
: > "$WORK/scanraw"
while IFS= read -r f; do
[ -f "$f" ] || continue
lv=$(lv_of "$f"); [ -z "$lv" ] && continue
if [ "$lv" != "$last_lv" ]; then seg_map "$lv" || { last_lv=""; continue; }; last_lv="$lv"; fi
bs=$(stat -f -c %S "$f" 2>/dev/null || echo 4096)
pv=$(first_extent_pv "$f" "$bs") || continue
sz=$(stat -c %s "$f" 2>/dev/null || echo 0)
printf '%s %s\n' "$pv" "$sz" >> "$WORK/scanraw"
cnt=$((cnt+1))
done < "$WORK/filelist"
[ "$cnt" -eq 0 ] && { echo " 结果 : 无法解析任何文件的位置"; return 0; }
echo " 落盘分布 :"
awk '{c[$1]++; b[$1]+=$2}
END{for (k in c) printf " %-14s %6d 个文件 %10.2f GB\n", k, c[k], b[k]/1073741824}' \
"$WORK/scanraw" | sort -k2 -rn
top=$(awk '{c[$1]++} END{for (k in c) if (c[k]>m) {m=c[k]; t=k} print t}' "$WORK/scanraw")
[ "$top" = "$SLOW_PV" ] && echo " 判定 : 警告 主要位于慢盘 $top" \
|| echo " 判定 : 正常 主要位于快盘 $top"
}
SLOW_HITS=0
for tgt in "${TARGETS[@]}"; do
echo "=================================================================="
echo "[$tgt]"
if [ "$SCAN" -eq 1 ] && [ -d "$tgt" ]; then scan_dir "$tgt"; continue; fi
file=$(pick_file "$tgt")
[ -z "${file:-}" ] || [ ! -f "$file" ] && echo " 结果 : 跳过(目录内没有大于 256KB 的文件)" && continue
echo " 比对文件 : $file ($(du -h "$file" 2>/dev/null | awk '{print $1}'))"
lv=$(lv_of "$file"); echo " VG/LV : ${lv:--}"
seg_map "$lv" || { echo " 主物理卷 : $lv"; continue; }
bs=$(stat -f -c %S "$file" 2>/dev/null || echo 4096)
: > "$WORK/rawpvs"
extents_of "$file" | while read -r p; do [ -z "$p" ] && continue; pv_at $(( p * bs )); done > "$WORK/rawpvs"
total=$(wc -l < "$WORK/rawpvs" | tr -d ' ')
[ "$total" -eq 0 ] && echo " 结果 : 跳过 ------ 未取到 extent 信息" && continue
sort "$WORK/rawpvs" | uniq -c | sort -rn > "$WORK/tally"
top_pv=$(awk 'NR==1{print $2}' "$WORK/tally")
top_n=$(awk 'NR==1{print $1}' "$WORK/tally")
echo " 主物理卷 : $top_pv"
if [ "$top_pv" = "$SLOW_PV" ]; then
echo " 判定 : 警告 位于慢盘($top_n/$total extents)"; SLOW_HITS=$((SLOW_HITS+1))
else
echo " 判定 : 正常 位于快盘($top_n/$total extents)"
fi
[ "$SHOW_ALL" -eq 1 ] && { echo " extent 分布:"; awk '{printf " %-14s %4d 个 extent\n", $2, $1}' "$WORK/tally"; }
done
echo "=================================================================="
echo "慢盘标记 SLOW_PV=$SLOW_PV ;命中 ${SLOW_HITS} 项"
用法:
bash
bash pvmap.sh /var/lib/mysql/ibdata1 # 单文件,展开所有 extent
bash pvmap.sh /var/lib/mysql /opt /home # 目录 → 取其中最大文件
bash pvmap.sh -S /var/lib/mysql # 整体扫描:统计目录内所有大文件的落盘分布
bash pvmap.sh -S -d 8 /var/lib/clickhouse # 扫描深度改为 8 层
6.4 实测结果
[/var/lib/mysql]
主物理卷 : /dev/sda2 判定 : 警告 位于慢盘
[/opt]
主物理卷 : /dev/sda2 判定 : 警告 位于慢盘
[/home]
主物理卷 : /dev/sda2 判定 : 警告 位于慢盘
[/data]
主物理卷 : /dev/sdb 判定 : 正常 位于快盘
整体扫描(-S)给出更精确的比例:
| 目录 | 慢盘(sda2) 文件数 | 慢盘容量 | 快盘(sdb) |
|---|---|---|---|
/var/lib/mysql |
262 / 273 | 7.73 GB / 7.80 GB | 11 个文件 0.07 GB |
/opt |
199 / 200 | 3.04 GB / 3.32 GB | 1 个文件 0.28 GB |
/opt/minio/data |
2 / 300 | 0.01 GB | 298 个文件 1.46 GB |
/data |
--- | --- | 全部 |
到这里,两个核心症状的因果关系完全闭合:
| 症状 | 对应证据 |
|---|---|
| 数据库查询/写入慢 | 全部 .ibd、ibdata1、undo_001 都在 sda2,await 数百毫秒 |
| 重启部署应用慢 | /opt 下 199 个 jar 在 sda2,应用启动时顺序读被拖 |
七、根因结论
sda 这块虚拟磁盘的后端存在故障,且长期未被发现。
两层证据:
-
已确证的固定扇区损坏 ------
blk_update_request: critical target error反复出现在sector 414198112与sector 304861544,跨数月。配合 PVSCSItimeout=180,命中这些区域的 I/O 会挂起最长 180 秒。 -
无日志报错但持续存在的间歇性延迟 ------ 在没有任何新内核错误的窗口里,sda 依然出现
await 547 ms / svctm 333 ms / %util 100%(而当时只有 3 个 IOPS)。说明除了坏扇区,数据存储层还有独立的延迟抖动来源(同 datastore 其它虚机争抢、存储路径拥塞等)。
而这个问题之所以"炸得这么大",是因为架构上的三个放大器:
| 放大器 | 说明 |
|---|---|
| 根分区跨两块盘线性拼接 | 数据按 extent 分布,一半路径要经过病盘 |
| 单盘混部 | MySQL / ClickHouse / MongoDB / MinIO 抢同一个 PVSCSI 队列 |
| swap 与 /home 独占慢盘 | 又多了 55 G 的活跃数据区在病盘上 |
八、处置方案
8.1 前置约束
真实环境的约束往往比技术方案更硬:
- 服务器物理槽位已插满,无法新增磁盘
- VG(卷组)已无空闲 extent
- root 是 XFS,XFS 不支持缩小
- 待迁移数据没有备份落点
结论:任何依赖"加盘"或"扩 LV"的方案都走不通。
8.2 核心思路:同文件系统重写迁移
关键洞察来自一个简单的探测:
bash
dd if=/dev/zero of=/probe-1.bin bs=1M count=1024 status=none
dd if=/dev/zero of=/probe-2.bin bs=1M count=1024 status=none
dd if=/dev/zero of=/probe-3.bin bs=1M count=1024 status=none
sync
bash pvmap.sh /probe-1.bin /probe-2.bin /probe-3.bin
结果:
[/probe-1.bin] 主物理卷 : /dev/sdb 判定 : 正常 位于快盘(1/1 extents)
[/probe-2.bin] 主物理卷 : /dev/sdb 判定 : 正常 位于快盘(2/2 extents)
[/probe-3.bin] 主物理卷 : /dev/sdb 判定 : 正常 位于快盘(2/2 extents)
新写入天然落在快盘 ------ 因为 XFS 分配新块时优先使用"最佳空闲 extent",而空闲大头在 sdb 段。
于是方案成型:
不移动 LVM extent,而是"让数据重新落一次盘"。
复制到新路径(新 extent 自动落快盘)→ 落盘校验 → 同分区
mv换名(瞬间,不复制数据)→ 启动验证。
8.3 完整流程
阶段划分(关键是把停机时间压到最短):
| 阶段 | 动作 | 是否需要停机 |
|---|---|---|
| 1 | 在线初拷:rsync 全量到暂存目录 |
否 |
| 2 | 停服 + rsync --delete 增量对齐 |
是(通常 <1 分钟) |
| 3 | 落盘校验:pvmap.sh -S 必须判定为快盘,否则回滚 |
是 |
| 4 | 同文件系统换名:mv 两次,瞬间完成 |
是 |
| 5 | restorecon + 启动 + 验证 |
--- |
核心命令:
bash
# 阶段 1:在线初拷(业务不受影响)
mkdir -p /srv/mysql
rsync -aAX --numeric-ids --delete /var/lib/mysql/ /srv/mysql/
# 阶段 2:停服 + 增量(这才是真正的停机窗口)
systemctl stop mysqld
rsync -aAX --numeric-ids --delete /var/lib/mysql/ /srv/mysql/
# 一致性复核,必须为 0
rsync -an --delete --stats /var/lib/mysql/ /srv/mysql/ | grep 'Number of regular files transferred'
# 阶段 3:落盘校验(不通过就回滚)
bash pvmap.sh -S /srv/mysql -n 300
# 阶段 4:换名(同分区 mv,只改目录项,不复制数据)
chown -R mysql:mysql /srv/mysql && chmod 750 /srv/mysql
mv /var/lib/mysql /var/lib/mysql.bak-$(date +%F)
mv /srv/mysql /var/lib/mysql
# 阶段 5:启动验证
systemctl start mysqld
mysql -e "SELECT @@datadir, @@uptime;"
这个方案的优势:
| 设计 | 原因 |
|---|---|
| 两阶段复制 | 停机从"十几分钟"压到 1 分钟以内 |
| 落盘校验作为硬闸门 | 确认方案成立才继续,否则自动回滚 |
同分区 mv 换名 |
不复制数据、瞬间完成 |
不改 my.cnf |
路径不变,配置零改动 |
保留 .bak |
可回滚 + 占住慢盘旧空间,防止新写入回流 |
执行结果:
[/srv/mysql]
扫描范围 : 最大的 214 个文件(>256KB)
落盘分布 :
/dev/sdb 179 个文件 6.65 GB
/dev/sda2 35 个文件 0.71 GB
判定 : 正常 主要位于快盘 /dev/sdb
[OK] 数据已落在快盘
[OK] 已切换为 /var/lib/mysql
[OK] mysqld 已启动
迁移后仍有 0.71 GB 落在慢盘,这是正常的 ------ XFS 分配时会顺手用掉慢盘上散布的零星空闲块。占比不到 10%,且多为低频文件,不值得再折腾一次。
8.4 效果验证
迁移后的实时采样:
| 指标 | 迁移前 | 迁移后 |
|---|---|---|
sda await |
582 ~ 10128 ms | 0.27 ~ 1.17 ms |
sda %util |
100% | 0 ~ 1.3% |
sda avgqu-sz |
33 ~ 146 | 0 ~ 0.01 |
%iowait |
17 ~ 28% | 0 ~ 0.93% |
await 从 10.1 秒降到 1.17 毫秒,相差约 8600 倍。
后来在关闭 swap 的过程中,sda 又贡献了一组更有力的对比:
| 迁移前 | 迁移后(关闭 swap 期间) | |
|---|---|---|
r/s |
3 | 766 |
await |
547 ~ 10128 ms | 0.92 ms |
%util |
100%(空转) | 67.8%(真在干活) |
迁移前做 3 个 IOPS 就卡 547 毫秒;迁移后做 766 个 IOPS 只需要 0.92 毫秒。
这反过来证明:sda 这块盘本身的性能是正常的,之前的"故障"是被大块 I/O 踩中坏扇区/后端问题放大出来的。
九、踩坑记录
坑 1:find 的 maxdepth 让 490 GB 数据凭空消失
排查中发现某目录下有一个 490 GB 的数据集,但脚本报告"目录内没有大文件"。
原因:对象存储的路径是 /opt/minio/data/<bucket>/<object>(第 4 层),而扫描脚本用的是 find -maxdepth 3。
教训:梳理落盘分布前,先确认 maxdepth 覆盖数据真实的目录层级。 后续给工具补上了 -d <深度> 参数。
坑 2:单文件抽样会给出完全相反的结论
用"目录里最大的文件"代表整个目录,第一次得到"MySQL 在快盘"的结论 ------ 因为最大的文件恰好是新生成的 binlog.000115(滚动产生的新文件,天然落在快盘)。
用 -S 整体扫描后真相反转:262/273 个文件、99% 容量都在慢盘。
教训:判断目录的整体分布必须全量扫描,不能抽样单个文件。 如果一定要抽样,至少抽样几百个并统计容量占比。
坑 3:在故障盘上执行 swapoff
swapoff -a 需要把 swap 里的页全部读回内存,而这块 swap 正好在病盘上,且是随机 4 KB 页读 ------ 最不擅长慢盘的访问模式。
本次耗时约 6 分钟,期间 dm-1 的 await 只有 0.9~1.7 ms(因为 MySQL 已经迁走),算是运气好。如果换在迁移前做,很可能触发 180 秒超时挂起。
更好的做法:不要 swapoff。 把 vm.swappiness 设为 1 就够了 ------ 系统不再主动换出,而已经换出去的页如果没人访问就一直躺着,不产生任何 I/O。收益相同,风险为零。
坑 4:删除文件不会释放 LVM extent
rm -rf 释放的是文件系统空间,不是 LVM 的 extent。所以:
- 即使删掉几百 GB 数据,
vg_free依然是 0 pvmove需要目标 PV 有空闲 extent,删文件并不能创造这个条件- 而 XFS 不支持缩小,所以
lvreduce的路也走不通
结论:VG 满 + XFS 的场景下,LVM 层面是死路,必须从文件系统层面想办法。
坑 5:sysstat 缺失导致无法事后复盘
问题发生时机器上没装 sysstat,没有 iostat、没有 sar 历史。所有"当时有多慢"的判断都只能依赖现场抓取的 30 秒样本。
建议:承载数据库的服务器,第一时间装上 sysstat 并确认 systemctl enable --now sysstat。
十、经验总结
判断层面
- 双盘(或多盘)对照法是最快的定位手段 ------ 如果一台机器上有表现正常和异常的同类设备,直接对比,比任何阈值判断都可靠。
- "低负载 + 高延迟"几乎等价于"设备或后端有问题" ------ 延迟与负载不相关时,不要再去优化应用和参数。
- 用"单位利用率承载的有效吞吐"来量化差距 ,比单纯比
await更能排除负载干扰(本例中差 80 倍)。 %iowait不是根因,svctm在新内核已废弃 ,判断规模仍以await+avgqu-sz+%util三者组合为准。- PVSCSI 的
timeout=180是个双刃剑 ------ 它给了存储层更多重试机会,但一次坏块就能让请求挂 3 分钟。看到await出现"秒级"数字,要优先怀疑这个。
排查层面
- 先建立观测,再谈优化。 没有
iostat/sar的机器,排查成本会成倍上升。 - 虚拟化环境下,"文件在哪个目录"不重要,"数据落在哪块盘"才重要。 落盘分布应该成为标准诊断维度。
- 内核日志里的
blk_update_request/critical target error要当回事 ------ 它直指存储后端,不是客户机的问题。 find -maxdepth要按数据实际层级设定,否则会得出完全错误的结论。- 目录级别的判断必须全量扫描,单文件抽样可能恰好选中"最不具代表性"的那个。
处置层面
- 磁盘满了不要急着加盘或扩 LV。 先量化"热点数据到底有多大"------本例中真正需要迁移的只有不到 30 GB,一块新盘都不需要。
- "复制 + 同分区换名"是在无法扩容时迁移数据的有效手段 (前提:新写入确实落在目标盘,用
dd探测验证)。 - 保留
.bak目录不只是为了回滚,它还占住了旧位置的空间,防止新数据回流。 - 两阶段复制(在线全量 + 停服增量)能把停机时间压到分钟级以下。
- 迁移脚本必须有硬闸门:落盘校验不通过就自动回滚,不能带着错误继续。
附录 A:常用命令速查
bash
# ---------- 建立观测 ----------
yum -y install sysstat iotop && systemctl enable --now sysstat
# ---------- 设备级 ----------
iostat -x 1 10 # 关键三列:%util / await / avgqu-sz
sar -d -p # 当天历史(10 分钟粒度)
sar -d -p -f /var/log/sa/sa18 # 指定日期文件
# 只看异常时段(await > 50ms)
sar -d -p -f /var/log/sa/sa18 | awk '$1=="sda" && $6>50'
# ---------- 进程级 ----------
pidstat -d 1 10 # 每秒各进程读写量
iotop -b -o -n 5 -d 2 # 按累计 I/O 排序
cat /proc/<pid>/io # 两次采样求差
lsof -p <pid> | grep '/path/' # 定位具体文件
# ---------- 队列 / 内核 ----------
for d in /sys/block/sd*; do echo "$d $(cat $d/queue/scheduler) $(cat $d/queue/nr_requests)"; done
for d in /sys/block/sd*/device/timeout; do echo "$d=$(cat $d)"; done
dmesg -T | grep -iE 'critical target error|blocked for more than|hung_task'
# ---------- LVM 布局 ----------
pvs --segments -o pv_name,pvseg_start,pvseg_size,lv_name
lvs -a -o lv_name,lv_size,seg_start_pe,seg_pe_ranges,devices
vgs -o vg_name,vg_size,vg_free
# ---------- 落盘分布 ----------
bash pvmap.sh -S /var/lib/mysql -n 300
附录 B:内核参数调优对照
| 参数 | 调整前 | 调整后 | 原因 |
|---|---|---|---|
transparent_hugepage |
always |
never |
MySQL 官方要求;THP 导致内存分配抖动与延迟毛刺 |
queue/scheduler |
deadline |
noop |
VMware 虚拟盘推荐 |
queue/read_ahead_kb |
4096 |
256 |
4 MB 预读在随机读场景下严重浪费带宽 |
queue/nr_requests |
128 |
256 |
与 queue_depth=254 匹配 |
vm.swappiness |
30 |
1 |
避免换出 |
vm.dirty_ratio |
30 |
10 |
28 G 内存下 30% 意味着 8 G 脏页才回写,造成写入尖峰 |
vm.dirty_background_ratio |
10 |
5 |
让后台回写更早启动 |
持久化方式:sysctl 写 /etc/sysctl.d/99-io.conf;块设备参数用 udev 规则;THP 用 systemd unit。
bash
# /etc/udev/rules.d/60-ioscheduler.rules
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/scheduler}="noop"
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/read_ahead_kb}="256"
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/nr_requests}="256"
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/rotational}="0"
附录 C:遗留事项
| 项目 | 说明 |
|---|---|
| sda 的物理故障 | 客户机内无法修复,需在虚拟化/存储侧处理(检查 VMDK 健康、datastore 延迟、是否有快照链) |
/opt 浅层 jar |
3.3 GB 在慢盘,只影响重启速度,可放维护窗口处理 |
/home 与 swap |
都独占慢盘,属于架构遗留;无维护窗口可暂不动 |
.bak 目录 |
观察 3~5 天业务稳定后再清理 |
| 偶发卡顿观察 | 每日 sar -d -p 检查是否还有 await 超过 50 ms 的时段 |
结语
这次排查最值得记录的一点是:"磁盘慢"这个描述本身没有信息量。真正有价值的是把它拆成三个可验证的问题:
- 是设备饱和,还是后端延迟?(
%utilvsawait组合) - 是负载问题,还是设备问题?(延迟是否与负载相关)
- 是哪些数据踩在了病盘上?(落盘分布)
这三个问题一旦有答案,"怎么解决"就只剩下工程约束的取舍了 ------ 本例中,物理槽位满、VG 无空闲、XFS 不能缩小,三个约束锁死了常规方案,最后的出路反而是最朴素的那个:
既然新数据会自动落到快盘,那就让它重新落一次。