Next 15 SSG cache 跑自托管 nginx —— 一次踩坑记录
我们花了一整天盯着 stale 的生产 HTML 才发现:Next 15 默认给每个 prerender 页面发 `s-maxage=31536000`、`export const revalidate = N` 并不改 emit 的 Cache-Control(只改 ISR 后台计时器)、我们自托管的 nginx 对此一无所知。下面是修法。
如果你跑 Next.js 15 on Vercel,本文跟你大概无关 —— Vercel 的 edge cache 跟 Next 构建管道集成、deploy 时自动 invalidate SSG 路由。如果你自托管 Next 挂 nginx(或任何不接 deploy-time purge 的 cache),你可能继承了 framework 默认行为里那个不响的脚枪。
Next 15 默认发什么
对一个 prerendered (SSG) 路由,Next 15 在响应头里发:
cache-control: s-maxage=31536000
x-nextjs-cache: HIT
x-nextjs-prerender: 1
x-nextjs-stale-time: 4294967294`s-maxage=31536000` 是一年。设计是 Vercel 风格 edge cache 在 deploy 时显式 purge —— 一年不要紧、build 一落 cache 就过期。挂自托管 nginx,没人发那个 purge:cache 文件按文件诞生时刻起算一年有效,跟你部署了 12 次没关系。
为什么 `revalidate = N` 没用
我们第一发 `export const revalidate = 3600` 设在 locale layout。这是 ISR 后台 regen 的正确旋钮,但它控制 `x-nextjs-stale-time` 不控制 Cache-Control。上游 nginx 不读 `x-nextjs-stale-time`,它读 `cache-control`。所以那个加了 revalidate 的 deploy 落地后,edge 看不到任何变化。
真正修好的那一刀
在 `next.config.ts` 里显式 override header:
// next.config.ts
async headers() {
return [
{
source: "/:path*",
headers: [
{ key: "Cache-Control", value: "public, s-maxage=3600, must-revalidate" },
// ... other security headers
],
},
]
}这一刀在 upstream 层赢,不管 Next 内部算什么。nginx 在每次 deploy 的 1 小时内 re-fetch。`must-revalidate` 让 SWR 不悄悄过 TTL 还吐 stale body。Layout 上的 `revalidate` 一并保留 —— belt-and-suspenders,万一日后挪到 Vercel 还能用上 ISR 语义。
其他浪费时间的细节
- 没有 CI/CD。`git push main` 触发 changelog workflow 但不触发任何镜像构建。我们得先把手动 `docker build → save → scp → load → restart` 加进 runbook,cache 修法才有验证的机会。
- 容器 6 天没动。如果不靠 curl 比对 ETag,我们会继续 ship 一堆永远到不了生产的 fix。
- `x-nextjs-prerender: 1` 看着像健康信号,其实只是 Next 在说 "我服务的是静态路由缓存"。它跟上游 nginx 那一层匹不匹配没关系。
两天 5 个 PR 终于把 marketing 站 BP 从 96 推到 100、/dosia Performance 从 61 推到 92、SEO 从 92 推到 100 —— 昨天就在代码里、直到今天才在 edge 可见。这些数字不在 fix 写出来时改善,它们在带这个 fix 的 build 实际落到一个有缓存层 + 缓存层尊重它的容器里时才改善。
“如果你的修法体现在 edge 那一层不知道的头里,你就要花一天盯着 ETag 才搞清楚哪边在撒谎。”