我在一次部署后见到的最有价值的生产错误之一,看起来几乎平平无奇:
Failed to load script:
/_next/static/chunks/9253.647385b4be0958e4.js
它和分析工具故障、广告脚本失败、笼统的 Script error. 提示以及视频播放被中断等事件混在同一条错误流里。那里面大部分只是噪声。这个事件却不同:加载失败的资源属于我自己的 Next.js 应用。如果浏览器确实拿不到它,页面的一部分就可能停止工作。
但仅凭这条日志,我不知道分块文件为什么失败。可能只是暂时的网络问题,也可能是代理或 CDN 出错,或者文件确实不存在。还有一种可能:旧页面仍在请求上一次部署的分块文件,而服务器已经把那一版替换掉了。
最后一种情况很容易被低估,因为新部署本身完全可能是健康的。所有新访问者都能正常加载新版,而一个已经打开数小时的标签页却悄悄继续充当旧版客户端。
本文讨论的正是这种兼容性空档:为什么旧的 Next.js 标签页会在部署后失效,过期 HTML 和缺失的 /_next/static 资源如何造成版本错位,为什么过于激进的清理会加重问题,以及我会如何设计部署、旧资源保留、监控和恢复,让一次成功发布不会把早已打开应用的用户留在损坏的页面上。
第一条经验:不要把每个脚本失败都当成部署故障
最初的错误流里混合了完全不同的故障类型。第三方分析和广告脚本可能被内容拦截器、DNS 过滤、隐私功能、地区限制、防病毒软件或用户网络阻止。视频的 play() 承诺对象 可能被后续的 pause() 打断,而应用本身并没有故障。跨源的笼统 Script error. 往往也缺少足够信息,根本无法诊断。
应用自身的 Next.js分块文件加载失败应当获得不同的优先级。真正有用的分界并不是“有没有 JavaScript 错误”,而更接近:
第三方资源加载失败
-> 通常影响遥测或可选功能
应用自身的 /_next/static/*.js 加载失败
-> 应用代码可能无法使用
这个区分很重要,因为过于嘈杂的上报系统会把真正与页面损坏相关的错误淹没。在我的案例中,值得关注的是对 /_next/static/chunks/9253.647385b4be0958e4.js 的请求。日志证明应用自身的脚本加载失败了,但并没有证明部署后的版本错位就是原因。
我刻意把这条证据边界保留得很清楚:合理的原因并不等于已确认的原因。
一个仍然打开的浏览器标签页,本质上是旧版本的客户端
让我重新理解这个问题的思路很简单:部署之后,应用的多个版本可能同时继续存在。
假设版本 A 在 10:00 上线。用户打开页面,拿到该路由需要的 HTML 和 JavaScript。10:30 时版本 B 替换 A。新的访问者得到 B,但已经打开的标签页不会仅仅因为服务器变化就自动变成 B。
那个标签页里仍可能保留:
- 从版本 A 加载的 JavaScript 运行时;
- 版本 A 生成的路由和分块文件引用;
- 版本 A 预取的导航数据;
- 在版本 A 下创建的 React 状态;
- 已经从 A 下载的代码拆分模块;
- 对 A 中尚未下载模块的引用。
最后一项通常就是问题显现的位置。
如果这个页面以后可能需要的所有分块文件都已经进了浏览器缓存,用户也许可以一直使用而毫无察觉。但现代 Next.js 应用会拆分代码。路由切换、动态导入、弹窗、编辑器或稍后才使用的功能,都可能需要另一个 JavaScript 文件。旧运行时此时会请求一个在版本 A 中有效的资源 URL。
如果服务器仍然保存这个资源,一切可能继续正常。如果部署已经把它删除,旧客户端就可能收到 404,即使版本 B 本身完全健康。
带内容哈希的分块文件本来就适合长期缓存
Next.js 会有意给真正不可变的资源设置很长的缓存语义。当前自托管文档说明,文件名中带 SHA 哈希的不可变资源会以类似下面的一年期策略提供:
Cache-Control: public, max-age=31536000, immutable
这很合理,因为内容一变,URL 就会变化。以内容决定名称的文件不必每次请求都重新验证。如果后续构建产生了不同字节,也应当得到不同的资源 URL。
但这里有一个很容易忽略的后果:只要旧文档或旧运行时仍可能引用旧 URL,它就仍然有意义。
浏览器可以把哈希资源缓存一年,也无法解决这样一种情况:部署前浏览器从未下载过那一个具体文件,而第一次需要它时,源站已经把它删掉了。
因此,“静态文件不可变”和“可以立即删除上一版本的静态文件”不是同一件事。不可变性让旧资源可以安全保留,却不会让旧客户端停止请求它们。
当前的 Next.js 自托管指南明确把 JavaScript 或 CSS 资源缺失列为多服务器或滚动部署中版本错位的症状之一。即使错位发生在旧标签页与刚更新的源站之间,而不是两个同时工作的服务器之间,也属于同一类问题。
版本可能通过多种方式发生错位
“缓存问题”这个说法太模糊,无法作为有效诊断。我至少会分成四种机制,因为它们需要不同的修复方式。
1. 旧标签页请求一个部署前从未加载过的资源
这是长期打开标签页的经典情况。文档和运行时来自版本 A。版本 B 替换服务器上的文件。之后用户触发一个需要 A 中按需加载分块文件的操作。如果 A 的资源已经被删,请求就会失败。
2. 过期 HTML 指向已经不存在的分块文件
CDN、反向代理、服务工作线程、浏览器缓存或静态托管层都可能比预期更久地保留旧 HTML 文档。这个 HTML 仍然包含版本 A 的引用,而源站只剩版本 B。
如果 HTML 被错误地设置为长期 immutable,风险尤其高。带哈希的 JavaScript 和 HTML 不应被当成同一种缓存对象。分块文件 可以不可变,是因为它的 URL 由内容完成版本化。真正决定哪些分块文件URL 应当一起工作的,是 HTML。
3. 滚动部署或多实例部署混合提供不同版本
设想负载均衡器后有两个 Next.js 实例。一个已经是版本 B,另一个仍在版本 A。文档可能来自其中一个版本,而后续导航请求落到另一个版本。当前 Next.js 文档将这种情况称为版本错位,并指出它可能导致资源缺失、服务端函数 不匹配以及导航失败。
最安全的默认方式是只构建一次,并让参与同一次部署的所有实例运行同一个构建产物。Next.js 自托管文档也建议所有容器使用同一份构建和一致的构建标识,而不是每个副本独立重新构建。
4. 部署本身按错误顺序发布文件
即使没有旧标签页,非原子上传也可能短暂制造一个不可能正确的状态:
新 HTML 已经可见
+
新的分块文件尚不可用
或者反过来:
旧 HTML 仍然可见
+
旧的分块文件已经被删除
窗口再短也足够。用户只要有一次刚好落在这个窗口里即可。
危险的部署方式是“全部替换,再把旧目录删干净”
一个简单的部署脚本往往从类似下面的做法开始:
build
rsync --delete new-output/ production/
restart
它很有吸引力,因为生产目录始终与最新构建完全一致。但对长期存在的客户端来说,它并不友好。
对带哈希的静态资源而言,把目录清理到只剩一个版本,对浏览器几乎没有好处。旧文件不会和新文件冲突,因为 URL 不同。删除它们主要只是节省磁盘空间,却会把旧客户端中仍然有效的每一个引用都变成潜在的 404。
现在我把旧分块文件看作部署兼容性材料,而不是垃圾。
这并不意味着每个构建都要永久保存。它意味着清理应当是一项独立的保留策略,而不是发布最新版时顺手发生的副作用。
保留旧资源很有用,但任何有限保留期都不是完整方案
实际的自托管环境可以在一个缓冲期内保留旧的 /_next/static 资源。具体时间取决于使用方式。用户打开页面读两分钟就离开的网站,与全天保持打开的应用,风险特征完全不同。
可以用下面的方式估算最低保留期:
保留期 >=
过期 HTML 预计仍会存在的时间
+ 长期打开标签页的现实存活时间
+ 回滚窗口
+ 部署传播余量
这不是数学保证。一个浏览器标签页可以打开数周。不存在某个有限小时数,能够让旧客户端故障绝对不可能发生。
所以我更倾向于分层设计:
- 把前几个版本的不可变资源保留足够久,让正常的旧会话可以继续工作;
- 检测版本错位,让客户端能够转到当前版本;
- 当资源确实不可用时,提供一次安全的重新加载,或清楚可见的恢复路径;
- 监控缺失的应用自身 分块文件,用真实数据调整保留时间。
资源保留层可以预防大多数故障。恢复层负责处理任何有限保留期都无法彻底消除的尾部情况。
不要只按文件年龄盲目回收旧 分块文件
像“删除所有超过七天的文件”这样简单的清理规则也可能出错。当前版本可能继续复用一个较旧的哈希文件,只是因为内容没有变化,所以它在磁盘上的修改时间也很旧。
更可靠的资源回收模型应当了解具体版本:
- 保留仍处于兼容窗口内每个版本的清单或资源列表;
- 求出这些版本所引用全部资源路径的并集;
- 绝不删除这个受保护集合中的任何文件;
- 没有任何引用的资源,也要经过额外缓冲期后再移除。
如果小型部署觉得这套机制太复杂,有意让静态资源目录宽裕一些,往往比排查极少出现的客户端故障更便宜。带哈希文件尤其适合这么做:相同内容自然会复用稳定 URL,或者至少不会在同一个哈希名称下覆盖不相关内容。
我会避免的规则很简单:不要让共享 /_next/static 树上的 --delete 与新版本启用处在同一个操作里。
Next.js 已有明确的版本错位保护,但它不是旧资源仓库
当前 Next.js 支持使用 deploymentId 防止版本错位。配置可以写成这样:
// next.config.js
const nextConfig = {
deploymentId: process.env.DEPLOYMENT_VERSION,
}
module.exports = nextConfig
根据当前的 Next.js deploymentId 文档,配置后,框架管理的静态资源 URL 会附带 ?dpl=<deploymentId> 参数,客户端导航请求会携带部署信息,服务器也会在响应中表明自己的部署标识。当 Next.js 在导航时发现两者不一致,它可以改为执行完整页面导航,而不是继续使用不兼容数据进行客户端内导航。
?dpl=<deploymentId>
x-deployment-id
x-nextjs-deployment-id
data-dpl-id
这很有价值,但不能赋予它文档没有承诺的能力。文档明确说明,Next.js 不会读取入站的 ?dpl= 参数并据此把请求路由到某个版本。这个参数用于避免命中旧缓存。如果自托管源站已经物理删除旧资源,一个查询参数不会把文件重新变出来。
因此我把 deploymentId 看作版本错位检测和恢复机制,而不是良好部署流程或旧资源保留的替代品。
如果托管平台实现了按版本路由,基础设施可以做得更多。例如,当前的 Vercel 版本错位保护文档描述了版本锁定,使框架管理的请求可以继续解析到最初服务该客户端的部署。这属于平台能力,我不会假设任意 Nginx 或 CDN 配置天然具备它。
构建标识与部署标识解决相关但不同的问题
Next.js 在执行 next build 时还会生成构建标识。如果多个容器本应服务同一次部署,就不应因为每台服务器都独立执行了一次构建而悄悄变成不同构建。
确定性的构建标识可以绑定到发布标识,例如某个 Git 提交:
// next.config.js
const nextConfig = {
generateBuildId: async () => process.env.GIT_SHA,
deploymentId: process.env.GIT_SHA,
}
module.exports = nextConfig
这个例子只用于说明,并不是从我的生产代码复制出来的。重要的架构原则是:一个逻辑发布,应在所有服务它的实例上使用同一个一致构建产物和同一个部署身份。
generateBuildId 用于标识 Next.js 构建。deploymentId 则明确用于版本错位保护和避免旧缓存。两者有关联,但把名称当成同义词只会让排障更困难。
我会先发布资源,再把流量切到新文档
更安全的部署顺序是刻意不对称的。新的不可变资源可以在任何人引用它们之前就存在;新的 HTML 则不应引用尚未可用的资源。
概念上,我希望顺序如下:
1. 只构建一次版本 B
2. 上传 B 的 /_next/static 资源
3. 验证所需资源确实可以获取
4. 启动或准备 B 的服务器和运行时
5. 检查 B 的健康状态
6. 原子地把新文档流量切换到 B
7. 保持 A 的静态资源继续可用
8. 监控 B
9. 稍后再回收旧资源
如果应用采用静态导出,同样遵循这个原则:先上传带版本的资源,再发布引用它们的 HTML。如果采用反向代理后的服务器端渲染,则先准备新服务器,并只在健康检查通过后切换流量。
回滚也应当对称。保留上一版本目录和它的静态资源,才能在需要时回滚应用,而不必临时重新拼凑旧文件。
但这并不能让所有回滚都变得安全。数据库迁移或不兼容的后端接口约定,可能导致旧应用版本即使 JavaScript 文件还在也无法工作。静态资源保留解决的是静态兼容性问题,而不是系统中所有发布兼容性问题。
共享的不可变资源目录很适合简单自托管
对于规模不大的 Nginx 部署,一个直接的模式是把当前应用版本与共享静态资源库分开。
示例目录结构可以是:
/srv/app/releases/2026-08-13-a/
/srv/app/releases/2026-08-13-b/
/srv/app/current -> /srv/app/releases/2026-08-13-b/
/srv/app/shared/_next/static/...
每次部署都把新的 /_next/static 文件添加到共享目录,而不删除仍需保留的旧版本文件。Nginx 可以用不可变策略服务这个路径:
location ^~ /_next/static/ {
root /srv/app/shared;
add_header Cache-Control "public, max-age=31536000, immutable";
}
这段配置只是示例,并不表示我实际使用的 Nginx 配置就是这样。真实部署还要考虑权限、MIME 类型、不同压缩形式、CDN 行为以及构建输出的具体布局。
重要的是架构:指向当前版本的可变指针,与主要只追加内容的版本化资源库,应当拥有不同生命周期。
HTML 需要与哈希分块文件不同的缓存策略
最容易重现这个问题的方法,就是像缓存带内容哈希的资源一样缓存 HTML。
对动态渲染的 Next.js 页面,框架通常对用户特定的动态输出使用不可缓存的响应语义。静态页面和 ISR 页面采用不同策略,CDN 可以合理缓存它们。由 Nginx 提供的静态导出则更加依赖运维人员自行设置的响应头。
所以我不会给“整个网站”套用一条统一缓存规则,而是按对象类型思考:
/_next/static 中的哈希资源
较长的 max-age
immutable
可以安全保留
HTML / 路由文档
必须能够迁移到新版本
策略取决于渲染方式
存活时间不能超过它所引用的资源
RSC / 导航数据 / API 数据
使用独立的兼容性与新鲜度规则
如果使用 CDN,根据缓存设计,部署后可能需要清除新文档路径。仅仅因为出现新版本就清除 CDN 中的旧哈希 分块文件,通常会适得其反:如果源站也已删除它们,清除操作就移除了本来可能救下旧客户端的最后一份副本。
Next.js CDN 缓存指南在这里很有帮助,因为它把页面缓存与 /_next/static 资源使用的一年期 immutable 策略区分开来。
自动重新加载是恢复工具,不是主要部署策略
分块文件加载失败后,常见做法是“直接刷新页面”。这通常有效,因为完整页面导航会获取当前文档,而当前文档引用当前构建。
但每遇到脚本错误就盲目刷新会制造新的问题:
- 第三方脚本失败也可能触发毫无意义的刷新;
- 真正的服务器故障可能造成无限刷新循环;
- 未保存表单可能丢失用户输入;
- 完整页面导航会丢失 React 组件状态;
- 同一个损坏部署可能刷新后继续失败。
当前 Next.js 文档也明确提醒,用于版本错位恢复的完整页面导航可能丢失 useState 之类的组件状态,而 URL 或浏览器持久存储中的状态仍可能保留。
如果我增加客户端恢复,我希望它范围很窄,而且只尝试一次。示例实现可以是:
const RECOVERY_KEY = 'next-chunk-recovery-attempted'
function isOwnNextAsset(url: string) {
try {
const parsed = new URL(url, window.location.href)
return (
parsed.origin === window.location.origin &&
parsed.pathname.startsWith('/_next/static/')
)
} catch {
return false
}
}
window.addEventListener(
'error',
(event) => {
const target = event.target
if (!(target instanceof HTMLScriptElement)) return
if (!isOwnNextAsset(target.src)) return
reportChunkFailure({
page: window.location.href,
asset: target.src,
})
if (sessionStorage.getItem(RECOVERY_KEY)) return
sessionStorage.setItem(RECOVERY_KEY, '1')
window.location.reload()
},
true,
)
这只是刻意简化的示例。生产实现还应考虑样式表 分块文件、框架已知错误形式、刷新会破坏用户工作的流程,以及健康加载后如何清除恢复标记。
对于编辑器、结账流程或长表单,我可能更愿意显示“有新版本可用;请先保存工作,再重新加载页面”的提示,而不是强制刷新。
监控数据应当告诉我,这是否真的属于版本错位
只有“脚本加载失败”这一句话远远不够。要区分被删除的旧分块文件与偶发网络错误,我需要与部署有关的上下文。
有用的字段包括:
- 加载失败资源的 URL;
- 当前页面 URL;
- 资源是否属于应用自身;
- 客户端可见的发布或部署标识;
- 浏览器和操作系统;
- 把
navigator.onLine作为弱信号,而不是连接可用的证明; - 页面加载后经过的时间;
- 错误是否发生在部署后不久;
- 这是否是第一次恢复尝试;
- 在服务器端可观察时记录 HTTP 状态;
- 当时在源站或代理上实际服务该请求的发布版本。
有了这些信息,模式就会清晰得多。
如果不同网络上的大量用户都请求旧的哈希分块文件URL,而且新版本发布后源站立刻返回 404,那么没有保留旧资源就成为很有力的解释。如果只有一个用户遇到没有 HTTP 响应的网络层错误,版本错位就远没有那么确定。如果分块文件返回 200,却带着错误的 MIME 类型或返回 HTML 错误页,问题就在路由或代理配置,而不只是资源保留。
我还会把应用自身分块文件的失败与第三方资源失败分开告警。这是最直接由原始日志支持的监控改动:真正有意义的信号混在大量与应用损坏无关的浏览器噪声里。
复现测试很简单,但必须保留原来的旧标签页
这类错误很容易从普通发布测试中漏掉,因为工程师部署后往往立刻刷新页面。而刷新恰恰会破坏我们想测试的条件。
更好的手动测试是:
- 部署版本 A;
- 在接近生产的环境中打开标签页,并保持浏览器缓存开启;
- 只访问应用的一部分,让某些路由或按需加载功能仍未加载;
- 保持这个标签页打开;
- 部署版本 B;
- 不要刷新旧标签页;
- 触发一个需要此前尚未加载代码的路由或动态功能;
- 检查 Network 和 Console;
- 确认旧资源 URL 仍返回 200;
- 确认版本错位检测会在适当时执行受控的完整页面导航。
我还会在前方使用 CDN、滚动部署时同时运行两个服务器实例,以及已配置保留窗口到期之后,重复同样的测试。
一个不太明显的测试错误,是在 DevTools 中对所有内容启用“Disable cache”。这对某些诊断有用,但会改变浏览器行为。长期打开标签页的场景也应使用真实缓存来测试,因为浏览器缓存本身就是系统的一部分。
并不是每个分块文件失败都能靠保留旧文件解决
资源保留之所以有价值,正是因为它解决的是一个明确而狭窄的机制。它不应变成另一个万能解释。
应用自身的分块文件可能因为以下原因失败:
- 请求根本没有到达服务器;
- 连接被中断;
- 浏览器扩展拦截了请求;
- 某个 CDN 边缘节点临时故障;
- Nginx 把路径路由错了;
- 服务器返回了 HTML 错误文档而不是 JavaScript;
- 压缩或
Content-Encoding损坏; - 文件权限错误;
- 不完整部署根本没有上传这个 分块文件;
- 文件原本存在,但被过早删除;
- 客户端与服务器运行的是不兼容版本。
响应码和发生时间都很重要。每次发布后旧内容哈希 URL 都持续返回 404,与某个移动网络上一次 ERR_CONNECTION_RESET 讲述的是完全不同的故事。
所以我不会把原始事件改写成“我证明了过期 HTML 导致网站损坏”。我没有证明这一点。我观察到应用自身分块文件的真实失败,并把版本错位识别为值得通过设计消除的一种严重故障机制。
最安全的部署会把旧客户端视为发布范围的一部分
更深层的错误,是认为部署会在某一个瞬间把版本 A 完整替换成版本 B。
在服务器上,符号链接或容器编排器看起来也许就是这样。但网络中可能仍有旧 CDN 对象。浏览器里的 A 文档可能在 B 上线很久之后仍继续运行。滚动发布期间,两种服务器版本可能同时活跃。回滚时,B 也可能消失,A 再次成为当前版本。
所以真正的发布范围是一段时间,而不是一个时间点。
我现在围绕这个认识制定 Next.js 应用的部署规则:
- 每个逻辑发布只构建一次。不要让副本悄悄产生互不相同的构建输出。
- 先发布不可变资源,再发布对这些资源的引用。
- 在有意设定的兼容窗口内保留旧的哈希资源。
- 不要给可变 HTML 设置与哈希分块文件相同的缓存策略。
- 如果部署模型可能产生版本错位,就使用
deploymentId。 - 只有托管平台确实提供按版本路由时,才依赖平台级的错位保护。
- 恢复只尝试一次,并考虑用户当前状态。
- 把应用自身分块文件失败作为独立生产信号监控。
- 部署测试时保持一个旧标签页仍然打开。
- 旧资源以后再回收,不要在启用新版本的同一步骤中删除。
我现在采用的原则
构建通过、刚打开的新页面工作正常,并不能证明这次部署对已经在应用里的用户同样安全。
旧标签页不是无关紧要的残留物。它是真实客户端,正在运行真实的上一版本。
当我开始用这种方式看待部署之后,分块文件 问题就没那么神秘了。内容哈希给资源一个稳定身份,长期缓存让这个身份高效可用。但部署系统必须在足够长的时间里尊重这个身份,或者给客户端一个受控的前进方式。
我不需要让每个旧版本永久存活。我需要系统安全度过旧客户端与新服务器合理并存的那段时间。
现在我真正关心的部署契约是:新用户得到新版本,旧用户不会丢失其当前版本仍会请求的文件,而任何剩余的版本错位都会进入预先设计的恢复路径,而不是落到损坏页面。