8 月 2 日,博客页面照常打开,文章也都还在,三个订阅地址却一起返回了 500。
我一开始盯着前端 route 看。生成 XML 的代码不长,看起来很像那种改完一处异常、重新部署就能结束的小故障。后端负责提供订阅条目的接口又是 200,里面有 10 条公开内容,更让人觉得问题不会走得太远。
后来几天里,这个 500 先后经过了前端依赖、主题配置、上游同步和边缘缓存。每一处单独看都不复杂,放在一起却很容易互相遮住。现在线路已经恢复。回头看,单独记住某一行修复意义不大;那些一度同时成立、却看起来彼此冲突的结果,更值得写进复盘。
8 月 2 日,三个地址一起报错
当时 /feed、/feed.xml 和 /atom.xml 都返回 500。给 RSS 提供文章条目的后端接口正常,另一个 aggregate 接口却因为没有公开手记,在读取最新手记时返回了 NOT_FOUND。
旧 route 用同一个 Promise.all 等待这两个请求。feed 提供订阅条目,aggregate 只是补充站点标题、描述和主题配置;代码却让它们拥有了相同的决定权。aggregate 一旦失败,已经取回的 10 条内容也来不及生成 XML。
前几次排查里,我记过缓存、主题开关、内容数量和重新部署等猜测。它们都能解释一部分表象,却解释不了“后端条目稳定返回 200,三个前端入口同时为 500”。直到两个请求结果被并排放在一起,问题才缩到这段不合适的等待关系上。
第一版修复将 feed 保留为必需数据。feed 失败时,route 不伪造空订阅;aggregate 失败时,标题和描述退回 feed 已有的元数据,主题配置走受约束的默认值。聚焦测试覆盖了有主题、旧主题缺字段、没有主题、aggregate 请求失败和 SEO 字段为空等情况。那一版有 6 个测试,构建和生产冷请求也通过了。
我以为事情到这里已经结束。两天后,后台又给出了另一份答案。
8 月 4 日,后台还缺一项
继续检查生产配置时,主题 snippet 里没有 RSS 模块。前端 fallback 能让旧配置继续工作,后台却没有明确保存站点是否输出全文订阅。
这次改动没有进入文章表,也没有直接操作数据库。主题配置通过原有接口补入官方默认值:自定义元素为空,noRSS=false。写入前保存原值,写入后再读回来;移除新增的 RSS 字段以后,其余内容应与原值一致。公开 aggregate 随后也读到了相同语义,只是字段名被序列化为 no_rss=false。
代码里的默认值和后台保存的选择看起来相同,承担的事情却不同。前者让旧配置或临时请求失败时仍能生成结果,后者说明这个站点此刻明确允许什么。只看到页面恢复,很容易漏掉这项差别。
当时我差点将故障归档成“后台少了一个开关”。如果记录停在这里,8 月 8 日再看仓库时会更难解释。
线上没坏,源码却退回去了
8 月 8 日检查主分支,前一版 route 容错已经消失。每天同步上游的任务会用上游目录树替换当前工作树,然后只取回少量下游文件。RSS helper、测试和 route 修改没有全部进入保留范围,一次正常同步便覆盖了补丁。
生产当时仍然健康。后台配置已经补齐,公开域名也继续指向上一份成功部署。只看 /feed,我会得到“一切正常”的结论;等下一次部署,旧行为才可能重新上线。
后来的 PR #10 同时处理运行代码和同步流程。feed 继续提供必需内容,aggregate 改为可选请求。主题存在但缺少 RSS 字段时使用公开默认值;aggregate 整体不可用、无法确认全文偏好时,则按 noRSS=true 处理:订阅入口和原文链接保留,正文不直接输出。
同步任务也开始保留下游 helper、测试和工作流,并用精确锚点将少量 route 变化重放到最新上游代码上。锚点失效时任务会明确失败,不再安静地覆盖补丁。聚焦测试增加到 7 个,其中一个让真实 rejected Promise 进入配置解析,专门验证 aggregate 失败时的关闭策略。
PR 合并后的提交是 06991c0e5f7246372a3eb0bcbf05f727870b98c5。部署完成后,我重新走了一遍公开入口。
冷请求比刷新页面更可信
后来验收不再只看 /feed 的一个 200。两个域名分别请求 /feed、/feed.xml、/atom.xml、/thinking/feed 和 /says/feed,每个地址都带上唯一参数,避开已有的边缘缓存。
十个响应都是 200 application/xml,XML 根节点均为 rss。每个域名的五个入口,条目数依次为 10/10/10/17/20。缓存结果为 MISS、age=0,没有出现浏览器挑战。主 feed 的 GUID、日期、HTTPS 链接和自动发现标签也分别检查,条目链接能够打开。
/atom.xml 仍只是兼容地址。它返回的根节点是 rss,并没有因为路径名里带着 atom 就变成 Atom 格式。这条地址继续留在回归检查里,只为保护已经存在的订阅,不替它补一个当前没有的能力。
冷请求也解决了另一个误会。订阅结果可能被边缘缓存保留,一次普通刷新命中的有可能仍是旧版本。MISS、XML 解析和条目数量放到一起,才说明眼前这份部署真的从后端取回了内容并生成结果。
429 没有和 RSS 一起收尾
同一合并提交的生产部署和 RSS 冷验都通过,主分支的 Bundle Analysis 却失败了。静态生成进行到大约 42/85 个页面时,多语言页面集中请求 aggregate,后端开始返回 429 RATE_LIMITED。日志里同一接口先出现过 200,随后才进入限流。
这条失败没有被并进 RSS 故障。回退 route 容错不会减少静态构建的请求量,重复修改后台开关也帮不上忙。它需要另一组改动:控制构建并发、请求去重,或者为 aggregate 选择合适的缓存。
后来的 PR #11 才单独处理这条线。同一语言的 aggregate 读取开始共用缓存和单次在途请求,Vercel 构建也限制为一个预渲染 worker。冷构建最终生成 85/85 个页面,只发出 5 次 aggregate 请求,分别对应 5 种语言,响应全部为 200。
几天的记录摊开以后,可以看到几条各自独立的线:最初的 500 来自可选请求拖垮 route;后台确实缺少显式配置;同步任务又让已经通过测试的补丁从源码里消失;边缘缓存要求验收使用冷请求;构建 429 则在另一份补丁里收束。
我以前总想尽快找到唯一根因,再让其他异常都围着它解释。这一次,后端 200 和前端 500、线上健康和源码回退、生产部署成功和分析任务失败同时存在。承认它们属于不同阶段以后,排查反而变得简单了一些。
现在十个订阅入口都能解析,后台保存的意图和前端处理异常的方式也已经对上。同步锚点仍要跟着上游结构继续照看。记录停在这里,既没有把一个 500 写成灾难,也没有用后来通过的构建抹掉当时那次失败。