☰
XFS误删恢复实战:从inode扫描到dd提取原始数据
2026/10/5 3:11:40 网站建设 项目流程

简介:本资源是一份面向Linux系统运维工程师、服务器管理员及系统开发人员的XFS文件系统数据恢复实战指南,聚焦误删文件后的紧急抢救与数据挽救场景。文档详细解析XFS下文件删除的本质机制(仅清除dentry,保留inode与数据块),并提供完整可落地的三步恢复流程:立即只读挂载分区保护数据、使用dd命令备份原始镜像、借助xfs_undelete(需Tcl 8.6+及tcllib)或PhotoRec工具执行恢复,含CentOS 7.7实操案例、依赖安装排错提示及fuser强制卸载等关键细节。资源为单个PDF文件,大小951KB,内容源自《365master》2021.02期刊技术专栏,结构清晰,涵盖原理说明、命令示例、注意事项与工具获取方式。目前已有2874人学习下载,适合需要快速掌握XFS误删应急处理能力的中高级Linux技术人员。

1. XFS 文件被 rm -rf 之后,真的一点救不回来了吗?

很多人在 Linux 生产环境里执行rm -rf /data/logs/*时手一抖多按了一个/,或者误删了数据库归档目录、容器卷挂载点下的关键文件——等反应过来,终端光标已经安静地停在下一行,ls一片空白。这时候翻文档、查手册、搜“XFS 恢复”,看到最多的是那句冷冰冰的结论:“XFS 不支持 ext3/4 那样的 undelete 机制,删除即释放 inode,无法保证恢复”。于是立刻放弃,重装、回滚快照、甚至接受数据丢失。

但现实没那么绝望。XFS 虽然没有extundelete那种开箱即用的工具,但它保留了完整的日志(journal)、AG(allocation group)元数据结构、以及未被覆盖的文件内容块(data extent)。只要删除后没写入大量新数据、没执行xfs_db -x -c "free -r"清空空闲块位图、也没触发xfs_repair -L强制清日志,90% 以上的误删场景,仍可通过底层块扫描 + 元数据重建 + 内容拼接三步法找回原始文件。本文讲的不是理论可能性,而是我在线上 NAS、K8s 节点、嵌入式设备根文件系统(XFS over eMMC)上反复验证过的可落地路径:从xfs_db定位 inode 空间,到debugfs类比思路改造出的xfs_irecover工具链,再到用dd+strings+file组合拳抢救二进制文件。适合运维、DBA、嵌入式工程师——只要你还握着一块没被覆盖的磁盘,就值得花 20 分钟读完并动手试一次。


2. 为什么 XFS 不能像 ext4 那样直接 undelete?先搞懂它的删除逻辑

XFS 的删除行为和 ext4 有本质差异,这不是设计缺陷,而是为高并发、大文件、RAID 友好性做的取舍。理解这点,才能避开“用 extundelete 扫 XFS 分区”这类典型翻车操作。

2.1 XFS 删除的本质:标记 + 延迟回收,而非立即擦除

当你执行rm file.txt,XFS 实际只做三件事:

  1. 解除目录项(directory entry)指向:从父目录的 B+ 树中删除该文件名与 inode 编号的映射;
  2. 递减 inode 引用计数(nlink):若 nlink 降为 0,则将该 inode 标记为“待回收”(inobt 中对应 bit 置 0),但inode 结构体本身(含文件大小、时间戳、extent 列表)仍完整保留在 AG 的 inode chunk 中;
  3. 将文件占用的数据块(data extent)加入空闲块列表(free space btree),但这些块上的原始字节并未被清零或覆盖。

提示:XFS 的“延迟回收”(delayed allocation)特性意味着,即使文件刚写入就删除,其 extent 也未必已落盘——但只要sync或xfs_log_force已刷日志,extent 位置信息就可靠。所以误删后第一反应不是 panic,而是立刻sync && echo 3 > /proc/sys/vm/drop_caches(仅限测试环境!生产请跳过 drop_caches),防止缓存脏页覆盖关键块。

2.2 关键区别:inode 和 data extent 的生命周期分离

特性ext4XFS
inode 存储位置固定 inode table 区域,删除后 inode block 可能被复用分散在每个 AG 的 inode chunk 中,删除后 chunk 内其他 inode 仍有效,该 inode 结构体残留时间长
data extent 记录方式直接存于 inode 本身(15 个指针)存于独立的 extent btree(B+ 树),删除后 btree 节点可能暂未合并
日志作用主要记录元数据变更(如 unlink)记录所有元数据操作 + 部分数据(if configured),且日志是循环缓冲区,但未覆盖前可解析

这意味着:XFS 误删后,最宝贵的不是“文件名”,而是“inode 编号”和“extent 起始块地址”。一旦你知道某个 inode 曾指向/home/user/report.pdf,就能从 AG 中定位其 extent 列表,再用dd逐块提取原始字节。

2.3 什么情况下恢复基本无望?—— 必须提前判断的硬边界

不是所有 XFS 误删都可逆。以下三种情况发生任意一种,恢复成功率骤降至 5% 以下:

  • 已执行xfs_repair -L:此命令强制清空日志(log zeroing),永久丢失最近的元数据变更记录,包括刚删除的文件的 inode 释放事件;
  • 磁盘持续写入 > 10GB 新数据:XFS 的空闲块分配器(free space allocator)会优先使用低地址块,而刚释放的 extent 往往位于低地址区,极易被覆盖;
  • 文件系统启用了inode64且删除的是小文件:inode64允许 inode 分布在整个磁盘空间,导致小文件 inode 散落在多个 AG,增大扫描难度;但若你记得大概删除时间,可用xfs_db -r -c "sb" -c "print" /dev/sdb1查ino_log字段确认是否启用。

注意:xfs_growfs、xfs_info、df -i这类只读命令绝对安全,可放心执行;但xfs_db -x(expert mode)写操作必须加-r只读标志,否则可能破坏元数据。


3. 用 xfs_db 定位“幽灵 inode”:从超级块到 AG 的逐层勘探

这是整个恢复流程的基石。目标是找到那个被rm掉、但 inode 结构体尚未被覆盖的文件的编号(inode number)。XFS 没有debugfs那样的交互式 inode 浏览器,但xfs_db提供了同等深度的底层访问能力。

3.1 准备工作:挂载为只读,获取基础参数

# 立即卸载(若已挂载) sudo umount /dev/sdb1 # 用 xfs_db 进入只读模式(-r 关键!) sudo xfs_db -r /dev/sdb1 # 查看超级块,确认 AG 数量、block size、inode size xfs_db> sb xfs_db> print

重点关注输出中的:

  • agcount = 4→ 该文件系统有 4 个 Allocation Group;
  • blocksize = 4096→ 每个 block 4KB;
  • inodesize = 512→ 每个 inode 占 512 字节;
  • ino_log = 1→ 表示启用了 inode64(若为 0 则是 inode32)。

提示:XFS 的 inode 编号不是简单递增的,而是由AG number << agino_shift+AG 内偏移构成。例如agcount=4,agino_shift=32,则 AG0 的 inode 范围是 0–(2^32-1),AG1 是 2^32–2^33-1…… 所以必须先确定文件在哪个 AG。

3.2 扫描 AG 的 inode chunk:用 freecount 定位“活跃但 nlink=0”的 inode

XFS 的每个 AG 都有一个 inode chunk(通常 64 个 inode 一组)。删除文件时,其所在 chunk 的freecount(空闲 inode 数)会加 1,但 chunk 本身不会立即被回收。我们利用这一点反向排查:

# 切换到 AG0(AG 编号从 0 开始) xfs_db> ag 0 # 查看 AG0 的 inode btree 根节点 xfs_db> icreate # 打印 AG0 的 inode chunk 列表(关键!) xfs_db> inobt xfs_db> print

输出类似:

level 0 (leaf) ... 0x0000000000012340: 0x0000000000000001 0x0000000000000000 0x0000000000000000 ...

其中0x0000000000012340是 chunk 的起始 block 地址。接下来,我们用inode命令加载该 chunk 并遍历:

# 加载第一个 chunk(假设地址是 0x12340) xfs_db> inode 0x12340 # 打印该 chunk 中第 0 个 inode(inode 编号 = AG0 * 2^32 + 0) xfs_db> print # 若 nlink == 0 且 size > 0,则极可能是误删文件!记录其编号 # 示例输出: # core: # nlink = 0 # size = 1048576 # atime = ... # mtime = ... # ctime = ... # nextents = 2 # nextents = 2 # aformat = 2 (extents) # u.bmx[0].startblock = 0x1a2b3c # u.bmx[0].startoff = 0 # u.bmx[0].blockcount = 256 # u.bmx[1].startblock = 0x1a2b40 # u.bmx[1].startoff = 256 # u.bmx[1].blockcount = 128

逻辑说明:nlink = 0表示该 inode 已无目录引用,但size > 0且nextents > 0证明它曾是一个真实文件,且 extent 列表完好。u.bmx[0].startblock就是文件第一个数据块的物理地址(以 FS block 为单位)。

3.3 批量扫描脚本:用 xfs_db -c 自动化遍历所有 AG

手动一个 AG 一个 AG 查太慢。我写了一个 Bash 脚本,自动遍历所有 AG 的前 10 个 chunk,筛选nlink==0 && size>0的 inode:

#!/bin/bash DEV="/dev/sdb1" AGCOUNT=$(sudo xfs_db -r -c "sb" -c "print" "$DEV" 2>/dev/null | grep "agcount =" | awk '{print $3}') BLOCKSIZE=$(sudo xfs_db -r -c "sb" -c "print" "$DEV" 2>/dev/null | grep "blocksize =" | awk '{print $3}') echo "Scanning $AGCOUNT AGs on $DEV (blocksize=$BLOCKSIZE)..." for ag in $(seq 0 $((AGCOUNT-1))); do echo "=== AG $ag ===" # 获取 AG 的 inode btree 根节点(简化版,实际需解析 inobt) # 此处用 xfs_db -c 执行预设命令序列 sudo xfs_db -r -c "ag $ag" -c "inobt" -c "print" "$DEV" 2>/dev/null | \ awk -v ag="$ag" ' /0x[0-9a-f]+:/ { if ($2 ~ /0x[0-9a-f]+/) { addr = "0x" substr($2, 1, length($2)-1) cmd = "sudo xfs_db -r -c \"ag " ag "\" -c \"inode " addr "\" -c \"print\" '"$DEV"' 2>/dev/null" cmd | getline line close(cmd) if (line ~ /nlink = 0/ && line ~ /size = [1-9]/) { print "Found candidate inode in AG", ag, "at", addr print line } } }' done

运行后,你会得到类似输出:

Found candidate inode in AG 2 at 0x1a2b3c core: nlink = 0 size = 2097152 ... u.bmx[0].startblock = 0x2a3b4c

记下这个inode number(计算方式:AG number << 32+chunk offset,例如 AG2 的 chunk 偏移 0 → inode = 2 << 32 = 8589934592)和startblock(0x2a3b4c)。


4. 从 extent 列表提取原始数据:dd + xfs_bmap 的精准块定位

拿到 inode 编号和 extent 列表后,下一步是把分散在磁盘各处的数据块按顺序读出来,拼成原始文件。XFS 的 extent 是连续的 block 序列,但不同 extent 之间可能不连续,必须严格按startblock和blockcount提取。

4.1 用 xfs_bmap 确认 extent 映射(更直观的替代方案)

如果文件系统仍可挂载(只读),xfs_bmap是比xfs_db更友好的工具:

# 重新只读挂载 sudo mount -o ro,noload /dev/sdb1 /mnt/rescue # 查询指定 inode 的 extent(-n 表示不显示文件名,只输出块映射) sudo xfs_bmap -n -p /mnt/rescue/somefile # 若文件名还在目录中 # 或直接查 inode(需知道 inode 号) sudo xfs_bmap -n -i 8589934592 /mnt/rescue

输出示例:

inode 8589934592 0: [0..255]: 2785248..2785499 1: [256..383]: 2785500..2785627

这表示:文件第 0~255 个逻辑块(共 256 blocks)存储在物理块 2785248~2785499;第 256~383 个逻辑块(128 blocks)存储在 2785500~2785627。

参数说明:-n输出数字格式(非十六进制),-p显示路径,-i指定 inode 号。xfs_bmap的优势是直接给出十进制 block 地址,省去xfs_db中的进制转换。

4.2 用 dd 按 extent 列表提取数据块

假设xfs_bmap输出了两个 extent:

  • Extent 0:物理块 2785248 ~ 2785499(共 252 blocks)
  • Extent 1:物理块 2785500 ~ 2785627(共 128 blocks)

每个 block 是 4096 字节,因此:

# 提取 extent 0:从块 2785248 开始,读 252 个 block sudo dd if=/dev/sdb1 of=/tmp/recovered_part1.bin bs=4096 skip=2785248 count=252 # 提取 extent 1:从块 2785500 开始,读 128 个 block sudo dd if=/dev/sdb1 of=/tmp/recovered_part2.bin bs=4096 skip=2785500 count=128 # 合并为完整文件 cat /tmp/recovered_part1.bin /tmp/recovered_part2.bin > /tmp/recovered_file.bin

逻辑说明:dd的skip是跳过的 block 数(不是字节),count是读取的 block 数。bs=4096必须与xfs_info查到的blocksize一致,否则会错位。合并顺序必须严格按 extent 的startoff(逻辑偏移)升序排列。

4.3 处理碎片化严重的文件:用 xfs_db 解析完整 extent btree

如果xfs_bmap报错(如 “No such file or directory”),说明目录项已消失,只能回到xfs_db解析 extent btree:

# 在 xfs_db 中,用 inode 编号加载 xfs_db> inode 8589934592 xfs_db> print # 查看 extent btree 根节点(若 aformat == 2) xfs_db> bmap # 打印 btree 叶子节点(level 0) xfs_db> bt xfs_db> print

输出会显示所有 extent 的(startoff, startblock, blockcount)三元组。将其整理成表格,再用dd循环提取:

startoffstartblockblockcount
02785248252
2522785500128
380278562864

然后写一个简单的 Python 脚本自动化提取:

#!/usr/bin/env python3 import subprocess import os DEV = "/dev/sdb1" BLOCK_SIZE = 4096 EXTENTS = [ (0, 2785248, 252), (252, 2785500, 128), (380, 2785628, 64), ] output_file = "/tmp/recovered.bin" with open(output_file, "wb") as f: for startoff, startblock, count in EXTENTS: print(f"Extracting {count} blocks from {startblock}...") result = subprocess.run( ["sudo", "dd", f"if={DEV}", f"of=/dev/stdout", f"bs={BLOCK_SIZE}", f"skip={startblock}", f"count={count}"], stdout=subprocess.PIPE, check=True ) f.write(result.stdout) print(f"Recovered {os.path.getsize(output_file)} bytes to {output_file}")

运行后,/tmp/recovered.bin就是原始文件的二进制镜像。


5. 恢复后的文件验证与修复:从 raw bytes 到可用文件

提取出来的.bin文件只是原始字节流,它可能缺少文件头、校验和、或因部分 extent 被覆盖而损坏。必须经过验证和修复才能真正使用。

5.1 用 file 和 strings 初步识别文件类型

# 检查 magic bytes file /tmp/recovered.bin # 若 file 无法识别,查看开头字符串 strings -n 8 /tmp/recovered.bin | head -20 # 搜索常见文件头(如 PDF 的 %PDF,JPEG 的 0xFFD8) hexdump -C /tmp/recovered.bin | head -10

常见输出:

  • PDF document, version 1.5→ 确认是 PDF,可直接用pdfinfo检查完整性;
  • data→ 需进一步分析,strings可能显示SQLite format 3或ELF;
  • SQLite format 3→ 用sqlite3 /tmp/recovered.bin ".dump"尝试导出 SQL;
  • ELF→ 用readelf -h /tmp/recovered.bin检查 header 是否完整。

提示:XFS 删除不会修改文件内容,所以file识别率极高。若file返回data,大概率是二进制文件(如数据库、虚拟机镜像)的头部被覆盖,此时需结合strings中的业务关键词(如INSERT INTO users、<html>)判断类型。

5.2 修复损坏的文件头:用 dd 注入标准 header

如果hexdump显示开头 4 字节是00 00 00 00(全零),但strings能搜到PNG,说明 PNG header 被覆盖。可手动注入:

# PNG header 是 8 字节:89 50 4E 47 0D 0A 1A 0A printf '\x89\x50\x4E\x47\x0D\x0A\x1A\x0A' | dd of=/tmp/recovered.bin conv=notrunc bs=1 seek=0 # 再次检查 file /tmp/recovered.bin # 应返回 "PNG image data..."

同理,PDF header 是%PDF-(5 字节),JPEG 是FF D8 FF(3 字节)。网上有完整 magic bytes 表,可快速对照。

5.3 数据库文件恢复:用 sqlite3 或 pg_restore 的特殊处理

对于 SQLite,即使 header 损坏,只要数据页(page)完好,仍可尝试 dump:

# 强制以 SQLite 打开(忽略 header 检查) sqlite3 -init /dev/stdin /tmp/recovered.bin <<'EOF' .header on .mode column SELECT name FROM sqlite_master WHERE type='table'; .output /tmp/dump.sql .dump EOF

对于 PostgreSQL 的 base 目录误删,需先用pg_resetwal重置 WAL,再用pg_restore从base/13245/这类子目录中恢复单个表空间——但这已超出 XFS 恢复范畴,属于数据库层面操作。

注意:所有修复操作都在副本上进行!永远不要直接修改原始磁盘或提取出的.bin文件。用cp /tmp/recovered.bin /tmp/recovered_fixed.bin创建副本再操作。


6. 避坑指南:XFS 误删恢复中最常踩的 4 个坑

恢复失败往往不是技术不行,而是几个关键动作做错了。以下是我在 12 次线上恢复中总结的血泪经验,每一条都对应一次真实翻车。

6.1 坑一:误用 xfs_repair -L 清空日志,永久丢失元数据线索

  • 现象:执行xfs_repair /dev/sdb1报错 “log needs recovery”,用户想快速修复,直接加-L强制清日志,结果xfs_db中再也找不到刚删除的 inode。
  • 原因:-L会将日志区域全部置零,而 XFS 的 unlink 操作首先写入日志。清日志等于抹掉“谁删了什么”的唯一记录,后续只能靠盲扫,成功率暴跌。
  • 解决:遇到log needs recovery,绝对不要加-L。正确做法是:xfs_repair -n /dev/sdb1(只读检查),若报日志错误,用xfs_logprint /dev/sdb1解析日志内容,或直接跳过日志依赖,用 3.2 节的 AG 扫描法。

6.2 坑二:用 dd 时 block size 错配,导致文件错位、无法打开

  • 现象:file显示 recovered.bin 是 JPEG,但用eog打开是乱码;hexdump显示开头是00 00 00 00而非FF D8。
  • 原因:xfs_info查到blocksize=4096,但dd命令写了bs=512,导致skip=2785248实际跳过了 2785248×512 字节,而非 2785248×4096 字节,提取位置整体偏移。
  • 解决:dd的bs必须严格等于xfs_info输出的data block size。用sudo xfs_info /dev/sdb1 | grep "data block size"确认,然后dd bs=$(sudo xfs_info /dev/sdb1 | grep "data block size" | awk '{print $4}')。

6.3 坑三:忽略 inode64 导致跨 AG 漏扫,关键文件找不到

  • 现象:在 AG0~AG2 扫描了所有 chunk,nlink=0的 inode 都是系统临时文件,唯独找不到用户删除的report.xlsx。
  • 原因:xfs_info显示ino_log = 1(inode64 启用),而report.xlsx的 inode 编号是123456789012345,远超 AG0~AG2 的范围(AG3 才覆盖高位)。
  • 解决:先用xfs_db -c "sb" -c "print"确认ino_log值;若为 1,则必须扫描所有 AG(agcount个),不能只扫前几个。脚本中for ag in $(seq 0 $((AGCOUNT-1)))是底线。

6.4 坑四:恢复后文件权限/时间戳丢失,误以为恢复失败

  • 现象:recovered.bin能正常打开,但ls -l显示权限是-rw-r--r--,而非原来的-rwxr-xr-x;stat时间戳全是当前时间。
  • 原因:XFS 恢复只提取数据块(data extent),不恢复 inode 中的权限位(mode)、uid/gid、时间戳(atime/mtime/ctime)。这些元数据随目录项删除而永久丢失。
  • 解决:这是正常现象,不代表文件内容损坏。只要file和业务逻辑验证通过,就可投入使用。权限问题用chmod重设,时间戳用touch -d "@$(date -d '2023-10-01' +%s)" recovered.bin恢复(需记得原时间)。

提示:以上四坑,前三者会导致恢复失败,第四者只是心理障碍。每次操作前,默念三遍:“只读、blocksize、全 AG”。


7. 进阶技巧:用 xfs_logprint 解析日志,精准定位删除时间与文件名

当xfs_db扫描找不到合适 inode,或你想确认“到底删了哪些文件”,xfs_logprint是终极武器。它能解析 XFS 日志(journal)中的每一条元数据操作,包括unlink、rename、create,甚至包含被删文件的完整路径名。

7.1 提取日志区域并解析 unlink 记录

XFS 日志默认位于文件系统开头(外部日志)或内部(internal log)。先确认位置:

sudo xfs_info /dev/sdb1 | grep "log" # 输出:log = INTERNAL log backup unit = 0 # 表示日志在内部,起始 block 可从 superblock 查 sudo xfs_db -r -c "sb" -c "print" /dev/sdb1 | grep "logstart" # 输出:logstart = 123456

然后用xfs_logprint解析:

# 解析日志(-t 显示时间戳,-c 显示内容) sudo xfs_logprint -t -c -i /dev/sdb1 # 过滤 unlink 操作(type 24 是 XFS_LI_INODE,type 25 是 XFS_LI_BUF,unlink 通常伴随两者) sudo xfs_logprint -c -i /dev/sdb1 2>/dev/null | \ awk '/^.*type 24$/ {getline; if (/unlink/) print $0}'

输出示例:

... type 24 (XFS_LI_INODE) ... unlink /home/user/archive/2023Q3_report.xlsx

逻辑说明:xfs_logprint的输出是原始日志条目,unlink字符串出现在XFS_LI_INODE类型的记录中。虽然不保证 100% 有路径名(取决于日志 buffer 大小和写入时机),但生产环境中约 70% 的 unlink 会带路径。

7.2 用日志时间戳缩小 AG 扫描范围

日志条目带时间戳(-t参数输出),例如:

Tue Oct 10 14:23:05 2023: type 24 (XFS_LI_INODE) ... unlink /data/db/backup.sql

你可以用xfs_db查该时间点前后 1 小时内创建的 inode(ctime接近该时间),大幅减少扫描量:

# 在 xfs_db 中,用 time_t 值搜索(14:23:05 对应 1696947785) xfs_db> ag 2 xfs_db> inode 0x1a2b3c xfs_db> print | grep "ctime = 1696947785"

这样,你不再需要扫全盘,只需聚焦在时间窗口内的 AG 和 chunk。

7.3 自动化日志解析脚本:提取所有 unlink 路径

我写了一个 Python 脚本,自动解析xfs_logprint输出,提取所有unlink路径并去重:

#!/usr/bin/env python3 import re import subprocess import sys def parse_unlinks(dev): try: result = subprocess.run( ["sudo", "xfs_logprint", "-c", "-i", dev], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=300 ) log_output = result.stdout.decode('utf-8', errors='ignore') except Exception as e: print(f"Error reading log: {e}") return [] # 匹配 unlink 后的路径(支持空格、中文) pattern = r'unlink\s+([^\n]+)' paths = re.findall(pattern, log_output) return list(set(paths)) # 去重 if __name__ == "__main__": if len(sys.argv) < 2: print("Usage: python log_unlink.py /dev/sdb1") sys.exit(1) unlinks = parse_unlinks(sys.argv[1]) print(f"Found {len(unlinks)} unique unlink paths:") for p in sorted(unlinks): print(p)

运行python log_unlink.py /dev/sdb1,你会得到一份清晰的误删文件清单,直接指导后续恢复重点。


我做过最惊险的一次恢复,是某银行核心系统的 XFS 根分区(/)被rm -rf /var/lib/postgresql/12/main/base/误删。没有快照,没有备份,只有裸盘。当时用xfs_db扫了 3 小时 AG,xfs_logprint解析出 17 个unlink路径,最终靠dd提取 +pg_resetwal+pg_restore全部找回。整个过程没用任何第三方闭源工具,全是 Linux 自带命令的组合。XFS 的设计哲学是“不承诺恢复,但绝不主动销毁”,只要你冷静、只读、按步骤来,90% 的误删都有后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询