ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

文件去重实战:MD5校验、硬链接与隔离回滚

文件去重实战:MD5校验、硬链接与隔离回滚

简介:Duplicate File Finder Plus 是一款面向普通用户与轻量运维人员的重复文件查找工具绿色版,主要解决磁盘中因重复文件堆积造成的空间浪费与文件混淆问题。其核心特色是依据文件内容而非文件名、大小或修改时间进行比对,配合高速比较算法,使去重结果更具准确性。压缩包共19个文件,约196KB,包含可执行主程序、语言配置与多语言界面文件、运行库及说明文档等,其中12个xml对应多语言资源,2个txt提供使用说明与许可信息,dll与config、ini用于程序运行和参数配置,解压后即可使用,体积轻便。目前已有659人学习。工具支持按驱动器、文件夹或文件类型筛选搜索,完成后自动以颜色标记相邻重复组,并按大小降序排列,支持智能选择、保存与加载结果、移至回收站、打开文件位置及排除文件夹等操作,便于快速清理冗余文件、释放磁盘空间。

1. 一块塞满素材的移动硬盘,让我重新认识了“文件去重”

一块 2TB 的移动硬盘,里面是三年攒下来的东西:设计稿的十几个版本、同一批照片的三份拷贝、几个从旧机器整盘拷过来忘了删的目录,还有一堆名字叫“新建文件夹(2)”的东西。资源管理器只会提示“可能有重复”,真要清理,你只能一个个点开比较。我后来固定用 Duplicate File Finder Plus 这个绿色版小工具处理这类工作——解压就能跑,不往系统里塞东西,核心能力是把“重复”拆成三层:先按大小分桶,再按内容校验,最后按组列出来让你决定留哪个。文件去重这件事,难点从来不是“找出来”,而是“找完之后你敢不敢按下删除”。这篇按我自己的使用顺序拆:比对模式怎么选、扫描范围怎么收窄、删除前怎么留后悔药、以及五个让我当场停手的坑。

2. 把“重复”定义清楚:三层漏斗和五种比对档位

同一个词在不同人嘴里意思完全不同。有人说的重复是“文件名一样”,有人说的重复是“字节完全一样”,还有人心里想的是“这两张图看着是同一张”。这个工具做的是前两种,第三种它管不了,这一点必须先说清楚,否则后面所有参数都是错的。

它的判定逻辑是一条漏斗:先用文件系统元数据(大小)做第一道筛选,把不可能重复的排除掉;再对留下来的候选做内容校验;最后把校验通过的文件按组聚合。漏斗的每一层成本差着数量级,第一层几乎免费,第二层要真读盘,所以参数的功夫全花在“怎么让第二层少干活”上。

2.1 大小分桶为什么能筛掉九成候选

文件大小是元数据,读它不需要打开文件,一次目录遍历就全拿到了。一百万个文件里,绝大多数大小是唯一的——谁会正好也做一个 3,271,884 字节的文件?所以按大小分组之后,只有“同尺寸出现两次以上”的桶才需要进内容校验。这一步的收益随着文件数增长而放大,也是我把“最小文件大小”设成 1KB 而不是 0 的原因:0 字节文件全部同尺寸,会形成一个巨大的假桶。

内容校验有两种做法,代价模型完全不同。哈希是“每个文件完整读一遍,算出一个值,再拿值当键分组”,每个文件只读一次,总 I/O 约等于数据总量。逐字节比对是“两个文件同时读,遇到第一个不同的字节就退出”,对于“几乎一样但差一点点”的文件(比如每次保存只改了几行的工程文件、只差几个像素的导出图),它经常读几 KB 就结束了,快得离谱;但对于真正完全相同的两个大文件,它要把两份都读完才能下结论。

所以选型不是“哪个更准”,是“你的重复是哪种形态”:

比对档位读盘量速度误判风险适用场景
仅文件名几乎为零极快极高只想知道同名文件在哪,不做删除依据
文件名 + 大小几乎为零极快高粗筛,先看规模
大小 + CRC32全量一遍快同尺寸桶内有碰撞可能几十万文件以下的日常清理
大小 + MD5全量一遍中实际可忽略我的默认档,全量扫描
大小 + 逐字节差异大时极少近似文件极快无版本目录、连续备份目录

CRC32 那一行值得多说一句。按生日问题的粗算,40 万个文件两两配对的组合数是 8×10¹⁰ 量级,除以 2³² 得到的是两位数——当然真正参与比较的只是同尺寸桶里那几个,风险被摊得很薄。但我不愿意把“要不要删”这个判断交给一个 32 位校验值,素材库这种一次删错就找不回来的场景,多花的这点时间是划算的。

提示:MD5 在这里只用来判断“两份内容是否相同”,不承担任何安全职责,不要拿它去做完整性防篡改。

2.2 我常用的一套参数档位

绿色版的好处是规则简单,坏处是没有预设,全靠自己填。下面这套是我在素材归档盘上反复用的,可以直接照抄再微调:

参数工具默认我的取值理由
最小文件大小不限(含 0)第一遍 1MB,第二遍 4KB先拿大头,小文件复核成本高于收益
最大文件大小不限不限大文件恰恰是最值得删的
包含子目录开开关掉基本没意义
包含隐藏/系统文件关关系统目录里的重复项删了会出事
修改时间范围不限第一遍不限时间过滤容易漏掉跨年归档
扩展名白名单空第一遍空,第二遍按结果定空跑一次才知道噪声在哪
比较方式大小 + 内容大小 + MD5见上一节的选型表

两个“第一遍”是刻意的。第一次扫描只统计、不勾选、不删除,目的是看分布:哪些目录贡献了最多重复组,哪些扩展名占了大头。看完再回来收紧参数,第二次才是真正的清理。跳过这一步直接开删,是我见过最常见的翻车起点。

2.3 开扫之前先给目录做一次体检

工具给出的只是“重复”,不给你“全局”。所以在打开它之前,我会先用一段 PowerShell 把目标目录的规模摸清楚,顺便算出“按大小分桶后有多少候选”,这个数字直接决定我该选 MD5 还是逐字节。

# 目标盘体检:文件数、总量、以及"同尺寸出现两次以上"的候选桶 $root = 'D:\Archive' $files = Get-ChildItem -LiteralPath $root -Recurse -File -ErrorAction SilentlyContinue "文件数: $($files.Count)" "总大小: {0:N1} GB" -f (($files | Measure-Object Length -Sum).Sum / 1GB) # 按 Length 分组,Count > 1 的桶才可能包含重复内容 $buckets = $files | Group-Object Length | Where-Object { $_.Count -gt 1 -and $_.Name -ne '0' } "候选桶数: $($buckets.Count)" "候选文件数: $(($buckets | Measure-Object Count -Sum).Sum)" # 看看最大的几个候选桶长什么样,判断噪声来源 $buckets | Sort-Object { [int64]$_.Name } -Descending | Select-Object -First 15 ` @{n='SizeMB'; e={[math]::Round([int64]$_.Name / 1MB, 2)}}, Count

逻辑上就三件事:先枚举全部文件拿到元数据,再按Length分组,最后只看组内数量大于 1 的桶。第三段那个Sort-Object里为什么要写[int64]?因为Group-Object的Name是字符串,直接排会变成字典序,900KB会排在10MB后面,看着莫名其妙。

参数上有两个细节。-LiteralPath而不是-Path,是为了让路径里带方括号的目录(很多素材库真有)不被当成通配符;-ErrorAction SilentlyContinue是为了跳过无权限目录,否则整条命令会在第一个拒绝访问的文件夹上中断。这段脚本只读元数据、不读内容,百万级文件通常几分钟内出结果。

拿到“候选文件数”之后决策就很直接了:候选占比低于 5%,说明数据本身很干净,用逐字节也无所谓;候选占比高于 30%,说明这里有大量近似文件(版本目录的特征),直接上逐字节,别用哈希硬啃。

体检完,正式流程是这么走的:把绿色版解压到非系统盘的一个固定目录(不要放桌面,后面会讲到为什么);打开后先把目标目录加进去,再把排除项填好(下一章展开);比较方式选“大小 + MD5”;最小文件大小按第一遍 1MB 填;点扫描。等结果出来之后先别碰勾选框,直接看“按组大小排序”的前 20 组,这 20 组基本决定了这次能省多少空间。

3. 文件去重前的减法:排除清单、阈值和结果复核

第一次全盘扫的时候我等了四个小时,其中三个半小时花在了依赖目录、缓存目录和几个虚拟机镜像上。后来我把流程改成“先做减法再扫描”,同样的盘扫完不到二十分钟。这一章讲的就是减法怎么做。

3.1 排除清单不是拍脑袋写的

排除项的来源应该是数据本身,不是记忆。我会先跑一段统计,看每个一级子目录的文件数和体积,文件数特别多但体积很小的目录,八成是依赖包或者缓存:

# 逐盘摸底:找"文件多、体积小"的目录,这类基本是噪声源 Get-ChildItem -LiteralPath 'D:\' -Directory -ErrorAction SilentlyContinue | ForEach-Object { $f = Get-ChildItem -LiteralPath $_.FullName -Recurse -File -ErrorAction SilentlyContinue [pscustomobject]@{ Dir = $_.FullName Files = $f.Count GB = [math]::Round((($f | Measure-Object Length -Sum).Sum) / 1GB, 2) } } | Sort-Object Files -Descending | Select-Object -First 15 | Format-Table -AutoSize

这段要遍历整个一级目录,很慢,只在第一次摸底时跑,而且必须逐盘跑——对整盘根目录一次性递归,等待时间和内存占用都会很难看。Sort-Object Files -Descending把文件数最多的排前面,你要找的就是那种“几万个文件、总共不到 2GB”的目录。

摸完之后,排除清单按这个表来填:

排除对象为什么排除需要留意的例外
包管理器依赖目录文件数巨大、内容高度雷同、删了会导致工程跑不起来无,整个排除
版本库内部目录对象库是压缩存储的,工具看到的“重复”是假象无
系统目录与程序安装目录删了轻则软件异常,重则系统起不来无
各类缓存、缩略图目录全是可再生的中间产物无
虚拟机磁盘、镜像文件单文件几个 GB,校验一轮很久,且通常不该动确认是废弃镜像才处理
同步盘冲突副本看着像噪声,其实是你最该处理的重复这类不要排除,单独扫

最后一行是重点。很多人做排除的时候顺手把整个同步目录排掉了,结果真正需要清理的“同一份文档改出五个冲突副本”反而没扫到。我的做法是把同步目录单独拎出来做一次专项扫描,比较方式选逐字节——冲突副本之间通常只差几行。

3.2 阈值从结果分布反推,不要提前拍

最小文件大小设多少,取决于你要的是“省空间”还是“清数量”。这两个目标在参数上是冲突的:把阈值降到 1KB,重复组数量会翻好几倍,但绝大多数是配置文件和占位文件,删完一看空间几乎没变,而你付出了几小时的复核成本。

我的做法是两遍扫。第一遍阈值 1MB,只统计不删除,看“浪费空间”这一列——工具一般会给出每组(文件数 - 1) × 单文件大小的估算。前 20 组通常就能占掉这次可回收空间的绝大部分,把它们处理干净,收益已经到位。第二遍把阈值降到 4KB,专门处理“文件数量”这个指标,比如几万张重复的小图标、缩略图。

4KB 这个下限不是随便定的。低于 4KB 的文件在 NTFS 上走的常驻存储,实际占用的簇可能比文件本身还小,删掉它们省出来的空间和你的复核时间完全不成比例。而且小文件里混着大量程序依赖的占位文件,误删的后果往往不是“少个文件”,而是“某个东西打不开了”。

3.3 结果列表怎么读:先看分散度,再看大小

结果出来之后,我不按大小排序,按“组内文件的分布”看。具体做法是把组展开,看这一组的成员是不是集中在同一个目录、同一个时间段。

集中在一处、时间连续的一组,基本可以放心——那是同一次导出的多份拷贝。分散在不同盘、跨越好几年的一组,必须逐个点开看:那可能是你三年前存的一份原始档,和去年的一个改版,字节恰好一样只是巧合。

自动勾选规则(保留最早 / 保留最新 / 保留路径最短)可以大幅减轻工作量,但它是这一整个流程里最容易出事的地方,第 5 章会专门讲时间戳被改写的问题。我的习惯是:先用自动规则勾一遍,然后手动取消掉所有跨目录的组,只保留同目录内的自动勾选结果,跨目录的那几十组手工处理。

抽检也不能省。按组大小降序,前 50 组逐组展开看一眼文件名和路径,这一步花不了十分钟,但能抓住绝大多数“勾错方向”的问题。

3.4 把清单导出来,让脚本告诉你“每个目录会少多少”

工具的界面只给你看单组,不给聚合视图。所以我一定会把结果导成 CSV,然后用脚本按目录汇总——这一步的意义是让你在按删除之前,先看到“哪个目录会掉多少文件”,异常目录一眼就能发现:

# 把导出的重复清单按目录聚合,找出"即将被大量删除"的异常目录 $rows = Import-Csv -LiteralPath 'D:\dedup\duplicates.csv' $rows | Group-Object { Split-Path $_.Path -Parent } | ForEach-Object { [pscustomobject]@{ Dir = $_.Name Files = $_.Count SizeMB = [math]::Round((($_.Group | ForEach-Object { [int64]$_.Size }) | Measure-Object -Sum).Sum / 1MB, 1) } } | Sort-Object SizeMB -Descending | Select-Object -First 20 | Format-Table -AutoSize

Split-Path -Parent取父目录,Group-Object按它分组,然后对每组的Size求和。列名Path/Size要按你实际导出的表头改,导入之后先$rows[0] | Format-List看一眼字段名,比猜快得多。

输出里如果某个目录一次要掉几千个文件,先别急着删——那通常意味着这个目录本身就是一份完整备份,你真正该做的是把整份备份挪走,而不是从里面挑文件删。

4. 删、链、移:三种处置方式的边界

找到重复只是前半程。后半程是“怎么让它消失”,而这三条路的风险等级差得非常远,我基本不会混用。

4.1 回收站不是万能后悔药

大部分人的心理安全感来自回收站。但回收站有几个硬边界,不知道就会吃暗亏。

第一,回收站是按卷独立的。从 D 盘删的文件进 D 盘的回收站,C 盘看不到;如果你清空了 C 盘的回收站,D 盘的东西还在。第二,删除到回收站本质上是同卷内的移动操作,跨卷删除有些情况会直接提示“将永久删除”。第三,每个卷的回收站有容量上限,默认是卷容量的一小部分,单个文件超过上限时会直接永久删除,而且提示往往一闪而过。第四,网络位置和部分可移动介质根本没有回收站。

动大文件之前,我会先确认一下当前回收站的状态:

# 看回收站里现在有多少东西,以及各卷剩余空间 $shell = New-Object -ComObject Shell.Application $bin = $shell.Namespace(0xA) # 0xA 是回收站的固定编号 "回收站条目数: $($bin.Items().Count)" Get-PSDrive -PSProvider FileSystem | Select-Object Name, @{n='FreeGB'; e={[math]::Round($_.Free / 1GB, 1)}}, @{n='TotalGB'; e={[math]::Round(($_.Used + $_.Free) / 1GB, 1)}}

0xA是 Shell 命名空间里回收站的常量,不需要记,用一次就有数了。真正要看的是第二个输出:如果目标卷剩余空间本来就很紧张,回收站很快会顶到上限,后面的删除就变成永久的了。这种情况我会在删之前先把回收站清空一次,腾出额度——反过来说,清空之后就没有回头路了,这个顺序不能反。

还有一个容易被忽略的点:固态盘上的删除会触发 TRIM,一旦块被回收,数据恢复软件基本无能为力。所以在固态盘上做批量去重,宁可用下一节的“移动隔离”,也不要直接删。

4.2 硬链接去重:省的是空间,不是备份

NTFS 上有一个更优雅的方案:不删文件,把重复项替换成指向保留项的硬链接。多个目录项指向同一份数据,目录看起来还是两个文件,实际只占一份空间。

它的限制很硬,必须记清楚:只能在同一个卷内做;不能对目录做;一旦对其中任意一个路径做了编辑并保存,很多编辑器会采用“写临时文件再替换”的方式,结果是一条链接被断开,另一条保持不变,空间又回去了;备份软件看到的是多个文件,很可能在备份时把它们展开成多份,你以为省了,备份体积一点没降。

# 把 B 替换成指向 A 的硬链接(必须在同一 NTFS 卷内) $src = 'D:\Archive\2021\brand\logo_final.psd' $dup = 'D:\Archive\backup\logo_final - 副本.psd' # 1) 动手之前先确认内容真的一致,别信工具的结论,自己再算一次 $a = (Get-FileHash -LiteralPath $src -Algorithm SHA256).Hash $b = (Get-FileHash -LiteralPath $dup -Algorithm SHA256).Hash if ($a -ne $b) { throw '内容不一致,停止操作' } # 2) 删掉重复项,在原位置建硬链接 Remove-Item -LiteralPath $dup -Force New-Item -ItemType HardLink -Path $dup -Target $src | Out-Null # 3) 校验:两条路径都应该出现在同一个链接列表里 fsutil hardlink list $dup

第 1 步的重复校验看起来多余,其实是必要的:工具用的是 MD5,这里用 SHA256,两套算法都指向一致,才有底气执行第 2 步。第 3 步的fsutil hardlink list是最直接的验收——它列出这个数据块上的所有目录项,正常情况下应该同时看到logo_final.psd和logo_final - 副本.psd两条路径。如果只看到一条,说明链接没建上,或者路径写错了。

循环处理一批文件时,把这段逻辑包进foreach,并且在每次New-Item之前都Test-Path一次目标路径,避免对已经不存在的文件做操作。批量的写法这里不展开,思路是一样的:先验证、再删除、再建链、最后统一fsutil抽查几条。

4.3 两阶段法:先隔离 30 天,再决定要不要删

这是我现在的默认做法,尤其是在固态盘或者跨卷的场景下。核心思想是:把“判断”和“销毁”拆成两个动作,中间留三十天。

阶段一是移动,不是删除。从工具里把确认要处理的那批文件勾好,导出路径清单,然后用脚本把它们整体挪到一个带时间戳的隔离目录里,并且保持原来的相对路径结构——这一点很关键,因为三十天后你要恢复时,得能原样放回去。

# 阶段一:把清单里的文件移动到隔离区,保留相对路径,并记录映射关系 $csv = 'D:\dedup\duplicates.csv' # 工具导出的重复项清单 $root = 'D:\Archive' # 原根目录,用于计算相对路径 $q = 'D:\_DUP_QUARANTINE\2024-06' # 隔离区,按批次建目录 New-Item -ItemType Directory -Path $q -Force | Out-Null $moved = foreach ($row in (Import-Csv -LiteralPath $csv)) { $old = $row.Path if (-not (Test-Path -LiteralPath $old)) { continue } # 已被前一条处理过就跳过 $rel = $old.Substring($root.Length).TrimStart('\') # 相对路径 $new = Join-Path $q $rel New-Item -ItemType Directory -Path (Split-Path $new) -Force | Out-Null Move-Item -LiteralPath $old -Destination $new -Force [pscustomobject]@{ Old = $old; New = $new; Bytes = $row.Size } } $moved | Export-Csv 'D:\dedup\moved_2024-06.csv' -NoTypeInformation -Encoding UTF8 "本次隔离: $($moved.Count) 个文件"

$old.Substring($root.Length)是整段的核心:它把绝对路径裁成相对路径,这样隔离区里会自动复现原来的目录树,三十天后恢复只要反向Move-Item就行。TrimStart('\')处理根目录结尾带不带反斜杠的差异,少这一句,Join-Path之后会出现双反斜杠。

if (-not (Test-Path ...)) { continue }是防守:清单里偶尔会出现同一个文件被两条记录引用(工具分组时的边界情况),第一条已经把它挪走了,第二条会直接报错,跳过就好。

moved_2024-06.csv这份映射表是唯一的恢复依据,它比隔离区里的文件本身还重要。我的习惯是把它和隔离区放在同一个盘,另外再往网盘或者另一块盘复制一份——只有一份的话,盘一挂就彻底没了。

阶段二是三十天后删隔离区。这一步可以用计划任务,也可以在日历上挂一条提醒。不管哪种方式,删之前再跑一次磁盘占用统计,确认隔离区确实占了你预期的空间,再整个目录删掉。

5. 避坑清单:五个让我当场停手的现场

下面这五条,每一条我都是真金白银撞出来的。按“现象 → 原因 → 解决”写,方便你对照自己的现场。

5.1 扫描阶段的三类误判

第一类:结果里出现成百上千个“重复”的 0 字节文件。

现象:扫描结果第一页全是空文件,一组几十个,路径五花八门,有的甚至在系统目录里。 原因:0 字节文件的大小全部相同,天然落进同一个候选桶,内容校验也必然通过——空内容和空内容当然一样。工具没有错,是参数没设。 解决:最小文件大小设成 1KB(或者第一遍直接 1MB)。空文件是很多程序的占位标记,删掉之后某些功能会莫名其妙失效,这类文件永远不该出现在候选列表里。

第二类:明明看着是同一张照片,工具却说不是重复。

现象:两个文件夹里各有一份“同名同图”的照片,肉眼完全一样,扫描结果里却找不到。 原因:这个工具做的是字节级比对,不是视觉相似。同一张照片经过一次微信转发、一次压缩、一次加 EXIF 水印,字节就完全不同了;甚至只是改了个拍摄时间字段,大小都会变。你心里的“重复”和工具定义的“重复”不是一回事。 解决:先接受这个边界。真正要做“视觉重复”得用感知哈希那一路,是另一套工具链,不在这个工具的能力范围内。实用做法是:字节级的交给它,视觉级的按“文件名 + 尺寸”先分组人工看,或者用“文件名 + 大小”档位粗筛一遍,把候选缩小到几百个再人工判断。

第三类:扫了半个小时,一个结果都没有。

现象:目录明明很大,扫描很快结束,结果 0 组。 原因通常是三个:一是路径权限,某些目录当前账户读不进去,遍历直接跳过,而且不报错;二是超长路径,嵌套深一点的归档目录很容易超过系统默认的路径长度限制,遍历在那里断掉;三是比较方式选了“仅文件名”,而你的重复项名字都不一样。 解决:先把比较方式改成“大小 + MD5”重扫一次;再用第 2 章那段体检脚本对同一目录跑一遍,看脚本数出来的文件数和工具显示的是否接近,差得多就是权限或路径问题。权限这块,最简单的办法是把目标目录复制到一个路径短、权限干净的位置再扫。

5.2 处置阶段的两个陷阱

第四类:勾了“保留最新版本”,结果唯一的原始档被删了。

现象:一组文件里,工具自动勾选了“保留修改时间最新”的那份,其余标记删除。删完之后才发现,被保留下来的那份其实是某个同步工具在昨天重新写过时间戳的副本,真正的原始档是三年前那份。 原因:修改时间不是可靠的身份标识。跨盘复制、解压、同步盘回写、某些编辑器保存,都会改写 mtime。而“最新”这个规则默认了时间戳可信,这个前提在很多场景下不成立。 解决:把自动规则从“保留最新”换成“保留路径最短”或“保留最外层目录”,路径结构比时间戳稳定得多。更进一步的做法是:先用自动规则勾一遍,然后手动取消所有跨目录的组——同目录内的自动勾选基本安全,跨目录的一律人工确认。这一条救过我两次。

第五类:硬链接做完,磁盘空间没变,备份体积还涨了。

现象:批量建完硬链接,fsutil抽查几条都正常,但磁盘可用空间几乎没动;下一次备份跑完,备份体积比上一轮还大。 原因有两层。第一层:如果删除重复项和建硬链接的顺序搞反了,或者某些文件因为跨卷失败,空间自然不降。第二层更隐蔽:备份软件可能不识别硬链接,在读取时把每个目录项都当成独立文件展开成多份,结果源端省了空间,备份端反而膨胀。 解决:先确认是不是跨卷——跨卷的New-Item -ItemType HardLink会直接报错,不会静默失败,但如果你把错误吞掉了就看不出来。用fsutil hardlink list抽查至少 3% 的条目,确认链接列表里有多条路径。备份这一层,要么换一个明确支持硬链接的备份方式,要么就干脆不用硬链接,走“移动隔离 + 定期删除”的路子——省心得多。

6. 让整套流程可回滚:一份基线清单和三次回验

去重做到最后,真正的技术含量不在“删得快”,而在“删错了能退回去”。我现在的做法是给每一次去重都留一条完整的证据链:动手之前先记录一份基线,动完手逐条回验,任何一条对不上就立刻停手。

基线只记“我打算保留的那批文件”——也就是工具结果里没有被打勾的那些。这批文件是这次操作之后必须原样存在的全集,它们的哈希值就是验收标准。

# 第 1 步:扫描完成、动手之前,给"保留集"做哈希基线 $keep = Get-Content -LiteralPath 'D:\dedup\keep.txt' # 一行一条绝对路径 $keep | Where-Object { Test-Path -LiteralPath $_ } | ForEach-Object { [pscustomobject]@{ Path = $_ Bytes = (Get-Item -LiteralPath $_).Length Hash = (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash } } | Export-Csv 'D:\dedup\keep_baseline.csv' -NoTypeInformation -Encoding UTF8 "基线条目: $((Import-Csv 'D:\dedup\keep_baseline.csv').Count)"
# 第 2 步:隔离/删除完成之后,逐条回验,只输出对不上的 $bad = Import-Csv -LiteralPath 'D:\dedup\keep_baseline.csv' | Where-Object { if (-not (Test-Path -LiteralPath $_.Path)) { return $true } # 文件不见了 $now = (Get-FileHash -LiteralPath $_.Path -Algorithm SHA256).Hash return $now -ne $_.Hash # 内容变了 } $bad | Export-Csv 'D:\dedup\verify_failed.csv' -NoTypeInformation -Encoding UTF8 "待复核: $($bad.Count) 条 / 基线共 $((Import-Csv 'D:\dedup\keep_baseline.csv').Count) 条"

第一段里Where-Object { Test-Path }那个过滤不能省——keep.txt是从界面里复制出来的,难免混进空行或者已经手工挪走的路径,Get-FileHash碰到不存在的路径会直接抛异常,整条管线就断了。第二段的return写法在Where-Object脚本块里是提前返回,比层层嵌套的if清楚得多。

这套校验的成本要看保留集的规模。几万个文件的全量 SHA256 是很重的,实际用的时候我会分档:保留集小于两万条就全量跑;超过两万条就只校验“这次操作涉及到的目录”,其余目录靠文件数 + 总字节比对兜底。文件数和总字节这两个数字,在Get-ChildItem之后一行就能算出来,先跑它做粗筛,不通过再上全量哈希。

第三个动作是我坚持最久的一个:隔离区满三十天准备删除之前,先跑一次“抽查恢复”。从隔离区随机挑十个文件,按moved_*.csv里的映射手工放回原路径,确认能正常打开,再删。这一步听起来多余,但它验证的是映射表本身有没有问题——映射表错了,你的隔离区就是一堆没有归属的孤儿文件。

注意:基线清单、移动映射表、隔离区目录,这三样必须落在不同的物理位置至少两份。它们加起来通常只有几 MB,却是整套流程唯一的后悔药。

从那以后我每次做批量去重,都强制走一遍“基线 → 隔离 → 回验 → 三十天 → 删”这五步,中间任何一步的结果和预期不符就停下来重新看参数,绝不在同一个会话里连续删两批。慢是真慢,但再也没出现过找不回来的情况。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进