ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

强缓存与协商缓存:HTTP缓存策略详解与最佳实践

强缓存与协商缓存:HTTP缓存策略详解与最佳实践 1. 缓存到底在帮我们做什么做过前端性能优化的人早晚都会碰到“强缓存”和“协商缓存”这两个词。刚入行那会儿我其实很迷惑明明叫“协商缓存”怎么有时候加载资源完全不发请求后来才想明白这俩东西不是互斥关系而是同一套缓存体系里的两个层级。搞懂它们的区别能解释你遇到的绝大多数“改了代码页面不更新”或者“刷新半天还是转圈”的问题。先聊缓存的核心价值。用户在访问网站时真正下载页面、脚本、图片这些静态资源消耗的是网络带宽和服务器出口流量。如果每次刷新都把所有资源重新拉一遍一个普通的电商页面可能几十个请求每个请求几百毫秒页面能快才怪。缓存的目标很简单让浏览器尽量少问服务器要东西或者让服务器在确认“没变化”之后轻量回应一个304状态码而不是把整个文件再传一遍。这就画出了一条分界线强缓存是浏览器在本地直接判断缓存是否有效压根不跟服务器打招呼协商缓存是浏览器带着“上次我看过这个东西”的信息去问服务器服务器说“没变你继续用吧”浏览器就不重新下载。前者省请求后者省流量两者配合才能把静态资源压缩到极致。我还经常拿冰箱来类比。强缓存就像你家里囤了一箱牛奶只要没过保质期你直接喝就行不会跑去超市问“这牛奶还能喝吗”。协商缓存则是你把牛奶喝到快没了去超市买东西时顺便看一眼货架发现之前那箱还在保质期内、跟上次一模一样那就不用再搬一件新的回家——但你还是出了一趟门。这个类比能解释很多东西强缓存命中时根本不会产生网络请求协商缓存命中时会产生一个很轻的请求换回来的只是几十字节的响应头。所以接下来的内容我会先分别把这两种缓存的实现原理和关键字段讲透再给一套可以直接抄走的配置方案最后聊我怎么排查缓存相关的线上问题。这篇文章既适合刚接触HTTP缓存的新手也适合想系统整理缓存策略的工程师。全程不涉及太玄乎的理论全是我在实际项目里验证过的东西。2. 强缓存让浏览器连“问”都不问强缓存的特征是没有任何HTTP请求发送到服务器。浏览器根据响应头里带的信息直接决定这个资源还能不能继续用。判断的核心依据就是时间。2.1 Expires与Cache-Control两个时代的过期时间HTTP缓存刚发展起来的时候服务器是靠Expires这个响应头告诉浏览器“这个文件过期的时间点”。Expires的值是一个具体的GMT时间字符串比如Expires: Thu, 01 Jan 2026 00:00:00 GMT。浏览器拿到之后会拿本地时间和这个时间点对比没到就命中缓存。这里就埋着一个非常经典的坑Expires依赖客户端本地时间。用户把自己的电脑时间改成2008年你设置在2026年过期的缓存就会瞬间失效。更常见的情况是手机、车载设备这些终端时间不准导致明明服务器还觉得缓存没失效浏览器却非要重新下载。所以Expires这种HTTP/1.0时代的产物现在基本已经被Cache-Control替代了。Cache-Control用的是一个相对时间max-age3600代表“资源在缓存里存活3600秒”。相对时间的好处是它不关心客户端本地时间准不准只看这个资源从被拿下来那一刻起过去了几秒。这比绝对时间可靠得多也是现在所有主流站点都在用的方式。实际项目里我会在响应里同时看到两者后端框架一般会自动带上。但需要明白一条规则如果响应头里同时有Expires和Cache-ControlCache-Control的优先级更高。因为现代浏览器都遵循这个规定所以更多时候我们只需要关注Cache-Control就行Expires留着主要是为了兼容特别老的环境。2.2 Cache-Control关键指令逐项拆解很多人以为Cache-Control就是max-agexxx其实它是一个组合指令系统。下面这几个指令我在不同项目里都踩过坑分开讲清楚。max-ageseconds资源可缓存的最长秒数。这是强缓存的核心。s-maxageseconds这个指令是写给共享缓存看的比如CDN节点、反向代理。它不存在时会回退到max-age。它不作用于浏览器私有缓存。我在配置CDN时经常用它来区分“浏览器端缓存策略”和“CDN节点缓存策略”。public表示响应可以被任何缓存保存包括浏览器和中间代理。private表示响应只能被浏览器这类私有缓存保存不能存入共享缓存。常用于带用户信息的内容。no-cache很多人的理解是“不缓存”我最初也这么以为后来才发现它是“不要直接使用缓存”。意思是浏览器可以存这个响应但每次使用前必须先到服务器做一次协商缓存校验。也就是说它把缓存的使用权交回给服务器判断这是强转向协商的关键开关。no-store这才是真正意义的“不缓存”。浏览器收到这个指令后不会在本地保存任何副本适用场景是支付、订单、账号这类敏感接口。must-revalidate告诉浏览器缓存一旦过期必须向服务器重新验证不能拿来就用。它跟no-cache有点像但区别是no-cache是“每次都用前验证”must-revalidate是“过期后才验证”。在实际运营中我把这两个指令混用的情况很多逻辑别搞偏了。举个例子理解no-cache和no-store的区别一个新闻网站的HTML页面我会设置Cache-Control: no-cache这样浏览器可以缓存HTML但每次用户访问时都会去服务器问一句“内容变了吗”如果没变就返回304带宽开销极小。而用户提交订单返回的确认接口我会设置no-store绝不允许任何中间层把它缓存下来否则上一个用户的订单信息可能被下一个用户看到。2.3 强缓存的实际配置原则聊完指令说说怎么落地。一个很常见的错误配置是所有资源统一max-age86400一天之后全过期。这样做的结果是用户第二天再来访问时浏览器会对原来命中强缓存的资源全部发请求去协商虽然304也能省流量但请求数没有减少服务器压力依然存在。我个人的配置原则分三档。第一档是“带指纹的静态资源”比如app.3f8fd2b4.js、style.c924f9da.css这类构建产物文件名里的hash就是内容指纹。内容变了文件名就变文件名没变内容一定没变。这种资源可以放心大胆地设置max-age31536000一年甚至加上immutable指令表示在过期之前浏览器都不需要再验证。很多团队就靠这个让用户重复访问时静态资源全走本地缓存几乎不产生请求。第二档是“HTML入口页面”比如index.html。它是整个应用的导航入口必须保证用户拿到最新版本所以通常设置no-cache。这样每次都会走协商缓存但HTML文件本身很小304的成本几乎可以忽略。第三档是“动态接口数据”绝对不能照搬静态资源的策略。后面我会专门讲API的缓存设计。强缓存最方便调试的地方就是打开DevTools的Network面板能看到状态码一栏显示“200 (from disk cache)”或者“200 (from memory cache)”。这表示请求压根没发出。如果显示的是“304”说明强缓存没命中走的是协商缓存流程。这两种状态在性能指标上的意义完全不同排查时要注意区分。3. 协商缓存用一次请求换“不用下载整个文件”协商缓存跟前者的区别从名字里就能感觉到——“协商”这个词意味着双方要交换意见。浏览器首先要告诉服务器“我手里有一个缓存副本”服务器判断这个副本是不是最新的如果是就返回一个极小的响应说“你就接着用吧”如果不是就返回完整的新资源。3.1 Last-Modified与If-Modified-Since的老牌组合最早实现这种校验靠的是时间。服务器在响应头里返回Last-Modified: Wed, 29 Nov 2023 08:00:00 GMT代表这个文件最后的修改时间。浏览器把时间记下来下次请求时在请求头里带上If-Modified-Since: Wed, 29 Nov 2023 08:00:00 GMT。服务器收到后会拿这个时间跟文件当前的修改时间做对比如果文件没再被改过返回304改过就返回200并携带新文件。这看起来简单但实际用起来问题不少。第一个缺陷是精度只能到秒级。如果同一秒内文件被改了好几次最后修改时间并不会变化服务器误以为没改。第二个缺陷是文件修改时间变了但内容其实没变比如用脚本批量touch了一堆文件就会造成304失效白白浪费流量。第三个问题是多台服务器部署的情景文件在不同服务器上的修改时间可能不一致A服务器被改过而B服务器没同步时间协商结果就会出现漂移。所以实际应用里Last-Modified更适合作为兜底方案而不是主力。3.2 ETag与If-None-Match更靠谱的内容指纹为了解决时间不准的问题HTTP/1.1引入了ETag。它本质上是一个“内容指纹”由服务器根据文件内容或者其他属性计算出一个字符串比如ETag: 8f3fbc0a。只要文件内容变了这个指纹就必须变内容没变指纹就不会变。浏览器请求时会带上If-None-Match: 8f3fbc0a服务器用同一个算法重新计算当前文件的内容指纹跟请求头里的值比对。一致返回304不一致返回200加新内容。因为ETag不依赖时间所以能规避Last-Modified的秒级精度问题。这里有个容易踩的细节当响应头里同时有ETag和Last-Modified时浏览器会同时发送If-None-Match和If-Modified-Since但服务器只要看到If-None-Match就应该以它为准忽略掉时间校验。这一点在我排查问题的时候也踩过坑后面会细说。ETag也不是万无一失。如果你的站点有多台后端服务器而每台服务器计算ETag的算法不同比如有的用inode、有的用时间戳、有的用内容hash那同一资源在不同服务器上就会产生不同的ETag导致用户这次请求命中缓存下次请求换了一台服务器就立刻判定不匹配。所以ETag算法的选择在多实例部署时要非常谨慎尽量选择内容hash方式并且所有服务器保持同一套算法。3.3 304与200之间的隐藏成本协商缓存虽然省了流量但并没有省请求。每次命中304浏览器仍然要和服务端做一次完整的HTTP往返。一次请求耗时哪怕只有50ms几十个静态资源同时走协商缓存累计时间也不容小觑。所以我在实践中很少单独依赖协商缓存而是把它跟强缓存搭配使用强缓存覆盖绝大多数重复访问协商缓存负责处理“资源可能变了但我需要确认”的场景。再补充一个两个缓存“配合”的具体流程。第一次访问时服务器返回Cache-Control: max-age0或者no-cache这样的头不让浏览器强缓存直接用同时给出ETag/Last-Modified。此时浏览器把资源存下来并记住校验标识。第二次访问时浏览器发起请求带上If-None-Match或If-Modified-Since。服务器发现可以复用缓存就返回304不含资源实体。这个流程对动态HTML页面来说是最常见的组合。4. 实战搞一套能直接抄的缓存策略落到实际操作我平时搭项目时最常用的方案分两套一套是用Nginx做Web服务器给静态资源配置缓存另一套是在前端构建工具里用文件名hash来配合长缓存。4.1 Nginx中配置强缓存与协商缓存假设你有个以Nginx为入口的站点静态资源都放在/static目录下HTML入口是/index.html。一个相对成熟的配置是这样# HTML入口每次都向服务器确认但不重新下载 location /index.html { add_header Cache-Control no-cache, must-revalidate; etag on; } # 带哈希指纹的静态资源一年长缓存 location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|woff2?)$ { expires 365d; add_header Cache-Control public, max-age31536000, immutable; etag on; } # 普通接口不落缓存或者最多在客户端缓存几十秒 location /api/ { add_header Cache-Control no-store; }需要多说一句expires 365d这个指令其实是Nginx帮你生成Cache-Control: max-age31536000和Expires响应头的老写法但我们在上面又用add_header手动添加了一行Cache-Control这时Nginx不会自动合并这两行最终生效的Cache-Control会以最后一个add_header为准。所以如果没有写后面的add_header用expires就够写了就必须覆盖完整。这里还有个Nginx的坑要注意如果你用了add_header Cache-Control而且静态文件不是通过文件系统直接返回而是经过内部代理模块Nginx默认不会转发add_header到下一层。我在实际项目里被这个问题坑过一次配置看着没问题浏览器收到的响应里就是没有缓存头。排查时记得开启Nginx的调试日志看看是不是某一层代理把这个头吞掉了。4.2 前端构建产物hash文件名是长缓存的生命线前面反复提到“带指纹”这个前提。如果你没有给文件名加hash而是固定叫app.js、style.css那你绝对不要设置immutable因为发版后文件名不变但内容变了用户不会拿到新文件。我在真实项目里见过团队把max-age31536000用上了结果每次发布都要手动清CDN缓存用户还要强制刷新才能看到新版场面一度很混乱。正确做法是用Vite、Webpack这类构建工具开启内容hash。以Vite为例构建后文件会变成index-d3b79c0e.js这种格式Webpack里对应的是output: { filename: [contenthash].js }。只要源码变了hash就变文件名就变浏览器把它当成新资源请求源码没变文件名不变之前的强缓存就一直生效。这个方案的逻辑闭环是HTML文件永远是入口每次走协商缓存确认版本HTML里引用的静态资源文件名一旦变化浏览器必然会发新请求。发布时只需要更新HTML文件其他旧资源即使还在缓存里也不会再被引用用户可以无感完成版本升级。为了让这个闭环更顺利我在项目里还额外配置了构建后的“资源清单输出”比如build/manifest.json方便检查当前发布产物的文件名hash。这能在线上排查问题时快速确认用户到底加载的是哪个版本。4.3 动态接口的缓存取舍对于API我一般会把它们分成三类来对待。一类是全局配置、字典数据、静态公告这类“基本不变”的数据可以允许客户端短时间缓存比如Cache-Control: max-age300减少用户滑动时的重复请求。缺点是数据更新最迟5分钟才生效我一般会人工判断更新频率来定秒数。二类是用户无关但更新较频繁的列表接口比如首页推荐、热销榜单可以设置Cache-Control: no-cache然后由服务端自己用Redis做应用层缓存。这样HTTP层保证不会出现“缓存穿透”又能在秒级内刷新数据。三类是用户私有、涉及订单支付金额的接口一定要no-store。这类数据如果被中间代理缓存很容易出现用户A看到用户B的数据这种事故。这个底线不能破。真实踩过的教训曾经给一个带token的接口加了Cache-Control: public, max-age60结果用户切换账号后页面还能看到上一个账号的部分数据。原因就是这类响应被浏览器缓存了无论怎么换号读取的都是同一份数据。从那之后只要是包含身份信息的响应我再也不碰public。5. 常见问题与排查技巧实录缓存排障是每个前端工程师都会遇到的日常。这里整理几个高频场景和我的排查路径。5.1 改了代码用户访问的还是老版本这个问题几乎都和文件名有关。最常见的两种情况静态资源文件名没带内容hash同路径下的文件被浏览器用强缓存直接命中。CDN节点缓存没有刷新用户连到的边缘节点仍然返回旧文件。排查步骤我是这样走的。第一步在无痕窗口打开页面因为无痕窗口会绕过大部分本地缓存如果无痕模式能看到新版问题基本就是浏览器缓存如果无痕还是旧版问题大概率在CDN或网络代理层。第二步查看资源请求的响应头看Cache-Control里的max-age是否过大同时看请求是否命中了from disk cache。第三步如果确认是CDN缓存就刷新对应URL的CDN缓存。更根治的办法还是把文件名hash一劳永逸地做起来。5.2 明明设置了协商缓存却一直返回200这种情况我遇到时都先怀疑浏览器是不是根本没存这个资源。打开DevTools看请求头如果If-None-Match或者If-Modified-Since根本没带说明浏览器本地没有可用于协商的副本自然只能下载完整资源。导致这种状态的原因有可能是响应头里带了Cache-Control: private但不匹配场景甚至被某个中间层把缓存头给剥离了。另一种情况是服务器看到了If-None-Match但校验逻辑写错了。比如后端返回的ETag前后带引号不一致或者每次动态生成不同的弱ETag。这就得查服务端代码了。我建议排查时直接写一段脚本模拟带If-None-Match的请求看看服务器是否稳定返回304能快速定位是客户端问题还是服务端问题。5.3 DevTools中的三种状态分别代表什么打开Network面板你会看到资源状态栏有三种缓存相关表现200 (from memory cache)这次资源还在内存缓存里直接从内存读取。多发生在后退前进、同一页面未关闭的场景速度极快但不持久。200 (from disk cache)资源在磁盘缓存里同样没发请求刷新页面时很常见。304 Not Modified这个状态代表浏览器确实发起了网络请求但服务器验证后说“缓存还有效”所以没有返回资源实体。很多新人看到304就误以为“没缓存”看到from disk才觉得是缓存其实304恰恰是协商缓存命中的表现。用这个视角看网络请求列表能一眼判断当前资源的缓存策略是否按预期生效。5.4 中间代理和CDN导致的缓存污染即使你的源站缓存策略完全正确CDN节点也可能有自己的缓存TTL。比如源站设置了Cache-Control: no-cache,但CDN节点仍然缓存了旧文件。这种情况在改版后用户仍然看到旧页面时会反复出现。我通常在CDN配置里开“源站优先”或“遵循源站缓存指令”并给敏感路径单独加规则让CDN严格执行源站响应头。另外注意CDN上如果开启了Gzip后变更了内容编码响应头里的ETag在源站和节点之间也要保持一致。很多CDN会把源站ETag剥离或重算造成源站304而CDN端直接返回200的尴尬。6. 这些坑踩过之后我的最终建议缓存这块调优最理想的状态是把内容分成几类每类用不同策略而不是全站统一设置。我个人目前会在项目里维护一张“缓存策略表”静态资源扔到assets目录配置长缓存加immutableHTML入口配置no-cache并保留ETag面向公共数据的GET接口配置几十秒的max-age涉及私密信息的接口一律no-store。这张表基本能应对90%的业务场景。再分享一个检查缓存是否生效的小技巧在新标签页直接拖拽一个静态资源URL到地址栏按回车不按CtrlF5强制刷新。看Network面板里这个请求是from disk cache还是304就能判断线上缓存是否正确。如果你每次按普通回车访问首页看到大量资源来自disk cache说明强缓存链路是健康的那你现在的配置基本没问题。最后关于缓存我想说的是缓存不是越快越好而是要“该长就长该短就短”。给不应该缓存的东西加长缓存后果很严重给应该缓存的东西加no-store线上性能又会被拖垮。我踩过里面两类极端问题之后得出来的心得就是先写清楚每个资源的使用场景再动手加响应头比任何框架模板都管用。
返回列表