博客选题池被 reconcile 清空:唯一可信源的维护教训
本文最后更新于 2026年8月30日 凌晨
一条 reconcile 命令,把我攒了半年的 47 条博客选题全清了。根因不是脚本写错,而是我用两个源维护同一份数据,还让同步脚本只认其中一个。Git 历史救回了数据,但这个问题值得好好复盘:唯一可信源到底怎么维护。
事故现场
选题池放在博客仓库的 _data/topics.yml,Hexo 站点用它在「选题池」页面渲染所有候选选题。
某天我跑了这条命令:
1 | |
再打开 topics.yml,里面变成了空数组。CI 已经把空文件推到了 GitHub。
当时文件里的数据:
| 项目 | 值 |
|---|---|
| 清空前选题数 | 47 |
| 清空后选题数 | 0 |
| 只在本地存在、Issues 里没有的选题 | 12 |
| 恢复方式 | git 历史 |
12 条选题没有备份在 Issues 里。如果不是我习惯每次提交前 commit,这 12 条就真的没了。
根因:两个源,同步写成覆盖
选题池的维护历程有点绕。
最早我只在 _data/topics.yml 里手工加选题。后来想开放读者投稿,就在 GitHub Issues 里开了选题收集。Issue 和 YAML 都能加选题,两边开始不一致。
于是我写了 reconcile 脚本,想做「双向同步」。但实际代码写成这样:
1 | |
两个致命点:
① catch(() => []) 把错误变成了「空成功」。
我的 GitHub token 过期了,listIssues 返回 401。catch 直接把 401 吞掉,转换成了空数组。脚本拿到空数组,以为「Issues 上一个选题都没有」。
② 双向同步写成了单向覆盖。
我以为自己在做合并,实际代码是「Issues 覆盖 YAML」。Issues 为空 → YAML 也被写成空。47 条选题,一次写入,归零。
这个 bug 的本质:我嘴上说「两个都是源」,代码里却只认 Issues 一个源。当这个源返回空结果,所有本地数据都成了陪葬。
修复:回到唯一可信源
事故之后我把同步模型彻底简化了。
先明确:选题池的唯一可信源是本地 _data/topics.yml。
理由:
- 支持 git 历史,误删可回滚
- 支持离线编辑,不需要网络
- 我实际维护选题时,90% 的操作都发生在本地
GitHub Issues 降级为只读镜像:reconcile 只做「YAML → Issues」的单向推送,方向永远不变。Issues 上读者提的新选题,通过 issue 模板走到 GitHub Actions,由机器人开 PR 合入 YAML,而不是直接写进 Issues 再反向同步。
修复后的核心逻辑:
1 | |
注意两点变化:
- 空结果保护:本地数据低于阈值直接抛异常,不执行任何写操作
- 只增不删:同步只创建缺的 Issue,不碰已有的,更不会删
护栏:四条规则
这次事故值 47 条选题,换回来四条规则。现在我写的所有同步脚本都遵守:
规则一:空结果默认是异常。
任何拉数据的函数,返回空列表要么是「真的没有」,要么是「调用失败」。代码里必须区分。失败就抛异常,不要 .catch(() => [])。空结果应该让脚本停下来,而不是继续往下写。
规则二:同步只允许一个源。
双向同步听起来美好,实际是两套写入口、两套冲突逻辑。只保留一个源,其他全是镜像。镜像可以被重建,源不能被覆盖。
规则三:破坏性写入前先备份。
写入前打 tag 的成本几乎为零:
1 | |
一条命令。真出事的时候,这就是救命稻草。
规则四:数量突变要拒绝。
自动化脚本应该知道自己管理的数据大概有多少。47 → 0 这种数量级的跳跃,直接挂掉让人类来查,而不是老实执行。
教训
复盘结束时,最值钱的一句话是:维护唯一可信源,不是选一个地方存数据,而是让代码结构上也只有一个写入方向。
我当时以为「两个源」只是多一个入口,顶多数据不一致,手动合并一下就好。实际上,多源并存会让同步逻辑复杂到出 bug 而不自知。
现在我的选题池回到最简单的模型:本地 YAML 是源,Issues 是镜像,reconcile 只单向推送。运行三个月,没再出过问题。Git 历史里那个空 topics.yml 还在,每次提交前看一眼,提醒自己别再把 401 当成功。
参考文献