一次备份把磁盘打满:ZCode 3.16 迁移备份复盘

现象:服务每 10 秒报一次 ENOSPC

监控先报的是磁盘,不是应用。登录后看到 supervisor 日志在刷同一行:

[server-supervisor][trace:failed to persist status snapshot]
Error: ENOSPC: no space left on device, write

磁盘被一个目录吃掉了:

du -sh ~/.zcode/server/migration-backups
ls ~/.zcode/server/migration-backups | wc -l

结果是 324 个目录、每个约 176MB,增长约 15GB/天。每个目录里只有两个文件:

  • db.sqlite:约 183MB(会话主库)
  • tasks-index.sqlite:约 2.7MB

第一反应:是不是备份策略太保守

看到 migration-backups,直觉是「保留份数配置错了」。但先做了一步验证:

find ~/.zcode/server/migration-backups -name backup.json | wc -l
# 0

324 个目录,一个元数据文件都没有。而包里正常的备份路径会写 backup.json 并调用 pruneBackups()。这说明问题不在保留策略——这些目录根本没走带清理的那条代码路径。

证据链:迁移一直在跑,而且从不收敛

查迁移台账:

SELECT status, COUNT(*) FROM workspace_migration_ledger GROUP BY status;
-- completed | 326

全部 completed,看起来「都成功了」。但按路径分组:

SELECT workspace_path, COUNT(*)
FROM workspace_migration_ledger
GROUP BY workspace_path;

两条记录分别出现 163 次和 161 次,指向的是同一个物理目录的两个历史路径。

再看别名表:两条 alias 记录映射到不同的 canonical 身份,而且两边的 variant 列表都包含对方的键。于是每次解析 workspace 都会判定「需要合并」→ 全量复制两个 sqlite → 写入一条 completed 记录 → 但状态永远不收敛,下一次继续。

completed 不等于收敛。 这是整个问题的核心。

代码定位

3.16.x 包内的 SourceServerWorkspaceAliasRepo:

  • 迁移目录 backupRoot/<uuid> 直接复制 db.sqlite 与 tasks-index.sqlite
  • 这条路径没有调用任何清理函数
  • 包内搜 maxBackups / backupRetention / 相关环境变量:无结果,也没有可配置的保留策略

换句话说:只要触发条件成立,它就会每触发一次写一份 176MB 的完整副本,永不回收。

止血

先封顶增长,再谈修复:

  1. 每 5 分钟跑一次清理脚本,保留最新 2 份,其余删除,日志留痕
  2. 停掉泄漏源服务(确认不影响在用的 CLI 和其他入口后再停)
  3. 磁盘占用从 85% 降到 29%

清理脚本只是止血。它能保证磁盘不炸,但不解决「为什么每天都在写」。

根治方向

  • 统一 workspace 身份:同一物理目录只允许一条 canonical 记录,路径变更走别名而不是新建身份
  • 解析必须收敛:合并完成后要能判定「已无差异」,否则状态机永远在原地打转
  • 备份统一入口:所有写备份的代码路径共用同一套保留策略与元数据,禁止绕开
  • 迁移幂等:相同输入重复执行,不产生新的副作用

另外还挖出一个连带问题:项目表的唯一索引没有排除软删除行,旧项目占着名字导致改名失败,界面里出现 项目名 (2)。这类「墓碑数据参与唯一约束」的坑,清理时很容易被忽略。

可以复用的检查清单

  1. 每个写备份的路径,是否都有保留策略和统一入口?
  2. 备份目录是否有元数据(时间、来源、版本)?没有元数据就无法判断来源
  3. 状态机的 completed 是否等于「不再需要执行」?如果不是,必须有终态校验
  4. 迁移/同步逻辑是否幂等?重复执行会不会持续产生副作用?
  5. 磁盘告警是否看增长率,而不只是剩余百分比?
  6. 临时清理脚本是否有日志、有上限、有退出条件?

备份本身不是问题,没有边界的备份才是。任何「复制一份完整数据」的操作,都应该同时回答三个问题:放哪、留几份、什么时候删。