1. 先把http缓存这件事说清楚
聊到http缓存,很多人第一反应是"浏览器里那个Network面板,看有没有from disk cache"。但这背后的决策链路,远比你想象的复杂。作为前端和服务器之间的缓冲带,http缓存做的核心事情只有一件:减少重复传输,让资源只在该传输的时候传输。它直接决定了用户打开你网站的速度、服务器的负载压力,甚至是并发高峰时你的服务会不会被打挂。
我在实际项目里见过太多"调得稀里糊涂"的缓存配置:要么是Cache-Control: no-cache全都怼上,要么干脆不配让浏览器自己猜,要么是配了强缓存导致用户永远看到旧版本。这些问题的根源都一样:没搞懂http缓存的本质,也没想清楚自己服务的资源属性。
这篇内容适合谁?如果你是个前端开发者、后端接口维护者,或者正在为"为什么部署后用户还看到老页面"而头疼的运维同学,那这篇就是给你整理的。我会从http缓存的两个分支——强缓存和协商缓存讲起,结合真实场景给出配置方案,最后再聊聊我踩过的一些坑。这些东西搞明白之后,你会发现很多"玄学"问题其实都是缓存在捣鬼。
先说个生活化类比。强缓存就像你手机里存的离线地图,我本来就已经下载到本地了,走到哪儿都不用问服务器要数据;协商缓存则是你钱包里的会员卡,商家虽然不用每次都问你全名电话住址,但还是要你亮一下卡来证明身份。一个完全免交互,一个只做轻量确认——两种机制各有用武之地,也各有代价。
2. 强缓存:说不用请求,就不用请求
2.1 Cache-Control 才是现在的核心
强缓存怎么生效?关键就是响应头里的Cache-Control,通过它告诉浏览器这个资源能存多久、能不能被本地复用。最常见的写法是max-age=3600,单位是秒,表示从现在开始的一个小时内,这张图片、这个JS文件,浏览器都不会再向服务器发起任何请求,直接用本地副本。
Cache-Control的设计比较现代,字段丰富且扩展性好。除了max-age,还有几个容易被误解的字段,我一个个说。public表示任何中间节点(比如CDN、代理服务器)都可以缓存,适合静态资源;private表示只有最终用户的浏览器可以缓存,中间节点不能存,适合有账户体系的接口响应。no-store是彻底不允许缓存,响应不落盘、不占内存,适合登录态、支付结果这类敏感数据。no-cache反直觉的地方在于,它名字里带个"no",但并不是不缓存,而是缓存之前必须先验证,用术语说就是走协商缓存流程。
这几个字段的实际组合效果,我整理过一张自己平时配置参考的表:
| 配置值 | 缓存行为 | 典型场景 |
|---|---|---|
max-age=31536000, immutable | 本地直接命中,一年内绝不回源 | 带hash指纹的静态资源 |
max-age=60, private | 用户浏览器缓存60秒,中间节点不缓存 | 首页HTML片段 |
no-cache | 每次使用前都向服务器验证 | HTML页面、易变接口 |
no-store | 完全禁止任何缓存 | 银行对账单、支付接口 |
max-age=0, must-revalidate | 立即过期,必须验证才能用 | 实时性要求高的数据 |
这里面immutable是后面加上的字段,配合max-age用,告诉浏览器这个文件在过期前完全不需要再验证。它解决了我实际遇到的一个问题:浏览器在普通刷新时会重新验证资源,但带hash的文件明明不会变,白跑一趟浪费网络。加了immutable之后,Chrome就不会在过期前发这个验证请求了。
2.2 过期时间是怎么计算的
从阅读体验讲,大家都很容易忽略到底谁能决定"过期"这件事。两个头在抢这个话语权:老牌方案Expires和新方案Cache-Control里的max-age。前者是一个绝对的GMT时间,比如Expires: Wed, 21 Oct 2027 07:28:00 GMT;后者是一个相对时间,按秒计算。
这里有个坑:如果同时出现Cache-Control和Expires,现代浏览器一律以Cache-Control为准,直接忽略Expires。原因是Expires存在本地时间不一致的问题——用户把电脑时间改早一天,缓存有效期就被拉长了;max-age是从响应时间开始算的偏移量,跟系统时钟的关系没那么直接,相对更稳。
我在项目里验证过一次:同一个静态资源,既设置Expires为10分钟后过期,又设置Cache-Control: max-age=86400,结果用户第二天再看图片,依然是从本地缓存加载的。就是因为max-age把Expires覆盖了。所以各位在排查缓存问题时,先看清响应头里有没有同时存在这两个字段,别被旧协议干扰了判断。
2.3 强缓存失效之后呢
强缓存是有期限的,一旦过了max-age,浏览器就得重新处理。但这个时候,它并不是立刻发请求拿全量数据,而是先走协商逻辑问服务器一声——这个资源变了没有?如果服务器回答"没变,你用旧的",那就返回一个只有头没有体的响应,状态码是304 Not Modified。如果变了,就返回200带上新资源。
所以你会发现,一次看似普通的资源加载,实际在本地命中和服务器确认之间反复横跳。理解了这个过程,你才能看懂后面要说的浏览器开发工具里的那些状态码,不然你看到200和304很容易一头雾水。
我常见到有人误以为"强缓存过期了就是走网络重新加载",这是不对的。过期的强缓存不会直接变成本地不能用的垃圾,只是从"无条件信任"降级成了"需要验证的信任",这就是协商缓存的舞台。
3. 协商缓存:跟服务器确认一下再决定
3.1 Last-Modified方案:时间戳的利与弊
协商缓存里最传统的一对头是Last-Modified和If-Modified-Since。服务器在返回资源的时候,带上这个文件的最后修改时间。浏览器下次请求时,把时间通过If-Modified-Since再传回去,服务器拿这个时间和当前文件的实际修改时间对比,没变就回304。
这个方案简单直接,几乎所有Web服务器默认都支持,但它的局限性特别明显。首先,时间精度只能到秒,同一秒内发生的文件修改,服务器很难感知到差异;其次,文件内容变了但修改时间没变(比如你改了系统时间再覆盖回去),会判断失误;最后,有些场景下文件本身是动态生成的,根本没有"修改时间"这个概念,Last-Modified直接无从谈起。
我之前维护一个图片服务,运维脚本会自动批量重新生成缩略图,生成完修改时间都是一样的。结果用户请求缩略图时,服务器通过时间戳判断"没变",直接返回304,然而图片内容其实已经换成新版本了。这类问题特别隐蔽,排查到的时候,说实话有点心累。
3.2 ETag方案:内容指纹更精确
为了解决时间戳的糙,HTTP/1.1引入了ETag,本质上是给资源算一个内容指纹。ETag的常见实现方式包括根据文件大小、修改次数、内容hash或inode综合生成,比如 Nginx 默认用的是文件大小和修改时间的组合,但多数框架允许自定义。
浏览器把ETag通过If-None-Match回传给服务器,服务器把资源当前的ETag和这个值比对,不同就走200返回新内容,相同就走304。由于这是基于内容的比对,改动再细微都能被识别出来,完全可以弥补时间戳的粗糙。
需要注意的是,ETag也是双刃剑。每次请求服务器都得重新计算hash,在资源非常大或请求量很高的场景下,这部分计算是有CPU成本的。好在很多中间件和服务器都对ETag做了缓存优化,比如 Nginx 只在文件发生变化时重新计算,实际开销没有想象中那么大。
3.3 两个方案的优先级与服务器行为
如果响应同时带着Last-Modified和ETag,浏览器在请求时会同时发送If-Modified-Since和If-None-Match两个头。服务器收到之后,优先校验If-None-Match,只要ETag匹配就直接返回304,不再去检查If-Modified-Since。只有当If-None-Match不存在时,才回头去比对If-Modified-Since。
从设计意图上说,ETag的优先级高于Last-Modified,因为在协议设计者看来,内容的指纹比时间戳更可信。但这里提醒一下:如果你在生产环境同时开了两层验证,最好确认一下服务器配置到底是怎么组合的,尤其是用了CDN或网关之后,它们可能直接吞掉某些头,导致协商缓存策略根本没生效。
304本身是个极轻量的响应,理论上只有响应头没有响应体,对带宽的消耗基本可以忽略。但这也意味着,只要用户每次打开页面都要重新验证一遍资源,就必然会产生一个额外的往返RTT——延迟低还好,在网络不稳的移动网络下,这个延迟对体验是有感知的。所以协商缓存并非零成本,它只是比全量下载省流量。
3.4 什么时候选协商缓存而不是强缓存
这是一个经常被忽略的策略问题。强缓存能省掉请求,但代价是过期之前用户拿到的可能是旧版本;协商缓存每次都要请求,但能保证用户拿到的绝对是最新的。
判断标准其实特简单:资源允许容忍多久的旧版本?
- 版本号明确的静态资源:能容忍一年旧版本,直接强缓存一年。
- HTML入口文件:上一秒改下一秒就要生效,用
no-cache走协商缓存最好。 - 用户随时可能变化的头像、库存数量:最多容忍几分钟,匹配合适的
max-age。
我见过很多团队把API接口全都设成no-cache,每次请求全部打到服务器上,美其名曰"这样最安全"。这么干确实安全,但同时也等于直接放弃了浏览器和中间节点帮你扛流量的能力。如果一个接口的响应在30秒内不会变,那么让它在浏览器或CDN上缓存30秒,是完全没有问题的。后面我会专门讲怎么给接口分级设计策略。
4. 真实场景的缓存策略设计
4.1 静态资源:文件名指纹加长缓存是最稳的组合
说到静态资源的缓存,业内主流方案已经非常统一了:构建工具给文件名打hash指纹,比如app.7a2de3f9.js,然后对这个资源设置Cache-Control: max-age=31536000, immutable。因为文件名本身带了内容指纹,文件一变,hash就变,URL就变,浏览器自然去请求一个全新的URL,根本不存在"旧缓存"的问题。
这套玩法好用的核心原因在于:缓存键是URL,URL变了,旧缓存就自动失效了。而且由于hash内容稳定,一年不更新也不会有任何副作用。
这里最需要注意的是:HTML主文档千万别跟着静态资源一起长缓存。你部署新版本之后,如果用户拿到的HTML还是旧版的,而HTML里引用的JS还是带旧hash的URL,那用户就永远加载不到新代码。所以生产环境里常见的做法是:HTML用no-cache,里面的静态资源全部加hash长缓存。顺序不能反,这是多少人流过泪的事。
还有个小技巧:如果构建产物里CSS引用了图片、字体等子资源,也要确认子资源URL带了hash。否则你会遇到一个诡异现象:CSS更新了,图片还是走旧缓存,页面显示错乱。这类"间接引用"问题,排查起来比直接改JS还难找。
4.2 HTML页面:短缓存和协商缓存之间的取舍
HTML是应用入口,也是所有资源的容器。给HTML配缓存策略,本质上是决定"发布后多久生效"。最保守的做法是Cache-Control: no-cache,这样用户每次访问都会向服务器做一次验证。验证通过了就304,带宽消耗极小;新版本发布了就200,立即生效。这也是很多大型网站的实际选择。
但你要是真的对访问量很敏感,追求极致的首屏响应速度,可以考虑一个折中:给聚合SSR(服务端渲染)出来的HTML配一个极短的max-age=60。意思是这60秒内,同一个浏览器不会重新请求HTML,直接显示本地版本。代价是发布后最多有一分钟延迟。挑选哪个方案,取决于你的发布流程和对用户感知的要求。
我在做活动页演示项目X时,用的是max-age=30,整体效果很好:用户30秒内重复刷新不产生回源请求,30秒后自动回到最新版本。这个值你可以根据自己的发布节奏调整,但一般别超过5分钟,否则"发布后刷新页面旧版本迟迟不消失"的客诉会让你想删配置。
4.3 API接口:按数据变化频率分级缓存
很多人觉得API不该缓存,其实是被"数据一致性"吓住了。换个角度思考:接口响应本质上也是一份数据,如果数据变化不快,为什么不缓存呢?关键是你得给数据分类,别一刀切。
我在团队里推过一个三级分类策略:
- 一级:稳定数据,比如商品类目、城市列表、字典项,这类可能一整天都不变,直接响应头带
Cache-Control: max-age=3600,浏览器和CDN都能缓存。效果立竿见影,接口负载能掉一半。 - 二级:滚动数据,比如首页推荐流、热点列表,允许一两分钟延迟的,用
max-age=60。即使偏旧一点,对用户来说几乎无感知,但服务器压力被大幅削平。 - 三级:实时数据,比如库存余量、订单状态、优惠券核销,用
no-cache或者干脆no-store,确保任何中间环节都不缓存,避免超卖或状态错误。
这套分级策略实施下来,QPS峰值能削掉一大截。很多性能瓶颈问题,根本不需要加机器,先把缓存策略落地就能解决大半。当然,每个业务的数据变动频率不同,具体数值靠压测和观察来调。
有些团队会额外依赖Age响应头来确认CDN节点缓存的时长,这是个好习惯。如果接口经过CDN,看Age头就知道缓存已经存在多久了,方便判断新旧程度。排查"为什么接口数据没更新"时,第一眼就看这个头。
4.4 一份可以直接抄的 Nginx 配置示例
纸上谈兵没意思,我直接贴一份在多个项目里验证过的基础配置,指向静态资源和HTML的不同处理方式。你可以直接复制到你的站点配置里再微调。
# 静态资源:带hash指纹的,一年长缓存 location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; add_header ETag ""; try_files $uri =404; } # 图片目录:内容变更不频繁,15分钟级别缓存 location /images/ { add_header Cache-Control "public, max-age=900"; etag on; } # HTML主文档:每次都走协商缓存 location ~ \.html$ { add_header Cache-Control "no-cache, must-revalidate"; etag on; # 不要设置 max-age,否则会退化成强缓存 } # API反向代理:按接口类别覆盖,默认不缓存 location /api/ { proxy_pass http://backend_upstream; proxy_set_header Host $host; proxy_hide_header Cache-Control; add_header Cache-Control "no-cache"; }几个关键点:一是immutable只在明确知道URL不会变的情况下用,否则坑自己;二是ETag可以根据需要关掉或开启,某些业务场景下反而要主动关掉避免多次计算;三是add_header在location层级配置会和上层配置有覆盖逻辑,注意别重复导致奇怪的结果。我遇到过配置了Cache-Control但响应头里没出现的坑,就是被include块里的层级关系吃掉了。
4.5 CDN节点的缓存策略需要单独考虑
很多公司直接在上面这层再套了CDN,这就引入了一个新维度:CDN节点的缓存策略,跟浏览器的缓存策略是两套。一般规则是,CDN节点会忽略浏览器的private标记,但尊重public和max-age。如果你的资源想让CDN缓存,但浏览器不缓存,可以把头设成Cache-Control: public, max-age=86400, s-maxage=300。这里的s-maxage专门控制CDN等共享节点的缓存时长,优先级高于max-age。
这个字段很多人不熟,但它在大型站点上很有用。举个例子:图片你希望用户浏览器缓存一天,但CDN节点上希望10分钟回源一次,以便尽快抓取到运营更新的图——那max-age=86400, s-maxage=600就很合适。注意s-maxage主要被CDN和网关识别,老一些的实现可能不支持,配置之后需要验证下回源行为是否如预期。
CDN还有一种特殊的缓存标记叫做"刷新缓存"(purge),即使你的HTTP头里没配过期,管理员也能强制让节点上的副本失效。遇到"CDN缓存一直不刷新"的问题,先检查有没有头疼的主动刷新脚本,再查业务侧的响应头,别傻等。
5. 缓存问题排查实录
5.1 "明明改了代码,用户就是不变"
这是我在群里被问得最多的一类问题。先别急着骂用户浏览器,查这几步:
第一,看HTML有没有被缓存。用开发者工具打开网页,查看HTML文档的响应头,如果Cache-Control里带了max-age,而首页文件没有hash指纹,那问题基本就是它。第二,确认静态资源URL的hash是否真的变了。有时候构建工具因为配置问题,改完代码产出的文件名hash没变,CDN和浏览器自然认为没更新。第三,排查是不是网关或者代理层吞掉了你的Cache-Control头,这类隐形缓存最恶心。
我遇到过一个非常经典的事故:改了一个公共组件,结果所有页面都还是旧样式。排查半天,发现是CSS文件被CDN缓存了,而且CDN配置的缓存时间长达一个月。最后靠手动purge才解决。从那以后,我要求所有静态资源CDN缓存时间必须和hash绑定,而不是靠"确定时间过期"。
5.2 状态码到底怎么看
很多人看Network面板,只知道200和304,其实中间还有很多细节:
200 (from memory cache):内存缓存命中,当前页面里同一个资源已加载过,直接用内存副本,毫秒级返回。200 (from disk cache):磁盘缓存命中,之前访问存到硬盘的资源,还在强缓存有效期内。304 Not Modified:协商缓存验证通过,服务器确认没变化,浏览器接着用本地的副本。200:正常从服务器/代理拿到了新内容。
如果你发现自己加载一个资源返回的是200而不是from cache,说明缓存失效了。这时候别慌,看看请求头的Cache-Control、Pragma、If-None-Match等字段,以及响应头里的Cache-Control、ETag、Last-Modified,基本就能定位原因。
开发者工具还有一个细节容易被忽略:勾选Network面板的Disable cache后,所有资源都会绕过缓存直连服务器,这是调试缓存问题时的必备开关。但同时它也会掩盖真实用户场景,所以排查完记得关掉。
5.3 一份快速排查清单
我把平时排查缓存的步骤整理成了清单,贴在工位上,谁遇到问题照着走:
- 确认用户访问的是哪个URL,URL里带没带hash。
- 看响应头:
Cache-Control有没有被正确设置,Expires和Cache-Control是否冲突。 - 看请求头:
If-None-Match是否发出去,服务器有没有回应304。 - 看
Age头和Via头,判断响应是不是从CDN或代理来的。 - 换无痕窗口复测,排除浏览器终端自身的怪异刷新行为。
- 看服务器时间,本地时间和服务器时间飘了也会导致验证失败。
这个清单看起来很基础,但80%的缓存问题都能在清单走到一半时暴露出来。
5.4 "缓存不生效"的极端案例
在排查缓存问题时还要留意一个隐蔽点:有些Web框架会在全局启动时往响应里塞Cache-Control或Pragma头,而你业务代码里再加一层,最后以最后写入的为准。我曾经在一个Node服务里遇到过,框架默认给所有响应加了Cache-Control: private, no-cache,业务代码里想给某个接口单独开缓存,死活不生效。查到最后发现是框架给响应头做了只读处理,气得人够呛。
另外,HTTPS环境下的中间人代理默认不能随意缓存,必须通过Cache-Control显式声明,这也导致一些原本期望"上网代理帮我缓存"的场景失效。这类问题在自建代理环境或内网部署时比较常见,注意区分。
还有,如果浏览器页面本身有Pragma: no-cache的旧式头(一般是IE遗留习惯),它在HTTP/1.1规范里已经被废弃了,但某些老版本浏览器仍会强制绕过缓存。遇到奇怪的"就是不走缓存"问题,可以检查是否是某种兼容性代码写死了这个头。
6. 缓存配置的文明习惯与事后维护
6.1 上线前要修订的缓存头清单
团队协作场景下,缓存头的变更经常成为"隐性事故点"。改动一处缓存策略,可能直接引发线上的老版本回退。所以我在团队里要求,每次上线涉及缓存改动的,必须先评审。
需要评审的点包括:要改的URL范围是从根路径覆盖还是指定目录;新策略是让用户更快还是更慢看到更新;已发布版本的旧缓存预留了多久的过期时间;是否需要对现有CDN节点执行purge。
评审的意义在于防止"团队A调好了静态资源缓存,团队B上线新页面直接破功"这种互相踩脚的局面。现象在单个team里看不出,一放到多团队协作里就频繁发生。
6.2 想彻底清掉某个资源的缓存怎么办
大多数人会想"直接设置Cache-Control: no-cache并让用户刷新"。但你别忘了,旧响应头指定的过期时间还没到,浏览器根本不会重新请求这个URL——也就是说,新配的no-cache头本身就不会被下载到。这是一种先有鸡还是先有蛋的问题。
解决思路两个:一个是换URL(加版本号、hash、query参数),一个是对CDN和代理节点执行purge。换了URL之后,浏览器对老URL的缓存自然失去意义,新URL没有任何历史缓存包袱。这也是为什么很多团队推行的规范里,明确要求"静态资源必须带版本号或hash,否则不准上线"。这句话值得刻在墙上。
6.3 定期观察缓存命中率
缓存不是配完就能放着不管的东西。我习惯定期看几个指标:强缓存命中率(从熊+磁盘缓存的比例)、协商缓存304比例、回源请求总量、平均资源体积。如果强缓存命中率持续走低,说明可能有大量资源在频繁访问但URL一直变化,或者你设置的max-age太短了。
304比例过高通常有两种情况:一种是很多HTML和资源都没配强缓存,所有请求都跑到服务器验证;另一种是某些设备莫名触发了重新验证。前者可以从缓存策略入手,把该长缓存的长起来;后者就要结合日志看看是不是有什么奇怪UA在里面刷。
我在一个图片服务上优化过一轮,把短缓存改成版本号长缓存后,源站请求量直接掉了70%。那次结果改善最让我意识到:很多时候"服务器扛不住"不是机器不行,而是你给了它太多不该干的活。
7. 最后说点实在的
做了这么多年Web开发,缓存问题始终是我心里最"脏活累活"的部分,因为它不像SQL慢查询或大图没压缩那么直观,它的表现总是"有时候行,有时候不行"。但恰恰因为这种隐蔽性,谁在缓存上理解得够深,谁在生产环境里踩的坑就越少。
我个人在项目里的习惯是,每次和技术方案一起提交的,总有一份清晰的缓存表,写明URL模式、Cache-Control值、是否走CDN、预期变更频率。这张表会同步给后端和运维同学,省去后面N次"这个接口为啥这么慢"的扯皮。
如果你现在恰好被一个"缓存不生效"或"缓存生效错了"的问题困住了,别急着打补丁,先把整个请求链路上的头全部看一遍,从浏览器到Nginx到后端的响应,一步步收窄。绝大多数问题真的就只是某个头设错了而已。
希望能给正在跟http缓存较劲的你一点启发。