我花3天抓了一个幽灵bug,凶手藏在第4层

用户说页面内容没更新。我一看,明明刚发版。让他清缓存,好了。

第二天,另一个人报同样的问题。

我以为是个例,结果一周内收了 4 个类似的反馈。每次都是「清缓存就好了」,但清缓存不应该是一个生产环境的解决方案------那是用户在替我擦屁股。

我决定彻底查清楚。这一查就是 3 天,因为我低估了缓存的层数。

第1天:前两层,以为是浏览器的问题

打开 DevTools,Network 面板,复现问题。请求显示 from memory cache

内存缓存。最表层的一层。GET 请求返回 200 后浏览器存进内存,下次同资源直接从内存返回,关标签就没了。

用户只会按 F5 刷新,不会 Ctrl+Shift+R 强刷新。普通刷新在缓存有效期内不重新请求------所以用户永远看到旧的。

我加了 Cache-Control: no-cache,让浏览器每次都验证。心想搞定了。

第二天,新的反馈来了。

第2天:第3层,Service Worker 在中间搞鬼

这次我学乖了,先让用户开 DevTools 看请求到底走的哪条路。

from service worker

不是浏览器缓存的问题。是 Service Worker。

SW 拦截所有网络请求,它的更新机制是反直觉的:浏览器检测到 SW 文件有变化,下载新文件,但旧的 SW 仍然控制着页面。新 SW 进入 waiting 状态,要等所有旧页面都关闭才激活。

用户不关标签页------谁每天会关浏览器标签页?------旧 SW 就永远不会被替换。我发的新版本,用户可能三天后才看到。

加了 skipWaiting() 让新 SW 立即接管,配合 install 阶段清旧缓存。

javascript 复制代码
self.addEventListener('message', (e) => {
  if (e.data?.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

提交,发版。心想这次真的搞定了。

第三天,又来了一个反馈。

第3天:第4层和第5层,真正的凶手

我开始怀疑人生。DevTools 里请求显示正常,走网络,200。没有 memory cache,没有 service worker,没有 disk cache。

但用户看到的还是旧的。

我盯着 Response Headers 看了很久,然后注意到一行:

arduino 复制代码
Cache-Control: public, max-age=31536000, immutable

一年。整个站点被 Nginx 配了一年的缓存。

JS 和 CSS 文件名有 hash,每次构建生成新文件名,所以长缓存没问题。但 index.html 也被缓存了一年。用户拿到的是旧 HTML,旧 HTML 引用的是旧 JS 文件名------新 JS 永远不会被请求到。

这才是真正的凶手。前两层是表象,第3层是帮凶,第4层才是主谋。

修 Nginx 配置,HTML 和静态资源分开处理:

nginx 复制代码
# HTML:每次都验证
location / {
    add_header Cache-Control "no-cache";
}
# 带 hash 的静态资源:长缓存
location /assets/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

HTML 是小文件,no-cache 验证成本几乎为零。静态资源靠文件名 hash 保证内容变了 URL 就变,可以放心长缓存。

改完测试,终于好了。

但我又多花了一天,因为还有一个隐藏的第5层。

第5层:我们自己写的缓存

修完 Nginx 后,有一个页面还是偶尔出现旧数据。这次不是浏览器的问题,不是 SW,不是 HTTP 头------是我们自己代码里的 React Query 缓存。

typescript 复制代码
// 问题:tab 切换了,但 queryKey 没变
const { data } = useQuery(['list'], () => fetchList(tab));

tab 从「待处理」切到「已完成」,请求参数变了,但 queryKey 还是 ['list']。React Query 认为数据没变,直接返回旧缓存。

改成 ['list', tab],搞定。

还有一个坑:staleTimegcTime 搞混了。staleTime 控制多久内不重新请求,gcTime 控制无用数据多久后被清理。设反了,要么用户看到过期数据,要么请求太频繁。

3天抓到的5个凶手

回头看这 3 天,缓存 bug 难排查不是因为某一层特别复杂,而是因为你根本不知道凶手藏在哪一层。从外到内,至少叠着 5 层:

  1. 内存缓存------进程内存,关标签就没了
  2. 磁盘缓存------硬盘上,跨会话存活
  3. Service Worker------拦截网络请求,旧 SW 不走新 SW 进不来
  4. HTTP 缓存头------服务器说了算,HTML 被长缓存锁死是最常见的坑
  5. 应用层缓存------React Query / SWR / 你自己写的 Map

排查方法就一个:打开 Network 面板,看请求的 from

  • from memory cache → 第 1 层
  • from disk cache → 第 2 层
  • from service worker → 第 3 层
  • 直接走网络但内容旧 → 第 4 层或第 5 层

Ctrl+F5 只解决前两层。如果你按了强刷新还是旧的,问题在更深的地方。

一个教训

以前遇到缓存问题,我的第一反应是「清缓存试试」。现在我知道这句话的潜台词是「我不知道哪一层出了问题,但你先试试重启」。

下次别清缓存了。打开 DevTools,看 Network 面板的 from 列------第一线索就在那里。


相关推荐
IT_陈寒2 小时前
Java里用Stream.parallel()翻车实录,这性能还不如单线程
前端·人工智能·后端
wangchunyu1142 小时前
JavaScript 零基础入门教程
前端
hanchenxing3 小时前
前端异步任务三方案:Celery vs Redis Stream vs BullMQ 实战对比消息队列
前端·数据库·redis·异步任务·方案对比
dong_junshuai3 小时前
每天一个开源项目#100 OpenCodeReview:2.6万星的低噪声AI审查器
程序员·开源·github
mONESY3 小时前
LangGraph.js 从零上手:把 Agent 工作流从一条线变成一张网
javascript
默_笙3 小时前
⚓ AI 的"官方答题卡":withStructuredOutput 与结构化输出的终局之战
前端·javascript
掘金挖土3 小时前
前端手摸手跑路之 AI 应用开发(五)
前端·后端
弈栈录3 小时前
LangGraph 入门:用状态机设计可靠的 Agent 工作流
后端·程序员
小羊没烦恼!4 小时前
Memory 记忆设计讨论:Agent Memory 的数据模型可以怎么设计
java·开发语言·前端·c++·c#