【每日一面】浏览器缓存策略

2026-10-24 22 min 7542 字 -- 次阅读
摘要

强缓存不问服务器、协商缓存拿指纹校验,入口文件保新鲜、哈希资源吃长缓存,分层裁决的策略把缓存从玄学变成工程设计。

线上发了个紧急修复,用户却反馈看到的还是旧页面,你说说为什么?

这道题几乎每一轮面试都会换个说法出现,而答案永远指向同一套机制,浏览器缓存。缓存不是开关,而是一套由 HTTP 头声明的、分层裁决的策略:强缓存要不要问服务器、协商缓存拿什么问、问完之后听谁的。

把这三层机制搞清楚了,后面再遇到类似于,改了没生效、没改却重新下载这两类线上问题就都有了可以排查的路径。

基础问答

问:浏览器缓存有哪些类型?整体决策流程是怎样的?

答:

  1. 按存储位置分,有内存缓存(memory cache)、磁盘缓存(disk cache)、推送缓存(push cache)、Service Worker 缓存;

  2. 按协商方式分,就两类,强缓存与协商缓存。判断流程是,请求发出后先查 Service Worker,未命中再看强缓存(Cache-Control / Expires),命中直接用本地副本,状态 200,不走网络请求,未命中进入协商缓存,带 If-None-Match / If-Modified-Since 请求服务器,资源没变就返回 304,变了对返回 200 与新资源。

问:强缓存和协商缓存的区别是什么?

答:强缓存是缓存有效期内浏览器直接读本地副本,不发请求,DevTools 里显示 200 (from memory cache/disk cache),但缺点是有效期内资源更新了用户也看不到。

协商缓存是每次都发请求,但只带资源的指纹(ETag 或 Last-Modified),服务器比对后没变化就回 304(无响应体,只传头),变化了才回完整资源。一个节省请求、一个保证新鲜度,实践中总是组合使用。

问:Cache-Control 有哪些常用指令?

答:max-age=秒数(相对有效期,从响应生成时刻起算),no-cache(不是不缓存,是每次使用前必须协商),no-store(真正的不缓存,本地连存都不存),public/private(是否允许代理与 CDN 缓存),s-maxage(仅供共享缓存使用的有效期),immutable(资源永不变化,刷新也不协商),stale-while-revalidate(过期后先用旧的、后台再更新)。

Expires 是 HTTP/1.0 的绝对时间,同时存在时被 max-age 覆盖。

问:ETag 和 Last-Modified 有什么区别?

答:Last-Modified 记录资源最后修改时间,精确到秒,是 HTTP/1.0 机制,但是存在两个问题,一是一秒内改两次无法感知,二是某些文件周期性重写但内容没变,时间变了却要白白重新下载。

ETag 是资源的唯一指纹(通常是内容的哈希),内容变才变,能够很好的解决了上述两个问题,优先级也更高,协商时 If-None-Match 先于 If-Modified-Since 被比对。但代价是服务器要为每个资源计算并维护指纹。

问:Ctrl+F5 强制刷新和普通刷新(F5)有什么区别?

答:普通 F5 会让当前页面的资源跳过强缓存、直接走协商(请求带上 Cache-Control: max-age=0),协商缓存机制照常生效,资源没变仍返回 304。

Ctrl+F5 则是连协商流程也跳过,在请求中携带 no-cache,服务器必须返回完整的 200 资源,同时浏览器会把响应作为新副本存下来。所以改了代码线上没生效的问题,可以先问用户是普通刷新还是硬刷新,很多缓存问题其实就是没真正绕过协商缓存。

问:实际项目里怎么设计缓存策略?

答:按资源类型分治。带内容哈希的静态资源(main.a3f9c2.js)用长强缓存:Cache-Control: public, max-age=31536000, immutable,文件名变了缓存自然失效。HTML 入口文件禁用强缓存:Cache-Control: no-cache,保证每次拿到最新引用,这样哈希文件名的更新才能被发现。

API 接口按业务定:用户信息可短缓存,余额、库存这类强实时数据 no-store。

扩展延伸

一次请求要过几道关卡

浏览器对能不能用缓存副本的判定规则,有一条完整的链路:

flowchart TD
    A["发起请求"] --> B{"Service Worker<br/>是否拦截?"}
    B -->|"命中 fetch 处理"| C["由 SW 决定<br/>走网络 / Cache API"]
    B -->|"未注册 / 未命中"| D{"强缓存有效?<br/>Cache-Control / Expires"}
    D -->|"有效"| E["200 from cache<br/>不发请求"]
    D -->|"无效 / no-cache"| F{"协商缓存<br/>带 If-None-Match / If-Modified-Since"}
    F -->|"未变"| G["304 Not Modified<br/>复用本地副本"]
    F -->|"已变"| H["200 返回新资源<br/>并更新缓存"]

    style E fill:#e8f5e9,stroke:#1a7a3a,stroke-width:2px
    style G fill:#e8f5e9,stroke:#1a7a3a
    style H fill:#fff4e5,stroke:#e67e22

四级存储位置从先到后依次被查找:

存储位置查找顺序生命周期特点
Service Worker① 最先由 SW 策略决定可编程、可离线
memory cache②随页面 / 浏览器关闭释放读取最快,容量小
disk cache③持久化,跨会话容量大,是主战场
push cache④ 最后兜底(历史/少见机制)会话级,约 30 分钟HTTP/2 推送资源的临时存放(现代浏览器基本已经弃用,这里给读者看看,以后见了不会懵逼)

两级缓存的对照:

维度强缓存协商缓存
是否发请求不发发(只有请求头)
命中时状态200 (from cache)304
关键 HTTP 头Cache-Control、ExpiresETag / Last-Modified
响应体无304 无,200 有
新鲜度保证有效期内不保证每次校验,始终最新
适用资源内容哈希文件HTML 入口、低频更新资源

这里存在几个容易被忽略的细节:

  • from memory cache 与 from disk cache 的取舍:内存缓存读得快但随页面关闭释放,通常给小体积、高频复用的资源(脚本、样式);磁盘缓存持久,容量大,大图、字体、视频多落盘。这是由浏览器自行决定的,开发者无法指定。

  • 304 响应也会更新缓存元信息:协商成功时,服务器可在 304 里带回新的 Cache-Control,悄悄延长本地副本有效时间。

  • URL 是缓存主键,但不是唯一键:缓存主键通常是 method + URL,再叠加 Vary 指定的请求头和分区缓存键(顶级站点等)。同域同路径才是同一条缓存;query 不同即视为不同资源,这也是加版本号参数 ?v=2 能刷缓存的原理,但它的可靠性不如文件名哈希,因为代理也可能忽略 query。

协商缓存

以 ETag 为例,一次完整的协商如下:

sequenceDiagram
    participant B as 浏览器
    participant S as 服务器

    B->>S: GET /app.js(首次)
    S-->>B: 200 + ETag: "a3f9c2" + Cache-Control: no-cache
    Note over B: 存副本,记住指纹

    B->>S: GET /app.js + If-None-Match: "a3f9c2"
    alt 内容未变
        S-->>B: 304(无响应体)
        Note over B: 复用本地副本
    else 内容已变
        S-->>B: 200 + 新资源 + 新 ETag: "b7e1d4"
    end

两种指纹的对比:

维度Last-ModifiedETag
本质修改时间内容指纹
精度秒级无限制
分布式一致性服务器时间可能不一致需保证各节点算出同一指纹
内容不变但时间变了误判为已更新,白白下载正确判断未变
性能开销几乎为零需计算哈希、存储映射
优先级低(同时存在时被忽略)高

实践中 ETag 优先,Last-Modified 作为不支持 ETag 时的兜底。要注意 Nginx 默认的 ETag 由 mtime 与文件大小拼成,mtime 变了哪怕内容没变,指纹也变,这样就退化成了 Last-Modified 的精度问题,所以要精确就得用内容哈希。

Vary 头决定同一 URL 的多份副本:缓存键除了 URL,还要叠加 Vary 列出的请求头。最典型的是 Vary: Accept-Encoding,同一 URL 存 gzip 与 br 两份副本,按请求头各自命中,漏配则更危险,会把压缩副本发给不支持的客户端,导致页面乱码。

Vary: Accept-Language 让多语言站点同一地址按语言分副本。配置错误的代价就是缓存碎片化甚至命中率归零。

Vary: User-Agent 会让成百上千种 UA 各存一份,造成严重碎片化,若按 UA 返回不同内容却漏配 Vary,不同客户端可能串用彼此的副本,导致页面异常。

工程化缓存设计

现代前端构建产物天然适配缓存:文件名带内容哈希,内容一变名字就变。这套约定把缓存失效这个难题从 HTTP 层挪到了发布层。

nginx
# 静态资源:一年强缓存 + immutable
location ~* \.(js|css|png|jpg|woff2)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

# HTML 入口:每次协商,不落强缓存
location = /index.html {
    add_header Cache-Control "no-cache";
}

# 用户敏感接口:完全不缓存
location /api/user {
    add_header Cache-Control "no-store";
}

两道高频线上题的答案就藏在这套设计里:

  • 发版后用户看到白屏或旧页面:HTML 被强缓存了。旧 HTML 引用旧哈希文件,旧文件已随新版本下线,加载 404 即白屏;或 HTML 一直不更新,用户停在旧版本。修法就是入口 no-cache。当然,如果你的每个版本都有对象存储兜底,那也就不会有这个问题了。

  • 资源明明没改,流量却很大:哈希文件名没配长缓存,或被代理的 no-cache 波及。可以检查响应头是不是被网关统一改写了。

Service Worker 缓存是这条链上优先级最高的一环:注册了 SW 的页面,作用域内、受 SW 控制的请求会先过它的 fetch 事件(navigation preload、非 GET 请求等有例外),SW 可以决定回 Cache API、回网络,甚至支持缓存优先、后台更新(stale-while-revalidate 策略)。它带来离线能力的同时也会带来新问题,SW 文件本身会被浏览器缓存,各浏览器更新检查策略有差异(旧版 Chrome 曾有最长 24 小时的检查间隔),实践中常给 SW 文件配 no-cache,并用版本号或 skipWaiting 主动触发更新,而不是死等浏览器自己检查。

分区缓存(cache partitioning)则是近年的安全策略,缓存键从资源 URL变为顶级站点 + URL,第三方公共库无法再借别人站点的缓存白嫖,跨站资源各自独立,这个策略防的是通过缓存存在性探测用户历史的侧信道攻击。

代码示例

示例 1:构建产物指纹命名(以 Vite 为例)。

javascript
// vite.config.js —— 产物文件名带内容哈希
export default {
  build: {
    rollupOptions: {
      output: {
        assetFileNames: 'assets/[name].[hash][extname]',
        chunkFileNames: 'assets/[name].[hash].js',
      },
    },
  },
};
// main.a3f9c2d1.js —— 内容变,哈希变,URL 变,缓存自然失效

示例 2:Node 服务设置协商缓存。

javascript
app.get('/index.html', (req, res) => {
  const body = fs.readFileSync('index.html');
  const etag = `"${crypto.createHash('md5').update(body).digest('hex')}"`;

  res.set('ETag', etag);
  res.set('Cache-Control', 'no-cache');      // 每次协商

  // 生产环境需健壮处理弱校验(W/)、多值与逗号分隔;此处为简化示例
  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end();            // 未变:无响应体,但元信息仍随响应头返回
  }
  res.send(body);
});

示例 3:Service Worker 的缓存优先、后台更新策略。

javascript
// sw.js —— 先回缓存立刻响应,同时后台拉新版刷新缓存
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open('v2').then(async (cache) => {
      const cached = await cache.match(event.request);
      const fresh = fetch(event.request)
        .then((res) => { cache.put(event.request, res.clone()); return res; })
        .catch(() => cached);                // 断网兜底
      return cached || fresh;
    })
  );
});

示例 4:排查缓存问题的命令行验证。

bash
# 看响应头:确认 Cache-Control 与 ETag 是否符合预期
curl -sI https://example.com/index.html | grep -iE 'cache|etag|last-modified'

# 模拟协商:带 If-None-Match 重放,观察是否 304
curl -sI -H 'If-None-Match: "a3f9c2"' https://example.com/index.html \
  | head -n 1                              # HTTP/2 304 即协商生效

对比

对比维度强缓存协商缓存Service Worker 缓存
请求是否发出否是(仅头)由 SW 策略决定
命中成本零网络一次往返可离线命中
新鲜度有效期内无保证每次校验取决于策略
可编程性无无完全可控
典型场景哈希静态资源HTML 入口离线应用、秒开优化
主要风险更新不及时指纹维护成本SW 自身更新延迟

一句话:能用强缓存的用强缓存,保新鲜的交给协商缓存,需要离线与精细控制的上 Service Worker。

面试追问

追问1:Cache-Control 设了 no-cache,为什么 DevTools 里还能看到 from cache?

答:no-cache 的语义是副本仍写入缓存,但每次用前必须协商。no-cache 资源仍会写入本地副本,二次请求会正常发出协商。若服务器返回 304,DevTools 的状态列显示 304,Size 列可能显示“来自缓存/304”,这是协商命中后复用副本,不是强缓存命中。别把 no-cache 理解成不缓存,真正不缓存的是 no-store。

追问2:max-age 与 Expires 同时存在,听谁的?还有 s-maxage 呢?

答:要分缓存类型看。共享缓存(CDN/代理)中优先级是 s-maxage > max-age > Expires;浏览器私有缓存不看 s-maxage,按 max-age > Expires 规则判定。s-maxage 只对共享缓存生效;max-age 对两者都有效;Expires 是绝对时间,受客户端时钟影响,只做兜底。这也是为什么发版排查时要同时看浏览器拿到的头和 CDN 改写后的头,因为此两者可能不同。

追问3:发版后部分用户白屏,你怎么排查?

答:先让用户硬刷新(Ctrl+F5)看是否恢复,恢复即缓存问题。再看 HTML 响应头,若是 from cache 说明入口被强缓存,改 no-cache。然后看白屏的具体报错,若是旧 HTML 引用的哈希文件 404,说明新旧版本发布间隙里旧文件被清理,需要保留历史版本或做灰度兜底页。

追问4:CDN 场景下缓存策略要额外注意什么?

答:配置,CDN 遵循 s-maxage 与 public/private,源站的 private 会让 CDN 不缓存,误配会导致回源量暴涨。

刷新,发版后要主动刷新 CDN 缓存(purge / 预热),否则边缘节点仍持旧资源,即使有 no-cache 也不行。

缓存键,CDN 默认可能忽略 query 参数聚合缓存,?v=2 的版本号手段在 CDN 上其实并不可靠,只有文件名哈希才是最好的手段。

追问5:怎么验证一个站点的缓存策略是否合理?

答:用 DevTools 的 Network 面板做一次全流程走查:首次加载看每个关键资源的响应头(Cache-Control、ETag);二次刷新看哪些走 from cache(强缓存命中)、哪些 304(协商命中)、哪些还是 200 完整下载(漏配或 no-store);再模拟发版(改哈希)确认旧文件命中长缓存、入口正常更新。

判断标准是:静态资源二次加载几乎零流量、HTML 每次轻量协商、接口符合业务实时性要求。

写在最后

浏览器缓存的本质,是用副本的新鲜度换网络的往返成本,强缓存赌有效期内不变,协商缓存拿指纹做轻量校验,哈希文件名把失效问题前移到发布层。入口短、内容长、发布不断档,这三句话的落地,缓存就从玄学问题变成可验证的工程设计。

面试里能把 200 from cache、304、no-cache 的真实语义讲准,再把白屏排查路径说完整,基本上就已经没啥问题了。

评论