前端 OSS + CDN 缓存及发布流程优化方案
最近排查了一次前端偶发白屏问题,最后发现问题并不在 Vue,也不完全是浏览器缓存,而是静态资源的发布方式:每次上线前先清空对象存储,再重新上传构建产物。这个看起来再普通不过的发布流程,在 Hash 文件、动态 Chunk 和 CDN 同时存在时,其实埋着一个很容易被忽略的资源一致性问题。
最近碰到一个前端线上问题。Vue 项目正常构建,CI/CD 也正常发布,大多数用户访问没有问题,但偶尔会有人遇到 JS 加载失败、ChunkLoadError,严重一点直接白屏。
第一反应很容易想到缓存。是不是浏览器缓存了旧 JS?是不是 CDN 没刷新?是不是 Vue Router 懒加载的问题?
查到最后发现,这些都只是表象。
真正的问题出在一个用了很久、看起来也很合理的发布流程:
Vue Build → 清空对象存储 → 上传新的 dist问题恰恰就出在"清空"这一步。
一个看起来没问题的发布流程
Vue 生产构建之后,JS 和 CSS 通常都会带 Hash。
比如 V1:
app.a81f93.js
UserPage.b72ac1.js重新构建 V2 后可能变成:
app.c93fd2.js
UserPage.e82da4.js这么设计本来就是为了缓存。文件内容变化,Hash 跟着变化,URL 也就变了。所以 app.a81f93.js 和 app.c93fd2.js 实际上可以看成两个完全不同的静态资源。
问题在于,我们过去的发布方式会在上传 V2 之前,把 V1 的资源全部删除。
于是一个很微妙的时间窗口出现了。
假设某个用户在 V2 发布前已经打开了网站。他的浏览器现在运行的是 app.a81f93.js。这时候开始发布 V2。旧资源被删除,新资源陆续上传。用户本身可能什么感觉都没有,因为主 JS 已经加载到了浏览器里。
但当他点击一个之前没有访问过的页面时,Vue 开始加载动态 Chunk:
UserPage.b72ac1.js问题来了。这个文件刚刚已经被发布流程删除了。
旧页面 → 旧 Runtime → 请求旧 Chunk
↓
CDN / 对象存储
↓
404
↓
ChunkLoadError如果这个 Chunk 又恰好是当前页面必须加载的代码,用户看到的就可能是一片白屏。
这时候你让用户"Ctrl + F5 刷新一下",可能真的好了。但这显然不算解决问题。
CDN 会让事情变得更复杂
继续排查之后,还发现 index.html 的缓存策略也不够明确。
这会产生另外一种情况。新版本已经发布了,但某些 CDN 节点或者客户端仍然拿到了旧 index.html。旧 HTML 里面引用的自然还是 app.a81f93.js,偏偏发布的时候又已经把这个文件删除了。
旧 index.html → 引用旧 Hash JS → 旧文件已经删除 → 404到这里就能发现,这个问题其实不应该归结为一句简单的"CDN 缓存导致的"。CDN 只是放大了问题。
真正的问题是:我们的发布流程默认认为所有用户会在发布完成的一瞬间同时切换到新版本。
但现实世界根本不是这样的。有人刚打开页面,有人已经开了几个小时;不同 CDN 节点的缓存状态可能不同;浏览器里的 JS Runtime 也不会因为服务器发布了新版本就突然消失。
新旧版本同时存在一段时间,其实才是正常状态。
换个思路:不要删除旧版本
想明白这一点之后,解决方案反而简单了。
发布新版本时,不要先清空对象存储。改成:
Vue Build → 生成新 Hash 资源 → 上传 JS / CSS / Chunk
↓
检查资源是否完整
↓
最后上传 index.html
↓
刷新 CDN 入口缓存这里真正重要的是最后那一步:index.html 最后更新。
因为对于一个 SPA 来说,index.html 某种程度上就是版本入口。
在新资源还没有准备好的时候,线上继续运行 V1。等 V2 的 JS、CSS、Chunk 全部上传完成,最后再更新 index.html。版本才真正完成切换。
整个过程就从"先破坏旧版本,再创建新版本",变成"旧版本正常运行,准备完整的新版本,切换入口"。
这个区别看起来很小,稳定性却完全不同。
即使新资源上传到一半 CI/CD 挂了,也没有关系。旧 index.html 还在,旧 Hash 文件也还在。线上用户甚至不知道刚才发生过一次失败的发布。
index.html 和 Hash JS,本来就不应该用同一种缓存策略
解决发布顺序以后,还有缓存。
以前很容易陷入一种思路:有缓存问题,那就不要缓存。其实也不对。
Hash 静态资源恰恰应该大胆缓存。比如:
app.a81f93.js
chunk-vendors.b82ac1.js
app.c92fd1.css只要构建机制正常,同一个 URL 对应的内容就不会再发生变化。内容变了?那就生成 app.c93fd2.js。
所以这类资源完全可以:
Cache-Control: public, max-age=31536000, immutable一年缓存都没什么问题。
真正需要谨慎的是 index.html。它承担的是告诉浏览器"现在应该加载哪个版本"。因此它不应该长期持有旧版本,可以采用重新验证的策略:
Cache-Control: no-cache, max-age=0, must-revalidate发布之后再刷新 CDN 中对应的 HTML 入口。
最终整个缓存模型就很清楚:
index.html
│
不长期缓存 / 重新验证
│
切换版本
│
┌─────────┴─────────┐
↓ ↓
Hash JS / CSS Dynamic Chunk
│ │
└────── 长期缓存 ────┘这里还有一个以前比较容易忽略的细节:HTML 里的 <meta http-equiv="cache-control" ...> 不能替代真正的 HTTP 缓存策略。浏览器和 CDN 最终看的,还是服务器返回的 Response Header 和 CDN 配置。
所以排查这类问题时,我现在更习惯直接:
curl -I https://example.com/index.html看真实响应,而不是只检查前端代码。
那旧 JS 一直不删,不会把存储撑爆吗?
这是我改方案之后马上想到的第二个问题。不在发布时删除旧资源是对的,但永远不删除肯定也不行。
不过这里有一个很重要的区别:资源清理不应该属于发布流程。
发布解决的是"新版本怎么安全上线",清理解决的是"已经退出使用的历史资源什么时候回收"。这是两件事。
因此我最后采用的是一个保护期思路。比如:
V1 正在运行 → 发布 V2 → V2 成为当前版本,V1 进入历史版本
↓
保留一段时间
↓
确认当前版本不再引用
↓
标记为可清理
↓
对象存储生命周期自动删除保护期可以根据业务情况设置,比如 7 天。
这里还有个坑:不能简单按照文件创建时间删除。
因为一个 Hash 文件完全可能连续几个版本都没有变化。比如某个公共 Chunk 一个月没改,它的创建时间已经很早,但当前版本可能依然在引用。所以"创建时间很老"并不等于"已经没有版本使用"。
更稳妥的方式是由发布记录或独立清理任务判断资源是否真正退出线上版本,满足保护期以后再给它标记一个状态,例如 release-status = obsolete,然后让对象存储生命周期规则负责最终删除。
这样发布和清理就彻底分开了。
最后整个发布流程变成了这样
改造前:
Build → 删除所有线上资源 → Upload → 希望中间不要出问题改造后:

现在再回头看,最初的问题其实并不复杂。
只是以前我们把前端发布理解成了:把服务器上的旧文件换成新文件。
但带 Hash 的现代前端应用,更合理的理解应该是:创建一个新版本,然后切换入口。
旧版本不是在新版本发布的一瞬间就失去价值。因为互联网环境里永远存在旧页面、旧 Runtime、CDN 边缘节点、尚未完成的用户会话。所以新旧版本允许短暂共存,反而是一件正常的事情。
这次改造最后留下来的一句话,我觉得基本可以概括整个方案:
HTML 负责版本切换,Hash 资源保持不可变;发布只负责新增和切换,清理由独立流程延迟完成。
缓存本身不是敌人。真正需要解决的,是版本、资源和缓存之间的一致性。