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 天)
ClickHouse5.19 TB
MySQL 8.0.404.06 TB
MongoDB581 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 没有历史采样,问题发生时没留下任何数据,事后无法回溯

这本身就是一个严重的运维缺口。第一件事就是补上:

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 或 > 队列深度一半排队积压

组合判读规则(这一步决定后面往哪查):

%utilawait结论
设备已饱和,负载真的过大
请求堵在后端(控制器 / 存储路径)
不是磁盘瓶颈,去查锁、缓冲池、网络

注意:%iowait只说明 CPU 在等 I/O,不是根因

4.2 双盘对照法:最有说服力的证据

这台机器有个天然优势:两块虚拟盘共用同一个 PVSCSI 控制器。这提供了一个完美的对照组 —— 同一时刻、同一控制器、同一套内核参数,两块盘的表现直接可比。

实测结果(同一 30 秒窗口内的逐秒采样):

时刻设备w/swkB/savgqu-szawaitsvctm%util
1sda22710.104.5 ms6.4114%
2sda961519145.98476 ms10.42100%
3sda69708116.851606 ms14.2999%
4sda0053.07100%
5sda26452.745292 ms500 ms100%
6sda26453.7410128 ms500 ms100%
7dm-02554947.1930350 ms40.00100%

同一时刻 sdb 的表现:

设备max %utilmax awaitmax aqumax wkB/s
sda100.010128 ms1461519
sdb3.814.9 ms3.673235

决定性的一条:某次采样中 sda 每秒只有 2 个写请求,队列里却积压 53.7 个,最久的等了 10.1 秒,而设备显示 100% 忙。

这不是"忙",是"卡死" —— 请求进了设备就不返回,内核只能留在队列里反复重试。

4.3 单位利用率折算:把差距量化

更有杀伤力的对比方式,是把"每 1% 利用率能承载多少吞吐"算出来:

设备w/swkB/ssvctm%util每 1% util 承载
sdb25443250.16 ms4.0%约 1081 kB/s
sda93134510.71 ms99.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)

#!/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 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/mysql262 / 2737.73 GB / 7.80 GB11 个文件 0.07 GB
/opt199 / 2003.04 GB / 3.32 GB1 个文件 0.28 GB
/opt/minio/data2 / 3000.01 GB298 个文件 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 核心思路:同文件系统重写迁移

关键洞察来自一个简单的探测:

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 两次,瞬间完成
5restorecon + 启动 + 验证

核心命令:

# 阶段 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 await582 ~ 10128 ms0.27 ~ 1.17 ms
sda %util100%0 ~ 1.3%
sda avgqu-sz33 ~ 1460 ~ 0.01
%iowait17 ~ 28%0 ~ 0.93%

await 从 10.1 秒降到 1.17 毫秒,相差约 8600 倍。

后来在关闭 swap 的过程中,sda 又贡献了一组更有力的对比:

迁移前迁移后(关闭 swap 期间)
r/s3766
await547 ~ 10128 ms0.92 ms
%util100%(空转)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:常用命令速查

# ---------- 建立观测 ----------
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_hugepagealwaysneverMySQL 官方要求;THP 导致内存分配抖动与延迟毛刺
queue/schedulerdeadlinenoopVMware 虚拟盘推荐
queue/read_ahead_kb40962564 MB 预读在随机读场景下严重浪费带宽
queue/nr_requests128256queue_depth=254 匹配
vm.swappiness301避免换出
vm.dirty_ratio301028 G 内存下 30% 意味着 8 G 脏页才回写,造成写入尖峰
vm.dirty_background_ratio105让后台回写更早启动

持久化方式:sysctl/etc/sysctl.d/99-io.conf;块设备参数用 udev 规则;THP 用 systemd unit。

# /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 浅层 jar3.3 GB 在慢盘,只影响重启速度,可放维护窗口处理
/home 与 swap都独占慢盘,属于架构遗留;无维护窗口可暂不动
.bak 目录观察 3~5 天业务稳定后再清理
偶发卡顿观察每日 sar -d -p 检查是否还有 await 超过 50 ms 的时段

结语

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

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

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

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

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值