Http权威指南笔记(七)——缓存

缓存是HTTP通信过程中非常重要的一个环节,现在应该很少能找到不支持缓存的客户端和代理了。所以学习缓存对我们理解客户端和代理有非常大的帮助。

1 缓存的优点(解决的问题)

为什么要使用缓存呢,因为使用缓存具有如下几个优点:

  • 可以减少冗余数据的传输,节省资源
  • 环节网络带宽的问题,可以达到用较少的带宽便能很快的加载页面
  • 降低原始服务器的负载
  • 降低距离时延,加载的页面距离我们越远需要的时间也就越长

1.1 冗余数据的传输

如果不使用缓存,所有的客户端每次需要的数据都从服务器获取。如果每次我们获取的内容和都不一样,这没问题,但是实际情况是,我们可能很多时候获取的都是同一份文档或者同一些数据,这个时候如果我们将这些数据或者文档缓存起来,后面客户端再想需要的时候,就可以直接从缓存中获取,面一遍一遍的传输这些冗余信息。

1.2 带宽瓶颈

一般情况网络服务商为我们提供的本地网络带宽都会比真正远程连接的带宽要宽。如果所有的请求都需要经过远程请求回来,这样可能出现由于远程带宽的限制,导致获取速度非常缓慢,所以如果我们能在本地网络获取到数据,就能避免这样的问题。

1.3 原始服务器的过载

当所有的客户端每次都从原始服务器请求数据,那么服务器很容出现过载的情况,因为访问量非常大,而且如果再出现一些突发事件,使得大家几乎处于同一时间去访问同一资源。这样对原始服务器的性能要求就非常高,否则很容易就会造成服务器死掉,所以通过缓存,将部分请求通过缓存进行处理,而不需要真正将请求交给服务器处理,就能大大减少服务器的承载量。

1.4 距离时延

网络传输速度非常快,好的传输介质可以接近光速(具体传输速度和传输介质也有非常大关系)。但是毕竟还是有速度限制,既然有速度限制,意味着距离越长肯定所用的时间也就越长。所以当原始服务器离我们很远的时候,请求时延就会比较严重,如果在本地有缓存,那就会好很多了。

2 缓存中的一些概念

说完缓存的优点和能够帮忙解决的问题,接下来我们介绍几个缓存中会使用到的几个概念。

2.1 命中和未命中

虽然面描述中缓存有非常多的好处,但是任何缓存也不肯讲所有Web上的资源进行缓存。首先要装下所有的资源的容量就不敢想象,而且我们服务器张的资源并不是一层不变的,随着时间的推移,部分数据或者文档是有更新的。所以缓存一般都是缓存部分资源。这个时候,如果客户端的请求能够被缓存满足,那么称为缓存命中(cache hit),如果缓存无法满足客户端的请求,需要转发给服务器进行处理,就称为缓存未命中(cache miss)

2.2 再验证

当客户端请求达到缓存的时候,缓存有所请求资源的副本。但是并不是有副本就能满足客户端要求的,因为缓存所保存的副本并不一定还有效,可能副本是很早以前的了,但是服务器上对应的该资源已经更新。所以还要求缓存要有能够对我们缓存的资源副本能够进行“新鲜度”检测。而这种检测就称为再验证
HTTP协议也为这种再验证机制提供了很好的支持,当需要验证的时候,缓存可以发送一条较小的验证请求给服务器(HTTP提供了几个验证机制,后面会展开说明,这里以常用的If-Modified-Since为例),服务器会对该请求进行处理,如果缓存的资源还是新鲜可用的,就只需要返回304 Not Modified进行响应即可,无需传送整个资源。这种情况被称为再验证命中或者缓慢命中。这种情况请求速度比单纯的缓存命中会慢一些,但是由于没有传输整个资源,所以相对于从服务器请求资源会快一些。再验证结果一般有如下三种:

  • 再验证命中:缓存中的资源副本有效,服务器返回HTTP 304 Not Modified响应;
  • 再验证未命中-如果服务器还有存在该资源,但是缓存的资源副本失效,服务器会向客户端发送一条普通的HTTP 200 OK且带有完整内容的响应
  • 对象被删除:服务器对象已经被删除了,这时候发送一个 404 Not Found响应

2.3 命中率

由缓存提供的服务的请求所占的比例称为缓存命中率(cache hit rate),也称为缓存命中比例。所以该值取值区间为0~1,一般使用百分比表示。缓存命中率的统计,有些是包含了再验证命中,有些没有包含,这个看自己需要什么了。一般来说,该值在40%左右是比较合理的一个命中率。
上面的缓存命中率是以请求次数为统计基础,这样可能存在一个问题,如果某些资源非常大,但是却很少命中,这个时候出现的结果是:缓存命中率相对较高,但是实际流量的消耗也非常大。这个时候,使用字节命中率进行统计可能更为准确。其表示缓存提供的字节,在所有传输的字节中所占的比例。

最后这里补充一点,HTTP规范里面并没有规定怎么区分响应式来自缓存还是服务器。某些代理缓存的响应会包含Via首部添加一些信息说明用于判断,很多时候都是没有的,这时候我们可以通过Date、或者Age等首部信息进行判断。也有可能没有想先信息用于判断。

3 缓存的拓扑结构

3.1 私有缓存和公有缓存

一般来说,缓存可以分为私有缓存和公有缓存。
私有缓存一般为某个用户专享缓存,如大部分流量的缓存功能,一般浏览器会将资源缓存在本地电脑中。
公有缓存是一个群体或者某个用户团体共同享有,比如一个企业中,都使用同一个代理缓存,这个时候,再进行资源缓存的时候,不用每个用户都缓存一份资源,只需要在公有的代理缓存上缓存资源即可。
私有缓存和公有缓存

3.2 层次结构的代理缓存

实际应用当中,大部分的缓存都会呈现出一种层次结构。在这种结构中,较小缓存未命中的请求会被导向给较大缓存。基本四线是,越靠近客户端的地方使用较小的、廉价的缓存,在更高的层次中使用更大、更强的缓存,如下图所示:
缓存的层次结构
上述图中,我们可以看到有两级缓存结构(这里暂时认为浏览器中没有缓存功能),当第一级缓存命中的时候,直接返回,如果未命中,继续转发到第二级缓存,以此类推。
这里还要注意点的是,缓存层级结构不能无限拉长,如果太长的缓存结构,因为每个缓存都会有部分性能和时间消耗,如果太长了,这种消耗就会变的很明显。至于多少层级比较合适,这个就要看具体环境和缓存的性能等综合考虑。

3.3 网状缓存

上面说了一种层次结构的缓存结构,实际当中还有很多呈现为一种网状结构的网状缓存。这种缓存结构,缓存在寻找下一个节点的时候要复杂一些,需要判断具体与哪个缓存或者是直接与服务器进行对话。一般网状缓存要具有以下几个基本功能:

  • 能够根据URL在父缓存或者服务器之间进行动态选择
  • 选择父缓存的时候,能动态选择一个较优父缓存
  • 允许其他缓存节点对本缓存节点内容的访问,但是不允许英特网流量通过他们的缓存

4 缓存的处理步骤

一般缓存的处理会经过下面几个步骤:

  1. 接收——缓存从网络中读取抵达的请求报文。
  2. 解析——缓存对报文进行解析,提取出 URL 和各种首部。
  3. 查询——缓存查看是否有本地副本可用,如果没有,就获取一份副本(并将其保存在本地)。
  4. 新鲜度检测——缓存查看已缓存副本是否足够新鲜,如果不是,就询问服务器是否有任何更新(后面会专门有一小节对此进行说明)。
  5. 创建响应——缓存会用新的首部和已缓存的主体来构建一条响应报文。
  6. 发送——缓存通过网络将响应发回给客户端。
  7. 日志——缓存可选地创建一个日志文件条目来描述这个事务。

整个请求流程如下图所示:
缓存处理步骤

5 缓存管理控制

这一小节我们转么介绍一下缓存的管理控,主要介绍新鲜度检测和缓存控制。

5.1 新鲜度的保持

服务器上的资源并不是一层不变的,这些资源随着时间的推移可能会被修改,更新、或者删除。如果服务器上的内容已经更改,但是缓存依然给客户端的是之前旧的数据,那这个数据就是没用的。所以我们的缓存,必须要保证其资源的新鲜度。HTTP规范中,将这些保持一致的机制称为文档过期(document expiration)服务器再验证(server revalidation)

5.1.1 文档过期

HTTP协议中,我们可以通过Cache-Control和expires首部来控制资源的过期,就好比在超时里面购买的东西,有了一个过期时间一样。示例如下:
资源过期
有了文档过期时间,缓存就知道什么时候可以直接使用缓存资源,什么时候该项服务器验证或者请求最新的资源。
Cache-Control和expires都可以用于文档过期,区别主要是expires使用的是一个绝对时间,而Cache-Control使用的是一个相对时间,可以避免服务器和缓存时间不同步的时候发生问题,所以优先推荐使用Cache-Control,具体秒速如下表所示:

首  部 描  述
Cache-Control:max-age max-age 值定义了文档的最大使用期——从第一次生成文档到文档不再新鲜、无法使用为止,最大的合法生存时间(以秒为单位)
Cache-Control: max-age=484200
Expires 指定一个绝对的过期日期。如果过期日期已经过了,就说明文档不再新鲜了
Expires: Fri, 05 Jul 2002, 05:00:00 GMT

5.1.2 服务器再验证方法

当我们的资源达到过期时间后,并不是一定就需要重新从服务器获取新的资源,而是会发起前面所说的服务器再验证。再验证的概念前面已经介绍过,这里就不再赘述,下面主要介绍一下再验证的一些方法。
HTTP规范中定义了5个条件首部,但对于再验证来说,最常用的就是如下两个:

首  部 描  述
If-Modified-Since:<date> 如果从指定日期之后文档被修改过了,就执行请求的方法。可以与Last-Modified 服务器响应首部配合使用,只有在内容被修改后与已缓存版本有所不同的时候才去获取内容
If-None-Match:<tags> 服务器可以为文档提供特殊的标签(参见ETag),而不是将其与最近修改日期相匹配,这些标签就像序列号一样。如果已缓存标签与服务器文档中的标签有所不同,If-None-Match 首部就会执行所请求的方法

除了这两个,另外三个包括 If-Unmodified-Since(在进行部分文件的传输时,获取文件的其余部分之前要确保文件未发生变化,此时这个首部是非常有用的)、If-Range(支持对不完整文档的缓存)和 If-Match(用于与 Web 服务器打交道时的并发控制)。
这里重点关注前面两个即可。

  1. If-Modified-Since: Date
    通过该首部进行再验证的时候,缓存想服务器发起一个携带该头部的GET请求,该请求一般被称为IMS请求,服务器收到请求后,一般会做如下处理:
  • 如果能够识别该首部,就会判断所请求的资源在指定的日期后是否有更改,如果有更改,那么该条件为真,就会返回一个携带全新资源的响应给缓存,一般还包含一个新的过期时间;如果在该时间后资源没有修改,条件为false,服务器一般会返回一个304 Not Modifed的响应(不会携带文档资源)给缓存,同时根据需要也会更新部分相应头的信息,比如新的过期时间。
  • 如果服务器不能识别该头部,一般就会把其当初普通的GET请求,直接返回所请求资源的内容。
    一般If-Modified-Since会和Last-Modified配合使用,Last-Modified一般是服务器告诉缓存,该资源最新一次的修改时间是什么时候,缓存下次使用If-Modified-Since进行验证的时候,即可使用该时间。
    最后再使用该方式进行验证的时候,还有一点需要注意的是,有些服务器在处理该请求头的时候,不是按照时间进行先后对比,而是按照字符串进行匹配对比。
  1. If-None-Match: Tag
    有些时候,上面的If-Modified-Since: Date并不一定能满足我们的需求。考虑下面几种情况:
  • 服务器上有一份文档,会被定期进行数据写入,但是写入的数据不一定发生变化。这个时候,如果写入的数据本身没有变化,但是使用If-Modified-Since: Date进行判断,结果就是服务器会重新返回一份相同内容的文档给缓存。
  • 服务器不能准确判断资源修改的时间
  • 如果文档的修改时间和验证的时间间隔小于1s,可能会由于精度不够,造成判断错误
    上面集中情况下,If-Modified-Since: Date首部并不能很好的工作,为了解决这些问题,HTTP又引入了If-None-Match: Tag的方式进行比较。工作原理就是服务器给一份资源加上一个标签(比如一个序列号,版本名称等),当对资源修改的时候,同时修改该标签,然后当缓存使用If-None-Match: Tag进行验证的时候,就可以对比标签,如果标签不匹配,就说明有修改了,返回新的资源内容,并且携带一个Etag首部,用于告知缓存本次资源的标签值。如果匹配则一样返回304 Not Modified就行了。
    缓存在使用该请求头的时候,标签的值可以包含多个,用于告诉服务器,这几个标签对应的副本,我本地都有缓存了。如:
If-None-Match: "v2.6"
If-None-Match: "v2.4","v2.5","v2.6"
If-None-Match: "foobar","A34FAC0095","Profiles in Courage

上面两种验证方式都是可用的,而且可以同时使用,那在使用过程中,他们的优先级是怎么样的呢,原则总结如下:

  • 如果服务只返回了一种验证方式Last-Modified或者Etag,那么就直接使用一种即可,如果两者都提供了,那么下次验证的时候,就需要两者都用上。
  • 服务在接收验证请求的时候,如果请求里面只有一种验证,按照之前的介绍的逻辑处理,入股有包含两种验证方式,则需要在两种方式的条件都满足的时候,才能返回304 Not Modified。

介绍完上面两个比较重要的验证方式后,这里HTTP/1.1开始还支持了一种“弱验证器”的特性。主要是用于,有些时候,虽然我们资源有细微修改,但是缓存的内容还是可以继续使用的。这个时候也还是希望验证通过,而不是重新返回新的资源内容。弱验证器用“W/”前缀进行标识,如下:

ETag: W/"v2.6"
If-None-Match: W/"v2.6”

不管相关的实体值以何种方式发生了变化,强实体标签都要发生变化。而相关实体在语义上发生了比较重要的变化时,弱实体标签也应该发生变化。

5.2 缓存控制

上面一小节讲了缓存如何对新鲜度进行验证。一般该验证的前提是缓存的资源过期了的时候才进行。所以这一小节,我们就来介绍怎么控制缓存的过期时间。
HTTP规范提供了一下几种方式来帮助服务器控制缓存:

  • 附加一个 Cache-Control: no-store 首部到响应中去;
  • 附加一个 Cache-Control: no-cache 首部到响应中去;
  • 附加一个 Cache-Control: must-revalidate 首部到响应中去;
  • 附加一个 Cache-Control: max-age 首部到响应中去;
  • 附加一个 Expires 日期首部到响应中去;
  • 不附加过期信息,让缓存确定自己的过期日期。

下面我们就分别介绍着集中控制方式,有些比较容易混淆的我们会放在一起对比说明。

5.2.1 no-store和no-cache

这两个响应头都能能防止缓存提供未经验证的资源缓存给客户端。使用格式如下:

Pragma: no-cache
Cache-Control: no-store
Cache-Control: no-cache

其中,Cache-Control是HTTTP/1.1中的规范,而Pragma是为了兼容HTTP/1.0+所保留的使用方式,优先使用Cache-Control的方式。
虽然no-store和no-cache都能防止缓存提供未经验证的资源给客户端,但是两者还是有一定区别:

  • no-store:表示禁止缓存对响应的内容进行复制保存,及该条响应不能进行缓存
  • no-cache:缓存可以保存一份副本在本地,但是每次使用之前都必须同服务器进行验证

5.2.2 max-age和expires

Cache-Control: max-age 表示的是从服务器将文档传来之时起,可以认为此文档处于新鲜状态的秒数。还有一个 s-maxage 首部(注意 maxage 的中间没有连字符),其行为与 max-age 类似,但仅适用于共享(公有)缓存,使用格式如下:

Cache-Control: max-age=3600
Cache-Control: s-maxage=3600

如果我们不想改响应被缓存,可以设置其值为0即可。
expires的作用和max-age一样,都是设置缓存资源的新鲜时间,但是其值为绝对值,具体区别前面已经介绍过,这里不再赘述。

如果服务器这两个值都没有提供的情况下,缓存可以根据一定的算法自己确定一个有效期,一般会根据文档修改间隔进行处理。这种情况的参考意义不大,这里就不详细说明了。感兴趣的朋友可以自己去了解一下相关算法。

5.2.3 must-revalidate

某些时候,我们为了节省资源,可以配置缓存使用一些过期对象。但是如果服务器希望某些对象严格按照过期信息来提供新鲜的对象。这个时候可以在响应中添加Cache-Control:must-revalidate头部进行限制。如果对象过了有效期,进行再验证的时候,服务器出现问题,不可用的时候,不能使用之前过期缓存,应该返回504 GateWay TimeOut进行说明。

5.2.4 客户端对缓存的控制

除了服务器可以通过Cache-Control首部来对缓存进行控制,客户端也可以使用该头部进行缓存控制,如:可以让缓存必须从服务器获取资源,或者必须进行新鲜度验证等。具体使用如下表所示:

指  令 目  的
Cache-Control: max-stale
Cache-Control: max-stale = <s>
缓存可以随意提供过期的文件。如果指定了参数<s> ,在这段时间内,文档就不能过期。这条指令放松了缓存的规则
Cache-Control: min-fresh=<s> 至少在未来<s> 秒内文档要保持新鲜。这就使缓存规则更加严格了
Cache-Control: max-age = <s> 缓存无法返回缓存时间长于<s> 秒的文档。这条指令会使缓存规则更加严格,除非同时还发送了max-stale 指令,在这种情况下,使用期可能会超过其过期时间
Cache-Control: no-cache
Pragma: no-cache
除非资源进行了再验证,否则这个客户端不会接受已缓存的资源
Cache-Control: no-store 缓存应该尽快从存储器中删除所有缓存信息,因为可能包含一些敏感信息
Cache-Control: only-if-cached 只有当缓存中有副本存在时,客户端才会获取一份副本

6 缓存当中一些相关时间的计算

在确定缓存是否新鲜的时候,只需要确定两个时间即可,一个是该缓存的新鲜生存期(freshness lefttime),一个是该缓存副本已经使用的试用期(age)。如果age<freshness left time。那则说明该缓存还是有效的,新鲜的。
所以下面的内容,就是介绍如何结合一些缓存控制首部内容算出这两个值进行比较。

6.1 使用期的计算

使用期是指从服务器发出响应那一刻起后面所经过的总时间。所以这里包含了响应从服务器到缓存中间的传输时间,资源达到缓存后,缓存对其进行处理的时间,再加上资源被保存好之后真正在缓存中保留时间。
计算规则如下:

响应传输延迟时间=max(0, 收到响应的时间-响应头Date时间)
不考虑传输延迟的使用时间 = max(响应传输延迟时间,响应中age );//这里的响应age是指缓存代理自己发出响应时候的age头部的值(表示资源已经产生多长时间),这里取两者大的一个是为了保守计算
传输延迟时间 = 响应时间 - 请求时间; // 使用期中至少不低于这个值,也是为了保守计算
考虑延迟的使用时间=不考虑传输延迟的使用时间+传输延迟时间
停留缓存时间 = 当前时间 - 响应时间;//计算缓存一级停留的时间
最终保守使用期 = 考虑延迟的使用时间 + 停留缓存时间 // 这个值不是精准,是保存估计的值

使用伪代码计算规则如下:

/*
 * age_value 当代理服务器用自己的头部去响应请求时,Age标明实体产生到现在多长时间了。
 * date_value HTTP 服务器应答中的Date字段 原始服务器
 * request_time 缓存的请求时间
 * response_time 缓存获取应答的时间
 * now 当前时间
 */
apparent_age = max(0, response_time - date_value); //缓存收到响应时响应的年龄 处理时钟偏差存在时,可能为负的情况
 
corrected_received_age = max(apparent_age, age_value);  //  容忍Age首部的错误
 
response_delay = response_time - request_time; // 处理网络时延,导致结果保守
 
corrected_initial_age = corrected_received_age + response_delay;
 
resident_time = now - response_time; // 本地的停留时间,即收到响应到现在的时间间隔
 
current_age   = corrected_initial_age + resident_time;

通过上面的计算,我们最终就可以获得当前资源缓存的使用期了,该值相对来来说是一种保守估计值,比如同时存在Date和Age响应头的时候,我们会取其中使用期较长的时间,计算网络延迟的时候,我们也是直接结算从发起请求到相应达到整个网络延迟,所以最终得到的结果也是一个保守值。

6.2 新鲜生存期的计算

前面提到,要判断一份缓存是否新鲜可用的,除了使用期外,还需要确定一个新鲜生存期,需要比较两者才能得出结论,所以这一小节我们看下怎么计算新鲜生存期。
这里我们先不考虑客户端对缓存控制的情况,但从服务器来看新鲜生存期。
在服务器我们可以通过多种方式确定其新鲜生存期,一般遵循如下优先级进行确定:

max-age 》 expires - date_header 》 factor * max(0,date_header - last_modified_date)》default_cache_min_date

获取到最终的值后,还需要检查结果是否超过缓存的最大或者最小新鲜度。伪代码如下:

/**
    * heuristic 启发式过期值应不大于从那个时间开始到现在这段时间间隔的某个分数
    * Max_Age_value_set  是否存在Max_Age值  Cache-Control字段中“max-age”控制指令的值
    * Max_Age_value  Max_Age值
    * Expires_value_set 是否存在Expires值
    * Expires_value Expires值
    * Date_value Date头部
    * default_cache_min_lifetime
    * default_cache_max_lifetime
    */
   public int server_freshness_limit() {
       int factor = 0.1; //典型设置为10%
 
       int heuristic = false; //  启发式 默认为false
 
       if (Max_Age_value_set) {   // 优先级一为 Max_Age
           freshness_lifetime = Max_Age_value;
       }elseif(Expires_value_set) {  //   优先级二为Expires
           freshness_lifetime = Expires_value - Date_value;
       }elseif(Last_Modified_value_set) { //  优先级三为Last_Modified
           freshness_lifetime = (int)(factor * max(0, Date_value - Last_Modified_value ));
           heuristic = true; //  启发式
       }else{ 
           freshness_lifetime = default_cache_min_lifetime;
           heuristic = true; //  启发式
       }
 
       if (heuristic) {
           freshness_lifetime = freshness_lifetime > default_cache_max_lifetime ? default_cache_max_lifetime : freshness_lifetime;
           freshness_lifetime = freshness_lifetime < default_cache_min_lifetime ? default_cache_min_lifetime : freshness_lifetime;
       }
 
       return freshness_lifetime;
 
   }

通过上面的计算,我们得到了服务器指定的新鲜生存期,但是实际应用当中,除了服务器,客户端也可以通过一些首部对缓存进行控制,这里我们将客户端的控制考虑进来,再进行修正计算,伪代码如下:

/**
    * Max_Stale_value_set  是否存在Max_Stal值  Cache-Control字段中“max-stale”的值
    * Min_Fresh_value_set  是否存在Min_Fresh值  Cache-Control字段中“min-fresh”的值
    * Max_Age_value_set 是否存在Max-Age值  Cache-Control字段中“max-age”的值
    */
int sub client_modified_freshness_limit
{
    int age_limit = server_freshness_limit( ); // 获取服务器设置的新鲜生存期

    if (Max_Stale_value_set)
    {
        if (Max_Stale_value == INT_MAX)
        { age_limit = INT_MAX; }
        else
        { age_limit = server_freshness_limit( ) + Max_Stale_value; }
    }

    if (Min_Fresh_value_set)
    {
    age_limit = min(age_limit, server_freshness_limit( ) -
           Min_Fresh_value);
    }

    if (Max_Age_value_set)
    {
        age_limit = min(age_limit, Max_Age_value);
    }
}

最终通过上面的修正计算,我们得到了最终的新鲜生存期的值。最后通过比较新鲜生存期和使用期的值,就能确定该缓存资源是否有效了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章