Next.js 部署后页面一直是旧的?记一次三层缓存排查实战

2026/7/16·2 views

背景

我的站点是一个 Next.js(App Router)项目,部署方式很朴素:服务器上 git pullnpm run buildnpm 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";   // ← 文件末尾

问题就在这:路由段配置(dynamicrevalidate 等)只在服务端组件中生效。写在 "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.confinclude 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):

  1. 手动刷新全部缓存一次,把存量旧页面清掉;
  2. 把 HTML 的缓存策略设置为遵循源站——源站已经返回 no-store,之后 CDN 自然不会再缓存 HTML;
  3. /_next/static/* 可以放心让 CDN 长期缓存,因为文件名带内容哈希,永不冲突。

总结:一套正确的 Next.js 自部署缓存姿势

分层原则:HTML 永不缓存,带哈希的静态资源永久缓存。

资源 缓存策略
HTML(页面本身) no-store / no-cache,任何一层都不缓存
/_next/static/*(带内容哈希) max-age=31536000, immutable,随便缓存
/upload/* 等用户上传文件 按需设置,如 max-age=2592000

排查清单(从外到内):

  1. curl -I https://域名/ 看外网响应头 —— x-cachecc: s-maxage、CDN 标识各说明一层;
  2. curl -I http://127.0.0.1:端口/ 直连源站 —— 区分"代码没生效"和"中间层缓存";
  3. 检查 "use client" 文件里有没有写路由段配置 —— 不生效且无警告
  4. 检查 nginx 有没有全局 proxy_cache(宝塔用户重点检查 proxy.conf);
  5. 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()