HTTP缓存这块,说实话属于那种“看着简单,一用就翻车”的知识点。面试题里背得滚瓜烂熟,真到了线上资源更新不了、用户反馈页面还是旧版的时候,才发现自己根本搞不清楚强缓存和协商缓存的边界到底在哪。我做了这么多年Web开发和性能优化,自己也踩过不少坑,今天就把这套机制从头到尾扒一遍,把该说的原理、该有的实操、该避的坑一次性讲清楚。
这篇内容适合谁看?后端开发、前端开发都想得明白缓存怎么配;运维和DevOps要懂CDN和Nginx层缓存如何与业务配合;刚入门没多久的新人,也能通过这篇文章建立完整的HTTP缓存心智模型。不管你是纯浏览器端缓存,还是涉及CDN、网关层缓存,这套底层逻辑基本是通用的。
1. HTTP缓存是什么,为什么要折腾它
1.1 缓存解决的核心问题
一个用户在浏览器里输入网址、按下回车,到页面完整展示,中间要经过DNS解析、TCP连接、TLS握手、发送HTTP请求、服务器处理、返回响应、浏览器解析渲染。一个页面里几十个JS、CSS、图片资源,如果每次都重新从服务器拉一遍,这个成本极其夸张。
我做过一个真实的性能优化案例:一个后台管理系统,首屏要加载80多个静态资源,未缓存时这些资源加起来约4.5MB。开启合理的缓存策略后,第二次访问这些静态资源基本不再消耗网络流量,耗时降低到原来的十分之一左右。这就是缓存的直接价值:减少重复请求、节省带宽、降低服务端压力、大幅提升加载速度。
HTTP缓存机制实际上是一个多级体系,从浏览器缓存、CDN缓存、反向代理缓存,到服务端缓存、数据库缓存,每一层都有自己的策略和生命周期。而我们今天重点讲的强缓存和协商缓存,是其中最核心、最直接影响每个HTTP请求是否发出去的决策机制。
1.2 一个必须建立的认知:缓存不是“不请求”,而是“少请求”或“不重复传输”
很多人对缓存的理解就是“资源被存下来了,所以下次直接用”。这不完全对。缓存协议里有两个关键动作:
- 强缓存(也叫本地缓存、强制缓存):浏览器判断本地缓存未过期,直接使用,连请求都不发给服务器。
- 协商缓存(也叫对比缓存、弱缓存):浏览器不确定缓存是否还能用,于是带着缓存的标识去问服务器——“我这个版本还能用吗?”服务器说“可以用”(304),那就继续用本地缓存;服务器说“不行了,内容变了”(200),那就返回新内容。
所以核心区别在于:强缓存省请求,协商缓存省流量。一个根本不出门,一个出门但带得少。这个认知放到后面理解Header配置时会非常有用。
1.3 缓存的载体和层级
浏览器缓存通常分两层:内存缓存(memory cache)和磁盘缓存(disk cache)。内存缓存读取快,但存活时间短,浏览器关闭后基本就没了;磁盘缓存容量大、持久化,但读取相对慢一些。强缓存命中的资源可能来自memory cache,也可能来自disk cache,Chrome的Network面板里会直接显示(比如from memory cache或from disk cache)。
另外,CDN节点和反向代理(比如Nginx)也会形成缓存层。它们和浏览器缓存遵循的HTTP协议是同一套,但配置位置和生效范围不同。我们在浏览器看到的缓存策略,同样会影响到CDN层是否缓存。这也是为什么很多公司会把静态资源的Cache-Control设置为public,就是为了让CDN也能缓存一份,用户就近访问。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 强缓存:浏览器说了算,不找服务器
强缓存的核心逻辑非常“霸道”:只要我手里这份缓存还没过期,我就不问你服务器,直接用本地的。能让浏览器作出这个判断的,靠的是响应头里的两个字段:Expires和Cache-Control。
2.1 两个关键响应头:Expires和Cache-Control
Expires是HTTP/1.0时期的标准,指定一个绝对的过期时间,比如:
http复制Expires: Wed, 21 Oct 2026 07:28:00 GMT
浏览器看到这个时间还没到,就直接用缓存;超过这个时间,就认为缓存失效。
但Expires有个非常明显的缺陷:它是绝对时间,依赖客户端本地时间。如果用户电脑的时间不对(比如调快了、调慢了),或者时区混乱,缓存判断就可能出错。所以后来HTTP/1.1引入了Cache-Control,它更强大,使用相对时间,不依赖客户端时钟。比如:
http复制Cache-Control: max-age=3600
这表示从响应生成时刻起,3600秒内浏览器可以直接使用缓存。
如果两个字段同时出现,Cache-Control的优先级更高。这一点是规定,也是实际行为,只要响应头里有Cache-Control,Expires基本就是个摆设。我建议现在的新项目干脆只考虑Cache-Control,Expires了解即可,更多是历史兼容需要。
2.2 Cache-Control常用指令逐个拆解
Cache-Control这个字段支持多个指令,用逗号分隔,看起来简单,坑不少。
max-age=<seconds>:缓存有效期的相对秒数。比如max-age=86400表示一天。s-maxage=<seconds>:专门给共享缓存(如CDN、代理服务器)用的有效期,优先级高于max-age。浏览器会忽略它,这个我要特别提一下,很多人不知道它只管CDN层。public:允许所有缓存服务器缓存该响应,包括CDN、代理等。private:只允许浏览器等私有缓存存储,不允许CDN或代理缓存。适合包含用户个性化信息的响应。no-cache:这个名字非常坑。它不是“不缓存”,而是“可以缓存,但每次使用前必须向服务器验证一下是否有效”。也就是说,它强制走协商缓存,即使缓存还没过期。no-store:这个才是真正的“完全不缓存”。既不存本地,也不走协商,每次请求都完整地从服务器拉取。适合敏感信息,比如银行交易页面。must-revalidate:如果缓存过期了,必须向服务器重新验证,不能继续用过期缓存。与no-cache有点类似,但语义上更侧重于过期后的行为。immutable:告诉浏览器,这个资源在过期前绝对不会变,连“刷新页面时重新验证”都可以跳过。这个指令对静态资源非常有用,但注意兼容性,以及确认你的资源确实带了内容hash。
我们实际配置中,最典型的组合是:
http复制Cache-Control: public, max-age=31536000, immutable
这种写法通常用于带版本号或内容hash的静态资源,比如app.8f3k2a.js,一年内都不会变。
2.3 实操:用Chrome DevTools验证强缓存是否生效
开一个实际项目,打开Chrome DevTools的Network面板,勾选“Disable cache”去掉(模拟正常用户),然后访问页面。随便点开一个静态资源,看一下Headers里的响应头。
如果配置了强缓存,你会看到类似这样的信息:
- 第一次访问:状态码200,Size列显示具体的资源大小。
- 第二次访问:状态码200,但Size列显示
from disk cache或from memory cache。
这里注意一个细节:即使命中了强缓存,状态码依然是200,但不会真正走网络加载。很多人看到200就以为没缓存,其实要看Size列。如果显示from disk cache,说明本次请求根本没有发送到服务器。
另外,把鼠标悬停在from disk cache上,可以看到缓存的资源类型、大小、存储位置等。这时候切到Application面板的Cache Storage,也能看到该域名下的缓存条目。
2.4 强缓存没生效?大概率是这几个原因
我在实际项目中遇到过很多“为什么我给静态资源配置了Cache-Control,但还是每次请求都回源”的案例。排查顺序一般是:
- 确认响应头真的带了Cache-Control:用curl或者Postman直接请求资源,看响应头。有时候Nginx配置了,但后端应用又覆盖了,或者反向代理层把响应头剥掉了。
- 确认没有
no-cache和no-store:这俩是与强缓存互斥的。 - 确认请求头里有没有
Cache-Control: no-cache或Pragma: no-cache:某些浏览器插件、代理工具会自动加这些头,强制绕过强缓存。 - 确认服务器时间是否正常:虽然Cache-Control用的是相对时间,但服务器生成响应时也会有Date头,如果服务器时间错乱,会影响有效期计算。
- 某些情况下,浏览器针对HTML文档的处理策略和静态资源不同,HTML可能默认就是
no-cache策略,这个见后面的浏览器行为部分。
3. 协商缓存:每次都问一句,但条件过期
协商缓存的核心逻辑,用一句人话概括就是:“我手上有一个旧版本,我把它告诉服务器,你看看我能不能继续用旧的。”
这个过程需要两个Header参与:请求时带上“验证凭证”,服务器返回“是否还能用”。根据凭证的不同,可分为三种形式。
3.1 第一组凭证:Last-Modified / If-Modified-Since
服务器在首次响应时,带上资源的最后修改时间:
http复制Last-Modified: Tue, 22 Oct 2024 10:15:30 GMT
浏览器把这份资源缓存后,如果缓存过期需要重新验证,下一次请求会带上:
http复制If-Modified-Since: Tue, 22 Oct 2024 10:15:30 GMT
服务器拿到这个时间,和资源当前的实际修改时间做对比:
- 如果资源在
Last-Modified之后没有变化,返回304 Not Modified,响应体为空,浏览器继续用本地缓存。 - 如果资源发生了变化,返回
200 OK,同时带上新内容和新时间的响应头。
这个机制实现简单,很多静态服务器都默认支持。但它的缺陷也明显:
- 修改时间精度是秒级,如果文件在1秒内被修改多次,无法准确感知。
- 有些服务器或存储系统返回的
Last-Modified时间不准确,比如动态生成的接口,每次时间都在变,导致协商缓存形同虚设。 - 文件内容改了,但修改时间被人为恢复成旧时间,也会判断失误。
3.2 第二组凭证:ETag / If-None-Match
ETag是HTTP/1.1引入的更现代化的验证机制。服务器给资源生成一个唯一标识,通常是根据文件内容、大小、修改时间等信息算出的哈希值:
http复制ETag: "64f1d2b8-1a2b3c"
浏览器再次请求时,带上:
http复制If-None-Match: "64f1d2b8-1a2b3c"
服务器比较这个值和当前资源的ETag:
- 一致,返回304。
- 不一致,返回200和新内容。
ETag比Last-Modified精确得多,因为它是基于内容生成的指纹。只要内容变了,哪怕只有1个字节,ETag都会改变,304/200的判断也就更加可靠。
复杂一点,ETag分强验证和弱验证。强验证要求字节级一致,弱验证用W/前缀表示,只要求语义等同即可。实际应用中,大多数场景用强验证就够了,弱验证更多用在需要一定容错的缓存场景。
3.3 两组凭证同时存在时,哪个说了算
如果响应头里同时带了Last-Modified和ETag,浏览器请求时会同时带上If-Modified-Since和If-None-Match。此时服务器的判断逻辑是:
- 优先验证
If-None-Match(即ETag),只要ETag不一致,即使Last-Modified没变化,也返回200。 - ETag一致,再辅以Last-Modified做进一步判断。
这个过程里,ETag的优先级高于Last-Modified。我建议服务端配置时,能上ETag就上ETag,Last-Modified作为兜底即可,这样准确性和兼容性都能兼顾。
3.4 完整流程:一个协商缓存的请求到底经历了什么
用一张时序来描述:
code复制浏览器 服务器
| 第一次请求 |
|--------------------->|
| 生成ETag
|<--------------------|
| 200 + ETag + 内容 |
| 缓存内容
| 第二次请求(缓存过期) |
| If-None-Match: xxx |
|--------------------->|
| 比较ETag |
|<--------------------|
| 304 Not Modified |
| 继续使用缓存 |
这个流程中,304响应是不带响应体的,所以网络传输量极小,主要节省的是“完整内容重传”的流量和带宽。但开销仍然有:至少有一次真实的HTTP请求到达服务器并返回。所以协商缓存的价值定位是“在无法确认内容不变时,用最小成本去验证”。
3.5 实操:Nginx中配置协商缓存
大部分情况下,Nginx静态服务默认就会带上Last-Modified和ETag。你不需要额外做什么,只要别用add_header把这两个头覆盖掉就行。
如果你想手动控制,可以在location中做如下配置:
nginx复制location /static/ {
etag on;
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
这里的etag on确保Nginx生成ETag响应头,expires 30d会自动生成Expires和Cache-Control头(等价于max-age=2592000)。你再额外加一个add_header Cache-Control的话,注意它和expires指令可能会产生重复定义,实际生效以你写的那条为准。
后端接口如果需要DIY协商缓存,以Node.js为例:
javascript复制const etag = require('etag');
app.get('/api/data', (req, res) => {
const data = getData();
const responseEtag = etag(JSON.stringify(data));
if (req.headers['if-none-match'] === responseEtag) {
res.status(304).end();
} else {
res.setHeader('ETag', responseEtag);
res.json(data);
}
});
这只是最朴素的演示,生产环境一般会用框架中间件承载。
4. 强缓存 vs 协商缓存:一张表讲清楚,然后看场景怎么选
4.1 对比维度速查表
把核心差异放在一张表里,方便随时查阅:
| 对比维度 | 强缓存 | 协商缓存 |
|---|---|---|
| 是否发出HTTP请求 | 不发出,直接读本地缓存 | 发出请求,由服务器判断 |
| 主要响应头 | Cache-Control、Expires | Last-Modified / ETag |
| 主要请求头 | 无(不发请求) | If-Modified-Since / If-None-Match |
| 命中后的状态码 | 200(from cache) | 304 Not Modified |
| 性能开销 | 最低,零网络请求 | 有少量网络开销 |
| 服务器压力 | 无 | 有,但流量极低 |
| 最大缺陷 | 过期前无法感知服务器内容变化 | 每次请求都要验证,无法完全省去请求 |
| 典型场景 | 带hash的静态资源、logo、图片 | HTML文档、个人资料、动态但有规律的接口 |
4.2 场景决策:哪些资源适合强缓存,哪些必须走协商
我平时做项目,会按资源类型做如下划分:
适合强缓存(高缓存、长有效期):
- 带内容hash的静态资源:比如webpack打包后的
app.a1b2c3.js、style.9f8e7d.css。 - 长期不变、体积较大的图片、视频、字体文件。
- 第三方SDK文件、公共库文件。
这类资源的特点就是“变了就换名字”,URL变化代表版本变化,旧的URL永远不变。这种情况下Cache-Control: public, max-age=31536000, immutable是最好用的。一年过期,过期之前浏览器绝不会回源,CDN也照单全收。
适合协商缓存(短缓存或不设置强缓存有效期):
- HTML文档本身:页面结构随时可能改,如果强缓存得太久,用户会一直看到老页面。推荐使用
Cache-Control: no-cache,强制每次用前都验证。 - 不经常变,但一旦变了必须立刻生效的接口:可以用ETag协商缓存,减少重复传输。
- 用户个性化数据:比如登录状态、购物车信息,一般
private, no-store或private, no-cache。
很多人容易把“不要缓存”和“不设置Cache-Control”混为一谈。实际上,HTTP规范在响应没有Cache-Control头但有Last-Modified时,浏览器可能采用启发式缓存,比如根据修改时间推断一个缓存寿命,这可能导致一些诡异的意料之外缓存。所以想禁止缓存,请明确写Cache-Control: no-store或用no-cache走协商验证。
4.3 缓存更新策略:不要为了更新而牺牲缓存
实际开发中最常见的纠结是:静态资源到底设多少缓存?如果设太短,性能变差;设太长,更新不生效。
我的答案是:静态资源一定要配合“内容hash”用长缓存,而不是短缓存。构建工具(webpack、Vite等)生成的产物文件名里一般都带着内容hash,app.8f3k2a.js里的8f3k2a就是内容哈希。只要JS代码变化,这个hash就变,等价于生成一个全新的URL,浏览器自然就不会再命中旧缓存。
所以,强缓存的时长可以大胆拉到max-age=31536000(一年),因为URL本身就是版本号。而HTML文件这种不带hash的入口文件,通常就走no-cache(协商验证),确保每次用户都能发现自己应用有新版本。这个组合是当下前端工程化事实上的标准方案。
4.4 接口场景:协商缓存 + 业务版本号组合
接口数据不同于静态文件,往往涉及用户权限、实时性要求高等问题。我的建议是:
- 接口层面一般默认
Cache-Control: private, no-cache,保证数据不会跨用户串,同时允许一定条件下的304验证。 - 如果某组数据确实变化不频繁,比如商品分类、城市列表,可以显式设置较长
max-age,配合CDN做加速。 - 需要强一致性的接口(比如下单、支付),必须
no-store,任何中间层都不得缓存。
具体怎么选,核心看两点:响应数据是否和当前登录用户有关?数据变化是否允许一定的延迟可见?和用户强相关、延迟不可接受的场景,一律no-store;其余场景可以放心用协商缓存甚至短时间强缓存。
5. 浏览器行为对缓存的影响:刷新、前进后退、地址栏回车都不一样
这个章节我放在后面重点讲,是因为它是最容易让人“测试时发现缓存没生效”的根源。很多人在DevTools里直接刷新页面做调试,然后得出“强缓存怎么不生效”的错误结论。
5.1 五种常见操作下的缓存策略差异
普通链接点击 / 地址栏回车 / 新窗口打开: 这是最“尊重”缓存的场景。浏览器按照Cache-Control和Expires的规则,优先命强缓存,过期了才发请求走协商缓存。如果你设置了长有效期,这次操作几乎不会发请求。
F5刷新(普通刷新): 浏览器会绕过强缓存,但保留协商缓存。也就是说,即使Cache-Control: max-age=31536000,F5刷新时浏览器也会把这个资源当作“可能过期”来处理,带上If-None-Match或If-Modified-Since去请求服务器。服务器如果判断没变,返回304。所以你会看到,F5刷新后静态资源的Size列是304而不是from disk cache。
这是好多前端新手第一次看到304时的困惑来源:“我明明设置了强缓存,为什么总是304?是不是配置没生效?”不是的,是F5这个动作本身改变了策略。
Ctrl+F5强制刷新: 浏览器会彻底绕过缓存,请求头里面会带上Cache-Control: no-cache、Pragma: no-cache,并且不发送If-None-Match和If-Modified-Since。服务器只能老老实实返回200和完整内容。这就是我们调样式、调脚本时最常用的“硬刷新”手段。
前进/后退: 浏览器不会重新加载页面,而是直接使用当前会话的缓存副本,基本不会发请求。
关闭标签页后重新打开: 相当于一次新的访问,但磁盘缓存还会生效,所以强缓存依然可以命中,表现和第一种类似。
5.2 DevTools中“Disable cache”勾选的影响
Chrome DevTools的Network面板里,有一个“Disable cache”复选框。它的作用是:在开发者工具打开期间,禁用所有缓存。也就是说,只要你勾上它并保持DevTools打开,那么所有请求都会按未缓存状态处理,状态码都是200,Size列都会显示实际大小。
我见过很多人在排查缓存问题时,一直开着“Disable cache”,然后怎么测都测不出缓存效果。所以,验证缓存是否生效前,请务必先去掉勾选。这是排查中最基础也最容易忽略的一步。
5.3 实战排查思路:如果用户反馈“页面一直不更新”
用户反馈“我看到的还是老版本”,需要分场景判断:
- 如果是HTML页面没更新:多半是因为没有给HTML设置
no-cache,或者CDN层把HTML缓存了。解决办法是HTML响应头配置Cache-Control: no-cache,并在推送新版本时刷新CDN缓存。 - 如果是JS/CSS没更新:多半是资源文件URL不变导致强缓存命中,解决办法是用hash文件名或改版本号参数。
- 如果用户是通过F5刷新看到的还是旧的:有可能是服务端返回的304有问题,比如ETag没有正确变化。这种情况建议用curl带
If-None-Match直接请求验证。
6. 常见问题与排查技巧实录
6.1 问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 改了静态资源,用户还是旧版 | 强缓存未过期,且URL未变 | 改用hash文件名,或缩短max-age |
| F5后资源总是304 | 正常现象,F5会强制走协商验证 | 无需处理 |
| 线上页面一直不更新 | HTML被CDN或浏览器缓存 | 检查HTML响应头,配置no-cache |
| 设置了no-store还有缓存 | 中间代理忽略no-store,或浏览器历史缓存 | 检查CDN配置,直接curl验证 |
| ETag没变化 | 服务端ETag生成规则有问题 | 确认ETag基于内容计算 |
| 304请求数量很多 | 强缓存设置过短或未设置 | 延长max-age,或用contenthash |
| 手机端图片/脚本不更新 | App内置WebView缓存策略差异 | 检查WebView缓存模式,有Cache-Control时一般遵循 |
6.2 我实际踩过的一些坑
第一个坑,是Nginx配置add_header会把继承的Cache-Control覆盖掉。有的Nginx版本里,add_header和expires指令同时存在时,可能会导致响应头里出现重复的Cache-Control或Expires,且顺序不确定。解决方法是显式写好,别依赖默认行为。
第二个坑,是后端接口返回了Cache-Control: private, max-age=60,然后我们又在Nginx层强制加了public,结果CDN真的把用户A的接口数据缓存给了用户B。这是很严重的越权风险,尤其接口里带手机号、订单号这类信息。所以共享缓存和私有缓存的边界必须非常清楚:涉及个人隐私数据,必须在源头就写private或no-store。
第三个坑,是ETag计算用的正则太粗暴。我之前维护过一个老系统,ETag是根据文件大小直接算的。文件大小一样但内容变了,ETag不变,结果协商缓存一直命中304,用户看到的都是旧内容。后来改成基于内容的hash才算彻底解决。
第四个坑,是关于immutable。刚看到这个指令时我觉得太香了,给所有静态资源都加了immutable。后来发现一个问题:如果我们重新部署时,HTML引用了老hash的资源,但用户已经缓存了带immutable的旧资源,又没及时拉取新HTML,就会导致页面引用一个不存在的文件(404)。核心解药还是:HTML不能缓存太久,静态资源hash一定要准确。
6.3 一个兜底思路:让缓存策略跟上版本迭代
做缓存配置,本质上是在“性能”和“新鲜度”之间找平衡。没有任何一套配置适用于所有项目,但可以总结一套稳妥的默认策略:
- 静态资源(JS、CSS、图片、字体):
Cache-Control: public, max-age=31536000, immutable。 - HTML文档:
Cache-Control: no-cache(走协商缓存)。 - 用户相关接口:
Cache-Control: private, no-store或no-cache。 - 通用数据接口(分类、配置、公告):
Cache-Control: public, max-age=300。
这套配置在我的多个项目里跑得都比较稳,既能保证加载速度,又不至于让更新成为噩梦。更重要的是一旦出了线上缓存问题,可以按这套逻辑快速排查到具体链路是哪个环节出了问题。
6.4 介绍几个调试缓存的神器级命令
排查缓存问题时,浏览器的DevTools是最常用的,但curl和在线工具也非常顺手:
bash复制# 查看完整响应头
curl -I https://example.com/app.js
# 带协商缓存头请求,测试服务器是否正确返回304
curl -I -H 'If-None-Match: "64f1d2b8-1a2b3c"' https://example.com/app.js
# 查看请求头,确认是否带了缓存验证字段
curl -v https://example.com/ 2>&1 | grep -i 'if-none\|if-modified'
如果测试CDN节点,还可以用curl -sI https://cdn.example.com/app.js -H "Cache-Control: no-cache"来强制回源验证。要判断资源在CDN上是否命中,看响应头里有没有X-Cache: HIT之类的字段,不同CDN厂商字段名不同,常见的是X-Cache、X-Cache-Lookup、Age等。
还有一个思路:Chrome的Network面板可以按协议和缓存状态过滤,比如输入status-code:304或from-cache,快速筛出命中和验证的资源,对分析整个页面的缓存命中率非常有效。
最后再分享一点我自己做缓存优化的体会
缓存配置看起来就是几行Header的事,但真要在线上做到“又快又稳”,需要你同时理解HTTP协议、浏览器行为、CDN特性、业务场景,甚至还得懂一点构建工具产物策略。我自己的习惯是:先定清楚哪些资源必须最新、哪些资源可以接受延迟,再倒推缓存策略。千万别拿着模板到处套,套出来的配置大概率会坑到后面的自己。
调试时还有一个实用小技巧:如果你在本地开发环境频繁改资源,可以给请求URL手动加查询参数,比如app.js?v=20250101,这样既不走缓存,又不用关掉DevTools的缓存开关。但生产环境尽量别用这种方式,因为查询参数一变,CDN缓存键也会变,旧的缓存可能一直占着空间释放不掉,最终还是文件名hash最干净。
HTTP缓存的坑,说多不多,说少不少,把强缓存、协商缓存、浏览器行为这三块吃透,线上绝大多数缓存问题都能一眼定位。希望这篇内容能帮你在下次被拉去排查“为什么不更新”的时候,少走点弯路。
