REST API -- 缓存和并发
阅读原文时间:2023年07月17日阅读:1

REST API -- 缓存和并发

https://www.cnblogs.com/cgzl/p/9165388.html

本文所需的一些预备知识可以看这里: http://www.cnblogs.com/cgzl/p/9010978.htmlhttp://www.cnblogs.com/cgzl/p/9019314.html

建立Richardson成熟度2级的POST、GET、PUT、PATCH、DELETE的RESTful API请看这里:https://www.cnblogs.com/cgzl/p/9047626.htmlhttps://www.cnblogs.com/cgzl/p/9080960.htmlhttps://www.cnblogs.com/cgzl/p/9117448.html

HATEOAS:https://www.cnblogs.com/cgzl/p/9153749.html

本文介绍缓存和并发,无需看前边文章也能明白吧。

本文所需的练习代码(右键另存,后缀改为zip):https://images2018.cnblogs.com/blog/986268/201806/986268-20180611132306164-388387828.jpg

缓存
根据REST约束:“每个响应都应该定义它自己是否可以被缓存”。本文就要介绍如何保证HTTP响应是可被缓存的,这里就要用到HTTP缓存的知识,HTTP缓存是HTTP标准的一部分(RFC 2616, RFC 7234)。

"除非性能可以得到很大的提升,否则用缓存是没啥用的。HTTP/1.1里缓存的目标就是在很多场景中可以避免发送请求,在其他情况下避免返回完整的响应"。

针对避免发送请求的数量这一点,缓存使用了过期机制。

针对避免返回完整响应这点,缓存采用了验证机制。

缓存是什么?

缓存是一个独立的组件,存在于API和API消费者之间。

缓存接收API消费者的请求,并把请求发送给API;

缓存还从API接收响应并且如果响应是可缓存的就会把响应保存起来,并把响应返回给API的消费者。如果同一个请求再次发送,那么缓存就可能会吧保存的响应返回给API消费者。

缓存可以看作是请求--响应通讯机制的中间人。

HTTP里面有三种缓存:

客户端缓存/浏览器缓存,它存在于客户端,并且是私有的(因为它不会与其它客户端共享)。
网关缓存,它是共享的缓存,位于服务器端,所有的API消费者客户端都会共享这个缓存。它的别名还有反向代理服务器缓存,HTTP加速器等。
代理缓存,它位于网络上,共享的,它既不位于API消费者客户端,也不在API服务器上,它在网络的其它地方。这种缓存经常被大型企业或ISP使用,用来服务大规模的用户。(这个不介绍了,我不会)

过期模型
过期模型让服务器可以声明请求的资源也就是响应信息能保持多长时间是“新鲜”的状态。缓存可以存储这个响应,所以后续的请求可以由缓存来响应,只要缓存是“新鲜”的。处于这个目的,需要使用两个Response Headers:

Expires Header,它包含一个HTTP日期,该日期表述了响应会在什么时间过期,例如:Expires: Mon, 11 Jun 2018 13:55:41 GMT。但是它可能会存在一些同步问题,所以要求缓存和服务器的时间是保持一致的。它对响应的类型、时间、地点的控制很有限,因为这些东西都是由cache-control这个Header来控制和限制的。

Cache-Control Header,例如Cache-Control: public, max-age=60,这个Header里包含两个指令public和max-age。max-age表明了响应可以被缓存60秒,所以时钟同步就不是问题了;而public则表示它可以被共享和私有的缓存所缓存。所以说服务器可以决定响应是否允许被网关缓存或代理缓存所缓存。对于过期模型,优先考虑使用Cache-Control这个Header。Cache-Control还有很多其它的指令,常见的几个可以在ASP.NET Core官网上看:https://docs.microsoft.com/en-us/aspnet/core/performance/caching/response?view=aspnetcore-2.1#http-based-response-caching

过期模型的工作原理,看下面的例子:

这里的Cache 缓存可以是私有的也可以是共享的。

客户端程序发送请求 GET countries,这时还没有缓存版本的响应,所以缓存会继续把请求发送到API服务器;然后API返回响应给缓存,响应里面包含了Cache-Control这个Header,Cache-Control声明了响应会保持“新鲜”(或者叫有效)半个小时,最后缓存把响应返回给客户端,但同时缓存复制了一份响应保存了起来。

然后比如10分钟之后,客户端又发送了一样的请求:

这时,缓存里的响应还在有效期内,缓存会直接返回这个响应,响应里包含一个age Header,针对这个例子(10分钟),age的值就是600(秒)。

这种情况下,对API服务器的请求就被避免了,只有在缓存过期(或者叫不新鲜 Stale)的情况下,缓存才会访问后端的API服务器。

如果缓存是私有的,例如在web应用的localstorage里面,或者手机设备上,请求到此就停止了。

如果缓存是共享的,例如缓存在服务器上,情况就不一样了。

比如说10分钟之后另一个客户端发送了同样的请求,这个请求肯定首先来到缓存这里,如果缓存还没有过期,那么缓存会直接把响应返回给客户端,这次age Header的值就是1200(秒),20分钟了:

总的来说私有缓存会减少网络带宽的需求,同时会减少从缓存到API的请求。

而共享缓存并不会节省缓存到API的网络带宽,但是它会大幅减少到API的请求。例如同时10000个客户端发出了同样请求到API,第一个到达的请求会来到API程序这里,而其它的同样请求只会来到缓存,这也意味着代码的执行量会大大减少,访问数据库的次数也会大大减少,等等。

所以组合使用私有缓存和共享缓存(客户端缓存和公共/网关缓存)还是不错的。但是这种缓存还是更适用于比较静态的资源,例如图片、内容网页;而对于数据经常变化的API并不太合适。如果API添加了一条数据,那么针对这10000个客户端,所缓存的数据就不对了,针对这个例子有可能半个小时都会返回不正确的数据,这时就需要用到验证模型了。

验证模型
验证模型用于验证缓存的响应数据是否是保持最新的。

这种情况下,当被缓存的数据将要成为客户端请求的响应的时候,它首先会检查一下源服务器或者拥有最新数据的中间缓存,看看它所缓存的数据是否仍然最新。这里就要用到验证器。

验证器
验证器分为两种:强验证器,弱验证器。

强验证器:如果响应的body或者header发生了变化,强验证器就会变化。典型的例子就是ETag(Entity Tag)响应header,例如:ETag: "12345678",ETag是由Web服务器或者API发配的不透明标识,它代表着某个资源的特定版本。强验证器可以在任意带有缓存的上下文中使用,在更新资源的时候强验证器可以用来做并发检查。

弱验证器:当响应变化的时候,弱验证器通常不一定会变化,由服务器来决定什么时候变化,通常的做法有“只有在重要变化发生的时候才变化”。一个典型的例子就是Last-Modified(最后修改时间)这个Header ,例如:Mon, 11 Jun 2018 13:55:41 GMT,它里面包含着资源最后修改的时间,这个就有点弱,因为它精确到秒,因为有可能一秒内对资源进行两次以上的更新。但即使针对弱验证器,时钟也必须同步,所以它和expires header有同样的问题,所以ETag是更好的选择。

还有一种弱ETag,它以w/开头,例如ETag: "w/123456789",它被当作弱验证器来对待,但是还是由服务器来决定其程度。当ETag是这种格式的时候,如果响应有变化,它不一定就变化。

弱验证器只有在允许等价(大致相等)的情况下可已使用,而在要求完全相等的需求下是不可以使用的。

HTTP标准建议如果可能的话最好还是同时发送ETag和Last-Modified这两个Header。

下面看看其工作原理。客户端第一次请求的时候,请求到达缓存后发现缓存里没有,然后缓存把请求发送到API;API返回响应,这个响应包含ETag和Last-Modified 这两个Header,响应被发送到缓存,然后缓存再把它发送给客户端,与此同时缓存保存了这个响应的一个副本。

10分钟后,客户端再次发送了同样的请求,请求来到缓存,但是无法保证缓存的响应是“新鲜”的,这个例子里并没有使用Cache-Control Header,所以缓存就必须到服务器的API去做检查。这时它会添加两个Headers:If-None-Match,它被设为已缓存响应数据的ETag的值;If-Modified-Since,它被设为已缓存响应数据的Last-Modified的值。现在这个请求就是根据情况而定的了,服务器接收到这个请求并会根据证器来比较这些header或者生成响应。

如果检查合格,服务器就不需要生成响应了,它会返回304 Not Modified,然后缓存会返回缓存的响应,这个响应还包含了一个最新的Last-Modified Header(如果支持Last-Modifed的话);

而如果响应的资源发生变化了,API就会生成新的响应。

如果是私有缓存,那就请求就会停在这。

但如果是共享缓存的话,假如10分钟之后另一个客户端发送了请求,这个请求也会到达缓存,然后跟上面一样的流程:

总的来说就是,同样的响应只会被生成一次。

对比一下:

私有缓存:后续的请求会节省网络带宽,我们需要与API进行通信,但是API不需要把完整的响应返回来,如果资源没有变化的话只需要返回304即可。

共享缓存:会节省缓存和API之间的带宽,如果验证通过的话,API不需要重新生成响应然后重新发送回来。

过期模型和验证模型还是经常被组合使用的。

组合使用过期模型和验证模型
可以这样做:

如果使用私有缓存,这时只要响应没有过期,那么响应直接会从私有缓存返回。这样做的好处就是减少了与API之间的通信,也减少了API生成响应的工作,减轻了带宽需求。而如果私有缓存过期了,那还是会访问到API的。如果只有过期(模型)检查的话,这就意味着如果过期了API就得重新生成响应。但是如果使用验证(模型)检查的话,我们可能就会避免这种情况。因为缓存的响应过期了并不代表缓存的响应就不是有效的了,API会检查验证器,如果响应依然有效,就会返回304。这样网络带宽和响应的生成动作都有可能被大幅度减少了。

如果是共享缓存,缓存的响应只要没过期就会一直被返回,这样虽然不会节省客户端和缓存之间的网络带宽,但是会节省缓存和API之间的网络带宽,同时也大幅度减少了到API的请求次数,这个要比私有缓存幅度大,因为共享缓存是共享与可能是所有的客户端的。如果缓存的响应过期了,缓存就必须与API通信,但这也不一定就意味着响应必须被重新生成。如果验证成功,就会返回304,没有响应body,这就有可能减少了缓存和API之间的网络带宽需求,响应还是从缓存返回到客户端的。

所以综上,客户端配备私有缓存,服务器级别配备共享缓存就应该是最佳的实践。

Cache-Control的指令
先看一下响应的Cache-Control常用指令:

新鲜度:
max-age定义了响应的生命期, 超过了这个值, 缓存的响应就过期了, 它的单位是秒.
s-maxage对于共享缓存来说它会覆盖max-age的值. 所以在私有缓存和共享缓存里响应的过期时间可能会不同.
存储地点:
public, 它表示响应可以被任何一个缓存器所缓存, 私有或者共享的都可以.
private, 它表示整个或部分响应的信息是为某一个用户所准备的, 并且不可以被共享的缓存器所缓存.
验证:
no-cache, 它表示在没有和源服务器重新验证之前, 响应不可以被后续的请求所使用.
must-revalidate, 使用它服务器可以声明响应是否已经不新鲜了(过期了), 那么就需要进行重新验证. 这就允许服务器强制让缓存进行重新验证, 即使客户端认为过期的响应也是可以的.
proxy-revalidate, 他和must-revalidate差不多, 但不适用于私有缓存.
其它:
no-store, 它表示缓存不允许存储消息的任何部分.
no-transform, 它表示缓存不可以对响应body的媒体类型进行转换.
上面这些都是由服务器决定的, 但是客户端可以覆盖其中的一些设定.

请求的Cache-Control常用指令:

新鲜度:
max-age, 它表示客户端不想要接收已经超过这个值的有效期的响应
min-fresh, 它表示客户端可以接受到这样的响应, 它的有效期不小于它当前的年龄加上这个设定的值(秒), 也就是说客户端想要响应还可以在指定的时间内保持新鲜.
max-stale, 它表示客户端可以接收过期的响应.
验证:
no-cache, 它表示缓存不可以用存储的响应来满足请求. 源服务器需要重新验证成功并生成响应.
其他:
no-store, 和响应的一样.
no-transform, 和响应的一样.
only-if-cached, 它表示客户端只想要缓存的响应, 并且不要和源服务器进行重新验证和生成. 这个比较适用于网络状态非常差的状态.
到目前也介绍了几个指令了, 其实大多数情况下使用max-age和public, private即可…

更多指令请查看: https://tools.ietf.org/html/rfc7234#section-5.2

Cache Headers
根据REST的约束, 为了支持HTTP缓存, 我们需要一个可以生成正确的响应Header的组件, 并且可以检查发送的请求的Header, 所以我们可以返回304 Not Modified或者412 Preconditioned Failed.

这个组件应该位于缓存的后端, ASP.NET Core里有个自带的属性标签 [ResponseCache] (https://docs.microsoft.com/en-us/aspnet/core/performance/caching/response?view=aspnetcore-2.1#responsecache-attribute), 它可以应用于Controller的Actions. 为设定适当响应缓存Header它可以指定所需的参数. 它只能做这些, 无法在缓存里存储响应, 它并不是缓存存储. 而且因为它好像不支持ETag, 所以暂时先不使用这个.

可以考虑CacheCow,它可以生成ETag,也支持.NET Core,但是它并没有内置中间件来返回304。所以我这里使用的是Marvin.Cache.Headers。

安装:

Startup的ConfigureServices方法里配置:

这里还可以配置Header的生成选项,但暂时先使用默认的配置。

然后在Configure方法里,把这个中间件添加在app.useMvc()之前:

这里就是处理并返回304的逻辑。

还需要设置一下Postman, 要保证Send no-cache header这一项是off的:

发送请求测试:

这是第一次访问,会执行Action方法,然后返回响应。响应的Header如上图所示,里面包含了缓存相关的Header。

默认的Cache-Control是public,max-age是60秒。Expires header也反映了过期的时间,也就是1分钟之后。

用于验证的ETag和Last-Modified也被生成和添加了,Last-Modified就是现在的时间。

ETag的生成逻辑并不是标准的一部分,这个可以由我们自己来决定。当让响应是等价的还是完全相等的也是由我们来决定。

默认情况下,这个中间件会考虑到请求路径、Accept、Accept-language 这些Header以及响应的body。

再次发送该请求,由于已经超过了1分钟,所以还是会走Action方法的:

然后在1分钟之内再次发送请求:

还是走了这个Action方法!!

Header还是有变化的。

这个现象是没有问题的,因为这个库只是负责生成Header和验证,它并不是缓存存储器。

想要缓存数据,那就需要一个缓存存储器了,可以是私有、公共的也可以是两者兼顾的。这个一会再说。

先来看看验证,如果一个响应是不新鲜的(过期的),我们知道这样话缓存必须进行重新验证,最好是用ETag进行验证,他会把ETag的值赋給If-None-Match这个Header:

这时就会返回304 Not Modified,而Action方法也不会执行。

下面测试一下PUT动作:

更新数据之后,我再发送一次之前的GET请求:

这次Action方法又被执行了,这说明验证失败了,因为ETag已经不一致了,当我发送PUT请求的时候,生成了一个新的ETag。

我们也可以对如何生成Header进行配置,打开Startup的ConfigureServices方法:

配置参数还是很多的,这里我分别为过期模型和验证模型修改了一个参数。

过期模型的max-age设为600秒。验证模型为Cache-Control添加了must-revalidate指令,也就是说如果缓存的响应过期了,那么必须进行重新验证。

再次发送那个GET请求:

重新执行了Action方法,也可以看到响应Header的变化。

缓存存储
之前只是生成了缓存相关的Header,还没有进行真正的存储,现在就介绍存储这部分。

缓存有私有的、共享的等。

私有的不在我们讨论的范围内,因为它在客户端。

私有和共享缓存,有一些缓存是两者的混合,根据你在哪使用它来决定给其类型。例如CacheCow。

微软提供了一个共享缓存,支持.NET Core:ResponseCaching中间件(https://docs.microsoft.com/en-us/aspnet/core/performance/caching/middleware?view=aspnetcore-2.1)。

这个中间件会检查Marvin.Cache.Headers这个中间件生成的Header,并把响应放到缓存并根据Header把它们服务给客户端,但是ResponseCaching中间件它自己并不会生成这些Header。

在ConfigureServices里注册:

然后在Configure方法里,把这个缓存存储添加到管道:

注意顺序,要保证它在UseHttpCacheHeaders()之前。

测试,发送GET请求:

这次会执行Action方法,返回响应。

再次发送GET请求:

这次没有走进Action方法里,而是从缓存返回的,这里还多了一个Age header,它告诉了我响应的”年龄“,他已经活了123秒了。

再次请求:

年龄变成了243秒,还是小于600秒。很显然这提高了应用的性能。。。

到目前我们可以生成Cache-Control和Etag的Headers了,但是还没有用到ETag的另一个功能:

并发控制
看下面这个情况,很常见:

两个客户端1和2,客户1先获取了id为1的Country资源,随后客户2也获取了这个资源;然后客户2对资源进行了修改,先进行了PUT动作进行更新,然后客户1才修改好Country然后PUT到服务器。

这时客户1就会把客户2的更改完全覆盖掉,这是个常见问题。

针对这样的问题,我们需要使用一些处理并发冲突的策略:悲观并发控制和乐观并发控制。

悲观并发控制意味着资源是为客户1锁定的,只要资源处于锁定的状态,别人就不能修改它,只有客户1可以修改它。但是悲观并发控制是无法在REST下实现的,因为REST有个无状态约束。

乐观并发控制,这就意味着客户1会得到一个Token,并允许他更新资源,只要Token是合理有效的,那么客户1就一直可以更新该资源。在REST里这是可以实现的,而这个Token就是个验证器,而且要求是强验证器,所以我们可以用ETag。

回到例子:

客户1发送GET请求,返回响应并带着ETag Header。然后客户2发送同样的请求,返回同样的响应和Etag。

客户2先进行更新,并把Etag的值赋给了If-Match Header,API检查这个Header并和它为这个响应所保存的ETag值进行比较,这时针对这个响应会生成新的ETag,响应包含着这个新的ETag。

然后客户1进行PUT更新操作,它的If-Match Header的值是客户1之前得到的ETag的值,在到达API之后,API就知道这个和资源最新的ETag的值不一样,所以API会返回412 Precondition Failed。

所以客户1的更新没有成功,因为它使用的是老版本的资源。这就是乐观并发控制的工作原理。

下面看测试,

客户1先GET:

客户2GET:

注意他们两个的ETag是一样的。

然后客户2先更新:

最后客户1再更新(使用的是老的ETag):

返回412。

本文比较短,一些关于缓存技术的内容并没有写,距离REST的主题有点远。

ASP.NET Core关于缓存部分的文档在这里:https://docs.microsoft.com/en-us/aspnet/core/performance/caching/?view=aspnetcore-2.1

本系列的源码在:https://github.com/solenovex/ASP.NET-Core-2.0-RESTful-API-Tutorial