这台博客跑在一台最便宜的阿里云 ECS 上:2 vCPU、实际可用内存 1.6 GiB、固定 3 Mbps 带宽,约束是「除服务器本身外新增付费 0 元」。这篇文章复盘整个工程链路的四个关键决策,以及它们各自是怎么被现实打脸又修正的。
最终形态的实测数据:push 后 2 秒完成构建发布与增量索引,站内搜索 p95 6.6 ms,检索服务常驻内存 122 MiB,Lighthouse 四项 100。这些数字后面是 8 个记录在案的缺陷(D32~D39)和三次架构级改判。
一、为什么不用 GitHub Pages / Vercel
第一个被否掉的是最省事的方案。理由按权重排:
- 境内访问不稳。读者在国内,GitHub Pages 的可达性看运气;这篇文章的读者大概率也用不了 Vercel 的默认域名。
- 检索与统计只能挂第三方。静态托管意味着搜索要么引第三方服务(境外依赖 + 付费 + 隐私),要么不做。自托管后,搜索是 60 行 SQL 的事。
- 「自有域名 + 自有数据」。备案域名指向自己的服务器,数据在自己手里——这本身就是练习运维的机会。
二、为什么构建放服务器本地(pull 式),而不是境外 Runner 推过来
直觉方案是 GitHub Actions:push 触发构建,构建完 SSH 推产物到服务器。实测否决:境外 Runner 到境内的 SSH 连接间歇丢包,2 分钟的更新 SLA 根本承诺不了。这是一次典型的「把不稳定面摊到整条链路上」的错误——CI 推送意味着每次发布都要赌一次跨境传输。
改成 pull 式之后,链路变成:push → (Gitee 中转)→ 服务器每 60 秒轮询发现新提交 → 服务器本地 git pull + hugo 构建 → 原子切版。构建从「不可控的境外网络任务」变成「服务器本地 2 秒的确定性操作」,不稳定的国际链路被压缩到一次 git fetch——甚至后来干脆把拉取源切到 Gitee,连这一次赌运气的 fetch 都消掉了。
这个演进的触发点值得单独说:GitHub 的 HTTPS 对境内 ECS 会间歇性 SYN 黑洞(connect 挂满 127 秒,恰好是 TCP SYN 重传上限),一度让每分钟一次的轮询变成每分钟一封告警邮件。最后的解法不是继续调告警阈值,而是让服务器和 GitHub 解耦:本地 push 同时推 GitHub 和 Gitee,服务器只跟境内的 Gitee 说话。GitHub 退化成纯展示副本——它挂了只影响简历仓库的新鲜度,不影响任何真实功能。
三、FTS5 为什么必须 jieba 预分词
SQLite FTS5 的默认分词器 unicode61 按 Unicode 词边界切词——对英文完美,对中文灾难:「标准库概览」整句是一个 token。意味着搜「标准库」永远搜不到「标准库概览」里的内容,除非用户输入整句。
解法是写入索引前先 jieba 分词,把「标准库 概览」以空格分隔喂给 FTS5。两个实现细节:
- 查询侧用
cut_for_search(更细粒度),索引侧用精确模式——查询 token 是索引 token 的超集,配合 OR 兜底,命中率优先。 - 摘要和锚点不交给 FTS5:FTS5 的
snippet()只能作用于索引列(已分词的文本),展示出来全是断词空格。所以索引列之外另存一张sections表(每节的原始文本 + Goldmark 自动生成的锚点 id),命中后由应用层挑最佳小节、拼<mark>高亮和「跳转到该节」的锚点。搜索结果的「命中正确段落并跳转」就是这么来的。
实测:站内搜索 p95 6.6 ms(含 jieba 查询分词),增量索引(一篇新文章)2 秒内可搜。
四、原子发布与失败保护:8 个真实缺陷喂出来的
发布链路的核心不变量只有一句:先建新版本、验证通过才切软链;任何一步失败,线上继续服务旧内容。deploy.sh 的顺序是固定的:flock → 拉取 → hugo 构建到全新 release 目录 → 产物健全性检查 → ln + mv -Tf 原子切软链 → nginx reload → 增量索引 → 清理旧版。
这套设计的价值不是纸面文章——上线头一天它就被真实触发了三次:
| 缺陷 | 触发 | 失败保护的表现 |
|---|---|---|
| D39 | 测量脚本用 PowerShell 拼探针页 front matter,日期值被解析器拆到下一行,TOML 空值 | hugo 构建失败 → 未切版,线上无恙;告警邮件即时送达 |
| D38 | 备份凭据文件粘贴断行,快照根本没推上远端 | 巡检新增「备份新鲜度」维度,5 分钟内报「备份失败」 |
| D32 | nginx 用 reload 后旧 worker 还持旧配置,自检撞上 404 | 改为「配置有变化才 restart」,顺带幂等 |
除此之外还有五个:告警无去抖变成风暴(D35)、去抖的「成功即清零」在边缘抖动下失效(D36)、安装脚本对拉起的 oneshot 只展示不查终态(D34)、logrotate 判据把 grep -q 的方向用反(D33)、探测 URL 漏了 Hugo 的分区前缀导致三轮「探的都是 404」(D41)。
这些缺陷的共同模式很有意思:没有一个是「代码写错逻辑」,全是「判据与被测对象错位」——探的 URL 不是真实 URL、查的返回码不代表真实状态、比的字符串被编码破坏、认定成功时其实只完成了一半。对应的防回归也被提炼成几条硬规则写进了缺陷档的总纲:判据来自产物扫描不来自意图;装载类判据查终态不查动作返回码;计数断言必须能让脚本停下;去抖必须覆盖恢复侧;生成结构化文本后必须回读关键字段。
五、为什么不用 Docker / WordPress / 第三方评论
- Docker:1.6 GiB 上容器常驻开销大于收益,systemd 单元 +
MemoryMax是更便宜的资源边界。 - WordPress/Halo:内容进数据库,「Git 是唯一内容源」就死了——push 更新、回滚、异地备份全部退化。
- 第三方统计/评论:境外脚本拖慢页面还要隐私政策;
navigator.sendBeacon+ 一张 SQLite 表,456 KB。
收个尾
这套系统的完整决策链、平台约束(备案拦截、镜像黑洞、内存预算)和运维手册都在仓库里。如果说有什么元教训:约束不是敌人。1.6 GiB 内存逼出了标准库 http.server 和 SQLite;0 元付费逼出了 Gitee 中转和自建统计;国际链路的黑洞逼出了本地构建和原子发布。每一层被砍掉的「标准方案」,最后都变成了更便宜、更快、更可解释的自己方案。