← 返回博客
架构2026-08-24 13:55:316 分钟 · 1,789 1

博客二番改造踩坑实录:ISR 的 DYNAMIC_SERVER_USAGE 与自托管统计三连坑

分类/标签页集体 500 的 DYNAMIC_SERVER_USAGE 根因与两次误诊、自托管统计从 umami 到 Fathom Lite 的三次换型、tracker.js 不自动上报的三连坑、nginx 缓存旧容器 IP 导致请求错位——十项功能批量改造的完整复盘。

上一篇文章写过给博客加「阅读体验三件套」,这次是第二梯队改造:阅读时长估算、热门页、PWA、搜索联想、评论数徽标、移动端目录、代码块语言标签、版权声明、数据库自动备份、自托管访问统计——十项一起上。功能本身都不难,难的是过程中踩的几个坑,值得单独记一篇。

坑一:category/tag 页集体 500,凶手是 DYNAMIC_SERVER_USAGE

现象

上线后某天点分类页:

https://www.wxbuluo.com/category/32?_rsc=xxx 500 (Internal Server Error)

tag 页同样炸。pm2 错误日志里只有一行被生产模式脱敏的报错:

⨯ [Error: An error occurred in the Server Components render...] {
  digest: 'DYNAMIC_SERVER_USAGE'
}

背景:为什么代码里会有这个雷

之前为了根治 CI 构建期跑 shiki 高亮导致 OOM,做过一次「B 方案」改造:

// category/[id]/page.tsx
export const revalidate = 600;
export function generateStaticParams() {
  return []; // 构建期不预渲染,首次访问按需生成 + 缓存
}

export default async function CategoryPage({ params, searchParams }) {
  const { id } = await params;
  const { page: pageParam } = await searchParams; // ← 雷
  ...
}

分页读页码用的是 await searchParams——这是动态 API。而这条路由声明了 revalidate,属于 ISR:首次访问时 Next 处于"缓存填充"上下文,此时 await 动态 API 直接抛 DYNAMIC_SERVER_USAGE,页面 500

文章页为什么没事?它没有分页,不碰 searchParams。所以这颗雷从 B 方案上线那天就埋下了,潜伏到有人点开分类页才爆。

排查路上的两个弯路

弯路一:怀疑 next/script 的 beforeInteractive。 当时正在接统计脚本,时间上高度可疑。后来把变量全部隔离测试才排除——不过顺手学到一件事:beforeInteractive 确实要求服务端注入 HTML 的特殊时机,能不用就不用。

弯路二:Suspense 包裹无效,而且本地验证给了假阳性。 按 Next 文档直觉,把消费 searchParams 的子树包进 <Suspense> 让动态部分流式渲染。本地 build 测试通过——但部署后依然 500。复盘才发现本地的"通过"是假象:本地 BFF 链路不通,getCategory() 返回空走了 notFound() 提前退出,根本没渲染到 Suspense 子树。Next 16 非 PPR 模式下,ISR prerender 的动态 API 检测不看 Suspense 边界。

教训:验证修复必须让完整链路真正执行到出问题的代码路径,"没报错"和"修好了"是两回事。

正解:把分页从 query 挪进路径

/blog?page=2   →   /blog/page/2
/category/32?page=2  →   /category/32/page/2

每个模块拆成两个路由文件复用同一个视图组件,页面不再消费 searchParams,ISR 完整恢复;顺带每页有了独立 URL,对 SEO 更友好。实在离不开 query 的搜索页,显式 export const dynamic = "force-dynamic",数据层靠 fetch 自带的 next.revalidate 缓存兜底。

一条经验法则:ISR 路由里不要碰 searchParams/cookies/headers 这些动态 API;需要按请求变化的维度,优先把它编码进路径。

坑二:自托管统计,三次换型才跑通

想要一个不依赖第三方的访问统计,过程比想象中曲折。

umami:镜像拉不到 + Prisma 连不上 MySQL

  • ghcr.io 匿名拉取被拒(denied),换南京大学镜像源 ghcr.nju.edu.cn 解决
  • 容器起来了,但 umami 内置 Prisma 引擎连 MySQL 8.0.27 报 received invalid response: 4a。试了 mysql_native_password 专用账号、检查协议版本,全无效——这是 Prisma 引擎与环境兼容性问题,继续调是深坑
  • 及时止损:自托管统计的可选项不止一个,别在一棵树上吊死

Fathom Lite:Go 单二进制 + SQLite

换成 usefathom/fathom,Docker Hub 又直连超时,docker.1ms.run 代理解决。容器一次启动成功,SQLite 存储,省掉所有数据库兼容性烦恼。

但 tracker.js 才是真·三连坑

Fathom Lite v1.2.1 的 tracker 是老式队列设计,官方文档又写得含糊,我前后错了三次:

  1. 裸引 tracker 报 TypeError:第一行就读 window.fathom.q,要求页面预先有一段官方 shim 定义队列函数
  2. 加了 shim 还是不上报:读源码发现它通过 document.getElementById("fathom-script") 找自己的标签、从 src 推导 /collect 上报地址——我用 next/script 生成的标签没有这个 id,上报地址推导为空
  3. 补了 id 还是没数据:再读源码,trackPageview 在整个文件里只有定义没有调用点,站点 ID 也只认 fathom('set','siteId',…) 命令而非 data-site 属性——这个版本的 tracker 根本不会自动上报

最终的正确接入:

<script>
  (function(){
    window.fathom = window.fathom || function(){
      (window.fathom.q = window.fathom.q || []).push(arguments);
    };
    window.fathom("set", "siteId", "wxbuluo");
    window.fathom("trackPageview"); // 首屏 PV
  })();
</script>
<script src="https://analytics.wxbuluo.com/tracker.js"
        id="fathom-script" defer></script>

还有个 SPA 特有问题:客户端路由切换不会重新加载页面,PV 会漏。加一个监听 pathname 变化的客户端组件在路由变化时补调 fathom('trackPageview'),注意首屏去重。

排查期间最有用的一招:直接读 tracker.js 源码(压缩后不到 3KB)+ 对照 nginx 访问日志看 /collect 有没有被请求——把「前端没发」和「后端没收」一刀切开,定位快得多。

坑三:compose 迁移之后,nginx 把统计请求打到了 API 网关

gin 和 fathom 原来都是裸 docker run 起的,启动参数只存在于运行时状态里,机器一重建就得靠回忆。于是把它们纳入 /docker/docker-compose.yml 统一编排。

迁移完发现 analytics 子域 404,且返回的是 Go 默认的 404 page not found——这是 gin 的口音!真相:nginx 对 proxy_pass http://fathom:8080 这种域名形式会在启动/reload 时解析 DNS 并缓存结果,容器重建换了内部 IP 后,nginx 还拿着旧 IP 在发请求,而那个 IP 已经被新起的 gin 占了。

处理:重建容器后记得 nginx -s reload 强制重新解析。如果上游经常变动,改用变量 + resolver 127.0.0.1 valid=10s(Docker 内嵌 DNS)让 nginx 每次请求动态解析。

其他小坑备忘

  • @giscus/react 包装组件不支持 onMetadataReceived 回调(类型定义里根本没有),评论数徽标改为原生监听 iframe 的 postMessage,记得校验 e.origin === "https://giscus.app"
  • 同名导出函数重复声明(新旧两版 getRank 共存)tsc 会直接拦下,重构前先全局搜一遍
  • 小内存服务器千万别在容器里跑 Go 编译,一次构建能把整台机压到 sshd 无响应——本地交叉编译 + 预编译 Dockerfile 是正道

写在最后

这轮改造最大的感受有两点:

  1. 潜伏雷比爆炸雷可怕。searchParams 那颗雷从埋下到引爆隔了很多天,中间没有任何报错。改造路由元数据(generateStaticParams、revalidate、dynamic)这类"影响所有页面"的东西时,要立刻把相关页面全部人工过一遍。
  2. 读源码比搜引擎快。tracker 三连坑的最终答案不在任何教程里,就在那 3KB 压缩 JS 里。npm 包再黑盒,解压格式化一下也就几百行。

相关推荐

本文为原创文章,采用CC BY-NC-SA 4.0协议授权,转载请保留署名与原文链接。原文链接:https://www.wxbuluo.com/article/147