# 客户端缓存

浏览器的缓存机制几乎是在万维网刚刚诞生时就已经存在,在HTTP协议设计之初,便确定了服务端与客户端之间“无状态”(Stateless)的交互原则,即要求每次请求是独立的,每次请求无法感知也不能依赖另一个请求的存在,这既简化了HTTP服务器的设计,也为其水平扩展能力留下了广袤的空间。但无状态并不只有好的一面,由于每次请求都是独立的,服务端不保存此前请求的状态和资源,所以也不可避免地导致其携带了重复的数据,导致网络性能降低。HTTP协议对此问题的解决方案便是客户端缓存,在HTTP从1.0到1.1,再到2.0版本的演进中,逐步形成了现在被称为“状态缓存”“强制缓存”(许多资料中简称为“强缓存”)和“协商缓存”的HTTP缓存机制。

状态缓存是指不经过服务器,客户端直接根据缓存信息对目标网站的状态判断,以前只有301/Moved Permanently(永久重定向)这一种;后来在RFC6797中增加了HSTS(HTTP Strict Transport Security)机制,用于避免依赖301/302跳转HTTPS时可能产生的降级中间人劫持(详见5.5节),这也属于另一种状态缓存。由于状态缓存所涉内容只有这么一点,后续我们就只聚焦讨论强制缓存与协商缓存两种机制。

无论是强制缓存还是协商缓存,原理都是在服务器对客户端请求的响应中附带一些条件,要求客户端在遇到相同的请求时,先判断一下条件是否满足,如果满足,就直接用上一次服务器给予的响应来代替,不必重新访问。这两种缓存机制的区别是它们采用了不同的判断条件来解决资源在客户端和服务器间的一致性问题。

# 强制缓存

HTTP的强制缓存对一致性问题的处理策略就如它的名字一样,十分直接:假设在某个时点到来以前,譬如收到响应后的10分钟内,资源的内容和状态一定不会被改变,因此客户端可以无须经过任何请求,在该时点前一直持有和使用该资源的本地缓存副本。

根据约定,强制缓存在浏览器的地址输入、页面链接跳转、新开窗口、前进和后退中均可生效,但在用户主动刷新页面时应当自动失效。HTTP协议中设有以下两类Header实现强制缓存。

  1. Expires

    Expires是HTTP/1.0协议中开始提供的Header,后面跟随一个截止时间参数。当服务器返回某个资源时带有该Header,意味着服务器承诺资源在截止时间之前不会发生变动,浏览器可直接缓存该数据,不再重新发请求,示例:

    HTTP/1.1 200 OK
    Expires: Wed, 8 Apr 2020 07:28:00 GMT
    

    Expires是HTTP协议最初版本中提供的缓存机制,设计非常直观易懂,但考虑得并不周全,它至少存在以下几个明显问题:

    • HTTP/1.1 200 OK Expires: Wed, 8 Apr 2020 07:28:00 GMT
    • 无法处理涉及用户身份的私有资源。譬如,某些资源被登录用户缓存在自己的浏览器上是合理的,但如果被代理服务器或者内容分发网络缓存起来,则可能被其他未认证的用户所获取。
    • 无法描述“不缓存”的语义。譬如,浏览器为了提高性能,往往会自动在当次会话中缓存某些MIME类型的资源,在HTTP/1.0的服务器中就缺乏强制手段不允许浏览器缓存某个资源。以前为了实现这类功能,通常不得不使用脚本,或者手工在资源后面增加时间戳(譬如“xx.js?t=1586359920”、“xx.jpg?t=1586359350”)来保证每次资源都会重新获取。

    关于“不缓存”的语义,在HTTP/1.0中其实预留了“Pragma:no-cache”来表达,但Pragma参数在HTTP/1.0中并没有确切描述其具体行为,随后就被HTTP/1.1中出现过的Cache-Control所替代。现在,尽管主流浏览器通常都会支持Pragma,但行为仍然是不确定的,实际并没有什么使用价值。

  2. Cache-Control

    Cache-Control是HTTP/1.1协议中定义的强制缓存Header,它的语义比Expires丰富了很多,如果Cache-Control和Expires同时存在,并且语义存在冲突(譬如Expires与max-age/s-maxage冲突)的话,规定必须以Cache-Control为准。Cache-Control的使用示例如下:

    HTTP/1.1 200 OK
    Cache-Control: max-age=600
    

    Cache-Control在客户端的请求Header或服务器的响应Header中都可以存在,它定义了一系列参数,且允许自行扩展(即不在标准RFC协议中,由浏览器自行支持的参数),其标准的参数主要有如下几个。

    • max-age和s-maxage:max-age后面跟随一个以秒为单位的数字,表明相对于请求时间(在Date Header中会注明请求时间)多少秒以内缓存是有效的,即多少秒以内不需要重新从服务器中获取资源。相对时间避免了Expires中采用的绝对时间可能受客户端时钟影响的问题。s-maxage中的“s”是“share”的缩写,意味“共享缓存”的有效时间,即允许被CDN、代理等持有的缓存有效时间,用于提示CDN这类服务器应在何时让缓存失效。
    • public和private:指明是否涉及用户身份的私有资源,如果是public,则可以被代理、CDN等缓存;如果是private,则只能由用户的客户端进行私有缓存。
    • no-cache和no-store:no-cache指明该资源不应该被缓存,哪怕是同一个会话中对同一个URL地址的请求,也必须从服务端获取,令强制缓存完全失效,但此时下一节中的协商缓存机制依然是生效的;no-store不强制会话中相同URL资源的重复获取,但禁止浏览器、CDN等以任何形式保存该资源。
    • no-transform:禁止以任何形式修改资源。譬如,某些CDN、透明代理支持自动GZip压缩图片或文本,以提升网络性能,而no-transform禁止了这样的行为,它不允许Content-Encoding、Content-Range、Content-Type进行任何形式的修改。
    • min-fresh和only-if-cached:这两个参数是仅用于客户端的请求Header。min-fresh后面跟随一个以秒为单位的数字,用于建议服务器能返回一个不少于该时间的缓存资源(即包含max-age且不少于min-fresh的数字)。only-if-cached表示客户端要求不给它发送资源的具体内容,此时客户端仅能使用事先缓存的资源来进行响应,若缓存不能命中,就直接返回503/Service Unavailable错误。
    • must-revalidate和proxy-revalidate:must-revalidate表示在资源过期后,一定要从服务器中进行获取,即超过了max-age的时间后,就等同于no-cache的行为,proxy-revalidate用于提示代理、CDN等设备资源过期后的缓存行为,除对象不同外,语义与must-revalidate完全一致。

# 协商缓存

强制缓存是基于时效性的,但无论是人还是服务器,其实多数情况下并没有什么把握去承诺某项资源多久不会发生变化。另外一种基于变化检测的缓存机制,在一致性上会有比强制缓存更好的表现,但需要一次变化检测的交互开销,性能上就会略差一些,这种基于检测的缓存机制,通常被称为“协商缓存”。另外,应注意在HTTP中的协商缓存与强制缓存并没有互斥性,这两套机制是并行工作的。譬如,当强制缓存存在时,直接从强制缓存中返回资源,无须进行变动检查;而当强制缓存超过时效,或者被禁止(no-cache/must-revalidate)时,协商缓存仍可以正常工作。协商缓存有两种变动检查机制,分别是根据资源的修改时间进行检查,以及根据资源唯一标识是否发生变化进行检查,它们都是靠一组成对出现的请求、响应Header来实现的。

  1. Last-Modified和If-Modified-Since

    Last-Modified是服务端的响应Header,用于告诉客户端这个资源的最后修改时间。对于带有这个Header的资源,当客户端需要再次请求时,会通过If-Modified-Since把之前收到的资源最后修改时间发送回服务端。

    如果此时服务端发现资源在该时间后没有被修改过,就返回一个304/Not Modified的响应,无须附带消息体,即可达到节省流量的目的,如下所示:

    HTTP/1.1 304 Not Modified
    Cache-Control: public, max-age=600
    Last-Modified: Wed, 8 Apr 2020 15:31:30 GMT
    

    如果此时服务端发现资源在该时间之后有变动,就会返回200/OK的完整响应,在消息体中包含最新的资源,如下所示:

    HTTP/1.1 200 OK
    Cache-Control: public, max-age=600
    Last-Modified: Wed, 8 Apr 2020 15:31:30 GMT
    
    Content
    
  2. ETag和If-None-Match

    ETag是服务端的响应Header,用于告诉客户端这个资源的唯一标识。HTTP服务端可以根据自己的意愿来选择如何生成这个标识,譬如Apache服务端的ETag值默认是对文件的索引节点(INode)、大小和最后修改时间进行哈希计算后得到的。对于带有这个Header的资源,当客户端需要再次请求时,会通过If-None-Match把之前收到的资源唯一标识发送回服务端。

    如果此时服务端计算后发现资源的唯一标识与上传回来的标识一致,说明资源没有被修改过,就返回一个304/Not Modified的响应,无须附带消息体,即可达到节省流量的目的,如下所示:

    HTTP/1.1 304 Not Modified
    Cache-Control: public, max-age=600
    ETag: "28c3f612-ceb0-4ddc-ae35-791ca840c5fa"
    

    如果此时服务端发现资源的唯一标识有变动,就会返回200/OK的完整响应,在消息体中包含最新的资源,如下所示:

    HTTP/1.1 200 OK
    Cache-Control: public, max-age=600
    ETag: "28c3f612-ceb0-4ddc-ae35-791ca840c5fa"
    
    Content
    

    ETag是HTTP中一致性最强的缓存机制,譬如,Last-Modified标注的最后修改只能精确到秒级,如果某些文件在1s以内被修改多次的话,它将不能准确标注文件的修改时间;又如果某些文件会被定期生成,可能内容并没有任何变化,但Last-Modified却改变了,导致文件无法有效使用缓存,这些情况Last-Modified都有可能产生资源一致性问题,只能使用ETag解决。

    ETag也是HTTP中性能最差的缓存机制,在每次请求时,服务端都必须对资源进行哈希计算,相比简单获取一下修改时间,开销要大了很多。ETag和Last-Modified是允许一起使用的,服务端会优先验证ETag,在ETag一致的情况下,再去对比Last-Modified,这是为了防止有一些HTTP服务端未将文件修改日期纳入哈希范围内。

    到这里为止,HTTP的协商缓存机制已经能很好地适用于通过URL获取单个资源的场景,为什么要强调“单个资源”呢?在HTTP协议的设计中,一个URL地址是有可能提供多份不同版本的资源的,譬如,一段文字的不同语言版本,一个文件的不同编码格式版本,一份数据的不同压缩方式版本,等等。因此针对请求的缓存机制,也必须能够提供对应的支持。为此,HTTP协议设计了以Accept*(Accept、Accept-Language、Accept-Charset、Accept-Encoding)开头的一套请求Header和对应的以Content-*(Content-Language、Content-Type、Content-Encoding)开头的响应Header,这些Header被称为HTTP的内容协商机制。与之对应的,对于一个URL能够获取多个资源的场景,缓存也同样需要有明确的标识来获知根据什么内容返回给用户正确的资源。此时就要用到Vary Header,Vary后面应该跟随一组其他Header的名字,譬如:

    HTTP/1.1 200 OK
    Vary: Accept, User-Agent
    

    以上响应的含义是应该根据MIME类型和浏览器类型来缓存资源,获取资源时也需要根据请求Header中对应的字段来筛选出适合的资源版本。

    根据约定,协商缓存不仅在浏览器的地址输入、页面链接跳转、新开窗口、前进、后退中生效,而且在用户主动刷新页面(F5)时同样是生效的,只有用户强制刷新(Ctrl+F5)或者明确禁用缓存(譬如在DevTools中设定)时才会失效,此时客户端向服务端发出的请求会自动带有“Cache-Control:no-cache”。

Last Updated: 6/30/2021, 4:53:13 PM