前言
缓存技术存在于应用场景的方方面面。从浏览器请求,到反向代理服务器,从进程内缓存到分布式缓存,其中缓存策略算法也是层出不穷。
假设一个网站,需要提高性能,缓存可以放在浏览器,可以放在反向代理服务器 ,还可以放在应用程序进程内 ,同时可以放在分布式缓存系统中。
从用户请求数据到数据返回,数据经过了浏览器,CDN,代理服务器,应用服务器,以及数据库各个环节。每个环节都可以运用缓存技术。
从浏览器/客户端开始请求数据,通过 HTTP 配合 CDN 获取数据的变更情况,到达代理服务器(Nginx)可以通过反向代理获取静态资源。
再往下来到应用服务器可以通过进程内(堆内)缓存,分布式缓存等递进的方式获取数据。如果以上所有缓存都没有命中数据,才会回源到数据库。
缓存的请求顺序是:用户请求 → HTTP 缓存 → CDN 缓存 → 代理服务器缓存 → 进程内缓存 → 分布式缓存 → 数据库。
看来在技术的架构每个环节都可以加入缓存,看看每个环节是如何应用缓存技术的。
HTTP 缓存
当用户通过浏览器请求服务器的时候,会发起 HTTP 请求,如果对每次 HTTP 请求进行缓存,那么可以减少应用服务器的压力。
当第一次请求的时候,浏览器本地缓存库没有缓存数据,会从服务器取数据,并且放到浏览器的缓存库中,下次再进行请求的时候会根据缓存的策略来读取本地或者服务的信息。
私有缓存
私有缓存是绑定到特定客户端的缓存------通常是浏览器缓存。由于存储的响应不与其他客户端共享,因此私有缓存可以存储该用户的个性化响应。该种缓存
如果响应包含个性化内容并且你只想将响应存储在私有缓存中,则必须指定 private
指令。
arduino
Cache-Control: private
共享缓存
共享缓存位于客户端和服务器之间,可以存储能在用户之间共享的响应,可以供多个客户端使用的缓存,通常依赖于代理服务器。
客户端发起的第一个请求通过代理服务器访问源服务器,缓存生效后会存放在代理服务器,后续客户端发起的相同请求,均由代理服务器提供缓存服务,共享缓存可以减轻源服务器的压力
其实共享代理可以细分为代理缓存 和托管缓存 ,简单理解代理缓存 由代理服务器管理,位于客户端与原始服务器之间,减轻服务器负载、提高响应速度。托管缓存由内容提供商或 CDN 管理,分布全球,提供更快的内容传递和更好的用户体验。
缓存处理机制
下方为整体处理机制示意图,后文很多内容针对图中某些环节进行扩展讲解
上面强缓存中的 Pragma 大多数用于 http 1.0(http 1.1 中也适用),而 Cache-Control 只能应用于 http 1.1
需要注意的是,Expires 为服务端返回的过期时间,这种判断缓存是否过期的方式在 HTTP 1.0 用的比较多,到了 HTTP 1.1 会使用 Cache-Control 的 max-age 属性替代(单位是秒)
Last-Modified/If-Modified-Since 规则
在客户端第一次请求的时候,服务器会返回资源最后的修改时间 ,记作 Last-Modified。客户端将这个字段连同资源缓存起来。
当客户端再次请求服务器时,会把 Last-Modified 连同请求的资源一起发给服务器,这时 Last-Modified 会被命名为 If-Modified-Since,但其内容一样。
服务器收到请求,会把 If-Modified-Since 字段与服务器上保存的 Last-Modified 字段作比较:
- 若服务器上的 Last-Modified 最后修改时间大于请求的 If-Modified-Since,说明资源被改动过 ,就会把资源(包括 Header+Body)重新返回给浏览器,同时返回状态码 200。
- 若资源的最后修改时间小于或等于 If-Modified-Since,说明资源没有改动过 ,只会返回 Header,并且返回状态码 304。浏览器接受到这个消息就可以使用本地缓存库的数据。
注意,Last-Modified 和 If-Modified-Since 指的是同一个值,只是在客户端和服务器端的叫法不同。
ETag / If-None-Match 规则
客户端第一次请求的时候,服务器会给每个资源生成一个 ETag 标记。这个 ETag 是根据每个资源生成的唯一 Hash 串,资源如何发生变化 ETag 随之更改,之后将这个 ETag 返回给客户端,客户端把请求的资源和 ETag 都缓存到本地。
在浏览器第二次请求服务器相同资源时,会把资源对应的 ETag 一并发送给服务器。在请求时 ETag 会被命名为 If-None-Match,但其内容不变
服务器收到请求后,会把 If-None-Match 与服务器上资源的 ETag 进行比较:
- 如果不一致,说明资源被改动过,则返回资源(Header+Body),返回状态码 200。
- 如果一致,说明资源没有被改过,则返回 Header,返回状态码 304。浏览器接受到这个消息就可以使用本地缓存库的数据。
规则使用时机
那么什么时候使用修改日期(Last-Modified)再验证和实体标签再验证(ETag)呢?
- 如果服务器返回了一个 ETag 首部,客户端就必须使用 If-None-Match 实体标签再验证;
- 如果服务器返回了一个 Last-Modified 首部,客户端就可以使用 If-Modified-Since 修改日期再验证;
- 如果 ETag 和 Last-Modified 都提供了,客户端就应该同时使用这两种再验证方案。
如果 HTTP/1.1 缓存或服务器收到的请求既带有 If-Modified-Since,又带有 If-None-Match,那么只有这两个条件都满足时,才能返回 304 Not Modified 响应。
缓存控制策略
对于网站来说,缓存是达到高性能的重要组成部分,缓存需要合理配置,因为并不是所有资源都是永久不变的。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-store
,带有no-store
的响应不会被缓存到任意的磁盘或者内存里,它才是真正的 no-cache
。
下面是对三个容易混淆的指令进行对比说明:
首部 | 描述 |
---|---|
Cache-Control: no-store | 不使用缓存。 |
Cache-Control: no-cache | 无论本地副本是否过期,都需要请求源服务器进行验证。 |
Cache-Control: must-revalidate | 如果本地副本未过期,可以使用本地副本;否则,需要请求源服务器进行验证。 |
CDN 缓存
HTTP 缓存 主要是对静态数据进行缓存,把从服务器拿到的数据缓存到客户端/浏览器。
如果在客户端和服务器之间再加上一层 CDN,可以让 CDN 为应用服务器提供缓存,如果在 CDN 上缓存,就不用再请求应用服务器了。并且 HTTP 缓存提到的两种策略同样可以在 CDN 服务器执行。
CDN 的全称是 Content Delivery Network,即内容分发网络。其目的是通过在现有的 Internet 中增加一层新的网络架构,将网站的内容发布到最接近用户的网络"边缘",使用户可以就近取得所需的内容,解决 Internet 网络拥塞状况,提高用户访问网站的响应速度。
从技术上全面解决由于网络带宽小、用户访问量大、网点分布不均等原因所造成的用户访问网站响应速度慢的问题。
CDN 分发系统架构
CDN 工作流程
用户访问某个站点的内容时,若该站点使用了 CDN 网络,则在用户会在域名解析时获得 CDN 网络 GSLB 设备的 IP 地址。GSLB 设备根据其预设的选择策略(如,地理区域、用户时间等)为用户选择最合适的内容缓存节点,并且使用某种方式(如,基于 DNS、基于 HTTP 重定向、基于 IP 欺骗的方式等)导引用户访问所选的内容缓存节点。
用户继续向缓存节点发出请求,若缓存中包含请求的内容,则直接返回给用户,否则从核心 Web 服务器中获取该内容,缓存后返回给用户。这样当用户再次访问相同内容或其他用户访问相同内容时,可以直接从缓存中读取,提高了效率。
负载均衡缓存
说完客户端(HTTP)缓存和 CDN 缓存,我们离应用服务越来越近了,在到达应用服务之前,请求还要经过负载均衡器。
虽说它的主要工作是对应用服务器进行负载均衡,但是它也可以作缓存。可以把一些修改频率不高的数据缓存在这里,例如:用户信息,配置信息。通过服务定期刷新这个缓存就行了。
以 Nginx 为例,我们看看它是如何工作的:
- 用户请求在达到应用服务器之前,会先访问 Nginx 负载均衡器,如果发现有缓存信息,直接返回给用户。
- 如果没有发现缓存信息,Nginx 回源到应用服务器获取信息。
- 另外,有一个缓存更新服务,定期把应用服务器中相对稳定的信息更新到 Nginx 本地缓存中。
进程内缓存
通过了客户端,CDN,负载均衡器,我们终于来到了应用服务器。应用服务器上部署着一个个应用,这些应用以进程的方式运行着,那么在进程中的缓存是怎样的呢?
进程内缓存又叫托管堆缓存,以 Java 为例,这部分缓存放在 JVM 的托管堆上面,同时会受到托管堆回收算法的影响。
由于其运行在内存中,对数据的响应速度很快,通常我们会把热点数据放在这里。
在进程内缓存没有命中的时候,我们会去搜索进程外的缓存或者分布式缓存。这种缓存的好处是没有序列化和反序列化,是最快的缓存。缺点是缓存的空间不能太大,对垃圾回收器的性能有影响。
目前比较流行的实现有 Ehcache、GuavaCache、Caffeine。这些架构可以很方便的把一些热点数据放到进程内的缓存中。
这里我们需要关注几个缓存的回收策略,具体的实现架构的回收策略会有所不同,但大致的思路都是一致的:
- FIFO(First In First Out):先进先出算法,最先放入缓存的数据最先被移除。
- LRU(Least Recently Used):最近最少使用算法,把最久没有使用过的数据移除缓存。
- LFU(Least Frequently Used):最不常用算法,在一段时间内使用频率最小的数据被移除缓存。
在分布式架构的今天,多应用中如果采用进程内缓存会存在数据一致性的问题。
解决方案
保障进程内缓存数据一致性,有以下几种方案
单节点通知修改方案
第一种方案,可以通过单节点通知其他节点。如上图:写请求发生在 server1,在修改完自己内存数据与数据库中的数据之后,可以主动通知其他 server 节点,也修改内存的数据。
这种方案的缺点是:同一功能的一个集群的多个节点,相互耦合在一起,特别是节点较多时,网状连接关系极其复杂。
消息队列修改方案
第二种方案,可以通过 MQ 通知其他节点。如上图,写请求发生在 server1,在修改完自己内存数据与数据库中的数据之后,给 MQ 发布数据变化通知,其他 server 节点订阅 MQ 消息,也修改内存数据。
这种方案虽然解除了节点之间的耦合,但引入了 MQ,使得系统更加复杂。
前两种方案,节点数量越多,数据冗余份数越多,数据同时更新的原子性越难保证,一致性也就越难保证。
Timer 修改方案
第三种方案,为了避免耦合,降低复杂性,干脆放弃了"实时一致性",每个节点启动一个 timer,定时从后端拉取最新的数据,更新内存缓存。在有节点更新后端数据,而其他节点通过 timer 更新数据之间,会读到脏数据。
应用场景
为什么不能频繁使用进程内缓存?
分层架构设计,有一条准则:站点层、服务层要做到无数据无状态,这样才能任意的加节点水平扩展,数据和状态尽量存储到后端的数据存储服务,例如数据库服务或者缓存服务。
可以看到,站点与服务的进程内缓存,实际上违背了分层架构设计的无状态准则,故一般不推荐使用。
那么什么情况下可以考虑使用进程内缓存?
情况一,只读数据,可以考虑在进程启动时加载到内存。
其实此时也可以把数据加载到 redis / memcache,进程外缓存服务也能解决这类问题。
情况二,极高并发,如果透传后端压力极大的场景,可以考虑使用进程内缓存。
例如,秒杀业务,并发量极高,需要站点层挡住流量,可以使用内存缓存。
情况三,一定程度上允许数据不一致业务。
例如,有一些计数场景,运营场景,页面对数据一致性要求较低,可以考虑使用进程内页面缓存。
再次强调下,进程内缓存的适用场景并不如 redis/memcache 广泛,不要为了炫技而使用。
分布式缓存
与进程内缓存相对应的就是进程外缓存,它拥有更大的缓存容量,并且可以部署到不同的物理节点,通常会用分布式缓存的方式实现。
分布式缓存是与应用分离的缓存服务,最大的特点是,自身是一个独立的应用/服务,与本地应用隔离,多个应用可直接共享一个或者多个缓存应用/服务。
既然是分布式缓存,缓存的数据会分布到不同的缓存节点上,每个缓存节点缓存的数据大小通常也是有限制的。
数据被缓存到不同的节点,为了能方便的访问这些节点,需要引入缓存代理,类似 Twemproxy。他会帮助请求找到对应的缓存节点。
同时如果缓存节点增加了,这个代理也会只能识别并且把新的缓存数据分片到新的节点,做横向的扩展。
为了提高缓存的可用性,会在原有的缓存节点上加入 Master/Slave 的设计。当缓存数据写入 Master 节点的时候,会同时同步一份到 Slave 节点。
一旦 Master 节点失效,可以通过代理直接切换到 Slave 节点,这时 Slave 节点就变成了 Master 节点,保证缓存的正常工作。
每个缓存节点还会提供缓存过期的机制,并且会把缓存内容定期以快照的方式保存到文件上,方便缓存崩溃之后启动预热加载。
当缓存做成分布式的时候,数据会根据一定的规律分配到每个缓存应用/服务上,需要缓存的数据量比较大就需要扩展多个缓存节点来实现,这么多的缓存节点,客户端的请求不知道访问哪个节点怎么办?缓存的数据又如何放到这些节点上?
下面介绍三种缓存数据分片的算法,有了这些算法缓存代理就可以方便的找到分片的数据
哈希分片
Hash
分片的算法就是对缓存的 Key
做哈希计算,然后对总的缓存节点个数取余。你可以这么理解:
比如说,我们部署了三个缓存节点组成一个缓存的集群,当有新的数据要写入时,我们先对这个缓存的 Key
做比如 crc32
等 Hash
算法生成 Hash
值,然后对 Hash
值模 3,得出的结果就是要存入缓存节点的序号。
这个算法最大的优点就是简单易理解,缺点是当增加或者减少缓存节点时,缓存总的节点个数变化造成计算出来的节点发生变化,从而造成缓存失效不可用。
所以如果采用这种方法,最好建立在你对于这组缓存命中率下降不敏感,比如下面还有另外一层缓存来兜底的情况下。那有没有更好的方案能解决这个问题呢?那就是一致性 Hash
分片算法。
一致性哈希分片
用一致性 Hash
算法可以很好地解决增加和删减节点时,命中率下降的问题。在这个算法中,我们将整个 Hash
值空间组织成一个虚拟的圆环,然后将缓存节点的 IP
地址或者主机名做 Hash
取值后,放置在这个圆环上。当我们需要确定某一个 Key
需要存取到哪个节点上的时候,先对这个 Key
做同样的 Hash 取值,确定在环上的位置,然后按照顺时针方向在环上"行走",遇到的第一个缓存节点就是要访问的节点。比方说下面这张图里面,Key 1
和 Key 2
会落入到 Node 1
中,Key 3
、Key 4
会落入到 Node 2
中,Key 5
落入到 Node 3
中,Key 6
落入到 Node 4
中。
这时如果在 Node 1
和 Node 2
之间增加一个 Node 5
,你可以看到原本命中 Node 2
的 Key 3
现在命中到 Node 5
,而其它的 Key
都没有变化;同样的道理,如果我们把 Node 3
从集群中移除,那么只会影响到 Key 5
。所以你看,在增加和删除节点时,只有少量的 Key
会 漂移 到其它节点上,而大部分的 Key
命中的节点还是会保持不变,从而可以保证命中率不会大幅下降。
不过,事物总有两面性。虽然这个算法对命中率的影响比较小,但它还是存在问题:
- 缓存节点在圆环上分布不平均,会造成部分缓存节点的压力较大;
- 当某个节点故障时,这个节点所要承担的所有访问都会被顺移到另一个节点上,会对后面这个节点造成压力。
一致性哈希的脏数据问题
极端情况下,比如一个有三个节点 A、B、C
承担整体的访问,每个节点的访问量平均,A
故障后,B
将承担双倍的压力(A 和 B 的全部请求),当 B
承担不了流量 Crash
后,C
也将因为要承担原先三倍的流量而 Crash
,这就造成了整体缓存系统的雪崩。
解决方案就是在一致性 Hash
算法中引入虚拟节点的概念,它将一个缓存节点计算多个 Hash
值分散到圆环的不同位置,这样既实现了数据的平均,而且当某一个节点故障或者退出的时候,它原先承担的 Key
将以更加平均的方式分配到其他节点上,从而避免雪崩的发生。
其次,就是一致性 Hash
算法的脏数据问题。为什么会产生脏数据呢? 比方说,在集群中有两个节点 A
和 B
,客户端初始写入一个 Key
为 k,值为 3
的缓存数据到 Cache A
中。这时如果要更新 k
的值为 4
,但是缓存 A
恰好和客户端连接出现了问题,那这次写入请求会写入到 Cache B
中。接下来缓存 A
和客户端的连接恢复,当客户端要获取 k
的值时,就会获取到存在 Cache A
中的脏数据 3,而不是 Cache B
中的 4
。
所以,在使用一致性 Hash
算法时一定要设置缓存的过期时间,这样当发生漂移时,之前存储的脏数据可能已经过期,就可以减少存在脏数据的几率。
很显然,数据分片最大的优势就是缓解缓存节点的存储和访问压力,但同时它 也让缓存的使用 更加复杂。在 MultiGet(批量获取)
场景下,单个节点的访问量并没有减少,同时节点数太多会造成缓存访问的 SLA
(即"服务等级协议",SLA
代表了网站服务可用性)得不到很好的保证,因为根据木桶原则,SLA
取决于最慢、最坏的节点的情况,节点数过多也会增加出问题的概率,因此我推荐 4
到 6
个节点为佳。
按照数据范围分片
常见场景就是按照 时间区间
或 ID区间
来切分。例如:按日期将不同月甚至是日的数据分散到不同的库中;将 userId
为 1~9999
的记录分到第一个库, 10000~20000
的分到第二个库,以此类推。某种意义上,某些系统中使用的 "冷热数据分离" ,将一些使用较少的历史数据迁移到其他库中,业务功能上只提供热点数据的查询,也是类似的实践。
这样的优点在于:
- 单表大小可控
- 天然便于水平扩展,后期如果想对整个分片集群扩容时,只需要添加节点即可,无需对其他分片的数据进行迁移
- 使用分片字段进行范围查找时,连续分片可快速定位分片进行快速查询,有效避免跨分片查询的问题。
缺点:
- 热点数据成为性能瓶颈。连续分片可能存在数据热点,例如按时间字段分片,有些分片存储最近时间段内的数据,可能会被频繁的读写,而有些分片存储的历史数据,则很少被查询
参考链接
- 一篇文章让你明白你多级缓存的分层架构 - 掘金 (juejin.cn)
- 分布式多级缓存 SDK 设计的思考-腾讯云开发者社区-腾讯云 (tencent.com)
- 多级缓存架构设计 - 秦羽的思考 - 博客园 (cnblogs.com)
- HTTP 缓存 - HTTP | MDN (mozilla.org)
- 30 分钟搞懂 HTTP 缓存 - 掘金 (juejin.cn)
- 【CDN 最佳实践】CDN 缓存策略解读和配置策略 - 知乎 (zhihu.com)
- CDN 图解(秒懂 + 史上最全) - 疯狂创客圈 - 博客园 (cnblogs.com)
- 009.Nginx 缓存配置 - 木二 - 博客园
- 进程内缓存,究竟怎么玩?-阿里云开发者社区 (aliyun.com)
- Hash 分片,一致性 Hash 分片和按照数据范围分片三种常用的数据分片方式-腾讯云开发者社区-腾讯云 (tencent.com)
本文由博客一文多发平台 OpenWrite 发布!