CentOS 7 虚拟机磁盘 I/O 卡顿排查实录:从 iostat 异常到虚拟盘后端故障的完整定位

一台运行 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 G
  • root LV 总容量约 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 -xsar -d
3. 进程层 谁在产生 I/O? pidstat -diotop/proc/<pid>/io
4. 虚拟化层 请求堵在控制器还是后端? 队列深度、dmesg、多盘对照
5. 宿主机/存储层 是后端问题还是租户争抢? 宿主机侧 DAVG/KAVG、VMDK 健康

三、第一阶段:先建立观测能力

第一个发现:机器上根本没装 sysstat。

复制代码
$ iostat -V
-bash: iostat: 未找到命令

没有 iostat / pidstat / sar,意味着:

  • 看不到设备级指标,只能靠 vmstatwa
  • 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、Writeback 48 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 errorESXi 存储层返回的 SCSI target error,不是客户机文件系统的错误
  • PVSCSI 设备 timeout = 180 秒 → 命中这些区域的 I/O 会重试最长 180 秒,期间同队列的其它请求全部排队

六、第四阶段:定位"谁在用慢盘"------落盘分布分析

6.1 为什么需要这一步

常规排查到这里会陷入困境:知道是 sda 有问题,但不知道哪些数据在 sda 上

而这台机器的 root 分区横跨两块盘,同一目录下的文件,可能一个在慢盘、一个在快盘。所以问题的答案不是"哪个目录慢",而是"哪个文件的物理位置在慢盘"。

6.2 原理

LVM 线性逻辑卷的物理布局是确定的:

  1. lvs -o seg_pe_ranges 读出 LV 的段(segment)顺序,算出在 LV 逻辑空间里 PV 的切换边界
  2. filefrag -v <文件> 读出文件各 extent 的物理块号,换算成 LV 内的字节偏移
  3. 两者比对,得出每个 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 --- --- 全部

到这里,两个核心症状的因果关系完全闭合:

症状 对应证据
数据库查询/写入慢 全部 .ibdibdata1undo_001 都在 sda2,await 数百毫秒
重启部署应用慢 /opt 下 199 个 jar 在 sda2,应用启动时顺序读被拖

七、根因结论

sda 这块虚拟磁盘的后端存在故障,且长期未被发现。

两层证据:

  1. 已确证的固定扇区损坏 ------ blk_update_request: critical target error 反复出现在 sector 414198112sector 304861544,跨数月。配合 PVSCSI timeout=180,命中这些区域的 I/O 会挂起最长 180 秒。

  2. 无日志报错但持续存在的间歇性延迟 ------ 在没有任何新内核错误的窗口里,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-1await 只有 0.9~1.7 ms(因为 MySQL 已经迁走),算是运气好。如果换在迁移前做,很可能触发 180 秒超时挂起。

更好的做法:不要 swapoffvm.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


十、经验总结

判断层面

  1. 双盘(或多盘)对照法是最快的定位手段 ------ 如果一台机器上有表现正常和异常的同类设备,直接对比,比任何阈值判断都可靠。
  2. "低负载 + 高延迟"几乎等价于"设备或后端有问题" ------ 延迟与负载不相关时,不要再去优化应用和参数。
  3. 用"单位利用率承载的有效吞吐"来量化差距 ,比单纯比 await 更能排除负载干扰(本例中差 80 倍)。
  4. %iowait 不是根因,svctm 在新内核已废弃 ,判断规模仍以 await + avgqu-sz + %util 三者组合为准。
  5. PVSCSI 的 timeout=180 是个双刃剑 ------ 它给了存储层更多重试机会,但一次坏块就能让请求挂 3 分钟。看到 await 出现"秒级"数字,要优先怀疑这个。

排查层面

  1. 先建立观测,再谈优化。 没有 iostat / sar 的机器,排查成本会成倍上升。
  2. 虚拟化环境下,"文件在哪个目录"不重要,"数据落在哪块盘"才重要。 落盘分布应该成为标准诊断维度。
  3. 内核日志里的 blk_update_request / critical target error 要当回事 ------ 它直指存储后端,不是客户机的问题。
  4. find -maxdepth 要按数据实际层级设定,否则会得出完全错误的结论。
  5. 目录级别的判断必须全量扫描,单文件抽样可能恰好选中"最不具代表性"的那个。

处置层面

  1. 磁盘满了不要急着加盘或扩 LV。 先量化"热点数据到底有多大"------本例中真正需要迁移的只有不到 30 GB,一块新盘都不需要。
  2. "复制 + 同分区换名"是在无法扩容时迁移数据的有效手段 (前提:新写入确实落在目标盘,用 dd 探测验证)。
  3. 保留 .bak 目录不只是为了回滚,它还占住了旧位置的空间,防止新数据回流。
  4. 两阶段复制(在线全量 + 停服增量)能把停机时间压到分钟级以下。
  5. 迁移脚本必须有硬闸门:落盘校验不通过就自动回滚,不能带着错误继续。

附录 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 的时段

结语

这次排查最值得记录的一点是:"磁盘慢"这个描述本身没有信息量。真正有价值的是把它拆成三个可验证的问题:

  1. 是设备饱和,还是后端延迟?(%util vs await 组合)
  2. 是负载问题,还是设备问题?(延迟是否与负载相关)
  3. 是哪些数据踩在了病盘上?(落盘分布)

这三个问题一旦有答案,"怎么解决"就只剩下工程约束的取舍了 ------ 本例中,物理槽位满、VG 无空闲、XFS 不能缩小,三个约束锁死了常规方案,最后的出路反而是最朴素的那个:

既然新数据会自动落到快盘,那就让它重新落一次。

相关推荐
tedcloud1231 小时前
emilkowalski/skills:让 AI 写出来的网页更有“设计感”
服务器·人工智能·开源·音视频·ai编程
kkkkkkkkkk_Z1 小时前
单片机|学习日记:单片机底层架构与寄存器操作全解析
linux·笔记·单片机·学习·51单片机
Shadow(⊙o⊙)1 小时前
OTOL设计模式 One Thread One Loop
服务器·网络·设计模式
xiaoye-duck1 小时前
《Linux 网络编程》深入理解 IP 协议(一):网络层基础与 IP 协议头详解
linux·网络·ip
Wang's Blog1 小时前
Java 接入Redis: Redis简介与NoSQL定位
java·服务器·redis
ao-weilai1 小时前
Linux网络编程:Linux Socket TCP
linux·服务器·网络
是个西兰花1 小时前
UDP套接字编程
运维·服务器·网络
顶点多余1 小时前
9.16 面试总结
linux·面试·职场和发展
不正经的码狗2 小时前
# Navicat Premium 17 安装使用指南
mysql·navicat premium