在恢复一个 12TB 多设备 BTRFS 池时编写的自定义工具,该池存在严重的 extent tree 损坏,原生命令(btrfs check --repair、--init-extent-tree 等)无法修复。
请参阅 INCIDENT-ANALYSIS.md,了解恢复过程的结构化案例研究、根本原因分类以及一系列建设性建议,供上游 btrfs-progs 改进之用,这些改进本可避免使用大部分这些工具。
仅在 btrfs check --repair 出现段错误、进入无限循环或使文件系统比之前更糟时使用这些工具。
已知有帮助的案例:
btrfs check --repair 在 [3/8] checking extents 时段错误(Issue #525)btrfs check --init-extent-tree 死锁btrfs check --repair 进入无限循环,重复相同的修复操作rescue=all,ro 挂载,无法读写挂载这些工具不适用于轻度损坏。 对于正常损坏,请先尝试 btrfs check --repair。
--write 的工具前,先备份元数据:
for DEV in sda1 sdb1 sdc1; do
sudo dd if=/dev/$DEV of=sb_${DEV}.bin bs=4096 count=1 skip=16
done
--write 为选择性启用)这些工具使用 btrfs-progs 内部 API,必须在 btrfs-progs 源码树内构建:
# 1. 克隆 btrfs-progs
git clone --depth 1 --branch v6.19.1 https://github.com/kdave/btrfs-progs.git
cd btrfs-progs
# 2. 应用 EEXIST 补丁(批量 backref 注入所需)
patch -p1 < path/to/btrfs_fixes/patches/alloc_reserved_tree_block_eexist.patch
# 3. 配置并构建基础 btrfs-progs
./autogen.sh
./configure
make -j$(nproc)
# 4. 将本仓库中的 .c 文件复制到 btrfs-progs 目录
cp path/to/btrfs_fixes/programs/*.c .
# 5. 对于每个程序,在 Makefile 中添加:
echo '
PROGNAME: PROGNAME.o $(objects) $(libs_shared)
@echo " [LD] $@"
$(Q)$(CC) -o $@ PROGNAME.o $(objects) $(libs_shared) $(LDFLAGS) $(LIBS)
' >> Makefile
# 6. 构建
make PROGNAME
建议执行顺序:
scan_and_fix_all_backrefs.c(最重要)最重要的工具。 递归遍历文件系统中的每个树(ROOT、CHUNK、EXTENT、FS、DEV、CSUM、UUID、FREE_SPACE),检测在 extent tree 中缺失 METADATA_ITEM backref 的元数据块。在单个事务中注入所有缺失的 backref,以避免“根树在提交之间移动”的问题。
用法:
sudo ./scan_and_fix_all_backrefs /dev/sdX # 仅扫描
sudo ./scan_and_fix_all_backrefs /dev/sdX --write # 扫描+注入
fix_owner_refs.c修复内联 TREE_BLOCK_REF 中的 owner,当它不匹配块的实际 btrfs_header_owner() 时。当块在失败的修复期间被重新分配到其他树时,会发生不匹配。
sudo ./fix_owner_refs /dev/sdX # 扫描
sudo ./fix_owner_refs /dev/sdX --write # 修复
fix_bad_levels.c修复具有错误 level 的 METADATA_ITEM 和 EXTENT_ITEM 条目。损坏的 level(例如 50、55、237)是由 btrfs check --repair 进入循环留下的垃圾。根据块的真正 btrfs_header_level() 进行验证。
sudo ./fix_bad_levels /dev/sdX # 扫描
sudo ./fix_bad_levels /dev/sdX --write # 修复
fix_duplicate_extents.c删除重复的 METADATA_ITEM(相同的 bytenr,但键中的 level 不同)。保留 level 与 btrfs_header_level 匹配的条目,删除另一个。
sudo ./fix_duplicate_extents /dev/sdX # 扫描
sudo ./fix_duplicate_extents /dev/sdX --write # 删除重复项
remove_stale_ptrs.c扫描 FS_TREE 的每个 level-1 节点。使用三项检查检测过时的子指针:owner 不匹配、first_key 不匹配,或 first key 的类型对于 FS_TREE 无效(例如 BLOCK_GROUP_ITEM)。使用 btrfs_del_ptr 删除它们。
sudo ./remove_stale_ptrs /dev/sdX # 扫描
sudo ./remove_stale_ptrs /dev/sdX --write # 删除
fix_uuid_tree.c / fix_csum_tree.c分别为 UUID 树 / CSUM 树创建一个空的叶子节点。当 ROOT_ITEM 指向一个已被重新分配到其他树的块时非常有用。内核在读写挂载时自动重新生成 UUID 树。使用空的 CSUM 树时,标记为 NODATASUM 的文件不会验证失败。
sudo ./fix_uuid_tree /dev/sdX
sudo ./fix_csum_tree /dev/sdX
set_nodatasum.c在常规文件 inode 上设置 BTRFS_INODE_NODATASUM 标志。如果 csum 树为空但文件仍然带有预期的校验和(这会导致读取错误),则使用此工具。启用 NODATASUM 后,内核会跳过 csum 查找。
sudo ./set_nodatasum /dev/sdX # 扫描
sudo ./set_nodatasum /dev/sdX --write # 应用
fix_fstree_node.c包含一个硬编码的过时块列表的版本。优先使用 remove_stale_ptrs,它能自动检测这些块。仅当需要手动控制要删除哪些特定块时使用。
add_backrefs.c包含一个硬编码的缺失 backref 列表的初始版本。优先使用 scan_and_fix_all_backrefs,它能自动检测它们。
当上述基础工具不足时(池中有 200K+ 错误,分布在多个树上),构建了这些附加工具:
scan_fstree_extents.c + scan_extent_tree.c第一遍和第二遍扫描器,分别遍历 FS_TREE 和 extent tree,生成包含每个 ref/extent 映射的 TSV 文件。用于构建 rebuild_extent_tree_apply 的输入,当需要从头重建 extent tree 时。
rebuild_extent_tree_apply.c(重写者)主要的第3阶段写入器。接收一个预合并的 ref 列表(来自 scan_fstree_extents + scan_extent_tree 的差异),并以每个事务 5000 个的块将 300 万以上的 EXTENT_DATA_REF 注入 extent tree。每 50K 个项目进行节流以避免 DM-SMR 重排序停滞。经过验证,在 3× WD40EFAX SMR 磁盘上成功完成 3,248,617 次插入,耗时约 34 分钟。
sudo ./rebuild_extent_tree_apply /dev/sdX1 refs_folded.txt to_insert.txt watermark.txt --dryrun
sudo ./rebuild_extent_tree_apply /dev/sdX1 refs_folded.txt to_insert.txt watermark.txt --write
patch_block_group_used.c当第3阶段写入器由于预先存在的重叠 file_extent_item 导致特定 bg 计算超限时,用于 BLOCK_GROUP_ITEM.used 的手术式单字段修补器。使用 btrfs_set_block_group_used 直接设置器,以避免 btrfs_update_block_group 的 space_info 计算(我们这里不想要)。预验证 flags & BTRFS_BLOCK_GROUP_DATA。
sudo ./patch_block_group_used /dev/sdX1 <bg_bytenr> <bg_length> <new_used> --write
remove_extent_items_by_key.c从 extent tree 中删除一个硬编码的 (bytenr, num_bytes, expected_inode) EXTENT_ITEM 列表。用于清理单个叶子中阻止 RO 挂载的重叠过时 extent。删除前对每个项目进行完整性检查(7个不变量,包括 inode 白名单)。以 rebuilding_extent_tree=1 + reinit_extent_tree=true 运行,以跳过空间计算(调用者首先通过 patch_block_group_used 手动修补 used)。
clean_orphan_dir_entries.c清理 FS_TREE 中的孤立 DIR_ITEM + DIR_INDEX 条目。每个事务处理 100 个条目。更新父 INODE_ITEM 的 i_size(减少 namelen × 2:关键错误已修复:v1 仅减少 namelen,导致目录处于无效状态)。针对关键顶级目录名称(例如 pelis、series、music、backups、homestorage)的硬编码排除列表。切勿按原始 namelen 减少 i_size:BTRFS 存储的是 namelen × 2 计算。
clean_orphan_inode_refs.c遍历 FS_TREE 中的 INODE_REF 项,其 key.offset(父 inode)位于孤立父列表中。跳过 INODE_EXTREF 以避免误报(EXTREF 的 key.offset 是哈希值,而非父 ID)。每个事务处理 32 个。
fix_dir_inode_counts.c为受先前孤儿清理错误影响的 DIR inode 重新计算 i_size = sum(name_len × 2) 和 nlink = 1。安全关键:如果任何 DIR 具有 nlink = 2,则对其路径执行一次 rm -rf 将静默删除数千个子目录(rmdir 炸弹)。遍历 DIR_INDEX 条目,交叉检查 DIR_ITEM 以检测哈希冲突(经经验验证,冲突为 0)。
remove_orphan_inode_subtrees.c从 FS_TREE 中删除孤立 inode 子树(DIR 家族 + 独立 REG)。对于每个目标:遍历并删除 EXTENT_DATA、INODE_REF、INODE_EXTREF、XATTR,最后是 INODE_ITEM。每个 DIR 家族一个事务(每个子树原子性),独立 REG 每 50 个一块。硬编码的偏执性排除列表。
⚠️ 主要安全警告:请参见下面的“防弹子集标准”。
remove_stale_ptrs_v2.cremove_stale_ptrs 的改进版本:检测带有 parent expected_key 的空叶子(v1 跳过了这种情况)、递归两级扫描(根→level1 + level1→叶子)、动态缓冲区(无 512 限制)、容忍 read_tree_block 失败。
insert_one_extent_poc.c带验证的单个 extent 插入的概念验证。用于在运行 rebuild_extent_tree_apply 之前验证 API 路径。
在 2026-04-05 会话期间,remove_orphan_inode_subtrees 在同一个 BUG_ON 断言上崩溃了两次,原因不同:
崩溃向量 1:在混合叶子(gen 3601,同时包含孤立和活跃 inode)上直接 btrfs_cow_block(leaf) → update_ref_for_cow 遍历子节点 → __btrfs_mod_ref(inc=1) 对过时的兄弟子节点操作 → btrfs_free_extent(phantom) 返回 -ENOENT → BUG_ON → SIGABRT。
崩溃向量 2(后来发现,通过过滤避免):在清理后,btrfs_del_items 将叶子内容减少到 LEAF_DATA_SIZE/4 = 4096 字节以下 → 调用 push_leaf_left(sibling) 或 push_leaf_right(sibling) → 如果兄弟节点具有 gen ≤ last_snapshot = 3701,则 btrfs_block_can_be_shared 返回 1 → update_ref_for_cow 进入 refs > 1 路径 → btrfs_inc_ref(cow_sibling, 0) → __btrfs_mod_ref(cow, level=0, inc=1) → 遍历过时兄弟节点的所有 EXTENT_DATA → btrfs_inc_extent_ref(phantom_bytenr) → BUG_ON(err) 在 extent-tree.c:1302 → SIGABRT。
标志 fs_info->rebuilding_extent_tree = 1 和 trans->reinit_extent_tree = true 并不能拯救 INC 路径:它们仅豁免 BTRFS_DROP_DELAYED_REF(在 extent-tree.c:3885 中验证)。BTRFS_ADD_DELAYED_REF(来自 btrfs_inc_ref)是致命的。
对于任何将要删除的目标 inode 的防弹标准:
INODE_ITEM 的叶子具有 gen > 3701(崩溃后)gen > 3701used 字节数 > 4096(不触发重新平衡)gen > 3701(即使条件 3 失败,重新平衡到崩溃后的兄弟节点也是安全的)disk_bytenr)在当前 extent tree 中可解析(backref 查找时不返回 -ENOENT)违反条件 3+4 中的任何一个都会触发崩溃向量 2。条件 5 通过 reinit_extent_tree 对 DROP 豁免,但对 INC 不豁免(而 push_leaf_left 调用的正是 INC)。
对于任何候选孤立 inode 集,遍历 FS_TREE 转储并根据5个防弹标准对每个目标叶子进行分类。示例模式(匿名化):
其中 ≥90% 的项是孤立的叶子是危险区域:它们肯定会减少到重新平衡阈值(LEAF_DATA_SIZE/4 = 4096 字节)以下,从而强制 push_leaf_left/right。如果父节点中的任何直接兄弟节点具有 gen ≤ last_snapshot,则推送会触发对该兄弟节点的 CoW,从而进入 btrfs_block_can_be_shared → refs > 1 → btrfs_inc_ref → __btrfs_mod_ref(inc=1) 路径,并在 btrfs_inc_extent_ref 中因 BUG_ON(err) 崩溃。
缓解措施:从输入文件中排除有问题的 inode。该工具处理所有通过预检验证的内容;对于同时包含安全和不安全目标的叶子,可以仅列出安全子集进行部分处理。每个家族的事务语义确保即使其他家族被排除,每个安全家族也能原子性地提交。
一次会话的经验结果:从 N 个候选孤立节点开始,应用所有 5 个条件后,最终的安全子集约为输入的 14%,但该子集提交时没有发生任何 BUG_ON,并且在写入前捕获的活跃文件的基线 sha256 上差异为 0 字节。
patches/alloc_reserved_tree_block_eexist.patch 修改了 btrfs-progs,当 alloc_reserved_tree_block 发现 METADATA_ITEM 已经存在时,返回 0 而不是传播 EEXIST。这是批量 backref 注入工作所必需的:当注入许多 backref 时,延迟 refs 系统也会尝试为通过 COW 新分配的块创建 METADATA_ITEM,从而与我们已插入的块发生冲突。
# 1. 备份
mkdir -p backup
for DEV in /dev/sdX1 /dev/sdY1; do
sudo dd if=$DEV of=backup/$(basename $DEV).sb bs=4096 count=1 skip=16
done
# 2. 确保文件系统已卸载
sudo umount /mnt/pool 2>/dev/null
# 3. 清零日志树(如果适用)
sudo btrfs rescue zero-log /dev/sdX1
# 4. 按顺序扫描并修复所有内容
sudo ./scan_and_fix_all_backrefs /dev/sdX1 --write
sudo ./fix_bad_levels /dev/sdX1 --write
sudo ./fix_owner_refs /dev/sdX1 --write
sudo ./fix_duplicate_extents /dev/sdX1 --write
sudo ./remove_stale_ptrs /dev/sdX1 --write
# 5. 重新扫描以验证收敛
sudo ./scan_and_fix_all_backrefs /dev/sdX1
sudo ./remove_stale_ptrs /dev/sdX1
# 6. 如果 csum 树损坏:
sudo ./fix_csum_tree /dev/sdX1
sudo ./set_nodatasum /dev/sdX1 --write
# 7. 尝试读写挂载
sudo mount -o rw /dev/sdX1 /mnt/pool
# 8. 如果能挂载,使用只读 btrfs check 验证
sudo btrfs check --force /dev/sdX1
每次修复都可能通过 COW 产生新问题:当工具修改 extent tree 时,btrfs 会对受影响的节点进行 COW。新节点会复制旧节点的指针,这可能会传播过时的指针。可能需要多次遍历。
数据 extent ref 不匹配未修复:这些工具仅处理元数据 backref。数据 extent 上的错误 ref 计数(在失败的 btrfs check --repair 运行后常见)不会被清理。
孤立 inode 未清理:FS_TREE 中指向不再存在的 inode 的孤立目录条目不会被删除。
不能替代 btrfs check --repair:这些工具针对特定场景。对于轻度或中度损坏,btrfs check --repair 更合适。
绝不要在多设备 BTRFS 文件系统上进行硬断电:组合的空闲空间树 + extent tree 损坏极难修复。
绝不要连续多次运行 btrfs check --repair,如果第一次运行没有解决所有问题:它可能进入无限循环,使文件系统急剧恶化。
始终在每次写入操作前备份超级块。
trans->reinit_extent_tree = true 是忽略对没有 backref 的块的延迟 ref 中 DROP 失败的关键。
fs_info->rebuilding_extent_tree = 1 在修复期间禁用空间检查。
一次包含多次插入的大提交优于多次小提交,因为中间提交会移动根树。
SB 中的 backup_slots 不是历史备份:它们只是最近 4 次提交的滑动窗口。一次 btrfs check --repair 循环执行 46,000+ 次提交,会在几分钟内轮换每个 slot 约 11,000 次,从而抹去任何可从内核恢复的崩溃前状态。要实现真正的保留,你需要显式的 btrfs subvolume snapshot 或 btrfs send 流到另一个设备。
reinit_extent_tree 是不对称的:仅豁免 BTRFS_DROP_DELAYED_REF,不豁免 BTRFS_ADD_DELAYED_REF。任何调用 的代码路径(包括重新平衡期间的 )在过时的叶子上仍会因 → 而崩溃。
这些工具是为一个特定的恢复案例编写的,在该案例中原生工具失败。它们未经一般用例的测试。 仅当你理解代码并接受数据丢失的风险时才使用它们。
如果可能,务必在尝试任何修复之前复制你的数据。
GPL-2.0(与 btrfs-progs 兼容,这些工具使用其内部 API)。
| 叶子 | Gen | 孤立项 / 总数 | 清理后 used(估计) | 重新平衡? | 直接兄弟节点 | 结论 |
|---|
$LEAF_A | 崩溃后 | 大部分孤立,大量清理 | 低于阈值 | 是 | 全部崩溃后 | ✓ 安全 |
$LEAF_B | 崩溃后 | 大部分活跃,少量清理 | 高于阈值 | 否 | 干净的父节点 | ✓ 安全 |
$LEAF_C | 崩溃后 | 接近100%孤立 | 远低于4096 | 强制是 | 崩溃前的过时节点 | ❌ 崩溃 |
btrfs_inc_refpush_leaf_left/rightbtrfs_inc_extent_refBUG_ON(err)处理损坏 FS_TREE 中的 inode 时的安全标准必须包括兄弟节点,而不仅仅是目标叶子本身。请参见“防弹子集标准”部分。
活跃文件的基线 sha256 是不变量的唯一经验证明。在任何写入操作前捕获它,之后进行对比。任何差异 = 回滚。
DIR 的 i_size 存储为 sum(name_len × 2),而不是 sum(name_len)。任何在删除条目时减少 i_size 的孤立清理工具必须减少 namelen × 2。如果出错,会使 DIR 处于无效状态,可能表现为 nlink = 2:如果池以读写模式挂载,这会触发 rmdir 炸弹(对父目录执行一次 rm -rf 可能静默删除数千个子目录)。
具有经验证据的专家评审代理至关重要。2026-04-05 会话使用了两位并行的 Opus 评审员(btrfs 内部 + 运维),他们根据 dump-tree 输出分析了提议的计划。他们捕获了一个确定性的崩溃向量(push_leaf_left → 过时兄弟节点),该向量本会重复之前的故障。没有经验性 dump-tree 分析的文本计划评审会遗漏这一点。