第一层:幼儿园阶段 ------ 为什么要有缓存?
首先要明白一个铁律:网络请求很慢,内存和硬盘很快。
想象一下:你是一位厨师(浏览器),客人(用户)点了一份宫保鸡丁(网页)。
没有缓存:
- 每次客人来,你都要打电话去农场(服务器)问:"有鸡肉吗?有花生吗?"
- 农场说"有",你再等快递送过来
- 客人饿晕了,页面还在转圈
有缓存:
- 第一次做完宫保鸡丁,你把菜谱和食材存进冰箱(本地缓存)
- 下次客人点同样的菜,直接从冰箱拿,5秒上桌
- 农场偶尔打电话告诉你:"菜谱更新了",你再同步一下
缓存的本质:用空间(本地存储)换时间(网络延迟),同时保证数据新鲜度。
第二层:小学阶段 ------ 缓存的"三级冰箱"
浏览器有三层缓存,像俄罗斯套娃,层层查找:
Service Worker(离线缓存)→ Memory Cache(内存缓存)→ Disk Cache(磁盘缓存)→ Push Cache(HTTP/2推送缓存)→ 网络请求
1. Service Worker Cache(私藏小金库)
- 位置:浏览器主线程之外,独立运行
- 特点:开发者完全控制,可以离线访问
- 场景:PWA应用,飞机模式下也能刷知乎
2. Memory Cache(案板上的食材)
- 位置:内存(RAM)
- 特点:极快(纳秒级),但容量小,页面关闭就消失
- 存储内容:Base64图片、小体积JS/CSS、当前页面的资源
3. Disk Cache(冰箱冷冻层)
- 位置:硬盘(SSD/HDD)
- 特点:较慢(毫秒级),容量大,持久保存
- 存储内容:大文件、不常变的资源、跨会话共享
4. Push Cache(服务员提前备菜)
- 位置:HTTP/2连接内
- 特点:服务器主动推送,未被使用就丢弃(会话期内)
- 场景:HTTP/2 Server Push,提前把可能需要的资源塞过来
查找顺序:Service Worker → Memory → Disk → Push → 网络
面试考点 :为什么同样的资源,刷新页面后from memory cache变成from disk cache?
- 首次加载:资源进Memory + Disk
- 刷新页面:HTML重新解析,原Memory缓存被清,从Disk恢复
- 新开标签:跨标签共享Disk缓存
第三层:中学阶段 ------ HTTP缓存协议(协商 vs 强缓存)
这是面试最高频的考点,两种缓存策略像两条不同的保鲜规则:
强缓存(Freshness Strategy)------ 看保质期
浏览器不问服务器,直接拿本地缓存。
判断依据 :Expires 或 Cache-Control
┌─────────────────────────────────────────┐
│ 浏览器:这包薯片保质期到明天,今天能吃吗? │
│ 自己看标签 → 能吃 → 直接吃(不发请求) │
└─────────────────────────────────────────┘
HTTP头:
yaml
Expires: Wed, 21 Oct 2025 07:28:00 GMT # 绝对时间(HTTP/1.0,已过时)
Cache-Control: max-age=31536000 # 相对时间,秒(HTTP/1.1,推荐)
Cache-Control: no-cache # 可以存,但每次要协商
Cache-Control: no-store # 完全不存,隐私数据
Cache-Control: private # 仅浏览器存,CDN不存
Cache-Control: public # 大家都能存
状态码 :200 (from disk cache) 或 200 (from memory cache)
协商缓存(Validation Strategy)------ 问仓库还有没有
缓存过期了,但不确定服务器有没有新版本,带着"证据"去问。
判断依据 :Last-Modified/If-Modified-Since 或 ETag/If-None-Match
arduino
┌─────────────────────────────────────────┐
│ 浏览器:这包薯片过期了,但看起来没坏? │
│ 打电话给仓库:"批次号A123,还有货吗?" │
│ 仓库:"还是A123,没换" → 304 Not Modified │
│ 仓库:"现在批次B456了" → 200 + 新货 │
└─────────────────────────────────────────┘
HTTP头:
yaml
# 方案A:时间戳(秒级精度,可能不准)
Last-Modified: Wed, 21 Oct 2025 07:28:00 GMT
If-Modified-Since: Wed, 21 Oct 2025 07:28:00 GMT # 请求头
# 方案B:内容指纹(优先级更高,精确到字节)
ETag: "33a64df5" # 服务器生成的唯一标识(文件内容哈希)
If-None-Match: "33a64df5" # 请求头
状态码 :304 Not Modified(没改,用缓存)或 200(改了,重新下载)
完整决策流程(必背)
yaml
┌─────────────┐
│ 发起请求 │
└──────┬──────┘
▼
┌─────────────────┐
│ Service Worker? │──Yes──► 查SW缓存 ──► 有?返回 : 走网络
└────────┬────────┘ No
▼
┌─────────────────┐
│ 有Cache-Control?│──No──► 查Expires ──► 过期?走协商 : 走强缓存
└────────┬────────┘ Yes
▼
┌─────────────────┐
│ max-age过期了? │──No──► 200 from cache(强缓存命中)
└────────┬────────┘ Yes
▼
┌─────────────────┐
│ 有ETag? │──Yes──► 发If-None-Match ──► 304? 用缓存 : 200更新
└────────┬────────┘ No
▼
┌─────────────────┐
│ 有Last-Modified?│──Yes──► 发If-Modified-Since ──► 304? 用缓存 : 200更新
└────────┬────────┘ No
▼
直接请求新资源
口诀:先强缓存(看时间),再协商(问指纹),最后才下载。
第四层:大学阶段 ------ 缓存的"暗坑"与黑魔法
坑点1:Cache-Control 的"障眼法"
yaml
Cache-Control: no-cache
误区 :以为不能缓存?
真相 :可以缓存,但每次用之前必须协商(问服务器能不能用)。
yaml
Cache-Control: no-store
真相 :这才是真正的禁止缓存,敏感数据用这个。
ini
Cache-Control: max-age=0
效果 :等于 no-cache,立即过期,走协商。
坑点2:ETag 的"分布式灾难"
场景:负载均衡,3台服务器轮询
bash
请求1 → 服务器A → ETag: "abc-123"
请求2 → 服务器B → ETag: "abc-456" # 同样内容,不同ETag!
请求3 → 服务器C → ETag: "abc-789"
后果:明明内容没变,ETag不同导致缓存失效,反复下载。
解决:
- 用
Last-Modified替代(时间戳一致) - 或配置服务器用内容哈希生成ETag(MD5相同则ETag相同)
- 或加
Cache-Control: public让CDN统一处理
坑点3:304 的"性能陷阱"
误区 :304没下载内容,所以很快?
真相 :304仍然要建立TCP连接(HTTPS还要TLS握手),发送HTTP请求,等待服务器响应。
优化 :强缓存直接本地读取,零网络开销。
数据对比:
- 强缓存:
0ms,本地磁盘读取 - 304协商:
50-200ms,取决于RTT - 200重新下载:
100ms-数秒,取决于资源大小
坑点4:Vary 头的"缓存分裂"
makefile
Vary: Accept-Encoding, User-Agent
作用 :告诉缓存服务器,哪些请求头不同就要存不同版本。
后果:
Accept-Encoding: gzip→ 存压缩版Accept-Encoding: br→ 存Brotli版User-Agent: Mobile→ 存移动端版
坑:Vary头太多 → 缓存爆炸,命中率暴跌。
第五层:博士阶段 ------ 缓存一致性模型(强一致性 vs 最终一致)
缓存失效的三种策略(计算机科学的终极难题)
| 策略 | 描述 | 适用场景 |
|---|---|---|
| Cache-Aside(旁路缓存) | 应用先查缓存,没命中查DB,再回填缓存 | 读多写少,最常用 |
| Read-Through(直读) | 缓存没命中自动查DB,对应用透明 | 需要缓存中间件(如Redis) |
| Write-Through(直写) | 写缓存同时写DB,同步完成 | 强一致性要求 |
| Write-Behind(异步写) | 先写缓存,异步批量写DB | 高性能,容忍短暂不一致 |
| Refresh-Ahead(预刷新) | 缓存即将过期时自动后台更新 | 热点数据,不允许击穿 |
浏览器特有的"新鲜度计算"(Heuristic Freshness)
场景 :服务器没给 Cache-Control 也没给 Expires,但给了 Last-Modified。
浏览器黑魔法:
scss
新鲜期 = (当前时间 - Last-Modified时间) × 10%
比如文件一年前修改,浏览器认为能缓存 365天 × 10% = 36.5天。
面试杀招 :解释为什么"啥也没配"的资源也会被缓存,以及为什么这是不可靠的(各浏览器算法不同)。
缓存污染与中毒(安全视角)
攻击场景:
- 攻击者请求
script.js?callback=alert(1) - CDN/浏览器缓存了这个带恶意回调的版本
- 正常用户请求
script.js(不带参数),但缓存命中了带毒版本
防御:
yaml
Cache-Control: no-cache # 有查询字符串就不缓存
# 或
Vary: Query-String # 不同参数不同缓存
第六层:上帝视角 ------ 现代浏览器的缓存架构演进
从单进程到多进程:缓存的"线程安全"
上古时代:
- 所有标签页共享一个缓存目录
- 标签A缓存的JS,标签B直接读取
- 问题:崩溃一个标签,全浏览器缓存损坏
现代架构(Chrome Site Isolation) :
css
浏览器进程(Browser Process)
↓
网络服务进程(Network Service)← 统一处理HTTP缓存
↓
渲染进程A(Renderer)──┐
渲染进程B(Renderer)──┼── 通过Mojo IPC访问缓存,相互隔离
渲染进程C(Renderer)──┘
关键改进:HTTP缓存由独立进程管理,Renderer崩溃不影响缓存完整性。
磁盘缓存的"物理结构"(Chrome的SimpleCache)
perl
磁盘缓存目录
├── index # 索引文件(快速查找)
├── data_0 # 数据块文件(小块资源)
├── data_1
├── data_2
├── data_3
├── f_000001 # 大文件(独立存储)
├── f_000002
└── ...
存储策略:
- 小文件(<16KB):存
data_*块文件,减少碎片 - 大文件:独立
f_xxxxxx文件,避免阻塞小文件读取 - 内存映射:热点索引常驻内存,磁盘IO异步化
缓存淘汰算法(LRU+优先级混合)
Chrome使用改进的LRU:
css
优先级 = 访问频率 × 时间衰减 + 资源类型权重
HTML/JS/CSS:高权重(页面核心)
图片:中权重
视频:低权重(体积大,但可能不再看)
淘汰顺序:
- 先删低优先级 + 最久未访问
- 磁盘空间不足时,触发后台清理
- 用户可手动"清除浏览数据"
第七层:Service Worker ------ 缓存的"终极形态"
从"浏览器控制"到"开发者控制"
ini
// sw.js - 拦截所有请求,完全自定义缓存策略
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => {
// 1. 缓存命中?直接返回
if (response) return response;
// 2. 否则走网络
return fetch(event.request).then(networkResponse => {
// 3. 动态更新缓存
caches.open('v1').then(cache => {
cache.put(event.request, networkResponse.clone());
});
return networkResponse;
});
})
);
});
缓存策略矩阵(Google推荐)
| 策略 | 代码模式 | 适用场景 |
|---|---|---|
| Cache First | 先查缓存,没命中再网络 | 静态资源,离线优先 |
| Network First | 先网络,失败再缓存 | API数据,实时性优先 |
| Stale-While-Revalidate | 立即返回缓存,后台更新 | 新闻列表,快速+新鲜兼顾 |
| Cache Only | 只用缓存 | 纯离线应用 |
| Network Only | 只用网络 | 实时性极强(股票行情) |
背景同步(Background Sync)------ 离线提交的救赎
ini
// 用户离线时提交表单
navigator.serviceWorker.ready.then(registration => {
registration.sync.register('submit-form');
});
// SW中处理
self.addEventListener('sync', event => {
if (event.tag === 'submit-form') {
event.waitUntil(
// 网络恢复后自动重试
sendFormDataFromIndexedDB()
);
}
});
第八层:CDN 与边缘缓存 ------ 缓存的"全球化"
多层缓存架构
markdown
用户浏览器 ──► CDN边缘节点 ──► 源站服务器
│ │ │
Memory/ Memory/Disk/ Disk/DB
Disk Cache 全局分布式缓存 原始数据
CDN缓存的"回源策略"
| 指令 | 含义 |
|---|---|
Cache-Control: s-maxage=3600 |
CDN共享缓存1小时(覆盖max-age) |
CDN-Cache-Control: max-age=3600 |
专用CDN头(Cloudflare等支持) |
Surrogate-Control: max-age=3600 |
另一种CDN专用头 |
缓存穿透、击穿、雪崩(经典面试三连)
| 问题 | 现象 | 解决 |
|---|---|---|
| 穿透 | 查询不存在的数据,每次都打到DB | 布隆过滤器,或缓存空值 |
| 击穿 | 热点key过期,瞬间大量请求打DB | 互斥锁,或逻辑过期(永不过期,异步刷新) |
| 雪崩 | 大量key同时过期,DB崩溃 | 随机过期时间,多级缓存,熔断降级 |
浏览器层面的防御:
ini
// Stale-While-Revalidate 模式防止击穿
const cache = await caches.open('api-cache');
const cached = await cache.match(request);
// 立即返回缓存(即使过期)
if (cached) {
// 后台异步更新
fetch(request).then(response => cache.put(request, response.clone()));
return cached;
}
第九层:实战优化 ------ 从"能用"到"极速"
项目场景 1:高频迭代的 B 端管理系统(如:飞书、钉钉网页版)
-
业务特点:
- 代码量巨大(JS 动辄 5MB 以上)。
- 版本更新极快(可能每天都要修复 Bug 发版)。
- 痛点:发版后,由于浏览器缓存了旧的 JS,用户报错(缓存不一致),或者发版后用户加载太慢。
-
极致优化方案(Webpack + Nginx):
- 第一步(基础) :Webpack 配置
contenthash。如果只改了"客户模块"的代码,打包出来的customer.a1b2.js名字变了,但"合同模块"contract.c3d4.js名字不变。 - 第二步(Nginx 调优) :
- 对
index.html设置no-cache:每次打开页面,浏览器都得问服务器"菜单换了吗?"。 - 对 JS/CSS 设置
public, max-age=31536000, immutable。
- 对
- 解决的实际问题 :
- 秒开 :用户第二次打开飞书,除了 HTML 那个几十字节的请求,所有几 MB 的 JS 全部从本地磁盘 0ms 读取,完全不走网络。
- 更新不报错 :发版后,HTML 里的 JS 路径变成了新哈希,浏览器发现名字变了,自动下载新代码。旧代码在缓存里互不干扰,彻底解决"发版后要清缓存"的低级 Bug。
- 第一步(基础) :Webpack 配置
项目场景 2:内容型 App 或 社交平台(如:小红书、今日头条)
-
业务特点:
- 首页是长列表(Feed 流)。
- 用户对"白屏"极度敏感,多转一秒圈圈就要关掉 App。
- 痛点:每次点开 Feed 流,都要等接口返回(数据协商),用户会看到 1-2 秒的 Loading 动画。
-
极致优化方案(SWR / staleTime 模式):
- 项目实践 :使用
React Query或SWR库请求首页列表接口。 - 配置 :设置
staleTime: 5分钟。 - 解决的实际问题 :
- 消灭 Loading 圈圈 :用户在 5 分钟内反复切换页面,数据直接从内存缓存 拿,瞬间呈现,完全没有加载状态。
- "先看后换" :如果超过 5 分钟,用户点开时,页面先展示上次留下的旧数据 (不白屏),同时后台静默发请求,等新笔记刷出来了,再无感替换。这就是用户感觉这些 App 运行飞快的核心秘密。
- 项目实践 :使用
项目场景 3:在线教育或视频平台(如:B站、慕课网)
-
业务特点:
- 视频分片文件(.ts 文件)非常多且大。
- 用户喜欢反复看同一个知识点(反复拖动进度条)。
- 痛点 :浏览器默认的
Disk Cache(磁盘缓存)像个"黑盒",空间满了会随机删文件。用户回头看一段视频时,发现刚才看过的片段被浏览器偷偷删了,又得重新缓冲,浪费流量且卡顿。
-
极致优化方案(IndexedDB 手动存储):
- 项目实践 :在网页端写一个
VideoCache类,利用Service Worker拦截视频请求。 - 逻辑 :
- 视频下载后,不交给浏览器自动管,而是由代码强行存入 IndexedDB(这是浏览器里一个几百 MB 到几 GB 的永久数据库)。
- 下次进度条拖回来,代码先去 IndexedDB 查:"这个片段我有吗?"如果有,直接转成 Blob 给播放器。
- 解决的实际问题 :
- 省钱:公司带宽费大幅下降,因为用户反复看同一个视频,流量消耗为 0。
- 极致丝滑 :即便用户断网了,只要之前看过的部分,进度条随便拖,完全不缓冲。
- 项目实践 :在网页端写一个
总结:我该怎么选?
| 你的项目类型 | 核心要用的缓存技术 | 一句话理由 |
|---|---|---|
| 普通的网站 / B端后台 | Webpack 哈希 + Nginx Immutable | 保证发版不报错,重复访问 0 耗时。 |
| 手机端 Feed 流 / 实时看板 | SWR (stale-while-revalidate) | 消灭 Loading 转圈,让用户感觉"数据瞬间就在那"。 |
| 大文件 / 离线优先 / 播放器 | Service Worker + IndexedDB | 绕过浏览器不可控的清理机制,实现持久化的二进制存储。 |
面试对话示范:
面试官 :你在项目中怎么做缓存优化的? 你 :我会分场景。比如在我们那个 [XX 管理系统] 里,我利用 Webpack 的 contenthash 配合 Nginx 的 immutable 头部,把静态资源加载耗时降到了 0ms;而在 [XX 首页 Feed 流] 中,我为了解决接口返回慢导致的白屏,引入了 SWR 机制,先用旧缓存渲染 UI 提升首屏速度,再后台静默更新。
第十层:未来趋势 ------ 缓存的" Web 3.0 时代"
1. 从"存响应"到"存数据":结构化缓存
- 现在的痛点(为什么虚) : 现在的
Cache API就像一个死板的仓库 。你存了一个 5MB 的 JSON 接口响应,如果你只想查"价格 > 100"的商品,你必须先把整个 JSON 读进内存,用 JS 去遍历。这太费内存和 CPU 了。 - 落地的业务场景 :大型离线应用(如:Figma、在线文档、移动端商城) 。
- 未来进化 :浏览器尝试将
IndexedDB(数据库)和Cache API(网络缓存)融合。 - 面试谈资 :你可以说:"现在的缓存是 URL 维度的 ,未来的缓存应该是 数据维度的。像 Google 正在推进的存储标准,就是希望让 Service Worker 能直接对缓存的二进制数据流进行搜索和过滤,而不是全量解析,这对低端机极其友好。"
- 未来进化 :浏览器尝试将
2. 从"手动预取"到"AI 智能猜": Speculation Rules API
- 现在的痛点(为什么虚) : 现在的
Preload(预加载)是程序员硬编码的。代码写死:用户点"详情页"时加载"评论插件"。 问题是:有的用户根本不看评论,你白白浪费了用户的流量。 - 落地的业务场景 :新闻资讯流(如:今日头条、知乎) 。
- 现在的技术动作 :Google 已经推出了 Speculation Rules API。它不再是简单的标签,而是一套动态规则。
- AI 的介入 :浏览器观察用户的路径。如果 90% 的人在看完文章后会点开"相关推荐",浏览器会在后台自动、低优先级地缓存下个页面的内容。
- 面试谈资 :你可以聊 "预测性性能优化"。这比单纯的缓存更超前,它是在用户还没动作时,通过浏览器的学习模型实现"零时延切换"。
3. 从"中心化"到"邻居互传":去中心化缓存(P2P)
- 现在的痛点(为什么虚) : 现在的缓存路径是:你 → CDN → 源站。 问题是:双 11 时,CDN 也会崩;而且公司要付给 CDN 供应商巨额的流量费。
- 落地的业务场景 :大型游戏资源下载(如:米哈游网页端、在线高清视频) 。
- 核心逻辑:如果我邻居刚才看过了《流浪地球2》,我再看的时候,浏览器能不能直接从邻居的电脑(或路由器)里通过局域网把缓存切片传给我?而不必再去几千公里外的服务器拿?
- 面试谈资 :你可以提到 "内容寻址缓存"。现在的缓存是按"链接"找,未来是按"内容指纹(Hash)"找。即便链接变了,只要文件内容一样,就能从全球网络任何一个节点获取,这能帮公司省下 70% 的 CDN 费用。
终极回答策略:从协议深度到架构广度的四维阐述
1. 核心定性(展现系统思维)
"我认为浏览器缓存不是孤立的几个 HTTP 头,而是一套由多方协同的复杂调度系统 。它向下对接底层的浏览器内核存储(Memory/Disk),向上承接前端工程化的构建产物(Webpack/Vite),向外延伸至全球分布的 CDN 节点。它的本质是在数据新鲜度(Freshness) 、**加载延迟(Latency)与网络成本(Cost)**之间寻找业务最优解。"
2. 决策链路(展现协议精度)
"在实际执行中,我将其总结为**'两级验证、零 RTT 追求'**。
- 第一级是本地自校验 :优先匹配
Cache-Control。我的准则是'静态资源全量immutable,入口文件严格no-cache',以此追求绝对的 0 RTT。- 第二级是云端再确认 :当强缓存失效,通过
ETag进行字节级比对。我会特别关注分布式环境下的 ETag 漂移问题,确保 304 命中率不因多台服务器生成的指纹不一致而崩盘。"
3. 工程落地(展现全栈理解)
"缓存策略必须与 CI/CD 流程深度绑定。
- 在构建层 ,通过
contenthash实现'文件内容即标识',让长效强缓存成为可能。- 在应用层 ,通过
Stale-While-Revalidate(SWR)模式,将网络请求异步化。即'先用旧数据渲染 UI,后台静默更新缓存',彻底消除用户感知的 Loading 状态,实现**'瞬时响应'**的极致 UX。"
4. 架构设计(展现大厂视野)
"针对大型复杂应用,我会设计**'三层递进式存储架构'**:
- L1(动态拦截层) :利用
Service Worker自定义缓存策略,处理离线可用和高频接口拦截。- L2(标准协议层):严格遵循 HTTP 语义,利用磁盘缓存存储海量静态资源。
- L3(边缘算力层) :在 CDN 边缘节点完成
Vary头的逻辑判断或 A/B 测试注入,减少回源压力。 这种设计能让首屏时间(FCP)在各种网络环境下保持在 300ms 级别。"
避坑指南:面试中的 3 个"反直觉"细节(必考点)
| 细节点 | 你的深度回答(加分项) |
|---|---|
| no-cache 的字面陷阱 | "不要被名字误导,no-cache 并不禁用缓存,它只是强制每次使用前必须通过协商确认。真正禁写磁盘的是 no-store。" |
| 304 的隐藏成本 | "304 虽省流量但不省时间。在高延迟环境下(RTT > 100ms),一次 304 协商可能比下载一个 10KB 的文件更慢,所以强缓存才是性能的终点。" |
| Vary 头的副作用 | "慎用 Vary: User-Agent。它会让 CDN 为成千上万个浏览器版本各存一份缓存,导致命中率雪崩,甚至拖垮源站。" |
终极速记卡片(临考前 30 秒看这个)
- 一个中心:以消除 RTT(往返时延)为中心。
- 两个基本点:强缓存看保质期(过期前不问),协商缓存看指纹(过期了再问)。
- 三项黑科技 :
immutable(刷新不重验)、SWR(先吃陈粮再换新米)、Service Worker(离线救星)。 - 四对头 :
Expires/Cache-ControlvsLast-Modified/ETag。
速记核心关键词
面试前记住这 5 个关键词,串联整个知识网:
| 关键词 | 含义 |
|---|---|
| 两级验证 | 强缓存(时间)+ 协商缓存(指纹) |
| 三级存储 | Memory → Disk → Service Worker |
| 四对头 | Cache-Control/Expires + ETag/Last-Modified |
| 304陷阱 | 协商仍有开销,强缓存才是极致性能 |
| SW革命 | 开发者接管缓存,离线优先成为可能 |
最后一句面试杀招:
"优秀的缓存策略不是配置几个HTTP头,而是深入理解浏览器从内存到磁盘、从本地到CDN的完整缓存链路,让数据在最合适的位置以最合适的形态存在,在性能、新鲜度和一致性之间找到业务最优解。"