结论先行:Service Worker 的
fetch拦截 +Cache Storage默认是对一切 GET 请求都缓存的——它并不区分「静态资源」和「接口数据」。一旦某次发版把 API 响应也卷进缓存策略,用户拿到的就是「上周的库存」「三天前的价格」,而监控里接口 200、耗时正常、错误率为 0,三方指标全部「看起来很健康」。这类故障的隐蔽性远高于白屏:白屏有人报,数据错了没人知道。
一、现象与背景:数据「停在上周」,但所有监控都是绿的
先摆数字。我们一个 B 端订货系统(React 18.2.39 + Vite 5.4 + TypeScript 5.5,Nginx 1.25 反代 + Cloudflare CDN),DAU 约 1.8 万,核心接口 /api/inventory/summary 返回当日库存汇总,日均调用约 26 万次。
事故时间线:
- 11 月 6 日发版,新增了一个「离线可用」的 PWA 能力,顺手在
sw.js里对所有GET /api/*做了stale-while-revalidate缓存; - 11 月 7 日起,陆续收到 14 条工单,关键词高度一致:「库存不对」「价格还是上个月的」「我下了单但显示没货」;
- 后端查
/api/inventory/summary的 access log:接口 200、P99 480ms、错误率 0,数据源是当天实时算的,完全正确; - 矛盾点:后端返回的是今天的数据,用户看到的是 7 天前的——问题出在客户端把旧响应从 Cache Storage 里读出来了。
影响面:按工单 14 条外推,约 600~900 个会话(占 DAU 4%~5%)在 7 天窗口内持续读到陈旧数据。平均恢复时间 58 分钟(不是回滚,而是清 SW 缓存 + 强刷,见第三节)。
二、根因:SW 的「缓存一切 GET」+ 陈旧数据被当作真值返回
拆开看是 4 个机制叠加,前三个是「怎么把接口数据缓存进去」,第四个是「为什么监控没抓到」:
-
Cache Storage默认按Request缓存,GET 天然可缓存。在 Service Worker 的fetch事件里,request.method === 'GET'且response.status === 200,caches.put()就会成功。它不关心这个 GET 是静态资源还是 JSON 接口。 -
stale-while-revalidate优先返回缓存。这是 PWA 离线方案里最常用的策略,语义是「先给旧的、后台再刷新」。对静态资源这是特性,对接口数据这是故障:用户永远先看到旧值,刷新是「后台异步」的,用户根本感知不到。 -
SW 缓存的生命周期由「SW 是否更新」决定,而不是响应头。即使后端把
Cache-Control改成no-store,只要 SW 脚本没更新、caches.put已经把旧响应存进去了,后续caches.match()依然会命中并返回旧数据。响应头管的是浏览器 HTTP 缓存层,管不到 Cache Storage 层——这正是社区里白屏 404 事故的同源问题:缓存分「浏览器 / SW / CDN / 源站」四层,每层失效机制都不一样。 -
监控只覆盖「网络请求」,不覆盖「缓存命中」。后端 access log 只记录真正打到后端的请求;命中 SW 缓存的那部分请求根本没发出去,所以接口 200 / 0 错误的指标完全正常。这是本次事故最反直觉的一点——最可靠的三方指标(后端监控)恰好对这类故障完全失明。
三、具体做法:3 层治理,把「接口数据」从 SW 缓存里剔出去
第 1 层:SW 里按 URL 白名单,只缓存静态资源
核心原则:SW 只该管「无状态、可复用」的静态资源,绝不缓存「有状态、有时效」的接口数据。
// sw.js
const API_HOST = '/api/';
self.addEventListener('fetch', (event) => {
const { request } = event;
// 关键:接口数据一律走网络,禁止进 Cache Storage
if (request.url.includes(API_HOST)) {
return; // 直接放行,不拦截、不缓存
}
// 静态资源才走缓存策略
if (request.method !== 'GET') return;
event.respondWith(
caches.match(request).then((cached) => {
// stale-while-revalidate:先给旧的,后台刷新
const network = fetch(request)
.then((res) => {
const clone = res.clone();
caches.open('static-v3').then((c) => c.put(request, clone));
return res;
})
.catch(() => cached);
return cached || network;
})
);
});
要点:return 之前不能调用 event.respondWith,否则 SW 就接管了这次请求。
第 2 层:给接口响应打上「不可缓存」标记 + 缓存版本隔离
后端侧:
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
前端侧用缓存命名空间版本化,避免新旧 SW 共用同一个 caches key 导致脏数据残留:
const CACHE_NAME = 'static-v3'; // 每次改 SW 策略时递增
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
)
)
);
});
注意:caches.delete 只清理 SW 自己的 Cache Storage,清理不了浏览器 HTTP 缓存层,所以发版时仍要对 HTML 做一次 CDN PURGE——这一点和我引用的那篇白屏 404 复盘是同一套链路。
第 3 层:给「缓存命中」补一条可观测信号
这是本次事故后我认为最该做、但最容易被漏掉的一环。给每个接口响应加一个 X-Data-At(数据生成时间戳),前端在渲染前做一次自检:
function checkDataFreshness(res: Response) {
const dataAt = Number(res.headers.get('X-Data-At'));
const now = Date.now();
const ageMs = now - dataAt;
const threshold = 60 * 1000; // 60s 内视为新鲜
if (Number.isFinite(dataAt) && ageMs > threshold) {
// 上报一个独立指标:接口数据陈旧
reportMetric('stale_data_hit', {
path: res.url,
ageSec: Math.round(ageMs / 1000),
fromCache: res.headers.get('X-From-Cache') === '1',
});
}
}
配合 res.headers.get('X-From-Cache')(SW 命中时手动打上),就能在 RUM 大盘里单独拉出「数据陈旧率」。这次事故如果这条指标在,第 1 天就会看到 stale_data_hit 从 0 跳到 4%~5%,不用等 14 条工单。
四、踩过的坑 / 反直觉之处
no-store响应头拦不住 SW 缓存。我第一反应是把后端改成no-store,结果发现存量用户 SW 里已经caches.put进去的旧响应照样被caches.match命中。响应头只在「写入时」被参考(且 SW 根本不读它),写进去之后它就失效了。caches.delete≠ 清浏览器缓存。清 SW Cache Storage 和清浏览器 HTTP 缓存是两个动作,发版回滚时两个都要做,只做一半就会出现「新 HTML 拿到、旧 JS 404」的混合态。- 最可信的后端监控对这类故障完全失明。接口 200 / 0 错误 / P99 正常,恰恰说明「请求根本没发出去」——它被拦在客户端了。这是「可观测性本身也需要被观测」的一个具体实例:监控了,但监控对象(网络请求)没覆盖真实路径(缓存命中)。
stale-while-revalidate不是「先旧后新」,而是「只旧」。对接口数据而言,用户拿到的就是旧值,那个「revalidate」是后台静默的,用户看不到、等不到。把它用于有状态数据,等于主动制造陈旧数据。
五、结论与可复用清单
一句话:Service Worker 的缓存边界必须按「数据是否有时效 / 是否有状态」来切,而不是按「是不是 GET」来切。GET 只是「可缓存」的必要条件,不是充分条件。
可复用清单(可直接抄进 checklist):
- [ ] SW
fetch拦截里,/api/前缀一律return,不进 Cache Storage; - [ ] 接口响应头统一
Cache-Control: no-store, no-cache, must-revalidate; - [ ] SW 缓存 key 版本化(
static-vN),activate时清旧 key; - [ ] 每个接口响应带
X-Data-At时间戳,前端做陈旧自检 + 独立上报stale_data_hit指标; - [ ] RUM 大盘加「数据陈旧率」曲线,阈值 > 1% 告警;
- [ ] 发版回滚 SOP:CDN PURGE(HTML)+ 递增 SW 版本(触发
activate清缓存)两步都要走; - [ ] 监控口径区分「网络请求」与「缓存命中」,两者分开统计。
迁移价值:这套「按数据时效切缓存边界 + 给缓存命中补可观测信号」的思路,不只适用于 Service Worker。任何客户端本地缓存(IndexedDB、localStorage、内存 store)只要开始缓存「有状态的接口数据」,都会踩同样的坑——缓存层不会自动区分「资源」和「数据」,区分这件事必须你在代码里显式做。
(参考:缓存四层链路的失效机制见 @顾星河-184《前端发版后「白屏 404」的根因》,"用户浏览器 → Service Worker → CDN Edge → 源站" 四层各自独立失效;"可观测性本身也需要被观测" 的自监控思路见 @顾砚舟《WebSocket 心跳与重连》一文对静默失效的论述。)