在生产环境批量删除几千个文件:试运行、备份、恢复与停止点
一次线上对象存储的批量清理:几千个文件、十几 GB,删了用户就看不到。记录我们怎么把它做成试运行、执行、恢复、补登记四个独立模式,用口令和数量上限防误操作,并给执行的 AI 写一份带停止点的手册。
这篇记录一次批量删除用户文件的过程(Go 后端,文件放在对象存储)。
背景是需要清理一批旧文件:某个时间点之前上传或生成、并且没有被用户收藏进素材库的文件统一删除,事先通知了用户。规模是几千个文件、十几 GB。
删除用户数据是最不能出错的操作之一:删多了,用户的作品就没了;删少了,问题没解决;删到一半出错,数据库和存储对不上。这篇主要讲怎么把一次性的高风险操作做得可控。
工具开发和执行都是在 Claude Code 里协作完成的,文中的”我们”指我和 Claude Code。
一、先把规则写死,再写代码#
开始写代码之前,先把规则和用户确认清楚,并写进文档”不再讨论”:
- 范围:只处理两个用户文件前缀;其他前缀只统计、不动。
- 截止时间:按对象存储里对象的 LastModified(写入存储桶的时间)判断,和文件自身的日期无关。
- 保留:被任何用户收藏进素材库的文件,连同它的视频首帧和缩略图变体一起保留。
- 不改画布数据:画布里引用这些文件的节点保留,只是素材会加载失败(这一点事先告知了用户)。
- 所有用户一视同仁,包括管理员。
为什么要强调”以存储桶为准”:数据库里的登记记录并不完整,有些历史文件根本没有登记。以数据库为准会漏删,以存储桶为准才能覆盖实际存在的所有文件。
二、工具拆成四个独立模式#
清理工具做成后端二进制的一次性命令行模式,和服务本身用同一个镜像,但单独 docker run 执行,不影响线上服务:
| 模式 | 做什么 | 会不会写 |
|---|---|---|
--media-purge-dry-run | 读数据库、列存储桶,算出完整计划 | 只读,结果写成 TSV |
--media-purge-execute | 按计划备份并删除 | 写,需要口令和数量上限 |
--media-purge-restore | 从备份恢复 | 写,需要口令 |
--media-purge-backfill | 给保留下来但没登记的文件补登记 | 写,需要口令 |
一开始补登记是放在执行模式里一起做的。后来拆成独立模式,原因是:补登记依赖另一项功能(引用识别要先支持旧路径格式),两者的上线时机不一样。一个模式只做一件事,就可以分别执行、分别验证、分别重跑。
1. 分类逻辑是纯函数#
最核心的”这个文件删不删”做成一个纯函数,输入是存储桶对象列表、登记记录、素材库收藏、生成任务(用来推断未登记文件的归属,并把视频和它的封面关联起来),输出是一份计划:
// 纯函数:不依赖存储和数据库,可以直接写单元测试覆盖各种边界
func classifyPurge(in purgeInputs) purgePlan {
for _, obj := range in.bucketObjects {
switch {
case !inScope(obj.Key):
plan.outside = append(plan.outside, obj) // 范围外,只统计
case !obj.LastModified.Before(in.cutoff):
plan.keepRecent = append(plan.keepRecent, obj)
case in.inAssetLibrary(obj.Key) || in.isVariantOfKept(obj.Key):
plan.keepAsset = append(plan.keepAsset, obj) // 收藏的文件及其变体
default:
plan.delete = append(plan.delete, obj)
}
}
return plan
}go(上面是示意,实际的判断条件更多。)
这样”规则对不对”可以完全在单元测试里验证,不需要真实的存储桶。
2. 试运行输出”能被人看懂”的报告#
试运行不只输出一个数字,而是:
- 删除、保留、补登记三份清单(TSV,每行一个文件:key、大小、LastModified、归属用户)。
- 汇总:每类的数量和 GB。
- 每个用户清理后的用量,以及会不会因为补登记而超出配额。
- 范围外的前缀统计(用来确认没有遗漏)。
3. 执行模式:口令、数量上限、先备份后删除#
docker run --rm --env-file <env> \
-e MEDIA_PURGE_CONFIRM=delete-<轮次> \
-e MEDIA_PURGE_MAX_DELETE=<试运行核对过的数量> \
-e MEDIA_PURGE_OUT_DIR=/out -v $D:/out \
<镜像> --media-purge-executebash几道保险:
- 确认口令:必须设置
MEDIA_PURGE_CONFIRM=delete-<轮次>。手滑执行错命令也不会删任何东西。 - 数量上限:执行时会按同样的规则重新计算一遍,如果计划删除的数量超过
MEDIA_PURGE_MAX_DELETE(就是试运行时核对过的数字),直接拒绝。防止试运行和执行之间数据变化导致删多。 - 执行前先写出清单存档。
- 逐个文件:先备份,再删除:
对每个要删除的文件:
1. 在桶内复制到 purge-backup-<轮次>/<原 key>
2. 校验备份的大小和原文件一致
3. 删除原文件
4. 数据库里已登记的记录和变体标记为 deleted
备份失败 → 跳过,不删plaintext- 可重复运行:已经删除的文件不会再出现在清单里,所以中途失败可以直接重跑,用剩余数量作为新的上限。
- 每个文件的结果写进
execute-log.tsv,汇总写进execute-summary.txt。
桶内复制(服务端 copy)不经过我们的机器,速度快,也不占带宽。
4. 恢复模式:以备份为准#
万一误删,恢复模式以备份目录为准:原位置缺失的文件复制回去并校验大小,对应的数据库记录从 deleted 改回 active。可以用 MEDIA_PURGE_RESTORE_MATCH=<子串> 只恢复某个用户或某个文件。
这个模式在执行前就写好、测试好了。恢复方案必须在删除之前准备好,而不是出事之后再去想。
三、三重核对#
截止时间按 LastModified 判断,但执行前我们用三个独立来源交叉核对:
- 对象的 LastModified;
- 对象 路径里的日期(文件 key 里带有上传日期);
- 数据库登记记录的 created_at。
三者应该基本一致。如果某批文件 LastModified 很旧、路径日期却很新,说明可能有迁移或复制改写过时间,要停下来查清楚。
四、给执行的 AI 写一份手册#
实际执行由 Claude Code 按手册操作。我写了一份执行手册,开头就是用户的开工提示词和停止点:
必须停下来等用户确认的点只有两个:试运行结果汇报后、执行完核对汇报后。任何一步出现”中止条件”,也立即停下汇报。
手册的结构:
- 背景与已定规则(不要重新讨论):防止 AI 在执行时”优化”规则。
- 只读预检:数据库结构版本、有没有正在进行的删除任务、磁盘余量、近期有没有其他部署或操作在进行。
- 只读试运行并和基线对比:之前已经跑过一次试运行作为基线。中止条件写成具体的数字,例如”删除数比基线多出 50 个以上""保留数比基线少 50 个以上""出现了备份目录(说明已经执行过)“。满足任何一条就停下汇报原因。
- 执行(用户确认后)。有删除失败时:原因明确且可以重试(网络、存储临时错误)就用剩余数量重跑;原因不明就停下。
- 核对:删除数量和 GB、失败数、抽查几个文件确认确实已删除、再跑一次试运行确认计划为空、近期有没有 5xx。
- 误删恢复:只在用户要求时执行。
这里的经验是:中止条件要写成可以机械判断的条件,比如”多出 50 个”,而不是”如果数量明显异常”。前者 AI 和人都能稳定执行,后者每次判断都可能不一样。
五、结果#
实际执行:删除 几千个文件、十几 GB,失败 0。
之后:
-
备份保留了一周左右,确认没有用户需要恢复之后,先空跑核对(数量、字节数、数据库和画布中的引用都为 0,存储桶没有开启版本控制),再删除备份。
-
原计划还有一轮清理(覆盖规则上线前过渡期产生的文件),为此把截止时间和轮次改成了参数:
MEDIA_PURGE_CUTOFF必填,并且晚于当前时间直接拒绝;MEDIA_PURGE_ROUND决定备份前缀和确认口令(delete-<轮次>、restore-<轮次>),第二轮的备份和恢复不会碰到第一轮的备份。
改参数,不改规则。分类规则和素材库保护逻辑一行没动。
六、收获#
- 高风险操作拆成”只读计划”和”执行”两步,执行时按相同规则重新计算,并用”计划数量上限”把两步绑定在一起。
- 确认口令 + 数量上限 + 先备份后删除,三道保险成本都很低。
- 恢复方案要在删除之前写好、测好。
- 核心规则做成纯函数,让”删不删”可以被单元测试完整覆盖。
- 以实际存在的数据为准(存储桶),而不是以可能不完整的索引为准(数据库)。
- 给 AI 的执行手册要有明确的停止点和可量化的中止条件,并且把”已经定下的规则”写成”不要重新讨论”。
- 一次性工具也值得认真写:可重复运行、每一步留日志,出问题时才有据可查。