【AI】两个仓库之间的数字对不上:构建期同步与它的三层兜底
症状:总数对得上,单项开始偏移
我的个人主页(lucky845.top)上有一块「站点档案」和一张「技术栈」柱状图,数字来自博客。
一开始这些数字是手写死的。博客那边发一篇新文章,主页纹丝不动——要我自己想起来去改。
我以为问题不大,直到某天做了一次逐项比对:
| 项 | 主页写死 | 博客真实值 | 状态 |
|---|---|---|---|
| 笔记 | 137 | 137 | 一致 |
| 分类 | 23 | 23 | 一致 |
| 标签 | 172 | 172 | 一致 |
| 归档页 | 12 | 12 | 一致 |
| Java | 74 | 75 | 少 1 |
| Spring Boot | 20 | 21 | 少 1 |
| Linux | 14 | 15 | 少 1 |
| 性能优化 | 13 | 14 | 少 1 |
这个结果很有意思:总数全对,单项开始漂移。
原因是那种「改完顺手更新一下总数」的惯性——加了一篇 Java 文章,你记得把 137 改成 138,但忘了把 Java 的 74 改成 75。
这就是手工同步的典型失效形态:不是全错,是错得刚刚好让你发现不了。
先想清楚:这不是「数字对不对」的问题
真正要解决的是:两个独立仓库(主页 / 博客)之间,数据在哪一点保持一致。
我的约束有三条:
- 主页是公开仓库,博客源码是私有仓库;
- 主页刚做完首屏优化(单文件 HTML 拆成了 Vite + Vue 3),不能为了几个数字把首屏拖回去;
- 博客可能临时不可达(部署中、证书、CI 抖动),任何情况下都不能让主页部署失败。
三个方案,否掉两个
方案 A:浏览器运行时抓取(否)
主页加载后 fetch 博客的接口。听起来最「实时」,但:
- 首屏多一次跨域请求,正好踩在刚修好的白屏问题上;
- 博客挂了或慢,主页就跟着挂或跟着慢;
- 用户白等一个「等数据才敢渲染」的区块。
运行时实时性换来的是用户端成本。 几个静态数字不值得。
方案 B:主页构建时读博客源码(否)
主页 CI 里把私有仓库 clone 下来自己算。问题是:
- 需要跨仓库凭据,还得维护;
- 主页要理解 Hexo 的目录结构和分类规则——把博客的实现细节泄漏到主页,博客一改结构主页就炸;
- 主页的构建时间从秒级变成分钟级。
耦合太重。
方案 C:构建期同步(选)
链路拆成三段,每段只做一件事:
博客 hexo generate
└─ 顺带产出 stats.json ← 博客自己最清楚自己的统计
↓ 静态文件
主页 npm run build
└─ 构建前抓 stats.json,写进 src/data/blog-stats.json
↓ 静态数据
Vite 打包进产物 ← 用户端零成本好处很直接:
- 网络请求只发生在构建机上,用户端零成本,不影响首屏;
- 统计口径由博客自己定义,主页只消费,不猜;
- 主页仓库里存着上一次成功的数据,天然就是缓存。
博客侧:把口径写进代码
博客是 Hexo。加一个 generator 就能在 hexo generate 时多吐一个文件:
hexo.extend.generator.register('stats', function (locals) {
const posts = locals.posts.toArray();
const perPage = Number(hexo.config.per_page) || 10;
const categories = locals.categories.toArray();
const tags = locals.tags.toArray();
const stack = tags
.map((tag) => ({ name: tag.name, count: tag.length }))
.filter((item) => item.count > 0)
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
.slice(0, 10);
return {
path: 'stats.json',
data: JSON.stringify({
posts: posts.length,
categories: categories.length,
tags: tags.length,
archivePages: Math.max(1, Math.ceil(posts.length / perPage)),
stack,
generatedAt: new Date().toISOString(),
}, null, 2),
};
});几个口径必须写在注释里,否则半年后没人知道 categories: 23 到底在数什么:
/**
* 口径说明(改之前先想清楚,主页的文案是跟着这些口径写的):
* posts 文章总数
* categories 分类节点总数(多级分类的每一层都算一个页面)
* tags 标签总数
* archivePages 归档页数(按 per_page 换算)
* stack 出现次数前 10 的标签
*/「分类 23 个」不是「23 个顶级分类」。 它是分类节点总数——多级分类的每一层都算一个页面。这个口径不改,是因为主页文案写的是「分类 23 个」,和 sitemap 里的 category 页面数完全对得上(24 个文件,其中 index 自身 1 个)。
这种「看起来像 bug 其实是口径」的地方,如果不写下来,下一个人一定会来「修」它。
主页侧:抓取 + 校验 + 三层兜底
主页构建前跑一个脚本,核心逻辑只有三件事:抓、验、退。
const SOURCE = process.env.STATS_URL || 'https://blog.lucky845.top/stats.json'
const TIMEOUT_MS = 10_000
const RETRIES = 2第一层:抓,带超时和重试
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
const response = await fetch(SOURCE, { signal: controller.signal /* ... */ })
if (!response.ok) throw new Error(`HTTP ${response.status}`)fetch 默认没有超时。不加 AbortController,博客无响应时构建会挂在那儿直到 CI 自己超时——那是最难排查的一种失败。
重试间隔递增(1.5s、3s),只重试 2 次。再多就是拿构建时间赌网络的稳定性。
第二层:验,结构不对就当没抓到
这一层是重点。「拿到了数据」和「拿到了对的数据」是两件事。
function validate(data) {
if (!data || typeof data !== 'object') return '不是对象'
for (const key of ['posts', 'categories', 'tags', 'archivePages']) {
const value = data[key]
// 0 视为无效:这个站常年上百篇笔记,任何一项为 0 都是数据不完整
if (!Number.isInteger(value) || value <= 0) return `${key} 不是正整数:${String(value)}`
}
if (!Array.isArray(data.stack) || data.stack.length === 0) return 'stack 不是非空数组'
const badItem = data.stack.find(
(item) => !item || typeof item.name !== 'string' ||
!Number.isInteger(item.count) || item.count <= 0,
)
if (badItem) return 'stack 里有非法条目'
if (typeof data.generatedAt !== 'string' || Number.isNaN(Date.parse(data.generatedAt))) {
return `generatedAt 不是合法时间:${String(data.generatedAt)}`
}
// 明显不合理的值说明博客那边口径变了,宁可不用
if (data.posts > 100_000 || data.stack.length > 50) return '数值超出合理范围'
return null
}其中有两条是踩出来的:
<= 0 而不是 < 0。 我一开始写的是「必须是整数」,结果 tags: 0 通过了校验,主页渲染出「0 个标签」。一个 137 篇文章的站不可能有 0 个标签——所以 0 必须当成「数据不完整」,而不是「一个合法的统计结果」。
上限防御。 posts > 100000 或 stack.length > 50 直接拒绝。这类检查平时永远不触发,触发的时候通常意味着博客那边口径变了(比如某次重构把 posts 算成了「页面数」)。宁可显示旧数字,也不要显示一个荒谬的新数字。
第三层:退,三级兜底
// 1. 抓 https://blog.lucky845.top/stats.json(博客构建时产出)
// 2. 抓不到就沿用 src/data/blog-stats.json 里上一次成功的数据
// 3. 连文件都没有才写 FALLBACK 常量关键顺序是:只在「能通过校验」时才覆盖本地缓存。
const cached = await readCached() // 读缓存时也走一遍 validate
if (cached) {
console.warn(`[stats] 抓取失败(${lastError}),沿用上次缓存:${cached.generatedAt}`)
return
}如果反过来——先写文件再校验——那么一次博客抖动就会把主页的缓存刷成空值,而且是静默的:构建成功、部署成功、数字归零。
最后还有一层兜底:脚本自身的异常也不能让构建挂掉。
main().catch((error) => {
// 走到这说明是脚本自身的 bug,同样不该让部署挂掉
console.warn('[stats] 脚本异常,跳过统计同步:', error)
})这类「装饰性数据」的同步,原则应该是:失败就降级,不要失败就阻塞。
实测:四种坏数据
写完不能只看代码。我造了四种坏输入,各跑一次:
| 输入 | 结果 |
|---|---|
| 博客 404 | 沿用缓存,构建成功 |
posts: "abc" | 退回兜底值 137 |
stack: [] | 数字仍更新,图表用兜底值 |
tags: 0 | 退回 172 |
第三条是设计取舍:stack 为空时数字更新、图表兜底。因为两者独立——总数是对的,只是标签榜暂时没拿到。
第四条就是上面说的 0 值漏洞,实测才发现。
还要验证「它真的是动态的」
最容易骗过自己的一种「验证」是:改了数据源,看到页面数字变了,就以为通了。但如果那只是缓存碰巧刷新了呢?
所以做了一次真实实验:
- 造一篇带新标签的测试文章;
- 真跑一次
hexo generate; - 看数字:
posts: 137 → 138
分类: 23 → 24
tags: 172 → 173
Java: 75 → 76- 删掉文章,再跑一次,全部还原。
这才叫验证:不是「看起来对了」,是「我让它变了一次,它跟着变了;我让它变回去,它跟着回去了」。
一个看起来像 bug 的地方:归档页永远是 12
部署完成后线上显示「归档 12 页」。你可能觉得它没更新——毕竟文章从 137 涨到了 140。
不是 bug。归档页数按 per_page: 12 换算:
archivePages = Math.max(1, Math.ceil(posts / 12))在文章数到 145 之前,它必然是 12。这是分页逻辑本身的性质,不是同步失效。
写在这里,是因为「数字不动」和「数字错了」在监控上看完全一样,不解释清楚下次又要重新排查一遍。
最后:还有什么是手工的
诚实交代边界——「公开项目 8 个」仍然是手写的。
GitHub 的公开仓库数量,博客的统计能力覆盖不到。要自动化得再接一次 GitHub API,引入 token 管理成本。这个成本我认为暂时不值得,所以没有擅自加。
想加的时候,链路也是同一个:构建期查一次 API,走同一套 validate + 兜底。
小结
| 决策 | 选择 | 理由 |
|---|---|---|
| 同步点 | 构建期 | 用户端零成本,不碰首屏 |
| 数据源 | 博客自己产出 | 统计口径归博客,主页不猜 |
| 失败策略 | 降级不阻塞 | 装饰性数据不该拖垮部署 |
| 覆盖条件 | 通过校验才覆盖 | 防止静默刷成空值 |
| 0 值 | 视为无效 | 137 篇文章不可能 0 个标签 |
如果只记一条:
跨仓库的一致性,要先选对「同步点」。
运行时同步是最贵的(用户付成本),构建时同步是最便宜的(构建机付一次成本)。
顺便说一句,这套链路现在反过来在帮我做验证:这篇文章发出去之后,主页的「笔记」应该从 137 自动变成 140。如果没变,说明同步链路坏了——那也是一条有用的结果。