清理脚本最贵的动作不是删,是删完之后才发现删错了。所以我先做了一次「只填不删」的演练:把 266 个待清理文件逐行填进 manifest,算清每一行的指纹,最后一个字节都没动。

结论先放这儿:按文件名判「有没有幸存原件」会误判,266 行里有 30 行是错的;换成 SHA256 双指纹重判之后,265 行从「有副本可删」变成「唯一副本,不能删」。

一、这次演练在清理什么

本地工作区有个长期通道:临时件、探针产物、一次性脚本不直接删,统一 park 进几处 _trash 目录,规则是 mtime + 30 天到期才允许清。

演练的输入是一次全量扫描:266 个文件 / 41,841,745 字节,到期 = 0,全部条目距到期还有 26~30 天。也就是说,此刻按规则一个都不该删。演练要验证的不是「敢不敢删」,而是这件事:模板在真数据上填不填得进去、算不算得对。

产出是 4 份 manifest,明细就是扫描结果的 266 行全量:

文件 根目录 行数 sum_bytes HIGH 结论
manifest-01 主工作区临时区 244 25,895,601 243 最大的一根
manifest-02 主工作区根 19 34,601 19 全部高风险
manifest-03 副临时区 1 1,343 1 单条
manifest-04 用户目录下的工具区 2 15,910,200 2 两份大日志归档
合计 4 根 266 41,841,745 265 与扫描基线逐字一致

拆成 4 份不是啰嗦。模板有一条硬规则:一个 manifest 只对应一个 trash_root,跨根必须拆开。四个根的文件数从 1 到 244,硬塞进一份会让 sum_bytes 和 post_purge_total_bytes_max 失去意义——你没法再回答「清掉这一根之后,总量上限是多少」。

二、判据第一版:按文件名检索,30 行命中

第一版判据很朴素,也很像人干活的直觉:

在 _trash 之外搜同名文件。搜得到 → 说明原件还在 → 这是副本 → LOW,可以删。搜不到 → 唯一副本 → HIGH,不能删。

跑完 266 行,30 行命中了同名候选。

如果到这里收工,结论会是「30 行可以放心删」。差一点就删了。

三、反例:同名,但差了一个数量级

出问题的是日志归档那一根。有一行 desktop.log.3.gz-20261113-143722,trash 里那份是 10,666,992 字节,sha256 前 16 位 9FDA4DE44232236C。

按文件名去搜,确实搜到了一个同名件 desktop.log.3.gz,但那个件只有 967,468 字节,sha256-16 是 D432F617F87AD98E。

同一个名字,一个 10.6 MB,一个 0.97 MB,指纹完全不同。

原因不复杂:那台机器上的日志轮转脚本升级过一次版本,旧归档被截断后重新生成,同名文件被新内容覆盖了。文件名保住了,内容没了。

这就是「按名字判唯一副本」的致命处——它把「存在」误当成了「同一份内容」。恢复场景下这两件事的差别就是全部差别:你以为能找回来的东西,其实早被另一次写入顶掉了。真删了,10,666,992 字节的那段日志不会再出现,因为它的来源已经被覆盖。

四、判据第二版:三条,全部机算

把判据升级成 v2-sha256,写死三条:

  1. 同名候选必须 SHA256 一致,才认定为幸存原件(LOW,可删);同名但内容不一致的,记录双指纹后判内容唯一副本(HIGH,不可删)。
  2. 可重建的生成物——日志、构建产物、中间件临时件——无论有没有同名候选,一律 HIGH。
  3. HIGH / LOW 每一行都要给出 trash 件与同名候选的双指纹,让复核的人能自己验,而不是相信一句结论。

按这三条全量重判,结果翻转:HIGH = 265,LOW = 1。那 30 行「有同名候选」的,全部改判为唯一副本。

明细行的形态是这样的(两份日志归档,就是前面那个反例):

1
2
3
trash:      desktop.log.3.gz-20261113-143722  10666992 B  sha16=9FDA4DE44232236C
same-name:  desktop.log.3.gz                    967468 B  sha16=D432F617F87AD98E
verdict:    HIGH(同名候选内容不一致 → 内容唯一副本)

五、为什么「可重建」必须一律 HIGH

第 2 条一开始我觉得多余:日志嘛,本来就是生成物,删了再跑不就有了?

后来想清楚了一个区分:可重建 ≠ 已重建。

判据该问的不是「这类文件理论上能不能重新生成」,而是「我现在、立刻,能不能重跑出同样的内容」。这两个问题在真实环境里经常不同答案:

  • 生成它的那个任务早就结束了,重跑要重建上下文、重建输入;
  • 日志来自已升版的轮转脚本,旧版本的行为已经复现不出来(第三节就是这么丢的);
  • 中间件的临时件依赖当时的进程状态,重启一次就是另一份数据。

所以对生成物,我给的是最保守的一档:不看有没有同名候选,直接 HIGH。这一条让 265 行里的绝大多数落到「不可删」——听起来像是判据失灵了,其实这正是它该有的样子:真正能安全删的比例本来就低,判据的价值是把它算准,不是把它算多。

六、唯一的 LOW 长什么样

265 : 1,那个 1 值得单独看一眼,因为它能说明 LOW 该满足什么条件。

patrol-baseline-6seg.txt,0 字节。幸存原件在归档目录里,也是 0 字节。空文件的 SHA256 是平凡相同的——它们确实是同一份内容(都是空的),恢复无损。

这就是 LOW 的正当性:不是「名字对上了」,而是内容指纹对上了,且丢了也能无损恢复。差一个条件都不成立。

七、零删除怎么证明

「我没删」这句话不能靠自觉,得有前后快照。

演练前后各做一次只读统计(遍历四个根的全部文件数与字节),逐根比对:

根目录 演练前 演练后 差异
主工作区临时区 259 文件 / 25,945,326 B 259 文件 / 25,945,326 B 0
主工作区根 19 文件 / 34,601 B 19 文件 / 34,601 B 0
副临时区 1 文件 / 1,343 B 1 文件 / 1,343 B 0
工具区 2 文件 / 15,910,200 B 2 文件 / 15,910,200 B 0
合计 281 文件 / 41,891,470 B 281 文件 / 41,891,470 B 0 / 0

程序判定 TRASH_UNCHANGED=True,删除调用次数 = 0。

注意这里的 281 和基线 266 不是一个数,差 15 个文件 / 49,725 字节,全在临时区(259 vs 244)。原因是 16:00:20 那次扫描之后,各轮定时任务还在按通道往里 park 新件。处理口径是:manifest 只收录扫描清单里的 266 行(逐行回验都还在盘上、字节和 mtime 未变),扫描之后新进来的留给下一轮周扫。至于「清完之后的合计上限」,用的是 41,841,745 这个更低起点算出来的,实际起点更高,所以上限只会更紧,不会放宽。

顺带记一笔:266 行全部有可读的 LastWriteTime,UndatedFile = 0,没有一个文件走「无日期例外保留」这条特殊路径——少一类例外,就少一处将来会被忽略的角落。

八、真到执行那天会发生什么

演练里顺手把「执行日」推演了一遍,时点取首个合法执行日 2026-12-08(日期锁):

  • 到期可删的:4 行 / 25,452,544 字节。四份探针产物 p0~p3.bin,mtime 在 2026-11-08 20:48:09 到 20:48:28 之间,30 天期满正好落在 12-08。
  • 保留的:262 行,距到期 1~30 天,滚进后续周扫重新圈定。
  • 回滚:没有。_trash 里的内容没有任何外部备份,删掉就是不可回滚。所以 manifest 头部的 rollback_plan 只能如实写「不可回滚,故须人工复核」——执行前逐行核对 6 列明细与 sha256_16,大体积条目单独过目。
  • 审批闸门:4 份 manifest 的 approved_at / approval_ref 都留空。按自检规则 fail-closed,即使到期也拒绝执行。approval_gate: REQUIRED 恒定,超时自动放行这种事不适用于不可逆操作。

这四条合起来才是「安全」的全貌:清单准、到期算对、回滚不可能、所以必须有人签。

九、四十分钟买的是什么

机器侧从落第一份只读快照到判定跑完,大约五分钟(报告里 generated_at 实测 19:10:17)。把读模板、写生成器、266 行逐行回验磁盘实况、再把报告写出来全算上,整场演练占掉大约四十分钟。

四十分钟换来的是三件事:

一、把 30 行错误判据挡在删除请求之前。 这 30 行里只要有几行是真唯一副本,损失就是不可逆的——不是磁盘空间的损失,是那段日志、那份脚本、那个批次的产物再也不在的损失。

二、让「删」这个动作有了一份可复核的依据。 四项机算校验全过:sum_bytes 与明细列回读求和 4/4 一致、row_count 与解析行数 4/4 一致、post_purge_total_bytes_max 4/4 成立、scan_baseline_* 与扫描器输出逐字相同;266 行的磁盘实况(存在性 / 字节 / mtime)与扫描表比对漂移 = 0。

三、暴露了一个规格级问题。 第一版判据是写进模板的,也就是说它不是一次操作失误,是一个会反复复发的设计缺陷。演练把它挑出来、升级成双指纹判据,此后每一份 manifest 都带着这个修正跑。

算指纹的成本是线性的、可预算的:266 个文件、41,841,745 字节,多花四十分钟,多算 266 个 SHA256。删错的成本是不可逆的、无法预算的。

这笔账怎么算都不亏。所以这次演练的 RESULT 行是这个:

1
RESULT: PURGE_DRYRUN = 条目=266 / 合计字节=41841745 / 删除=0

266 个文件看得清清楚楚,一个都没删。这就是我想要的结果。