发一次版,用户说页面还是老的,同事回一句「让他清一下缓存」。这话你大概率也听过,甚至自己说过。可「清缓存」并不是解法,它只是把一次配置失误的成本转嫁给了用户。浏览器缓存这套机制本身不算复杂,难的是它由好几层拼起来,强缓存、协商缓存、刷新行为、Service Worker,每一层都能单独让你的资源该更新的时候不更新,或者不该重下的时候重下一遍。这篇把这条判定链从头走到尾,讲清每个 header 卡在哪一步、no-cache 和 no-store 差在哪、ETag 和 Last-Modified 该留哪个、为什么 HTML 通常不配强缓存。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 浏览器拿到一个 URL 之后,缓存判定的完整顺序是什么
- 强缓存怎么工作,
Expires为什么被Cache-Control取代 Cache-Control各个指令的实际效果,no-cache和no-store到底哪个才是真不缓存- 协商缓存的两对 header,
Last-Modified加If-Modified-Since和ETag加If-None-Match - 分布式部署下为什么建议关掉
ETag,京东为什么只留Last-Modified - 强缓存配长了怎么解决发布更新问题,为什么 HTML 要单独对待
F5、Ctrl+F5、地址栏回车、前进后退,各自跳过哪一层缓存- Service Worker 站在这条链的哪个位置
# 一、先把整条判定链走一遍
浏览器缓存分两种,强缓存和协商缓存。名字听着像并列关系,实际是先后关系,它们在一次请求里是串在一起判定的。
浏览器加载资源时,先根据这个资源的一些 http header 判断它是否命中强缓存。强缓存如果命中,浏览器直接从自己的缓存中读取资源,不会发请求到服务器。比如某个 css 文件,浏览器在加载它所在的网页时,如果这个 css 文件的缓存配置命中了强缓存,浏览器就直接从缓存中加载这个 css,连请求都不会发送到网页所在服务器。
当强缓存没有命中的时候,浏览器一定会发一个请求到服务器,由服务器端依据资源的另外一些 http header 验证这个资源是否命中协商缓存。如果协商缓存命中,服务器会把这个请求返回,但是不会返回这个资源的数据,而是告诉客户端可以直接从缓存中加载这个资源,于是浏览器就又会从自己的缓存中去加载它。
当协商缓存也没有命中的时候,浏览器才从服务器加载资源数据。
所以这两者的共同点是,只要命中,最终读的都是客户端缓存里那份数据,而不是从服务器传回来的;区别在于强缓存不发请求到服务器,协商缓存会发请求到服务器,只是这个请求很轻,只传 header 不传 body。
先记住这个顺序,后面所有的行为差异都是从它推出来的。
# 二、强缓存的原理
# 2.1 命中强缓存在面板上长什么样
当浏览器对某个资源的请求命中了强缓存时,返回的 http 状态为 200,在 chrome 的开发者工具的 network 里面 size 会显示为 from cache。比如京东的首页里就有很多静态资源配置了强缓存,用 chrome 打开几次,再用 f12 查看 network,可以看到有不少请求就是从缓存中加载的。

这里补一句现在的情况。新版 Chrome 把这一列拆成了两种显示,from memory cache 和 from disk cache。前者存在渲染进程的内存里,关掉标签页就没了,速度最快;后者落在磁盘上,跨会话也在。你看到的是哪一种,取决于资源大小和当前内存压力,不用去纠结,两种都属于强缓存命中,都没有发出网络请求。
强缓存是利用 Expires 或者 Cache-Control 这两个 http response header 实现的,它们都用来表示资源在客户端缓存的有效期。
# 2.2 Expires 缓存原理
Expires 是 http1.0 提出的一个表示资源过期时间的 header,它描述的是一个绝对时间,由服务器返回,用 GMT 格式的字符串表示,如:Expires:Thu, 31 Dec 2037 23:55:55 GMT。
浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在 respone 的 header 加上 Expires:

拿到响应之后,浏览器会把这个资源连同所有 response header 一起缓存下来。这一点很容易被忽略,缓存命中的那次请求,你在面板里看到的 header 并不是这一次从服务器拿的,而是来自之前那次真实请求存下来的副本。所以有时候你改了服务端的响应头,本地面板里却还是老值,不是服务端没生效,是你在看一份快照。
浏览器再请求这个资源时,先从缓存中寻找,找到之后拿出它的 Expires 跟当前的请求时间比较,请求时间在 Expires 指定的时间之前就命中缓存,否则就不行。如果缓存没有命中,浏览器直接从服务器加载资源,Expires Header 会在重新加载的时候被更新。
Expires 的毛病出在「绝对时间」这三个字上。它由服务器生成,却由客户端来比对,两边的时钟只要对不上,判定结果就跑偏。用户手动把系统时间往前调一年,站点上所有静态资源当场全部过期;往后调,本该更新的资源可能永远不更新。这种问题在客服那边听到的描述往往是「我这台电脑就是不刷新」,排查起来相当劝退。
所以在 http1.1 的时候,提出了一个新的 header,就是 Cache-Control,这是一个相对时间,在配置缓存的时候,以秒为单位,用数值表示,如:Cache-Control:max-age=315360000。
# 2.3 Cache-Control 缓存原理
流程跟 Expires 那套是一样的。浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在 respone 的 header 加上 Cache-Control:

浏览器同样把资源连同所有 response header 一起缓存下来。再请求这个资源时,先从缓存中寻找,找到之后根据它第一次的请求时间和 Cache-Control 设定的有效期,计算出一个资源过期时间,再拿这个过期时间跟当前的请求时间比较,请求时间在过期时间之前就命中缓存。没命中就走服务器,Cache-Control Header 在重新加载的时候会被更新。
这里可以再往下抠一层。规范里算的其实不是「第一次请求时间」,而是响应里的 Date 头,再叠加 Age 头。Age 是中间代理(CDN、反向代理)填的,表示这份响应在代理那里已经躺了多少秒。所以一份 max-age=600 的响应,如果 CDN 上已经存了 500 秒,它回给你的时候会带 Age: 500,你本地只能再用 100 秒。调 CDN 缓存的时候盯一眼 Age,比盯 max-age 有用得多。
Cache-Control 描述的是相对时间,判定时全程只用客户端自己的时钟做减法,不涉及两台机器对表,所以比 Expires 可靠。这两个 header 可以只启用一个,也可以同时启用。当 response header 中 Expires 和 Cache-Control 同时存在时,Cache-Control 优先级高于 Expires:

同时下发两个的写法现在还很常见,纯粹是给早年只认 http1.0 的客户端兜底。今天基本可以只写 Cache-Control。
# 2.4 Cache-Control 各个指令到底在管什么
面试里问「强缓存怎么配」,一句 max-age 就答完了的,多半没在生产上真配过。Cache-Control 是一组指令,可以用逗号组合,不同组合的行为差得很远。
| 指令 | 实际效果 |
|---|---|
max-age=<秒> |
资源在本地的新鲜时长,超时后进入需要校验的状态 |
s-maxage=<秒> |
只对共享缓存(CDN、代理)生效,同时存在时对 CDN 覆盖 max-age |
public |
允许包括 CDN 在内的任何环节缓存 |
private |
只允许最终用户的浏览器缓存,CDN 不许存。带用户身份的接口响应必须用它 |
no-cache |
可以存,但每次用之前都必须回源校验一次 |
no-store |
完全不许存,硬盘内存都不许,每次都得完整重下 |
must-revalidate |
过期之后必须回源校验,不许在断网等情况下继续拿旧的顶上 |
immutable |
声明这份资源在有效期内绝不会变,用户按刷新时也不必回源校验 |
其中最容易搞混的是 no-cache 和 no-store。名字看着像同义词,行为完全不同。
no-cache 不是「不缓存」,它是「缓存了,但每次都要问一下服务器还能不能用」。资源照样存在磁盘上,请求照样发出去,只是不再依赖 max-age 那个倒计时,而是直接进入协商缓存的流程。服务端说没变,返回 304,浏览器还是读本地那份,一个字节的 body 都不传。所以 no-cache 其实是很省流量的配置。
no-store 才是字面意义上的不缓存。资源不落盘、不进内存,每次请求都得把完整内容重新传一遍。它的场景很窄,基本只有银行流水、验证码、包含敏感信息的接口响应才用得上。给静态资源配 no-store,等于主动放弃了所有缓存收益。
我一开始也是这么想的,以为 no-cache 就是关缓存,直到有次给一个 HTML 配了 no-cache,在 Network 里看到一片 304,才反应过来它压根没关,只是每次都去校验了一遍。
immutable 也值得单说。它解决的是这么一个场景:用户在页面上按了 F5,按常规行为浏览器会给所有子资源发一遍校验请求,哪怕它们的 max-age 还剩三百天。对于文件名里已经带内容 hash 的构建产物来说,这些校验请求全是白发的,因为内容变了文件名一定会变。加上 immutable,浏览器就会跳过这层校验。配合 hash 文件名,Cache-Control: public, max-age=31536000, immutable 是目前静态资源比较标准的一套写法。
再提一句 max-age 的取值。原文里那个 315360000 是十年,RFC 建议不要超过一年,也就是 31536000。超过一年的部分很多实现会自行截断,写得再大也没有额外收益。
# 三、强缓存怎么配,以及开发时怎么绕开它
强缓存的开关通常有两种设法,一种是在代码里给响应加 Expires 和 Cache-Control Header,另一种是配置 web 服务器,让它在响应资源的时候统一加上。
比如在 javaweb 里面,我们可以使用类似下面的代码设置强缓存。这段是原文里的写法,用来演示「在业务代码里手工塞缓存头」这件事:
java.util.Date date = new java.util.Date();
response.setDateHeader("Expires",date.getTime()+20000); //Expires:过时期限值
response.setHeader("Cache-Control", "public"); //Cache-Control来控制页面的缓存与否,public:浏览器和缓存服务器都可以缓存页面信息;
response.setHeader("Pragma", "Pragma"); //Pragma:设置页面是否缓存,为Pragma则缓存,no-cache则不缓存
上面第三行原文的注释有点问题,这里要纠正一下。Pragma 是 http1.0 的遗留字段,规范里只定义了 no-cache 这一个值,写成 Pragma 这种自造值没有任何标准行为,各家实现直接忽略。真要兼容老客户端,只写 Pragma: no-cache 就够了,而且今天基本已经没必要写。
反过来,关掉强缓存的老写法是这样: