SERIES · web开发

Next.js + Nginx 缓存排障:从 ChunkLoadError 到图片闪烁全链路分析

2026-03-13 · 7 min read · by GUMP

Next.js + Nginx 缓存排障:从 ChunkLoadError 到图片闪烁全链路分析

记录一次生产环境的缓存踩坑:chunk 404、CSS 无样式闪烁、图片每次重载——三个看似不同的问题,根源都指向同一套缓存配置的漏洞。

这个博客网站部署上线之后,收到了一些奇怪的反馈:页面有时候没有样式,图片每次刷新都重新加载,偶尔控制台还会报一个看起来很严重的 ChunkLoadError

三个问题,排查下来发现根源高度重合,值得一次性记录清楚。

问题现象

打开浏览器 DevTools,能看到三类错误交替出现:

bash
GET /_next/static/chunks/756bf93382790689.js 404
GET /_next/static/chunks/9d69d64a9fe60bc3.css 404
Uncaught ChunkLoadError: Failed to load chunk

同时视觉上有两个症状:

  1. FOUC(Flash of Unstyled Content):页面先以无样式状态渲染,CSS 加载后才"跳"到正常样式
  2. 图片每次重载:每次访问主页,所有图片都重新从网络加载,没有走缓存

根因分析

问题一:微缓存 updating 导致旧 HTML 泄漏

nginx 配置了 30 秒的页面微缓存,同时开启了 proxy_cache_use_stale updating

nginx
proxy_cache_valid 200 301 302 30s;
proxy_cache_use_stale error timeout ... updating;

updating 的语义是:缓存过期后,第一个请求立即返回旧缓存,同时后台异步拉取新内容。

问题在于:每次部署,旧缓存的 HTML 里引用的是旧构建的 chunk 哈希(如 756bf93382790689.js)。新部署后这些文件已不存在,但浏览器拿到旧 HTML 后会去请求它们,得到 404。

bash
缓存 30s 过期 → 新请求进来
→ nginx 立刻返回【旧 HTML】(含旧 chunk 哈希) ← 罪魁祸首
→ 后台拉取新 HTML
→ 浏览器用旧 HTML 请求旧 chunk → 404

问题二:CSS chunk 404 是静默失败 JS 的 ChunkLoadError 会被 React 的 Error Boundary 捕获,可以通过 error.tsx 处理。但 CSS 资源 404 是静默失败——浏览器不会抛出 JS 异常,只是默默忽略,样式丢失,这就是 FOUC 的来源。

两种 chunk 错误的处理路径完全不同,容易只修了 JS 那边而忽略 CSS。

问题三:/public/img/ 文件被 Next.js 发送 max-age=0 Next.js 对 /_next/static/ 下的文件(有内容哈希)会发送长缓存头,但对 /public/ 目录下的静态文件(无哈希,文件名固定),默认发送:

Cache-Control: public, max-age=0

这是合理的默认行为——文件名不变但内容可能更新,所以不敢设长缓存。

但我的 nginx 配置里 /img/ 没有专属 location,所有图片请求都落进 location / 的通用块,nginx 把这个 max-age=0 原样透传给浏览器。结果:浏览器每次访问都需要重新验证,图片看起来像是"重新加载"。

解决方案

  1. 移除 updating,避免旧 HTML 泄漏
bash
# 之前
proxy_cache_use_stale error timeout invalid_header http_500 http_502 http_503 http_504 updating;
# 之后:移除 updating
proxy_cache_use_stale error timeout invalid_header http_500 http_502 http_503 http_504;

代价是缓存过期后第一个请求需要等待上游响应,但消灭了旧 HTML 泄漏的窗口。

2. 为 /img/ 添加专属 location,覆盖 Next.js 的 max-age=0

nginx
location ~* ^/(img|favicon\.ico)(/|$) {
proxy_pass http://gump_next;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_cache gump_cache;
proxy_cache_key "$scheme$host$request_uri";
proxy_cache_valid 200 7d;
proxy_cache_lock on;
proxy_ignore_headers Set-Cookie Cache-Control; # 关键:忽略上游的 max-age=0
expires 7d;
add_header Cache-Control "public, max-age=604800" always;
add_header X-Cache-Status $upstream_cache_status always;
}

proxy_ignore_headers Cache-Control 让 nginx 忽略 Next.js 返回的缓存头,由 nginx 自己决定下发给浏览器的策略。

3. 为 /uploads/ 补充 nginx 层缓存

上传图片通过 API 路由 /api/uploads/ 提供服务,API 代码里已经正确设置了 immutable 头,但 nginx 没有专属 location,导致每次都穿透到 Next.js 进程:

nginx
location ^~ /uploads/ {
proxy_pass http://gump_next;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_cache gump_cache;
proxy_cache_key "$scheme$host$request_uri";
proxy_cache_valid 200 7d;
proxy_cache_lock on;
proxy_ignore_headers Set-Cookie;
add_header X-Cache-Status $upstream_cache_status always;
}

4. 背景图从 CSS background-image 改为 <Image priority>

Travel 模块的背景图通过内联样式设置:

tsx
// 之前:CSS background-image,不会被预加载
style={{ backgroundImage: `url('${backgroundImageUrl}')` }}

CSS 背景图不在浏览器的预加载扫描(Preload Scanner)范围内,HTML 解析完才开始下载,视觉上就是"晚出现"。

改成 Next.js <Image priority> 后,框架会在 <head> 注入 <link rel="preload">,图片和 HTML 并行下载:

tsx
// 之后:和 HTML 并行预加载
<Image src={backgroundImageUrl} fill priority sizes="50vw" />

排查工具

几个在这次排查中有用的方法:

  • X-Cache-Status 响应头:配置里加了 add_header X-Cache-Status $upstream_cache_status always,在 DevTools → Network 里可以直接看到每个请求是 HITMISS 还是 BYPASS
  • 强制刷新(Ctrl+Shift+R):绕过浏览器缓存,快速验证 nginx 层的行为
  • Network 面板的 Size(memory cache) / (disk cache) 表示走了浏览器缓存,数字大小表示从网络下载

小结

问题根因修复
ChunkLoadError / CSS 无样式nginx updating 返回含旧 chunk 哈希的 HTML移除 updating
图片每次重载/img/ 缺专属 location,Next.js 的 max-age=0 被透传加 location + proxy_ignore_headers Cache-Control
背景图晚加载CSS background-image 不进预加载扫描改用 <Image priority>

三个问题的排查路径不同,但修法有一个共同思路:nginx 的 location 匹配要精确,不能让静态资源走通用块