一文读懂HTTP Caching

一个典型的HTTP应用拓扑如下

user-agent — forward proxy … proxy server n … reverse proxy — origin server

user-agent通常是浏览器,也可以是第三方工具如curl,或HTTP相关模块,如python的request库。
forward proxy是正向代理,逻辑上靠近客户端,可以在浏览器的设置中指定。
proxy server n是处于中间位置,提供HTTP代理功能的代理服务器。
reverse proxy是反向代理,位于origin server服务端的边缘,由它代理提供WEB服务。
origin server真实提供WEB服务的源WEB服务器。

HTTP Cache是一个本地存储,保存了可缓存的响应消息。它可以分为private cache和public cache。比如浏览器上的cache就是一个private cache,仅为一个终端用户服务;而share cache就是为多个用户提供缓存服务的cache服务器,它可以在user-agent到origin server上的任何proxy上实现。

HTTP Cache的目的是降低WEB服务响应的时延,减少带宽消耗。它的基本工作逻辑是,保存上一次用户请求的结果到HTTP cache,以后有相同请求发往origin server时,则直接从cache中返回结果,比如响应消息直接从浏览器本地缓存中取,显然响应更快,而且没有带宽消耗。当然上述只是理想情况,具体到现实世界,还有很多因素需要考虑。

HTTP Caching原理图:
一文读懂HTTP Caching_第1张图片

可缓存的响应
并不是所有响应都是可以缓存的,其中既有安全性的考虑,也有管理策略的需求,还有现实的实现情况。
安全性的考虑:对于HTTP请求中包含Authentication头字段的响应,或包含Set-Cookie头字段的响应,缺省不会保存在public cache中,因为它们包含个人信息。如果确实需要保存,需要显示的设置Cache-Control: public, s-maxage,或must-revalidate中的一个。
什么样的响应会被缓存呢?RFC7234上写的很复杂,一般理解为GET方法,状态码是200,206,301,404的,并在响应中指定了缓存的新鲜度的响应就可以被缓存。
什么样的响应不会被缓存?其它方法,除非构造了合适的cache key,并被cache理解和支持;明确设置了Cache-Control: no-store的也不会被缓存。

响应的新鲜度:
网络上的资源并不是一成不变的,并且变化频率也不一样,对于某些资源可能实时在变化,某些资源有固定的更新周期,而另外一些资源可能10年8年也不会改变。按照上面缓存的工作逻辑,只有第一次请求发送到了origin server,后继的请求/响应都直接由cache返回,显然不能满足需求。为了解决这个问题,HTTP cache中引入了新鲜度的概念,为不同更新频度的资源指定不同的过期时间,新鲜度从响应生成时刻算起Date,到指定的过期时间Expiration time。即fressless _lifetime = Expiration time - Date,【注:如果是s-maxage或max-age这样的相对时间,则它们的值就是fressless _lieftime】相当于是给响应贴了一个保质期的标签,资源的状态在保质期内的称为fresh,否则称为stale。Expiration time可以有几个来源,分别是s-maxage,max-age,Expires,优先级也是从高到低,其中s-maxage仅针对share cache。另外,响应可能保存在某个中间的proxy cache上,当用户发请求获取时,响应已经保存在proxy cache一段时间,HTTP Cache引入了一个头字段Age, 标记自响应产生以来,到proxy cache发送回应时,响应经历的时间。所以判断一个响应是否新鲜的公式是fressness_lifetime>current_age。current_age的计算有比较复杂的逻辑,可以参考RFC7234,此处略过。
注:还有一个所谓启发式的过期时间,即如果上述决定新鲜度的时间origin server都没有提供,cache可以自行依据Last-Modifed字段和Date,计算一个新鲜度时间,通常是(Date - Last_modified)/10。

控制缓存的头字段:
Expires 响应保质期的绝对时间
Cache-Control 目前控制缓存策略的主要头字段
其它,如Pragma, 等效于发送方向的Cache-Control: no-cache,浏览器中强制刷新时可以看到,纯粹是为了兼容HTTP/1.0,可理解为已经废弃。

Cache-Control头字段:
有3点需要说明:

  1. 对于缓存的控制是单向的,对一个方向施加的指令,并不代表对另外一个方向施加了相同的指令,尽管某些指令在发送和接收方同时存在,但它们表达的语义和作用是不同的。
  2. 指令的施加是对请求/应答链上所有的HTTP Cache施加的,并不能指定对某个特定的HTTP cache施加某个指令。
  3. 某些指令仅针对share cache生效,对于private cache则忽略。如s-maxage, proxy-revalidate。

接收方向的Cache-Control directive
max-age: 指定资源【即术语representations】的过期时间。
s-maxage: 指定资源的过期时间,仅针对share cache。
no-store: 指示cache不缓存
no-cache: 缓存,但使用前无论缓存是否fresh都需要去origin server验证。所以可以保证客服始终获取新鲜的资源。
must-revalidate: 可以保存响应,过期则需去origin server验证,所以must-revalidate通常和max-age一起使用,如Cache-Control: must-revalidate, max-age=3600。
private/public: 决定缓存保存的位置。private仅private cache可以保存,反之,public可以保存在private和public cache。
其它还有一些头字段相对没那么重要,就略过了。

发送方向的Cache-Control directive:
max-age: 不接受age>指定时间的缓存。【注1】
max-stale: 可以接收过期的资源stale,但不能超过指定的时间。【注2】
min-fresh: 仅接收新鲜度不小于指定时间的资源
no-restore: 通知cache不要保存请求和缓存响应,并应该删除已存在的缓存。
no-cache: 缓存使用前,需要去origin server验证。
注1,注2 在MDN的表述中,并不清晰,为此博主还给他提了1个PR,并在最近一次的更新修正了,你敢信。

验证和验证器
首先要澄清的是,验证应由origin server进行,而不能由cache进行。因为验证涉及2类验证器:Last-Modified和ETage,二者都是由origin server提供。
验证的逻辑是,如果cache上的资源已经过期,当收到客户端请求,则由cache发请求询问origin server,询问是否资源已经改变,是则重发新响应返回200,否则继续使用缓存,返回304,并更新age。
如何判断资源是否改变?依据是资源的某种标识,就是上文说到的两种验证器:Last-Modified和ETag。
验证流程是:

  1. origin server回应响应时携带了验证器。
  2. 需要验证时,如资源已经过期,或指定缓存指令是Cache-Control: no-cache,强制要求验证,则请求中通过条件请求字段:If-Modified-Since或If-None-Match携带1中的验证器。
  3. 验证通过则返回304,不返回包体,并更新age,节省了带宽,否则返回完整的包体和200。

HTTP cache验证原理图:
一文读懂HTTP Caching_第2张图片

Vary和cache
cache是一个本地存储,同时提供了机制检索缓存,检索使用的是URI+请求方式,或省略为URI,称为cache key,或primary cache key。在有内容协商的情况下,单用URL并不能区分缓存。此时通过Vary字段标记,哪些字段会影响到缓存,把Vary字段标记的请求字段作为缓存的secordary key[注3]
注3:MDN举例的图中这块错了,其中Content-Encoding应该是Accept-Encoding,博主指出,并被确认,但现在还没改过来。

Varying response原理图
一文读懂HTTP Caching_第3张图片

网页开发的Revved策略
前面说过,对于某些资源,如CSS,JS可能10年8年不会变,我们可以为它指定一个超长的过期时间。但万一这个拥有超长缓存时间的资源发生了变化怎么办?此时可以从资源命名着手,采取所谓cache busting的技术,给文件名添加版本号hash值的方式命名,然后给引用它们的HTML文件设置一个较短的缓存时间,这样就可以解决静态文件的更新问题。

Nginx中的对于Cache-Control和Expires的控制:
Nginx中的ngx_http_headers_module,可以控制返回的Expires和Cache-Control,下面是官方给出的例子

expires    24h;
expires    modified +24h;
expires    @24h;
expires    0;
expires    -1;
expires    epoch;
expires    $expires;
add_header Cache-Control private;

用REDbot解读某些网站的Cache设置:
REDbot是一个开源工具,可以用来解读网站返回的HTTP信息。比如下面是解读百度首页的信息,我们重点关注Cache相关的信息。

一文读懂HTTP Caching_第4张图片
运用我们所学的知识,可以对百度首页Cache相关的设置做一个解读:
Cache-Control: private #表示不允许share cache存储该页面。
Date和Expires,可以看到Expires的时间早于Date,即这个页面创建时就已经stale了,不能被缓存使用。
Vary: Accept-Encoding #相当于指定了Cache key的secondary key为Accept-Encoding,表达缓存可以按压缩协商结果的不同,分别存储。
上述百度首页的缓存设置逻辑是:“我是百度的首页,页面内容会实时变化的,所以不要试图缓存我。”

这个截图是针对百度首页调用的JS文件的分析

一文读懂HTTP Caching_第5张图片
我们来分析一下它的Cache相关信息
文件名:jquery-1-edb203c114.10.2.js #显然是带版本号的,使用了cache busting的命名方式。
Cache-Control: max-age #设置了一个很大的值,大约可以在cache中保存一个月。
Age: 记录了它在proxy cache上存在的时间。
Last-Modified和Etag:是两个validator。
其中没有关于public/private的设置,REDbot提示说可以被所有cache缓存,看来public是缺省值。

这个文件的缓存信息表达的语义是:我是一个JS静态文件,可以在cache中保存很长时间,需要验证是否更新,可以携带Validator。

结合上面的HTML设置,符合Revved的缓存更新逻辑,你学废了吗?

MDN上关于HTTP Caching的几个小问题:
一文读懂HTTP Caching_第6张图片
实践和验证:
为进一步验证上述HTTP caching相关的理论知识,博主搭建了一个真实的实验环境如下:

工具:
user-agent: curl 7.6.1
origin server: nginx 1.20.1
proxy cache: openresty 1.19.9.1

拓扑:
client — proxy cache — origin server

由Nginx充当origin server, 有关HTTP caching的头字段在此设置,核心就是前面提到过的add_header和expires指令。

origin server 配置:

    server {
        listen  127.0.0.1:8080;

        access_log logs/103_access.log main;
        error_log logs/103_error.log debug;

        #注:同时设置Cache-Control,X-Accel-Expires,X-Accel-Expires优先级高
        #add_header Cache-Control 'max-age=30,stale-while-revalidate=3';#缓存30#add_header Vary *;#有Vary *,不缓存
        #add_header X-Accel-Expires 180;#通知代理缓存保存时间,尽管HTTP返回消息头部看不到该设置,由于它优先级高,设置以它为准
        #expires 120s;#expires的优先级高于proxy_cache_valid中设定的时间                                                      
        #add_header Cache-Control no-store;
        #add_header Cache-Control no-cache;
        add_header Cache-Control 'max-age=120,must-revalidate';
        #add_header Cache-Control 'max-age=120,proxy-revalidate';
        #add_header Cache-Control private;
        #add_header Cache-Control public;

        location / {
        }
    }

Openresty充当proxy cache, 开启缓存功能,核心是添加X-Cache-Status和打开revalidation.

proxy cache配置:

proxy_cache_path 103_proxy_cache levels=2:2 keys_zone=103_proxy_cache:100m max_size=200m inactive=5m loader_threshold=300 loader_files=200;

server {
    listen 80;
    server_name test;

    access_log logs/103_access.log main;
    error_log logs/103_error.log debug;
    
    location ~ /purge(/.*) {
        proxy_cache_purge 103_proxy_cache $scheme$1;
    }

    location / {
        proxy_cache 103_proxy_cache;
        proxy_cache_valid 200 1m;#优先级低于Expires                                                                                
        add_header X-Cache-Status $upstream_cache_status;
        proxy_cache_key $scheme$uri;
        proxy_http_version 1.1;
        proxy_cache_revalidate on;
        proxy_pass http://127.0.0.1:8080;
    }   

}

测试方法:用curl 发起请求,观察返回结果和相关日志。

以下以验证must-revalidate为例,分析测试的结果和日志
关键设置:
(1)在origin server中
add_header Cache-Control ‘max-age=120,must-revalidate’;
即缓存120秒,过期需要回源验证。
(2)在proxy cache中
proxy_cache_revalidate on;
注:缺省proxy_cache_revalidate off,需要显示设置为on, 否则不会向origin server 发条件请求。
辅助设置:
add_header X-Cache-Status #返回缓存的状态,包括MISS,HIT,EXPIRED,REVALIDATED等。

执行4次curl test -I,由于缓存120s过期,所以2-3次之间间隔2分钟,捕捉到缓存过期状态,又因为设置了proxy_cache_revalidate on,所以不会显示EXPIRED,直接显示REVALIDATED

[root@test01 ~]# curl test -I
HTTP/1.1 200 OK
Server: openresty/1.19.9.1
Date: Thu, 31 Mar 2022 04:12:24 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 620
Connection: keep-alive
Last-Modified: Wed, 23 Jun 2021 06:31:52 GMT
ETag: "60d2d558-26c"
Cache-Control: max-age=120,must-revalidate
X-Cache-Status: MISS
Accept-Ranges: bytes

[root@test01 ~]# curl test -I
HTTP/1.1 200 OK
Server: openresty/1.19.9.1
Date: Thu, 31 Mar 2022 04:12:27 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 620
Connection: keep-alive
Last-Modified: Wed, 23 Jun 2021 06:31:52 GMT
ETag: "60d2d558-26c"
Cache-Control: max-age=120,must-revalidate
X-Cache-Status: HIT
Accept-Ranges: bytes

[root@test01 ~]# curl test -I
HTTP/1.1 200 OK
Server: openresty/1.19.9.1
Date: Thu, 31 Mar 2022 04:14:33 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 620
Connection: keep-alive
Last-Modified: Wed, 23 Jun 2021 06:31:52 GMT
ETag: "60d2d558-26c"
Cache-Control: max-age=120,must-revalidate
X-Cache-Status: REVALIDATED
Accept-Ranges: bytes

[root@test01 ~]# curl test -I
HTTP/1.1 200 OK
Server: openresty/1.19.9.1
Date: Thu, 31 Mar 2022 04:14:36 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 620
Connection: keep-alive
Last-Modified: Wed, 23 Jun 2021 06:31:52 GMT
ETag: "60d2d558-26c"
Cache-Control: max-age=120,must-revalidate
X-Cache-Status: HIT
Accept-Ranges: bytes

日志文件

proxy-cache
192.168.31.133 - - [31/Mar/2022:12:12:24 +0800] "HEAD / HTTP/1.1" 200 0 "-" "curl/7.61.1" "-"
192.168.31.133 - - [31/Mar/2022:12:12:27 +0800] "HEAD / HTTP/1.1" 200 0 "-" "curl/7.61.1" "-"
192.168.31.133 - - [31/Mar/2022:12:14:33 +0800] "HEAD / HTTP/1.1" 200 0 "-" "curl/7.61.1" "-"
192.168.31.133 - - [31/Mar/2022:12:14:36 +0800] "HEAD / HTTP/1.1" 200 0 "-" "curl/7.61.1" "-"

origin-cache
127.0.0.1 - - [31/Mar/2022:12:12:24 +0800] "GET / HTTP/1.1" 200 620 "-" "curl/7.61.1" "-"
127.0.0.1 - - [31/Mar/2022:12:14:33 +0800] "GET / HTTP/1.1" 304 0 "-" "curl/7.61.1" "-"

结果分析和解读:
一共执行4次请求,
从curl返回结果看,
X-Cache-Status的状态是MISS,HIT,REVALIDATED,HIT,符合预期
从log记录看:
proxy cache 4次HEAD请求都收到并响应
origin server 响应2次,分别是第一和第三次,符合预期。
第一次没有缓存,所以origin server有回应。
第二次缓存命中,请求被proxy cache截获,未发送到origin server。
第三次因为验证缓存所以要回源验证缓存,所以请求打到origin server。
第四次命中缓存,请求被proxy cache截获,未发送到origin server。

origin server收到的proxy cache发送的条件请求日志:

2022/03/31 12:14:33 [debug] 19220#0: *2 http process request header line
2022/03/31 12:14:33 [debug] 19220#0: *2 http header: "Host: 127.0.0.1:8080"
2022/03/31 12:14:33 [debug] 19220#0: *2 http header: "Connection: close"
2022/03/31 12:14:33 [debug] 19220#0: *2 http header: "If-Modified-Since: Wed, 23 Jun 2021 06:31:52 GMT"
2022/03/31 12:14:33 [debug] 19220#0: *2 http header: "If-None-Match: "60d2d558-26c""
2022/03/31 12:14:33 [debug] 19220#0: *2 http header: "User-Agent: curl/7.61.1"
2022/03/31 12:14:33 [debug] 19220#0: *2 http header: "Accept: */*"
2022/03/31 12:14:33 [debug] 19220#0: *2 http header done

至此验证了must-revalidate的行为,即缓存过期时回源验证,否则直接用缓存。

其它cache相关的头字段,验证方式类似,不再赘述。

几点未解决的问题:
(1)测试Cache-Control: no-cache时,效果等同于Cache-Control: no-store,不知何故?—一个猜想如果expires设置为负数,即缓存过期,不缓存,Nginx会同时添加Cache-Control: no-cache,貌似Nginx中no-cache就是no-store的意思,和Nginx的实现有关。
(2)不知如何添加动态的Age头字段,add_header可以添加Age,但是静态的。
希望有高人指点~~~

BTW: Nginx对于HTTP caching有大量的可配置参数,需要另外研读。

学习HTTP Caching的常见误区:
(1)5个条件请求都可以用于HTTP Caching。其实只有If-None-Match和If-Modified-Since和HTTP Caching有关。其它3个条件请求针对其它的应用场景,不能想当然。关于HTTP 的条件请求可以参考一文读懂4个HTTP条件请求头部
(2)缓存是否过期是max-age倒计时到0,其实不是,是Age的值在增长,当max-age >= Age时,缓存是fresh的,没有age_value,即上游获取的age值为0,current_age是currected_initial_age+resident time,要计算驻留时间。

补充:
current_age的计算
所谓current_age是指从当前cache发送响应时Age字段的值,而计算中用到的age_value是从上游cache获得的current_age。Age的值本质是HTTP消息传输路径上消耗的传输时间+路径上各个cache累积的驻留时间。
一文读懂HTTP Caching_第7张图片
一点题外话:
博主学习HTTP Caching过程中,阅读了若干资料,说点体会:
RFC是最权威的,错误最少的,你敢质疑RFC,你飘了啊~~~但学习曲线最陡,比如某些细节和实现有关,它说得很模糊【比如启发式过期时间】,某些细节又考虑得过于细致,实现不一定实现【比如如果回应时碎片时,怎么合并,怎么缓存】,适合有一定基础的同学看。
MDN比较亲民,基本说的人话,但居然错误不少~,其实都是小错误,不存在概念性的重大错误,还是要相信MDN专家的水平。
网上的教程,多数太浅显,碎片化,错误也多,优点是先入个门,普及个概念,建立基本的知识体系,建议先看,再和MDN印证。
最后,是实践,搭建环境,上手实操。实践实践再实践,是学好一切计算机技术的终极奥义。

参考:
RFC7234
MDN
Nginx HTTP Proxy

你可能感兴趣的:(HTTP,http)