Next.js 部署后页面一直是旧的?记一次三层缓存排查实战
背景
我的站点是一个 Next.js(App Router)项目,部署方式很朴素:服务器上 git pull、npm run build、npm run start。某次更新后发现一个诡异的现象:
- 页面内容还是旧版本,怎么刷新都不变;
- 浏览器控制台一堆报错,
/_next/static/...的 chunk 请求 404、ChunkLoadError。
明明代码已经拉下来、build 也成功了,为什么线上还是旧的?
第一步:一条 curl 看清全部真相
排查缓存问题,最有用的一招就是直接看响应头:
bash
curl -I https://www.example.com
返回结果(节选):
x-nextjs-cache: HIT
x-nextjs-prerender: 1
x-nextjs-stale-time: 300
cache-control: s-maxage=31536000
x-cache: HIT
eo-cache-status: MISS
这短短几行暴露了三层缓存在同时工作:
| 响应头 | 谁发的 | 含义 |
|---|---|---|
x-nextjs-prerender: 1 + cache-control: s-maxage=31536000 |
Next.js | 首页被当作静态页预渲染了,并告诉下游"这个页面可以缓存一年" |
x-cache: HIT |
nginx | nginx 开了 proxy_cache,旧 HTML 直接从磁盘缓存返回,请求根本没到 Node 进程 |
eo-cache-status |
CDN(腾讯 EdgeOne) | CDN 也会遵守 s-maxage 把 HTML 缓存在边缘节点 |
于是整个链路变成了:Next.js 说"可以缓存一年" → nginx 缓存了 → CDN 也缓存了 → 用户永远看到旧页面。而旧 HTML 里引用的旧 chunk 文件已经被新一次 next build 删掉了,所以控制台报 404。
第二步:源头——force-dynamic 写在了客户端组件里,静默失效
首页明明是个高度动态的页面,为什么会被预渲染成静态?打开 src/app/page.tsx 一看:
tsx
"use client"; // ← 第 1 行
// ...几百行组件代码...
export const dynamic = "force-dynamic"; // ← 文件末尾
问题就在这:路由段配置(dynamic、revalidate 等)只在服务端组件中生效。写在 "use client" 文件里不会报错,只会被静默忽略——Next.js 照样把页面预渲染成静态,还给它配上了 s-maxage=31536000。
这是一个非常容易踩的坑:配置写了、build 也过了、没有任何警告,但它就是不生效。
修复:拆成服务端 wrapper + 客户端组件
把原来的 page.tsx 改名为 HomeClient.tsx(保留 "use client",删掉无效的 export const dynamic),再新建一个薄薄的服务端 page.tsx:
tsx
// src/app/page.tsx —— 服务端组件,不写 "use client"
import HomePage from "./HomeClient";
// 强制动态渲染,必须写在服务端组件里才生效
export const dynamic = "force-dynamic";
export default function Page() {
return <HomePage />;
}
重新 build,看输出的路由表:
┌ ƒ / ← 从 ○(Static) 变成了 ƒ(Dynamic)
├ ○ /tools/qr-code
├ ƒ /blog
首页变成 ƒ(按需服务端渲染),响应头也变成了 Cache-Control: no-store, must-revalidate。纯静态的工具页保持 ○ 不动——它们没有动态数据,静态化是合理的。
第三步:nginx 层——全局 proxy_cache 的坑
源站修好后再 curl,响应头一个字都没变,连 etag 都一样。x-cache: HIT 说明 nginx 还在从自己的磁盘缓存返回旧页面——旧响应带着"缓存一年"的头,nginx 一年内都不会再回源。
顺着配置找:站点配置里有 proxy_cache_purge cache_one,主配置 nginx.conf 里 include proxy.conf,打开一看:
nginx
# /www/server/nginx/conf/proxy.conf
proxy_cache_path /www/server/nginx/proxy_cache_dir levels=1:2 keys_zone=cache_one:20m inactive=1d max_size=5g;
# ...
proxy_cache cache_one; # ← 写在 http 全局层!
最后这行 proxy_cache cache_one; 写在了 http 全局层,等于给这台服务器上所有反向代理的站点都开了缓存。这是宝塔面板反代模板的默认行为,很多人根本不知道它的存在。
修复
bash
# 备份
cp /www/server/nginx/conf/proxy.conf /www/server/nginx/conf/proxy.conf.bak
# 注释掉全局启用缓存的那一行
sed -i 's/^proxy_cache cache_one;/# proxy_cache cache_one;/' /www/server/nginx/conf/proxy.conf
# 清空已有的旧缓存
rm -rf /www/server/nginx/proxy_cache_dir/*
# 检查并重载
nginx -t && nginx -s reload
proxy_cache_path 只是定义缓存区,留着无害;真正需要缓存的站点应该在自己的 location 里单独声明 proxy_cache,而不是全局一刀切。
注意:如果同一台服务器上有其他站点依赖这个全局缓存,需要在它们的 location 里单独补回
proxy_cache cache_one;。改之前先grep -rn "proxy_cache" /www/server/panel/vhost/nginx/确认。
第四步:CDN 层——手动刷新一次
源站和 nginx 都修好后,还有最后一层:CDN 边缘节点上仍然存着之前带 s-maxage=31536000 的旧页面。CDN 节点很多,某次 curl 显示 MISS 不代表所有节点都没缓存。
去 CDN 控制台(我用的是腾讯 EdgeOne):
- 手动刷新全部缓存一次,把存量旧页面清掉;
- 把 HTML 的缓存策略设置为遵循源站——源站已经返回
no-store,之后 CDN 自然不会再缓存 HTML; /_next/static/*可以放心让 CDN 长期缓存,因为文件名带内容哈希,永不冲突。
总结:一套正确的 Next.js 自部署缓存姿势
分层原则:HTML 永不缓存,带哈希的静态资源永久缓存。
| 资源 | 缓存策略 |
|---|---|
| HTML(页面本身) | no-store / no-cache,任何一层都不缓存 |
/_next/static/*(带内容哈希) |
max-age=31536000, immutable,随便缓存 |
/upload/* 等用户上传文件 |
按需设置,如 max-age=2592000 |
排查清单(从外到内):
curl -I https://域名/看外网响应头 ——x-cache、cc: s-maxage、CDN 标识各说明一层;curl -I http://127.0.0.1:端口/直连源站 —— 区分"代码没生效"和"中间层缓存";- 检查
"use client"文件里有没有写路由段配置 —— 不生效且无警告; - 检查 nginx 有没有全局
proxy_cache(宝塔用户重点检查proxy.conf); - CDN 控制台手动刷新存量缓存。
部署流程也要注意: next build 会直接覆盖 .next 目录并删掉旧 chunk,build 完必须真正重启进程(推荐 pm2),不能让旧进程带着旧 manifest 继续跑,否则同样会出现"旧 HTML 引用已删除的 chunk"的 404。
bash
git pull
npm run build
pm2 restart ainav # 而不是 Ctrl+C 后手动 start,谨防旧进程残留
最后一个无法完全避免的边角情况:部署瞬间,已经打开旧页面的用户点击跳转时会懒加载旧 chunk 而 404。这属于正常现象,刷新即恢复;在意的话可以在前端捕获 ChunkLoadError 自动 location.reload()。