【每日一面】浏览器缓存策略
强缓存不问服务器、协商缓存拿指纹校验,入口文件保新鲜、哈希资源吃长缓存,分层裁决的策略把缓存从玄学变成工程设计。
线上发了个紧急修复,用户却反馈看到的还是旧页面,你说说为什么?
这道题几乎每一轮面试都会换个说法出现,而答案永远指向同一套机制,浏览器缓存。缓存不是开关,而是一套由 HTTP 头声明的、分层裁决的策略:强缓存要不要问服务器、协商缓存拿什么问、问完之后听谁的。
把这三层机制搞清楚了,后面再遇到类似于,改了没生效、没改却重新下载这两类线上问题就都有了可以排查的路径。
基础问答
问:浏览器缓存有哪些类型?整体决策流程是怎样的?
答:
-
按存储位置分,有内存缓存(memory cache)、磁盘缓存(disk cache)、推送缓存(push cache)、Service Worker 缓存;
-
按协商方式分,就两类,强缓存与协商缓存。判断流程是,请求发出后先查 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、Expires | ETag / 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-Modified | ETag |
|---|---|---|
| 本质 | 修改时间 | 内容指纹 |
| 精度 | 秒级 | 无限制 |
| 分布式一致性 | 服务器时间可能不一致 | 需保证各节点算出同一指纹 |
| 内容不变但时间变了 | 误判为已更新,白白下载 | 正确判断未变 |
| 性能开销 | 几乎为零 | 需计算哈希、存储映射 |
| 优先级 | 低(同时存在时被忽略) | 高 |
实践中 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 层挪到了发布层。
# 静态资源:一年强缓存 + 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 为例)。
// vite.config.js —— 产物文件名带内容哈希
export default {
build: {
rollupOptions: {
output: {
assetFileNames: 'assets/[name].[hash][extname]',
chunkFileNames: 'assets/[name].[hash].js',
},
},
},
};
// main.a3f9c2d1.js —— 内容变,哈希变,URL 变,缓存自然失效示例 2:Node 服务设置协商缓存。
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 的缓存优先、后台更新策略。
// 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:排查缓存问题的命令行验证。
# 看响应头:确认 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 的真实语义讲准,再把白屏排查路径说完整,基本上就已经没啥问题了。