我们线上有一次发布事故,改了一个接口的返回数据,结果用户那边半小时内拿到的全是旧内容。查了半天,最后发现是Nginx默认给接口加了Cache-Control: max-age=1800,前端同学没注意,直接踩进了HTTP缓存机制的坑里。从那以后我就觉得,缓存这个东西,不把强缓存和协商缓存的原理彻底搞明白,早晚会在线上给你上一课。
所以这篇文章想把HTTP缓存机制这件事从头到尾讲透。不管你是前端、后端还是运维,只要你的服务跑在HTTP上,这事就跟你有关。我会从浏览器和服务器之间的缓存约定讲起,把强缓存、协商缓存的字段、流程、优先级、配合方式全部拆开,最后给出一套可以直接抄走的配置方案和排障思路。
1. 先搞清楚:一次没有缓存的HTTP请求到底有多“亏”
1.1 一次完整请求往返的成本,比你想象的更高
很多人觉得,一次HTTP请求不就是“发出去、收回来”吗?能有多慢?但你看一下时间线就知道,在一个完全没有缓存的页面里,加载一张图片、一个JS文件,背后经历的环节包括:DNS解析、TCP三次握手、TLS握手(如果走HTTPS)、发送请求头、服务器处理、返回响应体。如果页面里有几十个静态资源,这些环节要重复几十遍。
我举个例子你就明白了。假设一个页面有30个静态资源,每个资源完整往返需要200ms,没有缓存的情况下光这些资源的网络耗时就是6秒。如果其中有10个资源能被强缓存命中,浏览器连请求都不发,直接从本地读,省掉的不只是200ms,而是这一整个资源的所有网络环节。这就是缓存的魅力——它不是把请求变快,而是让很多请求压根就不会发生。
1.2 缓存机制不是“不访问服务器”,而是分两档策略
很多人把HTTP缓存理解成“浏览器把资源存下来,下次直接用”,这个说法对了一半。实际上HTTP缓存分成两条路线:一条是强缓存,资源没过期就直接用本地的,服务器完全不知情;另一条是协商缓存,浏览器会带着资源的标识去问服务器“我这个副本还能不能用”,服务器说“可以用”,那就继续用本地的,服务器说“不行”,那就返回新资源。
这两条路线不是互斥的,而是先后关系。浏览器收到响应时,先看强缓存规则,如果命中,本地直接用,完全不发请求。如果强缓存没命中,比如过期了,那就进入协商缓存流程,发一个带条件的请求去问服务器。所以你可以把强缓存理解为“免检通道”,把协商缓存理解为“安检通道”,两者配合起来才是一套完整的HTTP缓存机制。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 强缓存:服务器允许你“免检入场”
2.1 Expires和Cache-Control:两个头字段的前世今生
强缓存的核心是两个响应头:Expires和Cache-Control。Expires是HTTP/1.0时代的产物,它给的是一个绝对时间,比如Expires: Thu, 01 Dec 2025 08:00:00 GMT,浏览器在这个时间之前直接用本地缓存。这个设计看起来很直观,但问题很大:服务器时间和用户本地时间可能不一致,用户改一下系统时间,缓存就废了。
所以HTTP/1.1推出了Cache-Control,它用相对时间代替绝对时间,最常用的就是Cache-Control: max-age=3600,意思是“拿到资源后的3600秒内直接用缓存”。这个方案绕开了客户端时钟不准的问题,计算基准是浏览器收到响应的时间,完全不受系统时间影响。现在实际项目里基本都以Cache-Control为准,Expires已经很少单独使用了,但如果响应头里两个都有,Cache-Control的优先级更高。
2.2 强缓存命中后,你在Network面板里看到的样子
强缓存命中时有个非常明显的特征:浏览器Network面板里这个请求的Status是200,但Size列显示的是“from disk cache”或“from memory cache”,而不是具体的大小。前者表示从磁盘缓存读的,后者表示从内存缓存读的。而且这个请求根本不会出现在服务器的访问日志里,因为请求压根就没发出去。
这里有个细节值得注意:资源首次加载时,会像普通请求一样完整走网络,把响应头和资源内容都缓存下来。第二次再访问同一个URL时,浏览器根据Cache-Control判断还在有效期内,才直接走本地缓存。所以强缓存不是第一次请求就生效,而是从第一次拿到响应之后开始计时。
2.3 配置强缓存时最容易踩的“时间坑”
我在实际配置强缓存时踩过一个很典型的坑:给接口配置了max-age=600,觉得“10分钟应该够了”,结果产品希望用户改完数据后立即看到新效果,用户那边却要等10分钟。这就是强缓存的时间语义决定的——在max-age的有效期内,无论服务器数据怎么变,浏览器都不会知道。
所以配置强缓存的时间,核心原则是:只对“内容绝对不会变”或者“变了也无所谓”的资源开长缓存。比如静态图片、构建出来的带hash的文件名,文件名本身就带版本号,内容一变文件名就变,这种可以放心地配置一年。对于内容会动态变化的接口,要么不配强缓存,要么配一个很短的max-age,甚至用no-cache走协商缓存更稳妥。
3. 协商缓存:每次都问服务器一声“变没变”
3.1 Last-Modified/If-Modified-Since的校验逻辑
协商缓存的核心思路是“让浏览器带着资源标识去问服务器”。最早的实现是用时间做标识:服务器返回资源时,带上Last-Modified响应头,记录这个文件的最后修改时间。浏览器缓存了资源后,下次请求时会带上If-Modified-Since请求头,值就是之前拿到的Last-Modified时间。
服务器收到请求后,会拿这个时间和资源的实际最后修改时间做比对。如果资源没变,服务器返回304 Not Modified,响应体为空,浏览器继续用本地缓存。如果资源变了,服务器返回200,带上新的资源内容和新的Last-Modified。这个方案实现简单,Nginx、Apache都原生支持,但它有个明显的精度问题:Last-Modified的精度是秒级,如果资源在1秒内被修改了两次,那第二次修改服务器是感知不到的。
3.2 ETag/If-None-Match:更精确的“指纹比对”
为了解决Last-Modified精度不够的问题,ETag被设计出来了。ETag是服务器为资源生成的唯一标识,通常是根据文件内容、大小、修改时间算出来的一个hash值(比如Nginx默认会用文件大小和mtime组合)。资源内容一变,ETag就会变。浏览器请求时带上If-None-Match请求头,值就是之前收到的ETag。
服务器收到If-None-Match之后,会优先拿它和当前资源的ETag做比对。一致就返回304,不一致就返回200和新资源。由于ETag能做到内容级别的精确校验,它比Last-Modified更可靠。也是因为这个原因,HTTP规范里明确说了:如果请求头里同时有If-None-Match和If-Modified-Since,服务器必须优先处理If-None-Match。这个优先级是强制性的,不是可选建议。
3.3 两种校验方式怎么选,优先级怎么回事
在实际项目里,Nginx、Tomcat这些服务器通常会同时输出Last-Modified和ETag,两者不冲突,反而能互补。ETag负责精确校验,Last-Modified在ETag缺失时兜底。比如在分布式环境下,不同机器生成ETag的逻辑如果不完全一致,可能出现“同一个文件在两个节点上有不同ETag”的情况,这时候Last-Modified就能补位。
但如果你要手动设计协商缓存方案,我的建议是:优先用ETag,原因就一条,它不受时间精度影响。Last-Modified可以作为辅助字段留着,但不要把它当作唯一的判断依据。另外,ETag有两种风格——强ETag和弱ETag,强ETag要求内容逐字节一致才算相同,弱ETag(带W/前缀)只要语义上等价就算相同。常规的静态资源场景,用强ETag就行了。
4. 强缓存与协商缓存的配合流程:从一次页面刷新说起
4.1 一次刷新背后,两类缓存是怎么接力的
我们用一个真实的访问场景,把强缓存和协商缓存的配合关系走一遍。假设你第一次访问一个页面,页面里有一张图片logo.png,服务器返回的响应头里包含Cache-Control: max-age=300和ETag: "abc123"。
第一次访问的过程:浏览器发请求,服务器返回200和图片内容,浏览器把图片内容和这两个响应头都缓存起来。
5分钟之内再次访问:浏览器检查max-age还没过期,直接使用本地缓存,不发请求。此时你在DevTools里能看到“from disk cache”,服务器日志里没有这条请求。
5分钟之后再次访问:max-age过期了,强缓存失效。浏览器带着If-None-Match: "abc123"去问服务器。服务器对比之后发现资源没变,返回304,响应体为空。浏览器继续用本地缓存,但这次请求确实发送到服务器了。
这就是两类缓存的完整接力:先走强缓存“免检”,过期后再走协商缓存“问一声”。它们不是二选一,而是前赴后继的关系。
4.2 no-cache和no-store的命名陷阱
Cache-Control里有两个非常容易搞混的指令:no-cache和no-store。我第一次用的时候理所当然地以为no-cache就是“不缓存”,后来发现完全不是这回事。
no-cache的真实意思是“可以缓存,但每次使用前都必须去服务器验证一下”。它强制走协商缓存流程,不会命中强缓存。也就是说,资源被缓存了,但每次请求都会发出去,带If-None-Match或If-Modified-Since,服务器返回304就继续用本地,返回200就更新缓存。所以no-cache不是“不缓存”,而是“缓存但每次都要验证”。
no-store才是真正的“不缓存”,浏览器和所有中间代理都不允许保存这个资源的任何副本。每次请求都必须完整走网络。对于包含敏感信息的接口,比如订单详情、个人隐私数据,配置no-store是安全的做法。记住这个区别,后面排查问题的时候能少踩一半的坑。
4.3 不同类型资源的缓存策略参考
根据我自己的实践经验,不同类型资源的缓存策略是有一套相对固定的套路的。静态资源里,带hash指纹的JS/CSS可以配置长期强缓存,因为文件内容变化后文件名会变,相当于生成一个新的URL,不会跟旧文件冲突。图片、字体这类资源也适合长期缓存,除非你确定会原地替换同名文件。
对于API接口,情况要复杂得多。拿用户信息接口来说,如果用户修改了头像、昵称,我们肯定希望尽快看到新数据,这时候就不能配长强缓存。比较稳妥的做法是Cache-Control: no-cache,让每次请求都走协商缓存,服务器可以用ETag判断数据有没有变。数据没变就返回304,省流量;数据变了就返回200和新数据,保证新鲜度。
HTML文件本身我建议用no-cache或者很短的max-age。因为HTML是你的页面入口,它引用了哪些JS、CSS文件都由它决定,如果HTML被强缓存了,你发版后用户拿到的还是旧HTML,那就永远看不到新资源了。
5. 实战:Nginx配置、前端发布与缓存调试
5.1 一套可直接落地的Nginx缓存配置
理论讲再多,不如直接看配置。我在Nginx里一般是这样配置静态资源缓存的:
nginx复制location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
etag on;
if_modified_since exact;
}
这段配置干了三件事:一是通过expires 30d给旧版浏览器输出Expires头;二是显式声明Cache-Control为30天强缓存,immutable表示在有效期内连“刷新时重新验证”都不需要做;三是开启ETag,作为强缓存过期后的协商兜底。
对于HTML文件,配置要反过来,不能给长缓存:
nginx复制location / {
add_header Cache-Control "no-cache";
etag on;
}
这里用no-cache而不是no-store,目的是让浏览器每次都来问服务器“HTML变没变”,没变就304,变了就200,既保证新鲜度又节省带宽。这套配置我用了很久,一直很稳。
5.2 前端发版后用户还是旧版本:经典问题拆解
前端发版之后用户看不到新版本,是我见过的最高频缓存问题。它的根源几乎都是同一个:HTML或者某个入口文件被强缓存了。用户在浏览器里打开页面,拿到的还是旧HTML,旧HTML里引用的还是旧JS文件名,自然加载不到新代码。
解决方案有两个方向。第一个方向是在构建工具里给静态资源加hash指纹,比如webpack的contenthash,文件内容变了文件名就变,这样即使HTML被强缓存,用户刷新后也能从新HTML里拿到新资源名。第二个方向是确保HTML本身不走强缓存,用no-cache让每次访问都验证一次。两个方向配合起来,基本能解决99%的“用户看到旧版本”问题。
如果问题还是没解决,就教用户强制刷新一下(Cmd+Shift+R),强制刷新会绕过强缓存,但请求头里还是会带协商缓存字段。想彻底验证的话,直接开一个无痕窗口,无痕窗口没有历史缓存,所有资源都是全新加载的。
5.3 用Chrome DevTools快速定位缓存问题
最后分享一个排查缓存问题的固定思路。遇到“改了不生效”或者“加载慢”的问题,第一步打开DevTools的Network面板,刷新页面,找到对应请求,看一下Size列。如果显示“from disk cache”或“from memory cache”,说明被强缓存拦住了,先看响应头里的Cache-Control和Expires。
如果Size列显示的是数字,说明走了网络,再看Status列。304表示走了协商缓存,服务器确认没变,这时看响应头里的ETag或Last-Modified,和请求头里的If-None-Match或If-Modified-Since是否对应得上。200表示资源真的从服务器返回了,那就不是缓存的问题,该往业务代码的方向排查。
还有一个实用技巧:DevTools的Network面板可以右键请求,选择“Clear browser cache”,一键清空当前站点的缓存,比手动去设置里清要快很多。排查时再配合勾选Network面板里的“Disable cache”选项,就能模拟一个无缓存的全新访问环境,快速排除缓存干扰。
我个人调试时的习惯是:带hash的静态资源随便配长缓存,入口HTML一律no-cache,接口按业务需求决定。这套思路帮我避开了很多线上问题,也让我在面对“这个为什么没更新”这类问题时,能在一分钟内定位到到底是强缓存还是协商缓存出的问题。缓存机制本身不复杂,但把它彻底吃透,你才算真正理解了HTTP请求的来龙去脉。
