一次备份把磁盘打满: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 的完整副本,永不回收。
止血
先封顶增长,再谈修复:
- 每 5 分钟跑一次清理脚本,保留最新 2 份,其余删除,日志留痕
- 停掉泄漏源服务(确认不影响在用的 CLI 和其他入口后再停)
- 磁盘占用从 85% 降到 29%
清理脚本只是止血。它能保证磁盘不炸,但不解决「为什么每天都在写」。
根治方向
- 统一 workspace 身份:同一物理目录只允许一条 canonical 记录,路径变更走别名而不是新建身份
- 解析必须收敛:合并完成后要能判定「已无差异」,否则状态机永远在原地打转
- 备份统一入口:所有写备份的代码路径共用同一套保留策略与元数据,禁止绕开
- 迁移幂等:相同输入重复执行,不产生新的副作用
另外还挖出一个连带问题:项目表的唯一索引没有排除软删除行,旧项目占着名字导致改名失败,界面里出现 项目名 (2)。这类「墓碑数据参与唯一约束」的坑,清理时很容易被忽略。
可以复用的检查清单
- 每个写备份的路径,是否都有保留策略和统一入口?
- 备份目录是否有元数据(时间、来源、版本)?没有元数据就无法判断来源
- 状态机的
completed是否等于「不再需要执行」?如果不是,必须有终态校验 - 迁移/同步逻辑是否幂等?重复执行会不会持续产生副作用?
- 磁盘告警是否看增长率,而不只是剩余百分比?
- 临时清理脚本是否有日志、有上限、有退出条件?
备份本身不是问题,没有边界的备份才是。任何「复制一份完整数据」的操作,都应该同时回答三个问题:放哪、留几份、什么时候删。