无论你是前端,还是后端,日常工作中都免不了遇到和HTTP缓存相关的问题。比如发现本该更新的文件因为缓存而没有更新,通常的做法给请求资源的文件路径加个时间戳,简单粗暴又有效,但作为一个有追求的程序员,我们不应该仅停留在把问题解决了就OK了的层面上,更进一步,思考是否还有更好的解决方案?针对不同的场景、不同类型的资源,有没有更高效的缓存设计方案?希望你读完这篇文章之后,能对HTTP缓存有更深刻全面的理解,掌握它们,对我们解决日常一些复杂问题会有帮助。
一、HTTP 缓存是什么?
简单地说,HTTP 缓存是一种保存资源副本并在下次请求时直接使用该副本的技术。也就是说,当 HTTP 缓存发现请求的资源已经被存储,它会拦截请求,返回该资源的副本,而不会去源服务器重新下载。
如果没有缓存会有什么问题?
假设没有缓存,试想一下,客户端多次访问一个原始服务器页面时,服务器会多次传输同一份文档,每次传送给一个客户端。一些相同的内容会在网络中一遍遍地传输。这些冗余的数据传输会耗尽昂贵的网络带宽,降低传输速度,加重Web服务器的负载。
有了缓存,就可以保留第一条服务器响应的副本,后继请求就可以由缓存的副本来应对了,这样可以减少那些流入/流出原始服务器的、被浪费掉了的重复流量。通过复用以前获取的资源,可以显著提高网站和应用程序的性能。除此之外,其他连带的好处也显而易见,比如可以减少网络请求时间、降低带宽消耗、提升性能。
什么样的 HTTP 响应会被客户端缓存?
那什么样的响应会被缓存呢?
- 默认情况下,请求方法如 GET、HEAD的响应内容是可缓存的,在包含新鲜度信息的情况下,POST的响应内容也可以被缓存;
- 默认情况下,响应码如 200、206、300、301、302、404 等的响应内容可以被缓存;
- 响应头和请求头没有指明不使用缓存,如 Cache-Control: no-store。
以上是几种比较常见的情况。
二、什么是私有缓存和共享缓存?
私有缓存
仅供一个客户端使用的缓存,即客户端上的缓存仅供自己使用,通常只存在于如浏览器这样的客户端上。
每个客户端发起的第一个请求都会被源服务器处理。在缓存生效的情况下,同一个客户端后续的相同请求甚至不会被发送,而是由本地缓存提供服务。
共享缓存
可以供多个客户端使用的缓存,通常依赖于代理服务器。
客户端发起的第一个请求通过代理服务器访问源服务器,缓存生效后会存放在代理服务器,后续客户端发起的相同请求,均由代理服务器提供缓存服务,共享缓存可以减轻源服务器的压力。
概念相关的内容讲完,我们开始深入HTTP缓存具体的工作机制。
三、HTTP 缓存的处理流程
在正式开始之前,我们通过下面这张图通过宏观视角了解下HTTP 缓存的处理流程(执行顺序)。
请仔细研究这张图,它很重要,下文中的很多内容都是针对图中的某个环节进行扩展讲解。
四、缓存控制策略:Cache-Control
对于网站来说,缓存是达到高性能的重要组成部分,缓存需要合理配置,因为并不是所有资源都是永久不变的。Cache-Control 首部可以对缓存进行控制,Cache-Control 能用于 HTTP 请求和响应中,支持多个指令,以逗号分隔:
请求首部 | 描述 |
---|---|
Cache-Control: no-store | 不使用缓存。 |
Cache-Control: no-cache | 使用缓存前,无论本地副本是否过期,都需要请求源服务器进行验证(协商缓存验证)。 |
Cache-Control: max-age=秒 | 设置缓存存储的最大期限,超过这个期限缓存被认为过期,时间是相对于请求的时间。 |
Cache-Control: max-stale=秒 | 客户端愿意接收一个已经过期的资源。可以设置一个可选的秒数,表示响应不能已经过时超过该给定的时间。 |
Cache-Control: min-fresh=秒 | 客户端希望获取一个能在指定的秒数内保持其最新状态的响应。 |
响应首部 | 描述 |
---|---|
Cache-Control: no-store | 不使用缓存。 |
Cache-Control: no-cache | 使用缓存前,无论本地副本是否过期,都需要请求源服务器进行验证(协商缓存验证)。 |
Cache-Control: max-age=秒 | 设置缓存存储的最大期限,超过这个期限缓存被认为过期,时间是相对于请求的时间。 |
Cache-Control: s-maxage=秒 | 同 max-age ,仅适用于共享缓存。 |
Cache-Control: private | 私有缓存,响应只能被单个客户端缓存。 |
Cache-Control: public | 共享缓存,即由缓存代理服务器提供的缓存,响应可以被多个客户端缓存。 |
Cache-Control: must-revalidate | 如果本地副本未过期,则可继续供客户端使用,不需要向源服务器再验证;如果本地副本已过期(比如已经超过max-age ),在成功向源服务器验证之前,缓存不能用该资源响应后续请求。 |
Cache-Control: proxy-revalidate | 同 must-revalidate ,仅适用于共享缓存。 |
Cache-Control
有几个指令特别容易混淆,不能望文生义。比如no-cache
,并不是指不能用 cache,客户端仍会把带有 no-cache
的响应缓存下来,只不过每次不会直接用缓存,而是要先去服务端验证一下,所以其实no-cache
真正合适的名字才是 must-revalidate
。如果你想让客户端完全不缓存响应,应该用no-store
,带有no-store
的响应不会被缓存到任意的磁盘或者内存里,它才是真正的 no-cache
。
下面是对三个容易混淆的指令进行对比说明:
首部 | 描述 |
---|---|
Cache-Control: no-store | 不使用缓存。 |
Cache-Control: no-cache | 无论本地副本是否过期,都需要请求源服务器进行验证。 |
Cache-Control: must-revalidate | 如果本地副本未过期,可以使用本地副本;否则,需要请求源服务器进行验证。 |
五、客户端的缓存策略
客户端的缓存策略主要依赖以下几种实现:
- 浏览器的Refresh(刷新)或Reload(重载)按钮;
- 浏览器的无痕模式(隐私模式);
- 浏览器的前进、后退;
- 浏览器开发者工具的Disable cache(禁用缓存)。
实现也比较简单,通常就是在请求头里附加 Pragma: no-cache
和 Cache-Control
首部,这个请求会强制进行再验证,或者无条件地从服务器获取文档。
缓存资源存放在哪里?
在浏览器开发者工具的Network的Size栏会出现的三种情况:
客户端查找缓存的顺序:
- 先查找内存,如果内存中存在,从内存中加载;
- 如果内存中未查找到,就从硬盘获取,如果硬盘中有,从硬盘中加载;
- 如果硬盘中未查找到,就进行网络请求;
- 加载到的资源缓存到硬盘和内存。
六、强制再验证:Pragma: no-cache
与 Cache-Control: no-cache
效果一致,当响应头中包含该指令时,当客户端再次发起请求时,会强制要求使用缓存之前将请求提交到源服务器进行验证。
Pragma: no-cache
用来向后兼容只支持 HTTP/1.0 协议的缓存服务器。
七、缓存的新鲜度
什么是缓存的新鲜度?
生活中,我们可以通过生产日期确定一个食物是否新鲜,通过保质期确定它是否过期。那缓存通过什么方式确定它是否新鲜和是否过期呢?
在缓存文档过期之前,缓存可以以任意频率使用这些副本,而无需与源服务器联系。当然,除非客户端请求中包含有阻止提供已缓存或未验证资源的首部。一旦已缓存文档过期,缓存就必须与服务器进行核对,询问源服务器该文档是否被修改过,如果被修改过,就要获取一份新鲜(带有新的过期日期)的副本。
如何检测缓存是否新鲜?
使用期限:Cache-Control: max-age=秒
我们可以通过指定一个缓存的最大使用期限,相对于缓存的创建时间,如果超过了最大使用期限,就说明缓存已经不新鲜了。
Cache-Control: max-age=秒
举个例子:
如图所示,当响应头中Cache-Control的max-age设置为10秒时,意味着从第一次请求开始,该资源的缓存有效期是10秒,10秒内再次请求该资源会从缓存中读取;超过10秒,则客户端向源服务器发起请求,缓存的有效期又重新开始计时。
过期日期:Expires
我们还可以通过指定一个绝对的过期日期,如果过期日期已经过了,就说明缓存已经不新鲜了。
Expires: 过期日期
Expires 是 HTTP/1.0 的首部,Cache-Control 是 HTTP/1.1 的首部,Expires 首部和 Cache-Control:max-age 首部所做的事情本质上是一样的,但由于 Cache-Control 首部使用的是相对时间而不是绝对日期,所以更倾向于使用比较新的Cache-Control首部。绝对日期依赖于计算机时钟的正确设置。
八、服务端再验证
什么是服务端再验证?
仅仅是已缓存文档过期了并不意味着它和源服务器上的文档有实际的区别,这只是意味着要和服务器进行核对了,说明缓存需要询问源服务器文档是否发生了变化,这种情况称为“服务器再验证”。
服务端再验证有两种情况:
- 服务端文档发生了变化:缓存会获取一份新的文档副本,并将其存储在旧文档的位置上,然后将该文档发送给客户端;
- 服务端文档没有发生变化:缓存只需要获取新的首部,包含一个新的过期日期,并对缓存中的首部进行更新就行了,该文档还可以继续使用。
缓存并不一定要为每条请求验证文档的有效性——只有在文档过期时它才需要与服务器进行再验证。
如何进行服务端再验证?
修改日期再验证:If-Modified-Since
最常见的缓存再验证首部是If-Modified-Since。
If-Modified-Since: 最近修改日期
它的意思是,只有自某个日期之后资源发生了变化的时候,服务器才会执行请求:
- 如果自指定日期后,文档被修改了,If-Modified-Since 条件就为真,通常请求会成功执行。携带新首部的文档会被返回给缓存,新首部除了其他信息之外,还包含了一个新的过期日期。
- 如果自指定日期后,文档没被修改过,条件就为假,服务端会返回一个 304 NotModified 响应报文,不会返回文档的主体。
If-Modified-Since 的值为服务端上一次返回的 last-modified 的值。如果在此期间内容被修改了,最后的修改日期就会有所不同,源服务器就会回送新的文档。否则,服务器会认为缓存的最后修改日期与服务器文档当前的最后修改日期相符,会返回一个 304 NotModified 响应。
实体标签再验证:If-None-Match
有些情况下仅使用最后修改日期进行再验证是不够的,比如:
- 有些文档可能会被周期性地重写(比如,从一个后台进程中写入),但实际包含的数据常常是一样的。尽管内容没有变化,但修改日期会发生变化。
- 有些文档可能被修改了,但所做修改并不重要,不需要让世界范围内的缓存都重装数据(比如对拼写或注释的修改)。
- 有些服务器无法准确地判定其页面的最后修改日期。
- 有些服务器提供的文档会在毫秒级间隙发生变化(比如,实时监视器),对这些服务器来说,以一秒为粒度的修改日期可能就不够用了。
为了解决这些问题,HTTP允许用户对被称为实体标签(ETag)的“版本标识符”进行比较。
如果缓存中有一个实体标签ETag,它会与源服务器进行再验证,如果服务器上的实体标签仍然与之匹配,会返回一条 304 NotModified 响应。如果服务器上的实体标签已经发生了变化,服务器会在一个 200 OK 响应中返回新的内容以及相应的新 Etag。
If-None-Match 的值为服务端上一次返回的 ETag 的值。在 ETag 的值前面添加 W/ 前缀表示可以采用相对宽松的算法。
什么时候使用修改日期再验证和实体标签再验证?
- 如果服务器返回了一个 ETag 首部,客户端就必须使用 If-None-Match 实体标签再验证;
- 如果服务器返回了一个 Last-Modified 首部,客户端就可以使用 If-Modified-Since 修改日期再验证;
- 如果 ETag 和 Last-Modified 都提供了,客户端就应该同时使用这两种再验证方案。
如果HTTP/1.1缓存或服务器收到的请求既带有 If-Modified-Since,又带有 If-None-Match,那么只有这两个条件都满足时,才能返回304 Not Modified响应。
结尾
感谢你耐心的读到这里,HTTP 缓存是不是很简单?学以致用,如果上面的内容你都掌握了,日常工作中遇到和缓存相关的问题,相信你已经可以得心应手。
针对工作中的特殊场景,如何制定高效的缓存设计方案,相信也有了答案。限于篇幅,HTTP 缓存的部分知识并没有讲的面面俱到,你可以在网上找到更详细的参考资料来学习它们——当你需要用到它们的时候。
本文作者:Domy,欢迎留言交流。:)
得心应手。
针对工作中的特殊场景,如何制定高效的缓存设计方案,相信也有了答案。限于篇幅,HTTP 缓存的部分知识并没有讲的面面俱到,你可以在网上找到更详细的参考资料来学习它们——当你需要用到它们的时候。