跳转到内容

Service worker 缓存让旧版本持续数周

发布于

一个应用的 service worker 连续三周向部分用户提供旧版本。2026-09-21 发布的线上故障记录显示,部署状态成功,新代码在作者自己的环境中运行正常,控制台和服务器也都没有报错。受影响的浏览器一直返回缓存中的 HTML、JavaScript 与 CSS,没有请求服务器上刚部署的文件 (原始报告)。

作者先部署一个替代 service worker,让它立即激活、删除全部缓存、注销自身,并重新加载已经打开的页面。这个恢复用 worker 随后被另一个最小 worker 替代;后者不注册 fetch 处理器、不存储任何内容,并在激活时删除残留缓存(原始报告)。

这个应用的 service worker 原本是为了让应用可以安装到移动设备而加入的。报告称它只是一个不大的 sw.js 文件,但它的缓存行为覆盖了应用的导航文档、JavaScript 与 CSS。因此,服务器上虽然已经有新版本,受影响的浏览器仍然握有并继续使用旧资源(原始报告)。

这一区别解释了故障期间互相冲突的观察结果。作者部署修复后能在自己的环境看到它生效,部分用户却连续多日仍看到早先版本。部署状态与服务器输出只说明服务器能够提供什么;对这些浏览器而言,已安装的 worker 决定了它们不会回到网络请求被缓存的资源(原始报告)。

在找到 worker 之前,作者依次调查了部署、托管服务商的缓存、DNS 传播和应用代码。作者记录,,应用被重复部署了多次,作者还修改了本来正常工作的代码,因为此前的修复看起来没有生效。这些操作都没有改变正在向浏览器提供旧应用的本地缓存(原始报告)。

报告把问题长期存在归因于两项 service worker 生命周期细节。它称浏览器检查的是 sw.js 文件本身,所以该文件没有变化时就不会安装替代 worker;即使替代 worker 已经到达,只要应用标签页仍然打开,它默认仍会等待。因此,一直保留标签页的用户可能长时间继续受旧 worker 控制(原始报告)。

W3C explainer 独立描述了相同的生命周期边界:在 worker 作用域内导航时,浏览器会在后台检查更新;当 worker 脚本的字节内容不同时,新版本会被安装;旧版本则继续控制页面,直到使用它的标签页全部离开。Service Workers 规范把一个注册定义为同时容纳 installing、waiting 与 active worker,并说明 skipWaiting() 可让 worker 在仍有客户端使用该注册时从等待状态继续走向激活(W3C service worker explainer;Service Workers 规范)。

报告给出了 Chrome DevTools 中的两项检查。打开 Application,再进入 Service Workers;若 worker 显示为 waiting,就表明替代版本尚未取得控制权。再进入 Application 下的 Storage 并清除站点数据;在这次故障中,如果清除后出现预期版本,就说明旧画面来自缓存(原始报告)。

这两项检查把浏览器保留的状态与再次部署区分开。第一项展示生命周期状态,第二项直接改变报告最终认定为关键因素的本地状态。作者清除站点数据后看到了正确版本,因而能把 service worker 缓存与先前调查过的部署、托管缓存、DNS 和应用代码等解释分开(原始报告)。

报告采用的解决办法并不是从服务器删除 sw.js。作者指出,已经安装旧 worker 的浏览器仍会保留它,所以第一个替代版本被明确设计成一个清理开关。它的 install 监听器调用 self.skipWaiting(),activate 监听器则等待一组异步清理操作完成(原始报告)。

激活阶段先通过 caches.keys() 取得缓存名称,再用 caches.delete() 逐个删除;随后通过 self.registration.unregister() 注销 worker,并用 self.clients.matchAll({ type: 'window' }) 找出窗口客户端。最后,每个返回的客户端都被导航到它已有的 URL。这些都是报告恢复示例里的原样操作(原始报告)。

报告也解释了 skipWaiting() 在这个序列里的作用:新 worker 无需等待所有应用标签页关闭即可激活。激活之后,它清空缓存、注销自身,并重新加载已打开的页面。这个序列面向仍被早先安装版本控制的客户端,不要求每位用户自行发现并清除站点数据(原始报告)。

清理开关只是一次性恢复步骤。作者仍然希望应用保持可安装,所以随后换成了一个最小 service worker。第二个 worker 同样在安装时调用 skipWaiting();激活时,它列出并删除每个缓存,然后调用 self.clients.claim()(原始报告)。

这个最小 worker 没有 fetch 监听器。报告将其结果描述为不拦截请求,并让浏览器始终使用网络;它的激活清理也会删除早先实现遗留的缓存。这项设计符合作者写出的需求:保留 worker,同时结束导致本次故障的缓存行为(原始报告)。

对于有意支持离线使用的应用,报告划出了另一条边界:不要对 index.html 使用缓存优先策略;给缓存名称加版本,例如 cache-v2 与 cache-v3;在激活时删除旧缓存;并配置托管服务,避免缓存 sw.js 本身。这些建议尚未经独立复现(原始报告)。

部署状态与浏览器返回的内容没有对上。部署成功只能说明服务器上存在新文件,本地显示正确只能说明一台浏览器能够取得它们;两者都不能证明一个仍受旧 service worker 控制的浏览器会返回什么响应。需要单独检查 worker 与缓存,确认浏览器实际在运行哪个版本(原始报告)。

报告也说明了为什么反复修改应用代码在本次故障中无效。受影响客户端没有从服务器请求那些发生变化的资源。在 worker 生命周期推进、缓存被清除之前,再次部署应用不会触及提供旧响应的机制。因此,作者先让恢复版本处理已安装的 worker,之后才进入最小 worker 的稳定状态(原始报告)。

阅读两段代码时,恢复阶段与稳定阶段的区别同样重要。第一个 worker 同时删除缓存与自身注册,然后重载客户端;第二个 worker 保持注册、接管客户端,但不拦截请求。把它们看作两个阶段,才能保留报告赋予每段代码的用途(原始报告)。

最后,这条诊断路径从检查浏览器状态开始,而不是先修改原本正常的代码。报告结尾的建议是:下一次部署看起来没有生效时,先清除站点数据,再动应用代码。在这次事件中,这个动作直接检验了缓存假设,也让浏览器显示出服务器上的版本(原始报告)。

  • DevTools 观察所用的 Chrome 与操作系统版本尚未确认。
  • 每位受影响用户是否保留了完全相同的缓存内容尚未确认。
  • 每个受影响浏览器检查 sw.js 的频率尚未确认。
  • 诊断前是否已有受影响浏览器下载过替代 worker 尚未确认。
  • 托管产品以及 sw.js、index.html、JavaScript 与 CSS 返回的缓存控制响应头尚未确认。
  • 在该应用的目标浏览器里,最小 service worker 是否是可安装性的必要条件尚未确认。
  • 两个替代 worker 跨部署的具体顺序,以及各自保留了多长时间尚未确认。
  • 每个受影响设备上的开放客户端是否都成功完成了重新加载尚未确认。