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

作者:lqg_zone日期:2026/9/20

一台运行 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 天未重启

磁盘拓扑(关键)

1sda            300G  disk
2├─sda1           1G  part  /boot
3└─sda2         209G  part          <- LVM PV
4  ├─centos-root      lvm  /        <- 同时横跨 sda2  sdb
5  ├─centos-swap      lvm  [SWAP]   <- 独占 sda2
6  └─centos-home      lvm  /home    <- 独占 sda2
7sdb              1T  disk
8└─centos-root       lvm  /        <- root LV 的另一段
9

换算一下布局:

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

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

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

1$ iostat -V
2-bash: iostat: 未找到命令
3

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

  • 看不到设备级指标,只能靠 vmstatwa
  • sar 没有历史采样,问题发生时没留下任何数据,事后无法回溯

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

1yum -y install sysstat iotop
2systemctl enable --now sysstat
3
4# sysstat 会通过 /etc/cron.d/sysstat  10 分钟自动采样
5# 之后可用 sar -d -p 回溯当天任意时段
6

经验:任何承载数据库的服务器,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 更有说服力,因为它排除了"负载不同"的干扰。


五、第三阶段:低负载高延迟 = 后端问题

再看一组数据,答案就更明确了:

1# 同一个设备、同一分钟内
2sda  22 w/s  51 kB/s   aqu 0.01   await 0.27 ms   svctm 0.27   %util 0.60
3sda   3 w/s  48 kB/s   aqu 5.39   await 547 ms    svctm 333.33 %util 100.00
4

负载几乎相同,延迟差 2000 倍;而且负载最低的那一秒最卡。

这条规律非常有用:

设备的延迟与负载不相关(甚至负相关)时,问题一定不在"量",而在"设备/后端本身"。

同期的辅助证据:

  • %steal = 0 → 宿主 CPU 没有超分争抢
  • Dirty 只有 11.7 MB、Writeback 48 KB → 不是脏页回写限流
  • 平均写入 <600 kB/s、峰值 3.3 MB/s → 这个负载压不垮任何盘

于是可以排除:负载过大、内核参数、CPU 争抢、脏页限流。范围收敛到"sda 这块盘及其后端"。

另外一个强证据来自内核日志:

1blk_update_request: critical target error, dev sda, sector 414198112
2blk_update_request: critical target error, dev sda, sector 304861544
3
  • 集中在固定的两个扇区区段,反复出现
  • 时间跨度长达数月
  • 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)

1#!/usr/bin/env bash
2# pvmap.sh  判断文件/目录的数据实际落在哪块物理盘上(LVM 线性卷场景)
3set -u
4
5SHOW_ALL=0; SCAN=0; SCAN_N=200; SCAN_DEPTH=3
6SLOW_PV="${SLOW_PV:-/dev/sda2}"
7EXT_LIMIT="${FILEFRAG_LIMIT:-300}"
8TARGETS=()
9
10while [ $# -gt 0 ]; do
11  case "$1" in
12    -a|--all)   SHOW_ALL=1; shift ;;
13    -S|--scan)  SCAN=1; shift ;;
14    -n|--top)   SCAN_N="${2:-200}"; shift 2 ;;
15    -d|--depth) SCAN_DEPTH="${2:-3}"; shift 2 ;;
16    -h|--help)  sed -n '2,22p' "$0"; exit 0 ;;
17    *)          TARGETS+=("$1"); shift ;;
18  esac
19done
20[ "${#TARGETS[@]}" -eq 0 ] && { echo "用法: bash $0 [-a] [-S] <路径> [...]"; exit 1; }
21
22WORK=$(mktemp -d /tmp/pvmap.XXXXXX) || exit 1
23trap 'rm -rf "$WORK"' EXIT
24
25# 路径 -> LV 名(三级兜底,不依赖单一 LVM 字段)
26lv_of() {
27  local src real name out
28  src=$(findmnt -no SOURCE --target "$1" 2>/dev/null) || src=""
29  [ -z "$src" ] && src=$(df -P -- "$1" 2>/dev/null | awk 'END{print $1}')
30  [ -z "$src" ] && { echo ""; return 0; }
31  real=$(readlink -f "$src" 2>/dev/null) || real="$src"
32
33  if command -v lvs >/dev/null 2>&1; then
34    out=$(lvs --noheadings -o lv_dm_path,vg_name,lv_name 2>/dev/null \
35          | awk -v d="$real" '$1==d {print $2"/"$3; exit}')
36    [ -n "$out" ] && { echo "$out"; return 0; }
37  fi
38
39  case "$src" in
40    /dev/mapper/*)
41      name=${src#/dev/mapper/}
42      if command -v lvs >/dev/null 2>&1; then
43        out=$(lvs --noheadings -o vg_name,lv_name 2>/dev/null \
44              | awk -v n="$name" '{ if ($1"-"$2 == n) {print $1"/"$2; exit} }')
45        [ -n "$out" ] && { echo "$out"; return 0; }
46      fi
47      echo "$name"; return 0
48      ;;
49  esac
50  echo "$src"
51}
52
53# 生成「累积字节 -> PV」映射表
54seg_map() {
55  lvs --noheadings --units b --nosuffix -o seg_start,seg_size,seg_pe_ranges "$1" 2>/dev/null \
56    | awk '{ dev=$3; sub(/:[0-9]+-[0-9]+$/, "", dev); if (dev=="") next; c+=$2; print c, dev }' \
57    > "$WORK/segmap"
58  [ -s "$WORK/segmap" ]
59}
60
61pv_at() {
62  awk -v b="$1" '{ last=$2 } $1+0 >= b+0 { print $2; found=1; exit }
63    END { if (!found && last != "") print last }' "$WORK/segmap"
64}
65
66# 文件 ->  extent 的物理块号
67extents_of() {
68  filefrag -v -- "$1" 2>/dev/null | awk -v lim="$EXT_LIMIT" '
69    /^[[:space:]]*[0-9]+:/ && NF>=4 {
70      p=$4; gsub(/[^0-9]/, "", p)
71      if (p!="") { print p; n++; if (n>=lim) exit }
72    }'
73}
74
75first_extent_pv() {
76  local p
77  p=$(filefrag -v -- "$1" 2>/dev/null \
78      | awk '/^[[:space:]]*[0-9]+:/ && NF>=4 {print $4; exit}' | tr -dc '0-9')
79  [ -z "$p" ] && return 1
80  pv_at $(( p * $2 ))
81}
82
83pick_file() {
84  if [ -f "$1" ]; then printf '%s\n' "$1"; return 0; fi
85  [ -d "$1" ] || return 1
86  find "$1" -maxdepth "$SCAN_DEPTH" -type f -size +256k -printf '%s %p\n' 2>/dev/null \
87    | sort -rn | head -n 1 | cut -d' ' -f2-
88}
89
90scan_dir() {
91  local dir="$1" last_lv="" f lv bs pv sz cnt=0 nfiles top
92  find "$dir" -maxdepth "$SCAN_DEPTH" -type f -size +256k -printf '%s %p\n' 2>/dev/null \
93    | sort -rn | head -n "$SCAN_N" | cut -d' ' -f2- > "$WORK/filelist"
94  nfiles=$(wc -l < "$WORK/filelist" | tr -d ' ')
95  [ "$nfiles" -eq 0 ] && { echo "  结果     : 目录内没有大于 256KB 的文件"; return 0; }
96  echo "  扫描范围 : 最大的 $nfiles 个文件(>256KB,最多 $SCAN_DEPTH 层)"
97
98  : > "$WORK/scanraw"
99  while IFS= read -r f; do
100    [ -f "$f" ] || continue
101    lv=$(lv_of "$f"); [ -z "$lv" ] && continue
102    if [ "$lv" != "$last_lv" ]; then seg_map "$lv" || { last_lv=""; continue; }; last_lv="$lv"; fi
103    bs=$(stat -f -c %S "$f" 2>/dev/null || echo 4096)
104    pv=$(first_extent_pv "$f" "$bs") || continue
105    sz=$(stat -c %s "$f" 2>/dev/null || echo 0)
106    printf '%s %s\n' "$pv" "$sz" >> "$WORK/scanraw"
107    cnt=$((cnt+1))
108  done < "$WORK/filelist"
109  [ "$cnt" -eq 0 ] && { echo "  结果     : 无法解析任何文件的位置"; return 0; }
110
111  echo "  落盘分布 :"
112  awk '{c[$1]++; b[$1]+=$2}
113       END{for (k in c) printf "      %-14s %6d 个文件 %10.2f GB\n", k, c[k], b[k]/1073741824}' \
114    "$WORK/scanraw" | sort -k2 -rn
115
116  top=$(awk '{c[$1]++} END{for (k in c) if (c[k]>m) {m=c[k]; t=k} print t}' "$WORK/scanraw")
117  [ "$top" = "$SLOW_PV" ] && echo "  判定     : 警告 主要位于慢盘 $top" \
118                          || echo "  判定     : 正常 主要位于快盘 $top"
119}
120
121SLOW_HITS=0
122for tgt in "${TARGETS[@]}"; do
123  echo "=================================================================="
124  echo "[$tgt]"
125  if [ "$SCAN" -eq 1 ] && [ -d "$tgt" ]; then scan_dir "$tgt"; continue; fi
126
127  file=$(pick_file "$tgt")
128  [ -z "${file:-}" ] || [ ! -f "$file" ] && echo "  结果     : 跳过(目录内没有大于 256KB 的文件)" && continue
129  echo "  比对文件 : $file  ($(du -h "$file" 2>/dev/null | awk '{print $1}'))"
130
131  lv=$(lv_of "$file"); echo "  VG/LV    : ${lv:--}"
132  seg_map "$lv" || { echo "  主物理卷 : $lv"; continue; }
133
134  bs=$(stat -f -c %S "$file" 2>/dev/null || echo 4096)
135  : > "$WORK/rawpvs"
136  extents_of "$file" | while read -r p; do [ -z "$p" ] && continue; pv_at $(( p * bs )); done > "$WORK/rawpvs"
137  total=$(wc -l < "$WORK/rawpvs" | tr -d ' ')
138  [ "$total" -eq 0 ] && echo "  结果     : 跳过 —— 未取到 extent 信息" && continue
139
140  sort "$WORK/rawpvs" | uniq -c | sort -rn > "$WORK/tally"
141  top_pv=$(awk 'NR==1{print $2}' "$WORK/tally")
142  top_n=$(awk 'NR==1{print $1}' "$WORK/tally")
143  echo "  主物理卷 : $top_pv"
144  if [ "$top_pv" = "$SLOW_PV" ]; then
145    echo "  判定     : 警告 位于慢盘($top_n/$total extents)"; SLOW_HITS=$((SLOW_HITS+1))
146  else
147    echo "  判定     : 正常 位于快盘($top_n/$total extents)"
148  fi
149  [ "$SHOW_ALL" -eq 1 ] && { echo "  extent 分布:"; awk '{printf "      %-14s %4d  extent\n", $2, $1}' "$WORK/tally"; }
150done
151
152echo "=================================================================="
153echo "慢盘标记 SLOW_PV=$SLOW_PV ;命中 ${SLOW_HITS} 项"
154

用法:

1bash pvmap.sh /var/lib/mysql/ibdata1          # 单文件,展开所有 extent
2bash pvmap.sh /var/lib/mysql /opt /home       # 目录  取其中最大文件
3bash pvmap.sh -S /var/lib/mysql               # 整体扫描:统计目录内所有大文件的落盘分布
4bash pvmap.sh -S -d 8 /var/lib/clickhouse     # 扫描深度改为 8 
5

6.4 实测结果

1[/var/lib/mysql]
2主物理卷 : /dev/sda2     判定 : 警告 位于慢盘
3
4[/opt]
5主物理卷 : /dev/sda2     判定 : 警告 位于慢盘
6
7[/home]
8主物理卷 : /dev/sda2     判定 : 警告 位于慢盘
9
10[/data]
11主物理卷 : /dev/sdb      判定 : 正常 位于快盘
12

整体扫描(-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全部

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

症状对应证据
数据库查询/写入慢全部 .ibd、ibdata1、undo_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 核心思路:同文件系统重写迁移

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

1dd if=/dev/zero of=/probe-1.bin bs=1M count=1024 status=none
2dd if=/dev/zero of=/probe-2.bin bs=1M count=1024 status=none
3dd if=/dev/zero of=/probe-3.bin bs=1M count=1024 status=none
4sync
5bash pvmap.sh /probe-1.bin /probe-2.bin /probe-3.bin
6

结果:

1[/probe-1.bin]  主物理卷 : /dev/sdb   判定 : 正常 位于快盘(1/1 extents)
2[/probe-2.bin]  主物理卷 : /dev/sdb   判定 : 正常 位于快盘(2/2 extents)
3[/probe-3.bin]  主物理卷 : /dev/sdb   判定 : 正常 位于快盘(2/2 extents)
4

新写入天然落在快盘 —— 因为 XFS 分配新块时优先使用"最佳空闲 extent",而空闲大头在 sdb 段。

于是方案成型:

不移动 LVM extent,而是"让数据重新落一次盘"。

复制到新路径(新 extent 自动落快盘)→ 落盘校验 → 同分区 mv 换名(瞬间,不复制数据)→ 启动验证。

8.3 完整流程

阶段划分(关键是把停机时间压到最短):

阶段动作是否需要停机
1在线初拷:rsync 全量到暂存目录
2停服 + rsync --delete 增量对齐是(通常 <1 分钟)
3落盘校验:pvmap.sh -S 必须判定为快盘,否则回滚
4同文件系统换名:mv 两次,瞬间完成
5restorecon + 启动 + 验证

核心命令:

1# 阶段 1:在线初拷(业务不受影响)
2mkdir -p /srv/mysql
3rsync -aAX --numeric-ids --delete /var/lib/mysql/ /srv/mysql/
4
5# 阶段 2:停服 + 增量(这才是真正的停机窗口)
6systemctl stop mysqld
7rsync -aAX --numeric-ids --delete /var/lib/mysql/ /srv/mysql/
8# 一致性复核,必须为 0
9rsync -an --delete --stats /var/lib/mysql/ /srv/mysql/ | grep 'Number of regular files transferred'
10
11# 阶段 3:落盘校验(不通过就回滚)
12bash pvmap.sh -S /srv/mysql -n 300
13
14# 阶段 4:换名(同分区 mv,只改目录项,不复制数据)
15chown -R mysql:mysql /srv/mysql && chmod 750 /srv/mysql
16mv /var/lib/mysql /var/lib/mysql.bak-$(date +%F)
17mv /srv/mysql /var/lib/mysql
18
19# 阶段 5:启动验证
20systemctl start mysqld
21mysql -e "SELECT @@datadir, @@uptime;"
22

这个方案的优势:

设计原因
两阶段复制停机从"十几分钟"压到 1 分钟以内
落盘校验作为硬闸门确认方案成立才继续,否则自动回滚
同分区 mv 换名不复制数据、瞬间完成
不改 my.cnf路径不变,配置零改动
保留 .bak可回滚 + 占住慢盘旧空间,防止新写入回流

执行结果:

1[/srv/mysql]
2扫描范围 : 最大的 214 个文件(>256KB)
3落盘分布 :
4/dev/sdb          179 个文件       6.65 GB
5/dev/sda2          35 个文件       0.71 GB
6判定     : 正常 主要位于快盘 /dev/sdb
7
8[OK]   数据已落在快盘
9[OK]   已切换为 /var/lib/mysql
10[OK]   mysqld 已启动
11

迁移后仍有 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:常用命令速查

1# ---------- 建立观测 ----------
2yum -y install sysstat iotop && systemctl enable --now sysstat
3
4# ---------- 设备级 ----------
5iostat -x 1 10                       # 关键三列:%util / await / avgqu-sz
6sar -d -p                            # 当天历史(10 分钟粒度)
7sar -d -p -f /var/log/sa/sa18        # 指定日期文件
8
9# 只看异常时段(await > 50ms)
10sar -d -p -f /var/log/sa/sa18 | awk '$1=="sda" && $6>50'
11
12# ---------- 进程级 ----------
13pidstat -d 1 10                      # 每秒各进程读写量
14iotop -b -o -n 5 -d 2                # 按累计 I/O 排序
15cat /proc/<pid>/io                   # 两次采样求差
16lsof -p <pid> | grep '/path/'        # 定位具体文件
17
18# ---------- 队列 / 内核 ----------
19for d in /sys/block/sd*; do echo "$d $(cat $d/queue/scheduler) $(cat $d/queue/nr_requests)"; done
20for d in /sys/block/sd*/device/timeout; do echo "$d=$(cat $d)"; done
21dmesg -T | grep -iE 'critical target error|blocked for more than|hung_task'
22
23# ---------- LVM 布局 ----------
24pvs --segments -o pv_name,pvseg_start,pvseg_size,lv_name
25lvs -a -o lv_name,lv_size,seg_start_pe,seg_pe_ranges,devices
26vgs -o vg_name,vg_size,vg_free
27
28# ---------- 落盘分布 ----------
29bash pvmap.sh -S /var/lib/mysql -n 300
30

附录 B:内核参数调优对照

参数调整前调整后原因
transparent_hugepagealwaysneverMySQL 官方要求;THP 导致内存分配抖动与延迟毛刺
queue/schedulerdeadlinenoopVMware 虚拟盘推荐
queue/read_ahead_kb40962564 MB 预读在随机读场景下严重浪费带宽
queue/nr_requests128256与 queue_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。

1# /etc/udev/rules.d/60-ioscheduler.rules
2ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/scheduler}="noop"
3ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/read_ahead_kb}="256"
4ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/nr_requests}="256"
5ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/rotational}="0"
6

附录 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 不能缩小,三个约束锁死了常规方案,最后的出路反而是最朴素的那个:

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


CentOS 7 虚拟机磁盘 I/O 卡顿排查实录:从 iostat 异常到虚拟盘后端故障的完整定位》 是转载文章,点击查看原文


相关推荐


还在手动配 SSL 证书?这个 75K Star 的 Web 服务器让 HTTPS 自动到起飞
Hey_AI_Coder2026/9/12

摘要: 配置 HTTPS 证书有多痛苦?申请、验证、续期、重启,每一步都可能出错。Caddy 直接把这些全自动化了 服务器部署好了,网站能访问了,但浏览器地址栏显示「不安全」。你需要配 HTTPS,于是开始折腾 Let's Encrypt,申请证书、验证域名、配置 Nginx、重启服务。好不容易搞定了,三个月后证书过期,网站又挂了。 更糟的是,你有十几个站点,每个都要配证书,每个都要续期。某天凌晨三点,运维电话打来:「证书过期了,网站打不开」。 HTTPS 是必须的,但配证书的过程太痛苦了。


从 Token 到蒸馏:一步步理解大模型如何工作
杨杨杨大侠2026/9/4

本文面向刚开始了解大模型的读者,以本机运行的 Qwen2.5 0.5B 为例,解释 Token、参数、Transformer、训练、蒸馏和量化之间的关系。 先用一句话概括大模型: 大模型使用训练得到的参数,根据已有 Token,反复预测下一个 Token。 1. 一个模型里有什么 模型不只是一个权重文件。完整运行通常需要三部分: 结构配置:规定模型有多少层、每层多宽。 Tokenizer 和词表:负责文字与 Token ID 之间的转换。 参数:训练得到的大量数字,包括权重矩阵、偏置和归一


【AI大模型接入SDK】Deepseek API + Apifox
艾莉丝努力练剑2026/8/27

🎬 个人主页:艾莉丝努力练剑 ❄专栏传送门:《C语言》《数据结构与算法》《C/C++干货分享&学习过程记录》 《Linux操作系统编程详解》《笔试/面试常见算法:从基础到进阶》《Python干货分享》 ⭐️为天地立心,为生民立命,为往圣继绝学,为万世开太平 🎬 艾莉丝的简介: 文章目录 1 ~> LLM 接入体系总览1.1 两类主流接入方案1.2 工程定位1.3 技术栈映射 2 ~> 云端 API 接入方式(以 DeepSeek 为例)2.1


只面对一张表:KingbaseES 超表如何简化海量时序数据管理
一只牛博2026/8/19

设备数据最麻烦的地方,不是某一天突然写入一批数据,而是每秒都会有新数据进来。温度、压力、振动、电流、流量等指标不断产生,设备数量增加后,数据量几乎只会单向增长。业务页面通常只问两类问题:某台设备最近一小时的曲线,以及一批设备在某个时间段内的统计结果。数据库管理员面对的却是另一组问题:表拆到什么粒度,索引建在哪些表上,旧数据如何清理,新增设备是否需要发布脚本。 传统时序数据库方案经常把这些事情交给应用层。按月份、按设备或按两者组合拆表,确实能把数据分散开,但路由、建表、补索引和归档也随之进入业务代


C++ 模板深度解析:类型模板、非类型模板、特化与分离编译
在路上慢慢走2026/8/6

1. 引言 模板(Template)是 C++ 泛型编程的核心,它允许编写与类型无关的代码,极大地提高了代码的复用性和灵活性。理解模板的完整体系,包括其分类、特化机制以及编译模型,是掌握现代 C++ 高级特性的关键。本文将系统性地介绍模板的两大类别(类型模板与非类型模板)、模板特化(函数模板特化与类模板特化)以及模板分离编译的原理与实践,帮助你构建完整的模板知识框架。 2. 模板的分类 C++ 模板主要分为两大类:类型模板(Type Template)和非类型模板(Non-type Tem


写了三遍 Todo List,我终于搞懂了 React 父子组件到底怎么通信
To_OC2026/7/28

上来就踩了个最经典的坑 我上周写这个 Todo List 的时候,第一版写得特别快,二十分钟就把界面和逻辑堆完了。然后点复选框试了一下 —— 纹丝不动。 控制台没报错,代码看着也没写错,我对着 checked={todo.completed} 这行盯了十分钟,来回改了好几种写法,勾选状态就是不更新。当时我人都懵了,心想难道我学的 React 是假的? 后来随手打印了一下 todo 对象,发现值其实已经变了,但界面就是不刷新。那一刻我突然反应过来:我直接在子组件里改了 props 传过来的对象属性


拼多多笔试真题-多多的审批链(C++/Py/Java /Js/Go)
无限码力2026/7/20

多多的审批链 拼多多技术岗 4月26号笔试 第四题 题目内容 多多的部门中发起审批单有一套审批流程,审批关系可以抽象为一棵以 111 号节点为根的树,共有 nnn 个节点。对于每个 i(2≤i≤n)i(2 \le i \le n)i(2≤i≤n),给定它的直属上级 pip_ipi​,即审批树中存在一条从 pip_ipi​ 到 iii 的边。 对于任意节点 uuu,如果它发起一张审批单,那么审批单只能先提交给它的直属上级,再继续逐级上报到更高层。 现在多多最多可以选择 kkk 个节点作为“关键


图片是 Web 性能监控的重灾区:LCP 和 CLS 到底怎么测、怎么定位到具体那张图
谙忆10242026/7/12

做前端性能这几年,我踩过一个反复出现的坑:本地 Lighthouse 跑出来 95 分,一到线上真实用户那边,投诉页面"卡""跳"的却一堆。后来盯着数据看明白了——问题几乎都出在图片上,而且实验室环境根本复现不出来。 这篇就把"图片相关的 Web 性能怎么测、怎么监控、怎么定位到罪魁祸首那张图"讲清楚。重点是测量和监控这条链路,不是又一篇"图片懒加载十种写法"。 先分清两组概念:实验室数据 vs 真实用户数据 这是很多人一上来就混的地方,先掰开。 实验室数据(Lab):Lighthouse、We


别再只会 if err != nil:Go error 从错误链到工程实战详解
唐青枫2026/7/4

简介 Go 代码里最常见的错误处理大概是这样: result, err := doSomething() if err != nil { return err } 这几行代码不难,真正容易出问题的是后面的选择: 应该新建错误,还是包装原错误? 应该使用 ==,还是 errors.Is? 什么时候需要自定义错误类型? 错误应该在哪一层记录日志? 多个清理操作同时失败,应该返回哪一个错误? 普通错误、panic 和 recover 到底怎么分工? Go 没有把错误处理藏进异常机制,而是把错误当


Java 虚拟线程实战指南:从 Thread API 到 Spring Boot 高并发应用
唐青枫2026/6/26

简介 虚拟线程的英文名是 Virtual Thread,它是 Project Loom 带来的轻量级线程实现。 虚拟线程在 JDK 19、JDK 20 中经历了两轮预览,到了 JDK 21 正式发布。 简单理解: 平台线程:Java 线程长期绑定操作系统线程 虚拟线程:大量 Java 线程由 JVM 调度到少量操作系统线程上 传统 Java 服务经常采用“一请求一线程”的处理方式。 代码很直观,但平台线程数量有限。当大量请求都在等待数据库、HTTP 接口、文件或消息队列时,线程本身会先成为瓶颈

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读