多级缓存架构的分层设计

前言

在互联网高速发展的今天,缓存技术被广泛地应用。无论业内还是业外,只要是提到性能问题,大家都会脱口而出“用缓存解决”。

这种说法带有片面性,甚至是一知半解,但是作为专业人士的我们,需要对缓存有更深、更广的了解。

缓存技术存在于应用场景的方方面面。从浏览器请求,到反向代理服务器,从进程内缓存到分布式缓存。其中缓存策略,算法也是层出不穷,今天就带大家走进缓存。

正文

缓存对于每个开发者来说是相当熟悉了,为了提高程序的性能我们会去加缓存,但是在什么地方加缓存,如何加缓存呢?

假设一个网站,需要提高性能,缓存可以放在浏览器,可以放在反向代理服务器,还可以放在应用程序进程内,同时可以放在分布式缓存系统中。

 

Android 多级预缓存 多级缓存设计_多级缓存设计

 

从用户请求数据到数据返回,数据经过了浏览器,CDN,代理服务器,应用服务器,以及数据库各个环节。每个环节都可以运用缓存技术。

从浏览器/客户端开始请求数据,通过 HTTP 配合 CDN 获取数据的变更情况,到达代理服务器(Nginx)可以通过反向代理获取静态资源。

再往下来到应用服务器可以通过进程内(堆内)缓存,分布式缓存等递进的方式获取数据。如果以上所有缓存都没有命中数据,才会回源到数据库。

缓存的请求顺序是:用户请求 → HTTP 缓存 → CDN 缓存 → 代理服务器缓存 → 进程内缓存 → 分布式缓存 → 数据库。

看来在技术的架构每个环节都可以加入缓存,看看每个环节是如何应用缓存技术的。

1. HTTP缓存

当用户通过浏览器请求服务器的时候,会发起 HTTP 请求,如果对每次 HTTP 请求进行缓存,那么可以减少应用服务器的压力。

当第一次请求的时候,浏览器本地缓存库没有缓存数据,会从服务器取数据,并且放到浏览器的缓存库中,下次再进行请求的时候会根据缓存的策略来读取本地或者服务的信息。

 

Android 多级预缓存 多级缓存设计_系统架构_02

 

一般信息的传递通过 HTTP 请求头 Header 来传递。目前比较常见的缓存方式有两种,分别是:

  • 强制缓存
  • 对比缓存

1.1. 强制缓存

当浏览器本地缓存库保存了缓存信息,在缓存数据未失效的情况下,可以直接使用缓存数据。否则就需要重新获取数据。

这种缓存机制看上去比较直接,那么如何判断缓存数据是否失效呢?这里需要关注 HTTP Header 中的两个字段 Expires 和 Cache-Control。

Expires 为服务端返回的过期时间,客户端第一次请求服务器,服务器会返回资源的过期时间。如果客户端再次请求服务器,会把请求时间与过期时间做比较。

如果请求时间小于过期时间,那么说明缓存没有过期,则可以直接使用本地缓存库的信息。

反之,说明数据已经过期,必须从服务器重新获取信息,获取完毕又会更新最新的过期时间。

这种方式在 HTTP 1.0 用的比较多,到了 HTTP 1.1 会使用 Cache-Control 替代。

Cache-Control 中有个 max-age 属性,单位是秒,用来表示缓存内容在客户端的过期时间。

例如:max-age 是 60 秒,当前缓存没有数据,客户端第一次请求完后,将数据放入本地缓存。

那么在 60 秒以内客户端再发送请求,都不会请求应用服务器,而是从本地缓存中直接返回数据。如果两次请求相隔时间超过了 60 秒,那么就需要通过服务器获取数据。

1.2. 对比缓存

需要对比前后两次的缓存标志来判断是否使用缓存。浏览器第一次请求时,服务器会将缓存标识与数据一起返回,浏览器将二者备份至本地缓存库中。浏览器再次请求时,将备份的缓存标识发送给服务器。

服务器根据缓存标识进行判断,如果判断数据没有发生变化,把判断成功的 304 状态码发给浏览器。

这时浏览器就可以使用缓存的数据来。服务器返回的就只是 Header,不包含 Body。

下面介绍两种标识规则:

1.2.1. Last-Modified/If-Modified-Since 规则

在客户端第一次请求的时候,服务器会返回资源最后的修改时间,记作 Last-Modified。客户端将这个字段连同资源缓存起来。

Last-Modified 被保存以后,在下次请求时会以 Last-Modified-Since 字段被发送。

 

Android 多级预缓存 多级缓存设计_nginx_03

 

当客户端再次请求服务器时,会把 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。浏览器接受到这个消息就可以使用本地缓存库的数据。

 

Android 多级预缓存 多级缓存设计_redis_04

 

注意:Last-Modified 和 If-Modified-Since 指的是同一个值,只是在客户端和服务器端的叫法不同。

1.2.2. ETag / If-None-Match 规则

客户端第一次请求的时候,服务器会给每个资源生成一个 ETag 标记。这个 ETag 是根据每个资源生成的唯一 Hash 串,资源如何发生变化 ETag 随之更改,之后将这个 ETag 返回给客户端,客户端把请求的资源和 ETag 都缓存到本地。

ETag 被保存以后,在下次请求时会当作 If-None-Match 字段被发送出去。

 

Android 多级预缓存 多级缓存设计_Android 多级预缓存_05

 

在浏览器第二次请求服务器相同资源时,会把资源对应的 ETag 一并发送给服务器。在请求时 ETag 转化成 If-None-Match,但其内容不变。

服务器收到请求后,会把 If-None-Match 与服务器上资源的 ETag 进行比较:

  • 如果不一致,说明资源被改动过,则返回资源(Header+Body),返回状态码 200。
  • 如果一致,说明资源没有被改过,则返回 Header,返回状态码 304。浏览器接受到这个消息就可以使用本地缓存库的数据。

 

Android 多级预缓存 多级缓存设计_redis_06

 

注意:ETag 和 If-None-Match 指的是同一个值,只是在客户端和服务器端的叫法不同。

2. CDN 缓存

HTTP 缓存主要是对静态数据进行缓存,把从服务器拿到的数据缓存到客户端/浏览器。

如果在客户端和服务器之间再加上一层 CDN,可以让 CDN 为应用服务器提供缓存,如果在 CDN 上缓存,就不用再请求应用服务器了。并且 HTTP 缓存提到的两种策略同样可以在 CDN 服务器执行。

CDN 的全称是 Content Delivery Network,即内容分发网络。

 

Android 多级预缓存 多级缓存设计_多级缓存设计_07

 

让我们来看看它是如何工作的吧:

  • 客户端发送 URL 给 DNS 服务器。
  • DNS 通过域名解析,把请求指向 CDN 网络中的 DNS 负载均衡器。
  • DNS 负载均衡器将最近 CDN 节点的 IP 告诉 DNS,DNS 告之客户端最新 CDN 节点的 IP。
  • 客户端请求最近的 CDN 节点。
  • CDN 节点从应用服务器获取资源返回给客户端,同时将静态信息缓存。注意:客户端下次互动的对象就是 CDN 缓存了,CDN 可以和应用服务器同步缓存信息。

CDN 接受客户端的请求,它就是离客户端最近的服务器,它后面会链接多台服务器,起到了缓存和负载均衡的作用。

3. 负载均衡缓存

说完客户端(HTTP)缓存和 CDN 缓存,我们离应用服务越来越近了,在到达应用服务之前,请求还要经过负载均衡器。

虽说它的主要工作是对应用服务器进行负载均衡,但是它也可以作缓存。可以把一些修改频率不高的数据缓存在这里,例如:用户信息,配置信息。通过服务定期刷新这个缓存就行了。

 

Android 多级预缓存 多级缓存设计_系统架构_08

 

以 Nginx 为例,我们看看它是如何工作的:

  • 用户请求在达到应用服务器之前,会先访问 Nginx 负载均衡器,如果发现有缓存信息,直接返回给用户。
  • 如果没有发现缓存信息,Nginx 回源到应用服务器获取信息。
  • 另外,有一个缓存更新服务,定期把应用服务器中相对稳定的信息更新到 Nginx 本地缓存中。

4. 进程内缓存

通过了客户端,CDN,负载均衡器,我们终于来到了应用服务器。应用服务器上部署着一个个应用,这些应用以进程的方式运行着,那么在进程中的缓存是怎样的呢?

进程内缓存又叫托管堆缓存,以 Java 为例,这部分缓存放在 JVM 的托管堆上面,同时会受到托管堆回收算法的影响。

由于其运行在内存中,对数据的响应速度很快,通常我们会把热点数据放在这里。

在进程内缓存没有命中的时候,我们会去搜索进程外的缓存或者分布式缓存。这种缓存的好处是没有序列化和反序列化,是最快的缓存。缺点是缓存的空间不能太大,对垃圾回收器的性能有影响。

目前比较流行的实现有 Ehcache、GuavaCache、Caffeine。这些架构可以很方便的把一些热点数据放到进程内的缓存中。

这里我们需要关注几个缓存的回收策略,具体的实现架构的回收策略会有所不同,但大致的思路都是一致的:

  • FIFO(First In First Out):先进先出算法,最先放入缓存的数据最先被移除。
  • LRU(Least Recently Used):最近最少使用算法,把最久没有使用过的数据移除缓存。
  • LFU(Least Frequently Used):最不常用算法,在一段时间内使用频率最小的数据被移除缓存。

在分布式架构的今天,多应用中如果采用进程内缓存会存在数据一致性的问题。

这里推荐两个方案:

  • 消息队列修改方案
  • Timer 修改方案

4.1. 消息队列修改方案

应用在修改完自身缓存数据和数据库数据之后,给消息队列发送数据变化通知,其他应用订阅了消息通知,在收到通知的时候修改缓存数据。

 

Android 多级预缓存 多级缓存设计_redis_09

 

4.2. Timer 修改方案

为了避免耦合,降低复杂性,对“实时一致性”不敏感的情况下。每个应用都会启动一个 Timer,定时从数据库拉取最新的数据,更新缓存。

不过在有的应用更新数据库后,其他节点通过 Timer 获取数据之间,会读到脏数据。这里需要控制好 Timer 的频率,以及应用与对实时性要求不高的场景。

 

Android 多级预缓存 多级缓存设计_Android 多级预缓存_10

 

进程内缓存有哪些使用场景呢?

  • 场景一:只读数据,可以考虑在进程启动时加载到内存。当然,把数据加载到类似 Redis 这样的进程外缓存服务也能解决这类问题。
  • 场景二:高并发,可以考虑使用进程内缓存,例如:秒杀。

5. 分布式缓存

说完进程内缓存,自然就过度到进程外缓存了。与进程内缓存不同,进程外缓存在应用运行的进程之外,它拥有更大的缓存容量,并且可以部署到不同的物理节点,通常会用分布式缓存的方式实现。

分布式缓存是与应用分离的缓存服务,最大的特点是,自身是一个独立的应用/服务,与本地应用隔离,多个应用可直接共享一个或者多个缓存应用/服务。

 

Android 多级预缓存 多级缓存设计_Android 多级预缓存_11

 

既然是分布式缓存,缓存的数据会分布到不同的缓存节点上,每个缓存节点缓存的数据大小通常也是有限制的。

数据被缓存到不同的节点,为了能方便的访问这些节点,需要引入缓存代理,类似 Twemproxy。他会帮助请求找到对应的缓存节点。

同时如果缓存节点增加了,这个代理也会只能识别并且把新的缓存数据分片到新的节点,做横向的扩展。

为了提高缓存的可用性,会在原有的缓存节点上加入 Master/Slave 的设计。当缓存数据写入 Master 节点的时候,会同时同步一份到 Slave 节点。

一旦 Master 节点失效,可以通过代理直接切换到 Slave 节点,这时 Slave 节点就变成了 Master 节点,保证缓存的正常工作。

每个缓存节点还会提供缓存过期的机制,并且会把缓存内容定期以快照的方式保存到文件上,方便缓存崩溃之后启动预热加载。

5.1. 高性能

当缓存做成分布式的时候,数据会根据一定的规律分配到每个缓存应用/服务上。

如果我们把这些缓存应用/服务叫做缓存节点,每个节点一般都可以缓存一定容量的数据,例如:Redis 一个节点可以缓存 2G 的数据。

如果需要缓存的数据量比较大就需要扩展多个缓存节点来实现,这么多的缓存节点,客户端的请求不知道访问哪个节点怎么办?缓存的数据又如何放到这些节点上?

缓存代理服务已经帮我们解决这些问题了,例如:Twemproxy 不但可以帮助缓存路由,同时可以管理缓存节点。

这里有介绍三种缓存数据分片的算法,有了这些算法缓存代理就可以方便的找到分片的数据了。

5.1.1. 哈希算法

Hash 表是最常见的数据结构,实现方式是,对数据记录的关键值进行 Hash,然后再对需要分片的缓存节点个数进行取模得到的余数进行数据分配。

例如:有三条记录数据分别是 R1,R2,R3。他们的 ID 分别是 01,02,03,假设对这三个记录的 ID 作为关键值进行 Hash 算法之后的结果依旧是 01,02,03。

我们想把这三条数据放到三个缓存节点中,可以把这个结果分别对 3 这个数字取模得到余数,这个余数就是这三条记录分别放置的缓存节点。

 

Android 多级预缓存 多级缓存设计_Android 多级预缓存_12

 

Hash 算法是某种程度上的平均放置,策略比较简单,如果要增加缓存节点,对已经存在的数据会有较大的变动。

5.1.2. 一致性哈希算法

一致性 Hash 是将数据按照特征值映射到一个首尾相接的 Hash 环上,同时也将缓存节点映射到这个环上。

如果要缓存数据,通过数据的关键值(Key)在环上找到自己存放的位置。这些数据按照自身的 ID 取 Hash 之后得到的值按照顺序在环上排列。

 

Android 多级预缓存 多级缓存设计_多级缓存设计_13

 

如果这个时候要插入一条新的数据其 ID 是 115,那么就应该插入到如下图的位置。

 

Android 多级预缓存 多级缓存设计_redis_14

 

同理如果要增加一个缓存节点 N4 150,也可以放到如下图的位置。

 

Android 多级预缓存 多级缓存设计_nginx_15

 

这种算法对于增加缓存数据,和缓存节点的开销相对比较小。

5.1.3. Range Based 算法

这种方式是按照关键值(例如 ID)将数据划分成不同的区间,每个缓存节点负责一个或者多个区间。跟一致性哈希有点像。

例如:存在三个缓存节点分别是 N1,N2,N3。他们用来存放数据的区间分别是,N1(0, 100], N2(100, 200], N3(300, 400]。

那么数据根据自己 ID 作为关键字做 Hash 以后的结果就会分别对应放到这几个区域里面了。

5.2. 可用性

根据事物的两面性,在分布式缓存带来高性能的同时,我们也需要重视它的可用性。那么哪些潜在的风险是我们需要防范的呢?

5.2.1. 缓存雪崩

当缓存失效,缓存过期被清除,缓存更新的时候。请求是无法命中缓存的,这个时候请求会直接回源到数据库。

如果上述情况频繁发生或者同时发生的时候,就会造成大面积的请求直接到数据库,造成数据库访问瓶颈。我们称这种情况为缓存雪崩。

从如下两方面来思考解决方案:

缓存方面:

  • 避免缓存同时失效,不同的 key 设置不同的超时时间。
  • 增加互斥锁,对缓存的更新操作进行加锁保护,保证只有一个线程进行缓存更新。缓存一旦失效可以通过缓存快照的方式迅速重建缓存。对缓存节点增加主备机制,当主缓存失效以后切换到备用缓存继续工作。

设计方面,这里给出了几点建议供大家参考:

  • 熔断机制:某个缓存节点不能工作的时候,需要通知缓存代理不要把请求路由到该节点,减少用户等待和请求时长。
  • 限流机制:在接入层和代理层可以做限流,当缓存服务无法支持高并发的时候,前端可以把无法响应的请求放入到队列或者丢弃。
  • 隔离机制:缓存无法提供服务或者正在预热重建的时候,把该请求放入队列中,这样该请求因为被隔离就不会被路由到其他的缓存节点。
  • 如此就不会因为这个节点的问题影响到其他节点。当缓存重建以后,再从队列中取出请求依次处理。

5.2.2. 缓存穿透

缓存一般是 Key,Value 方式存在,一个 Key 对应的 Value 不存在时,请求会回源到数据库。

假如对应的 Value 一直不存在,则会频繁的请求数据库,对数据库造成访问压力。如果有人利用这个漏洞攻击,就麻烦了。

解决方法:如果一个 Key 对应的 Value 查询返回为空,我们仍然把这个空结果缓存起来,如果这个值没有变化下次查询就不会请求数据库了。

将所有可能存在的数据哈希到一个足够大的 Bitmap 中,那么不存在的数据会被这个 Bitmap 过滤器拦截掉,避免对数据库的查询压力。

5.2.3. 缓存击穿

在数据请求的时候,某一个缓存刚好失效或者正在写入缓存,同时这个缓存数据可能会在这个时间点被超高并发请求,成为“热点”数据。

这就是缓存击穿问题,这个和缓存雪崩的区别在于,这里是针对某一个缓存,前者是针对多个缓存。

解决方案:导致问题的原因是在同一时间读/写缓存,所以只有保证同一时间只有一个线程写,写完成以后,其他的请求再使用缓存就可以了。

比较常用的做法是使用 mutex(互斥锁)。在缓存失效的时候,不是立即写入缓存,而是先设置一个 mutex(互斥锁)。当缓存被写入完成以后,再放开这个锁让请求进行访问。

小结

总结一下,缓存设计有五大策略,从用户请求开始依次是:

  • HTTP 缓存
  • CDN 缓存
  • 负载均衡缓存
  • 进程内缓存
  • 分布式缓存

其中,前两种缓存静态数据,后三种缓存动态数据:

  • HTTP 缓存包括强制缓存和对比缓存。
  • CDN 缓存和 HTTP 缓存是好搭档。
  • 负载均衡器缓存相对稳定资源,需要服务协助工作。
  • 进程内缓存,效率高,但容量有限制,有两个方案可以应对缓存同步的问题。
  • 分布式缓存容量大,能力强,牢记三个性能算法并且防范三个缓存风险。

 

 

 

大流量下多级缓存设计

什么是多级缓存

所谓多级缓存,即在整个系统架构的不同系统层级进行数据缓存,以提升访问效率,这也是应用最广的方案之一。我们应用的整体架构如图1所示:

 

Android 多级预缓存 多级缓存设计_系统架构_16

图1 多级缓存方案

 

整体流程如上图所示:

1)首先接入Nginx将请求负载均衡到应用Nginx,此处常用的负载均衡算法是轮询或者一致性哈希,轮询可以使服务器的请求更加均衡,而一致性哈希可以提升应用Nginx的缓存命中率,相对于轮询,一致性哈希会存在单机热点问题,一种解决办法是热点直接推送到接入层Nginx,一种办法是设置一个阀值,当超过阀值,改为轮询算法。

2)接着应用Nginx读取本地缓存(本地缓存可以使用Lua Shared Dict、Nginx Proxy Cache(磁盘/内存)、Local Redis实现),如果本地缓存命中则直接返回,使用应用Nginx本地缓存可以提升整体的吞吐量,降低后端的压力,尤其应对热点问题非常有效。

3)如果Nginx本地缓存没命中,则会读取相应的分布式缓存(如Redis缓存,另外可以考虑使用主从架构来提升性能和吞吐量),如果分布式缓存命中则直接返回相应数据(并回写到Nginx本地缓存)。

4)如果分布式缓存也没有命中,则会回源到Tomcat集群,在回源到Tomcat集群时也可以使用轮询和一致性哈希作为负载均衡算法。

5)在Tomcat应用中,首先读取本地堆缓存,如果有则直接返回(并会写到主Redis集群),为什么要加一层本地堆缓存将在缓存崩溃与快速修复部分细聊。

6)作为可选部分,如果步骤4没有命中可以再尝试一次读主Redis集群操作。目的是防止当从有问题时的流量冲击。

7)如果所有缓存都没有命中只能查询DB或相关服务获取相关数据并返回。

8)步骤7返回的数据异步写到主Redis集群,此处可能多个Tomcat实例同时写主Redis集群,可能造成数据错乱,如何解决该问题将在更新缓存与原子性部分细聊。

应用整体分了三部分缓存:应用Nginx本地缓存、分布式缓存、Tomcat堆缓存,每一层缓存都用来解决相关的问题,如应用Nginx本地缓存用来解决热点缓存问题,分布式缓存用来减少访问回源率、Tomcat堆缓存用于防止相关缓存失效/崩溃之后的冲击。

 

虽然就是加缓存,但是怎么加,怎么用细想下来还是有很多问题需要权衡和考量的,接下来部分我们就详细来讨论一些缓存相关的问题。

如何缓存数据

接下来部将从缓存过期、维度化缓存、增量缓存、大Value缓存、热点缓存几个方面来详细介绍如何缓存数据。

过期与不过期

对于缓存的数据我们可以考虑不过期缓存和带过期时间缓存,什么场景应该选择哪种模式需要根据业务和数据量等因素来决定。

 

不过期缓存场景一般思路如图2所示:

Android 多级预缓存 多级缓存设计_redis_17

图2不过期缓存方案

 

使用Cache-Aside模式,首先写数据库,如果成功,则写缓存。这种场景下存在事务成功、缓存写失败但无法回滚事务的情况。另外,不要把写缓存放在事务中,尤其写分布式缓存,因为网络抖动可能导致写缓存响应时间很慢,引起数据库事务阻塞。如果对缓存数据一致性要求不是那么高,数据量也不是很大,则可以考虑定期全量同步缓存。

 

也有提到如下思路:先删缓存,然后执行数据库事务;不过这种操作对于如商品这种查询非常频繁的业务不适用,因为在你删缓存的同时,已经有另一个系统来读缓存了,此时事务还没有提交。当然对于如用户维度的业务是可以考虑的。

 

不过为了更好地解决以上多个事务的问题,可以考虑使用订阅数据库日志的架构,如使用canal订阅mysql的binlog实现缓存同步。

 

对于长尾访问的数据、大多数数据访问频率都很高的场景、缓存空间足够都可以考虑不过期缓存,比如用户、分类、商品、价格、订单等,当缓存满了可以考虑LRU机制驱逐老的缓存数据。

 

1. 过期缓存机制

即采用懒加载,一般用于缓存别的系统的数据(无法订阅变更消息、或者成本很高)、缓存空间有限、低频热点缓存等场景;常见步骤是:首先读取缓存如果不命中则查询数据,然后异步写入缓存并过期缓存,设置过期时间,下次读取将命中缓存。热点数据经常使用即在应用系统上缓存比较短的时间。这种缓存可能存在一段时间的数据不一致情况,需要根据场景来决定如何设置过期时间。如库存数据可以在前端应用上缓存几秒钟,短时间的不一致时可以忍受的。

 

2.   维度化缓存与增量缓存

对于电商系统,一个商品可能拆成如基础属性、图片列表、上下架、规格参数、商品介绍等;如果商品变更了要把这些数据都更新一遍那么整个更新成本很高:接口调用量和带宽;因此最好将数据进行维度化并增量更新(只更新变的部分)。尤其如上下架这种只是一个状态变更,但是每天频繁调用的,维度化后能减少服务很大的压力。

Android 多级预缓存 多级缓存设计_redis_18

图3 维度化缓存方案

按照不同维度接收MQ进行更新。

 

3.   大Value 缓存

要警惕缓存中的大Value,尤其是使用Redis时。遇到这种情况时可以考虑使用多线程实现的缓存,如Memcached,来缓存大Value;或者对Value进行压缩;或者将Value拆分为多个小Value,客户端再进行查询、聚合。

 

4.   热点缓存

对于那些访问非常频繁的热点缓存,如果每次都去远程缓存系统中获取,可能会因为访问量太大导致远程缓存系统请求过多、负载过高或者带宽过高等问题,最终可能导致缓存响应慢,使客户端请求超时。一种解决方案是通过挂更多的从缓存,客户端通过负载均衡机制读取从缓存系统数据。不过也可以在客户端所在的应用/ 代理层本地存储一份,从而避免访问远程缓存,即使像库存这种数据,在有些应用系统中也可以进行几秒钟的本地缓存,从而降低远程系统的压力。

 

 

 

千万级并发!如何设计一个多级缓存系统?

 

首先我们需要明白,什么是一个多级缓存系统,它有什么用。所谓多级缓存系统,就是指在一个系统 的不同的架构层级进行数据缓存,以提升访问效率。

 

我们都知道,一个缓存系统,它面临着许多问题,比如缓存击穿,缓存穿透,缓存雪崩,缓存热点等等问题,那么,对于一个多级缓存系统,它有什么问题呢?

 

缓存热点:多级缓存系统大多应用在高并发场景下,所以我们需要解决热点Key问题,如何探测热点key?

数据一致性:各层缓存之间的数据一致性问题,如应用层缓存和分布式缓存之前的数据一致性问题。

缓存过期:缓存数据可以分为两大类,过期缓存和不过期缓存?如何设计,如何设计过期缓存?

 

在这之前,我们先看看一个简单的多级缓存系统的架构图:

Android 多级预缓存 多级缓存设计_多级缓存设计_19

整个多级缓存系统被分为三层,应用层nginx缓存,分布式redis缓存集群,tomcat堆内缓存。整个架构流程如下:

 

当接收到一个请求时,首先会分发到nginx集群中,这里可以采用nginx的负载均衡算法分发给某一台机器,使用轮询可以降低负载,或者采用一致性hash算法来提升缓存命中率。

 

当nginx层没有缓存数据时,会继续向下请求,在分布式缓存集群中查找数据,如果缓存命中,直接返回(并且写入nginx应用缓存中),如果未命中,则回源到tomcat集群中查询堆内缓存。

 

在分布式缓存中查询不到数据,将会去tomcat集群中查询堆内缓存,查询成功直接返回(并写入分redis主集群中),查询失败请求数据库;堆内缓存。

如果以上缓存中都没有命中,则直接请求数据库,返回结果,同步数据到分布式缓存中。

 

在简单了解了多级缓存的基本架构之后,我们就该思考如何解决上面提到的一系列问题。

 

缓存热点

 

缓存热点,是一个很常见的问题,比如“某某明星宣布结婚”等等,都可能产生大量请求访问的问题,一个最麻烦也是最容易让人忽视的事情就是如何探测到热点key,在缓存系统中,除了一些常用的热点key外,在某些特殊场合下也会出现大量的热点key,我们该如何发现呢?有以下策略:

 

数据调研。可以分析历史数据以及针对不同的场合去预测出热点key,这种方式虽然不能百分百使得缓存命中,但是却是一种最简单和节省成本的方案。

 

实时计算。可以使用现有的实时计算框架,比如storm、spark streaming、flink等框架统计一个时间段内的请求量,从而判断热点key。或者也可以自己实现定时任务去统计请求量。

 

这里我们着重讨论一下第二种解决方案,对于热点key问题,当缓存系统中没有发现缓存时,需要去数据库中读取数据,当大量请求来的时候,一个请求获取锁去请求数据库,其他阻塞,接着全部去访问缓存,这样可能因为一台服务器撑不住从而宕机,比如正常一台服务器并发量为5w左右,产生热点key的时候达到了10w甚至20w,这样服务器肯定会崩。所以我们在发现热点key之后还需要做到如何自动负载均衡。

 

结合以上问题我们重新设计架构,如下图所示:

Android 多级预缓存 多级缓存设计_Android 多级预缓存_20

我们将整个应用架构分为应用层,分布式缓存、系统层以及数据层。

 

在应用层,我们采用nginx集群,并且对接实时计算链路,通过flume监控nginx日志,将数据传输到kafka集群中,然后flink集群消费数据进行统计,如果统计 结果为热点key,则将数据写入zookeeper的节点中,而应用系统通过监控znode节点,读取热点key数据,去数据库中加载数据到缓存中并且做到负载均衡。

 

实际上,对于应用系统中的每一台服务器,还需要一层防护机制,限流熔断,这样做的目的是为了防止单台机器请求量过高,使得服务器负载过高,不至于服务器宕机或者大量请求访问数据库。简单思路就是为每一台服务器设计一个阀值,当请求量大于该值就直接返回用户空白页面或者提示用户几秒后刷新重新访问。

 

数据一致性

 

数据一致性问题主要体现在缓存更新的时候,如何更新缓存,保证数据库与缓存以及各层缓存层之间的一致性。

 

对于缓存更新问题,先写缓存还是先写数据库,这里省略若干字。之前的文章介绍过,有兴趣的读者可以翻阅。

 

在单层缓存系统中,我们可以先删除缓存然后更新数据库的方案来解决其数据一致性问题,那么对于多级缓存呢?如果使用这种方案,我们需要考虑,如果先删除缓存,那么需要逐层去做删除操作,那么这一系列操作对系统带来的耗时也是和可观的。

 

如果我们使用分布式事务机制,就需要考虑该不该将写缓存放入事务当中,因为我们更新分布式缓存,需要走网络通信,大量的请求将导致网路抖动甚至阻塞,增加了系统的延迟,导致系统短时间内不可用。如果我们不将写缓存这一操作放入事务当中,那么可能引起短时间内数据不一致。这也就是分布式系统的CAP理论,我们不能同时达到高可用和一致性。那么该如何抉择呢?

 

这里我们选择保证系统的可用性,就一个秒杀系统来讲,短暂的不一致性问题对用户的体验影响并不大(当然,这里不涉及支付系统),而可用性对用户来说却很重要,一个活动可能在很短的时间内结束,而用户需要在这段时间内抢到自己心仪的商品,所以可用性更重要一些(这里需要根据具体场景进行权衡)。

 

在保证了系统的可用性的基础上,我们该如何实现呢?如果实时性要求不是很高,我们可以采用全量+增量同步的方式进行。首先,我们可以按照预计的热点key对系统进行缓存预热,全量同步数据到缓存系统。接着,在需要更新缓存的时候,我们可以采用增量同步的方式更新缓存。比如我们可以使用阿里Canal框架同步binlog的方式进行数据的同步。

 

缓存过期

 

缓存系统中的所有数据,根据数据的使用频率以及场景,我们可以分为过期key以及不过期key,那么对齐过期缓存我们该如何淘汰呢?下面有常用的几种方案:

 

FIFO:使用FIFO算法来淘汰过期缓存。

LFU:使用LFU算法来淘汰过期缓存。

LRU:使用LRU算法来淘汰过期缓存。

以上几种方案是在缓存达到最大缓存大小的时候的淘汰策略,如果没有达到最大缓存大小,我们有下面几种方式:

 

定时删除策略:设置一个定时任务,在规定时间内检查并且删除过期key。

定期删除策略:这种策略需要设置删除的周期以及时长,如何设置,需要根据具体场合来计算。

惰性删除策略:在使用时检查是否过期,如果过期直接去更新缓存,否则直接返回。

 

 

最全面的缓存架构设计

1:缓存技术和框架的重要性

互联网的一些高并发,高性能的项目和系统中,缓存技术是起着功不可没的作用。缓存不仅仅是key-value的简单存取,它在具体的业务场景中,还是很复杂的,需要很强的架构设计能力。我曾经就遇到过因为缓存架构设计不到位,导致了系统崩溃的案例。

2:缓存的技术方案分类

1)是做实时性比较高的那块数据,比如说库存,销量之类的这种数据,我们采取的实时的缓存+数据库双写的技术方案,双写一致性保障的方案。

2)是做实时性要求不高的数据,比如说商品的基本信息,等等,我们采取的是三级缓存架构的技术方案,就是说由一个专门的数据生产的服务,去获取整个商品详情页需要的各种数据,经过处理后,将数据放入各级缓存中。

3:高并发以及高可用的复杂系统中的缓存架构都有哪些东西

1)在大型的缓存架构中,redis是最最基础的一层。高并发,缓存架构中除了redis,还有其他的组成部分,但是redis至关重要。

  • 如果你的数据量不大(10G以内),单master就可以。redis持久化+备份方案+容灾方案+replication(主从+读写分离)+sentinal(哨兵集群,3个节点,高可用性)
  • 如果你的数据量很大(1T+),采用redis cluster。多master分布式存储数据,水平扩容,自动进行master -> slave的主备切换。

2)最经典的缓存+数据库读写的模式,cache aside pattern。读的时候,先读缓存,缓存没有的话,那么就读数据库。更新缓存分以下两种方式:

  • 数据发生变化时,先更新缓存,然后再更新数据库。这种适用于缓存的值相对简单,和数据库的值一一对应,这样更新比较快。
  • 数据发生变化时,先删除缓存,然后再更新数据库,读数据的时候再设置缓存。这种适用于缓存的值比较复杂的场景。比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据,并进行运算,才能计算出缓存最新的值的。这样更新缓存的代价是很高的。如果你频繁修改一个缓存涉及的多个表,那么这个缓存会被频繁的更新,频繁的更新缓存代价很高。而且这个缓存的值如果不是被频繁访问,就得不偿失了。

大部分情况下,建议适用删除更新的方式。其实删除缓存,而不是更新缓存,就是一个lazy计算的思想,不要每次都重新做复杂的计算,不管它会不会用到,而是让它到需要被使用的时候再重新计算。

举个例子,一个缓存涉及的表的字段,在1分钟内就修改了20次,或者是100次,那么缓存跟新20次,100次; 但是这个缓存在1分钟内就被读取了1次,有大量的冷数据。28黄金法则,20%的数据,占用了80%的访问量。实际上,如果你只是删除缓存的话,那么1分钟内,这个缓存不过就重新计算一次而已,开销大幅度降低。每次数据过来,就只是删除缓存,然后修改数据库,如果这个缓存,在1分钟内只是被访问了1次,那么只有那1次,缓存是要被重新计算的。

3)数据库与缓存双写不一致问题的解决方案

问题:并发请求的时候,数据发生了变更,先删除了缓存,然后要去修改数据库,此时还没修改。另一个请求过来,去读缓存,发现缓存空了,去查询数据库,查到了修改前的旧数据,放到了缓存中。

方案:数据库与缓存更新与读取操作进行异步串行化。(引入队列)

更新数据的时候,将相应操作发送到一个jvm内部的队列中。读取数据的时候,如果发现数据不在缓存中,那么将重新读取数据的操作也发送到同一个jvm内部的队列中。队列消费者串行拿到对应的操作,然后一条一条的执行。这样的话,一个数据变更的操作,先执行删除缓存,然后再去更新数据库,但是还没完成更新。此时如果一个读请求过来,读到了空的缓存,那么可以先将缓存更新的请求发送到队列中,此时会在队列中积压,然后同步等待缓存更新完成。

这里有两个可以优化的点:

  • 一个队列中,其实多个读缓存,更新缓存的请求串在一起是没意义的,而且如果读同一缓存的大量请求到来时,会依次进入队列等待,这样会导致队列最后一个的请求响应时间超时。因此可以做过滤,如果发现队列中已经有一个读缓存,更新缓存的请求了,那么就不用再放个新请求操作进去了,直接等待前面的更新操作请求完成即可。如果请求还在等待时间范围内,不断轮询发现可以取到值了,那么就直接返回; 如果请求等待的时间超过一定时长,那么这一次直接从数据库中读取当前的旧值。
  • 如果请求量特别大的时候,可以用多个队列,每个队列对应一个线程。每个请求来时可以根据请求的标识id进行hash路由进入到不同的队列。

最后,一定要做根据实际业务系统的运行情况,去进行一些压力测试,和模拟线上环境,去看看最繁忙的时候,内存队列可能会挤压多少更新操作,可能会导致最后一个更新操作对应的读请求,会hang多少时间,如果读请求在200ms返回,如果你计算过后,哪怕是最繁忙的时候,积压10个更新操作,最多等待200ms,那还可以的。如果一个内存队列可能积压的更新操作特别多,那么你就要加机器,让每个机器上部署的服务实例处理更少的数据,那么每个内存队列中积压的更新操作就会越少。其实根据之前的项目经验,一般来说数据的写频率是很低的,因此实际上正常来说,在队列中积压的更新操作应该是很少的。

举个例子:一秒就100个写操作。单台机器,20个内存队列,每个内存队列,可能就积压5个写操作,每个写操作性能测试后,一般在20ms左右就完成,那么针对每个内存队列中的数据的读请求,也就最多hang一会儿,200ms以内肯定能返回了。如果把写QPS扩大10倍,但是经过刚才的测算,就知道,单机支撑写QPS几百没问题,那么就扩容机器,扩容10倍的机器,10台机器,每个机器20个队列,200个队列。大部分的情况下,应该是这样的,大量的读请求过来,都是直接走缓存取到数据的,少量情况下,可能遇到读跟数据更新冲突的情况,如上所述,那么此时更新操作如果先入队列,之后可能会瞬间来了对这个数据大量的读请求,但是因为做了去重的优化,所以也就一个更新缓存的操作跟在它后面。

4)大型缓存全量更新问题的解决方案

问题:缓存数据很大时,可能导致redis的吞吐量就会急剧下降,网络耗费的资源大。如果不维度化,就导致多个维度的数据混合在一个缓存value中。而且不同维度的数据,可能更新的频率都大不一样。拿商品详情页来说,如果现在只是将1000个商品的分类批量调整了一下,但是如果商品分类的数据和商品本身的数据混杂在一起。那么可能导致需要将包括商品在内的大缓存value取出来,进行更新,再写回去,就会很坑爹,耗费大量的资源,redis压力也很大

方案:缓存维度化。举个例子:商品详情页分三个维度:商品维度,商品分类维度,商品店铺维度。将每个维度的数据都存一份,比如说商品维度的数据存一份,商品分类的数据存一份,商品店铺的数据存一份。那么在不同的维度数据更新的时候,只要去更新对应的维度就可以了。大大减轻了redis的压力。

5)通过多级缓存,达到高并发极致,同时给缓存架构最后的安全保护层。具体可以参照上一篇文章【亿级流量的商品详情页架构分析】。

6)分布式并发缓存重建的冲突问题的解决方案

问题:假如数据在所有的缓存中都不存在了(LRU算法弄掉了),就需要重新查询数据写入缓存。对于分布式的重建缓存,在不同的机器上,不同的服务实例中,去做上面的事情,就会出现多个机器分布式重建去读取相同的数据,然后写入缓存中。

方案:分布式锁:如果你有多个机器在访问同一个共享资源,那么这个时候,如果你需要加个锁,让多个分布式的机器在访问共享资源的时候串行起来。分布式锁当然有很多种不同的实现方案,redis分布式锁,zookeeper分布式锁。

zookeeper分布式锁的解决并发冲突的方案

  • (1)变更缓存重建以及空缓存请求重建,更新redis之前,都需要先获取对应商品id的分布式锁
  • (2)拿到分布式锁之后,需要根据时间版本去比较一下,如果自己的版本新于redis中的版本,那么就更新,否则就不更新
  • (3)如果拿不到分布式锁,那么就等待,不断轮询等待,直到自己获取到分布式的锁

7)缓存冷启动的问题的解决方案

问题:新系统第一次上线,此时在缓存里可能是没有数据的。或者redis缓存全盘崩溃了,数据也丢了。导致所有请求打到了mysql。导致mysql直接挂掉。

方案:缓存预热。

  • 提前给redis中灌入部分数据,再提供服务
  • 肯定不可能将所有数据都写入redis,因为数据量太大了,第一耗费的时间太长了,第二根本redis容纳不下所有的数据,需要根据当天的具体访问情况,实时统计出访问频率较高的热数据,然后将访问频率较高的热数据写入redis中,肯定是热数据也比较多,我们也得多个服务并行读取数据去写,并行的分布式的缓存预热。

8)恐怖的缓存雪崩问题的解决方案

问题:缓存服务大量的资源全部耗费在访问redis和源服务无果,最后自己被拖死,无法提供服务。

方案:相对来说,考虑的比较完善的一套方案,分为事前,事中,事后三个层次去思考怎么来应对缓存雪崩的场景。

  • 事前:高可用架构。主从架构,操作主节点,读写,数据同步到从节点,一旦主节点挂掉,从节点跟上。
  • 事中:多级缓存。redis cluster已经彻底崩溃了,缓存服务实例的ehcache的缓存还能起到作用。
  • 事后:redis数据可以恢复,做了备份,redis数据备份和恢复,redis重新启动起来。

9)缓存穿透问题的解决方案

问题:缓存中没有这样的数据,数据库中也没有这样的数据。由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。

方案:有很多种方法可以有效地解决缓存穿透问题,最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被 这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。另外也有一个更为简单粗暴的方法(我们采用的就是这种),如果一个查询返回的数据为空(不管是数 据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。

 

缓存击穿问题

缓存击穿,是指某个极度 “ 热点 ” 数据在某个时间点过期时,恰好在这个时间点对这个 KEY 有大量的并发请求过来,这些请求发现缓存过期一般都会从 DB 加载数据并回设到缓存,但是这个时候大并发的请求可能会瞬间 DB 压垮。

 

先来设想下面这么一个场景。数据源在 MySQL 数据库中,缓存的数据放在共享字典中,超时时间为 1 分钟。在这 1 分钟内的时间里,所有的请求都从缓存中获取数据,MySQL 没有任何的压力。但是,一旦到达 1 分钟,也就是缓存数据失效的那一刻,如果正好有大量的并发请求进来,在缓存中没有查询到结果,就要触发查询数据源的函数,那么这些请求全部都将去查询 MySQL 数据库, 直接造成数据库服务器卡顿,甚至卡死。

 

对于一些设置了过期时间的 KEY ,如果这些 KEY 可能会在某些时间点被超高并发地访问,是一种非常 “ 热点 ” 的数据。这个时候,需要考虑这个个问题。

 

如何避免缓存击穿?

 

1 、主动更新缓存:默认缓存是被动更新的。只有在终端请求发现缓存失效时,它才会去数据库查询新的数据。那么,如果我们把缓存的更新,从被动改为主动,也就可以直接绕开缓存风 暴的问题了,在 OpenResty 中,我们可以使用 ngx.timer.every 来创建一个定时器去定时更新。

 

缺点:每一个缓存都要对应一个周期性的任务;而且缓存过期时间和计划任务的周期时间还要对应好,如果这中间出现了什么纰漏, 终端就可能 一直获取到的都是空数据

 

2 、使用互斥锁:请求发现缓存不存在后,去查询 DB 前,使用锁,保证有且只有一个请求去查询 DB ,并更新到缓存。流程如下:

  • 获取锁,直到成功或超时。如果超时,则抛出异常,返回。如果成功,继续向下执行。
  • 再去缓存中。如果存在值,则直接返回;如果不存在,则继续往下执行如果成功获取到锁的话,就可以保证只有一个请求去数据源更新数据,并更新到缓存中了。
  • 查询 DB ,并更新到缓存中,返回值。