博客选题池被 reconcile 清空:唯一可信源的维护教训

本文最后更新于 2026年8月30日 凌晨

一条 reconcile 命令,把我攒了半年的 47 条博客选题全清了。根因不是脚本写错,而是我用两个源维护同一份数据,还让同步脚本只认其中一个。Git 历史救回了数据,但这个问题值得好好复盘:唯一可信源到底怎么维护。

事故现场

选题池放在博客仓库的 _data/topics.yml,Hexo 站点用它在「选题池」页面渲染所有候选选题。

某天我跑了这条命令:

1
npm run reconcile

再打开 topics.yml,里面变成了空数组。CI 已经把空文件推到了 GitHub。

当时文件里的数据:

项目
清空前选题数 47
清空后选题数 0
只在本地存在、Issues 里没有的选题 12
恢复方式 git 历史

12 条选题没有备份在 Issues 里。如果不是我习惯每次提交前 commit,这 12 条就真的没了。

根因:两个源,同步写成覆盖

选题池的维护历程有点绕。

最早我只在 _data/topics.yml 里手工加选题。后来想开放读者投稿,就在 GitHub Issues 里开了选题收集。Issue 和 YAML 都能加选题,两边开始不一致。

于是我写了 reconcile 脚本,想做「双向同步」。但实际代码写成这样:

1
2
3
4
5
6
7
8
9
10
// scripts/reconcile.ts —— 坏的那版
import { writeFileSync } from 'fs'
import { listIssues } from './github'

async function reconcile() {
// 从 Issues 拉全部 open 选题
const issues = await listIssues('topics').catch(() => []) // ① catch 吞了错误
// 用 Issues 覆盖本地 YAML
writeFileSync('_data/topics.yml', yaml.stringify(issues)) // ② 单向覆盖
}

两个致命点:

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// scripts/reconcile.ts —— 修复后的单向同步
import { readFileSync, writeFileSync } from 'fs'
import { createIssues, listIssues } from './github'

const MIN_TOPICS = 10 // 阈值:低于这个数拒绝写入

async function reconcile() {
const local = readLocalTopics()

if (local.length < MIN_TOPICS) {
throw new Error(`本地选题只有 ${local.length} 条,疑似异常,中止推送`)
}

await backupWithGitTag() // 推送前打 tag

const remote = await listIssues('topics')
const existing = new Set(remote.map(i => i.title))
const missing = local.filter(t => !existing.has(t.title))

for (const topic of missing) {
await createIssues(topic) // 只增量创建,不删除,不覆盖
}
}

注意两点变化:

  • 空结果保护:本地数据低于阈值直接抛异常,不执行任何写操作
  • 只增不删:同步只创建缺的 Issue,不碰已有的,更不会删

护栏:四条规则

这次事故值 47 条选题,换回来四条规则。现在我写的所有同步脚本都遵守:

规则一:空结果默认是异常。

任何拉数据的函数,返回空列表要么是「真的没有」,要么是「调用失败」。代码里必须区分。失败就抛异常,不要 .catch(() => [])。空结果应该让脚本停下来,而不是继续往下写。

规则二:同步只允许一个源。

双向同步听起来美好,实际是两套写入口、两套冲突逻辑。只保留一个源,其他全是镜像。镜像可以被重建,源不能被覆盖。

规则三:破坏性写入前先备份。

写入前打 tag 的成本几乎为零:

1
git tag backup-$(date +%F) && git push --tags

一条命令。真出事的时候,这就是救命稻草。

规则四:数量突变要拒绝。

自动化脚本应该知道自己管理的数据大概有多少。47 → 0 这种数量级的跳跃,直接挂掉让人类来查,而不是老实执行。

教训

复盘结束时,最值钱的一句话是:维护唯一可信源,不是选一个地方存数据,而是让代码结构上也只有一个写入方向。

我当时以为「两个源」只是多一个入口,顶多数据不一致,手动合并一下就好。实际上,多源并存会让同步逻辑复杂到出 bug 而不自知。

现在我的选题池回到最简单的模型:本地 YAML 是源,Issues 是镜像,reconcile 只单向推送。运行三个月,没再出过问题。Git 历史里那个空 topics.yml 还在,每次提交前看一眼,提醒自己别再把 401 当成功。


参考文献

  1. GitHub REST API - List repository issues
  2. Octokit.js - GitHub REST API client for JavaScript
  3. Single source of truth - Wikipedia
  4. Hexo 文档

博客选题池被 reconcile 清空:唯一可信源的维护教训
https://normdist.com/2026/08/28/ND-20260828-002-blog-topic-pool-reconciled/
作者
小瑞
发布于
2026年8月28日
许可协议