前言
为了提高系统的性能,一般会引入“缓存机制”,将部分热点数据存入缓存中,用空间换取时间,以达到快速响应的目的。
其实,缓存的应用远远不止存在于服务层(传统的Redis缓存),从客户端发起请求开始,经过域名服务器(DNS) → 内容分发服务器(CDN) → 反向代理服务器(Nginx),然后到达我们的分布式系统(ES),再经过分布式缓存服务(Redis、memcache) → 线程内缓存(Spring-cache、guava-cache),最后到达数据库(RDS),整个链路中每个节点都可以使用缓存,这就是所谓的“多级缓存”。其中缓存策略,算法也是层出不穷,今天就带大家走进缓存。
相关文章:
- 关于:Ngnix的搭建,参数,复杂均衡,反向代理和调优讲解【篇】(专题汇总)
- Guava Cache 原理分析与最佳实践
-
Redis 3.0 的六种缓存淘汰策略
参考文章:
- 性能为王:微服务架构中的多级缓存设计
正文
一、两种方式
1.1 传统缓存方式
考虑到 mysql 的性能瓶颈,传统方案中,只在服务层做一级缓存。
- 当请求到达 tomcat 后,先去 Redis 中获取缓存,不命中再去 mysql 中获取。
- 当 mysql 成功获取数据以后,返回给前端的同时,将数据缓存进 Redis 一份,以便下次请求命中。
1.2 多级缓存方式
多级缓存方案利用请求处理的每个环节,分别添加缓存,使最终到达 tomcat 的请求并发数远小于传统方案,达到减轻服务器压力,提升服务性能的目的。
二、多级缓存介绍
请收好下面的图 - “多级缓存架构总览”,下面将围绕这个架构展开,分别介绍一下每层缓存的使用:
2.1 客户端(HTTP)缓存
当用户通过浏览器请求服务器的时候,会发起 HTTP 请求,如果对每次 HTTP 请求进行缓存,那么可以减少应用服务器的压力。
当第一次请求的时候,浏览器本地缓存库没有缓存数据,会从服务器取数据,并且放到浏览器的缓存库中,下次再进行请求的时候会根据缓存的策略来读取本地或者服务的信息。
一般信息的传递通过 HTTP 请求头 Header 来传递。目前比较常见的缓存方式有两种,分别是:
- 强制缓存
当浏览器本地缓存库保存了缓存信息,在缓存数据未失效的情况下,可以直接使用缓存数据。否则就需要重新获取数据。
在 HTTP 1.1 会使用 Cache-Control 来完成这样的功能,Cache-Control 中有个 max-age 属性,单位是秒,用来表示缓存内容在客户端的过期时间。客户端第一次请求完后,将数据放入本地缓存。那么在 max-age 以内客户端再发送请求,都不会请求应用服务器,而是从本地缓存中直接返回数据。如果两次请求相隔时间超过了 max-age,那么就需要通过服务器获取数据。
- 对比缓存
需要对比前后两次的缓存标志来判断是否使用缓存。
浏览器第一次请求时,服务器会将缓存标识与数据一起返回,浏览器将二者备份至本地缓存库中。浏览器再次请求时,将备份的缓存标识发送给服务器。服务器根据缓存标识进行判断,如果判断数据没有发生变化,把判断成功的 304 状态码发给浏览器,这时浏览器就可以使用缓存的数据来。服务器返回的就只是 Header,不包含 Body,这样会很大程度上节约了带宽。
2.2 CDN缓存
CDN(Content Delivery Network),即内容分发网络,依靠部署在各地的边缘服务器,使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。
CDN 主要缓存对象是静态数据。如果在客户端和服务器之间再加上一层 CDN,可以让 CDN 为应用服务器提供缓存,当命中CDN缓存时,就不用再请求应用服务器了。
注: HTTP 缓存提到的两种策略同样可以在 CDN 服务器执行。
在互联网应用中,因为 CDN 设计多地域多节点组网前期投入成本较高,所以更多的中小型企业会可以选择阿里云、腾讯云等提供的CDN服务。
2.3 Nginx缓存
说完客户端(HTTP)缓存和 CDN 缓存,我们离应用服务越来越近了,在到达应用服务之前,请求还要经过负载均衡器 。
虽说它的主要工作是对应用服务器进行负载均衡,但是它也可以作缓存。可以把一些修改频率不高的数据缓存在这里,例如:用户信息,配置信息。通过服务定期刷新这个缓存就行了。
以 Nginx 为例,Nginx 是一款跨平台的,高性能的 Web 服务器,支持反向代理,负载均衡以及缓存功能。下面,来看看它是如何工作的:
- 用户请求在达到应用服务器之前,会先访问 Nginx 负载均衡器;
- 如果发现有缓存信息,直接返回给用户;
- 如果没有发现缓存信息,Nginx 回源到应用服务器获取信息;
- 另外,可以设置一个缓存更新服务,定期把应用服务器中相对稳定的信息更新到 Nginx 本地缓存中。
相关配置可参考下面:
2.4 进程内缓存
进程内缓存,是在应用中开辟一块内存空间,数据在运行时被存入这块内存,通过本地内存低延迟、高吞吐的特性提高程序的访问速度。由于其运行在内存中,对数据的响应速度很快,通常我们会把热点数据放在这里。
目前比较流行的实现:
- 框架的:Mybatis 框架的一二级缓存,SpringMVC 的页面缓存等;
- 进程内的:Ehcache、GuavaCache、Caffeine。
本地缓存的特点:
- 优点:读取本地内存,没有网络开销,速度更快;
- 缺点:存储容量有限,可靠性低(如重启后丢失),无法在集群中共享;
- 场景:性能要求高,缓存数据量少的地方。
由于目前的系统架构都是分布式的,即:一个服务被部署在多台机器上以实现高性能,而进程内缓存只能存在于当前服务器,所以就会存在进程内缓存数据一致性的问题,如何保障?可以采用 RocketMQ 实现消息的最终一致性方案:
2.5 分布式缓存(进程外缓存)
与进程内缓存不同,进程外缓存在应用运行的进程之外,它可以部署到不同的物理节点,并且拥有更大的缓存容量,通常会用分布式缓存的方式实现,如:Redis集群。
分布式缓存是与应用分离的缓存服务,最大的特点是:自身是一个独立的应用/服务,与本地应用隔离,多个应用可直接共享一个或者多个缓存应用/服务。
为了提高缓存的可用性,使部分节点失败或者大部分节点无法通信的情况下集群仍然可用,Redis集群使用了主从复制模型,每个节点都会有 N-1 个复制品(假设:一共有 N 个节点,则每个节点有一个 Master 和 N-1 个 Slave)。当缓存数据写入 Master 节点的时候,会同时同步一份到 Slave 节点。一旦 Master 节点失效,可以通过代理直接切换到 Slave 节点,这时 Slave 节点就变成了 Master 节点,保证缓存的正常工作。
在 Redis 集群中,因为缓存也是分布式部署的,这样就会产生一个问题:数据根据怎样的规律分配到每个缓存应用/服务上?这里介绍三种缓存数据分片的算法:
- 哈希算法
这个算法很好理解,就是对数据记录的关键值进行 Hash 运算,然后再对需要分片的缓存节点个数进行取模,利用得到的余数进行数据分配。Hash 算法是某种程度上的平均放置,策略比较简单。但是它有一个很大的不足:如果要增加缓存节点,对已经存在的数据会有较大的变动,因为节点个数变了,取模后的结果也就不同了。
- Range Based 算法
和哈希算法类似,这种方式是按照关键值(例如 ID)将数据划分成不同的区间,每个缓存节点负责一个或者多个区间,相当于预设好了区间。
例如:存在三个缓存节点分别是 N1,N2,N3。他们用来存放数据的区间分别是,N1(0, 100], N2(100, 200], N3(300, 400]。那么,数据根据自己 ID 作为关键字做 Hash 以后的结果就会分别对应放到这几个区域里面了。
- 一致性哈希算法
上面的2种方式似乎都不能解决缓存节点增减带来的问题,所以 Redis 集群引入了一致性 Hash算法(哈希槽)的概念 。一致性hash算法就是为了节点数目发生改变时,尽可能少的数据迁移而出现的。
Redis 集群有16384(2的32次方)个哈希槽,将数据按照特征值映射到一个首尾相接的 Hash 环上,同时也将缓存节点映射到这个环上。
每个key通过CRC16校验后对16384取模来决定放置哪个槽,这些值按照顺序在环上排列,集群的每个节点负责一部分hash槽。当需要增减缓存节点的时候,只会变动节点前后的部分数据,其他的数据不受影响,以此来将影响降到最低。
三、缓存的优缺点
一句话概况:更快读写的存储介质+减少IO+减少CPU计算=性能优化
3.1 缓存带来的好处
显而易见,缓存给我们带来最直接的体验就是“快”,我们来总结一下:
-
通过减少IO(包括磁盘和网络)来提高吞吐量,减少计算量(CPU计算)释放CPU;
-
通过缩短访问链路,减小访问时间,降低服务器和DB的压力,以达到高性能、高可用的目的;
-
通过切面的处理方式,可以在各层进行插拔,是所有性能优化最简单有效的解决方案。
对于不熟悉业务代码或算法的优化者,显然加一层缓存的复杂度和风险更低,而这一层看似简单的缓存,它给系统带来的性能优化有可能大大超过前者。
3.2 缓存带来的困扰
我们不能否认缓存给我们带来诸多便利,同时,我们不能忽略缓存确实也给我们带来了不少困扰:
-
数据的一致性、实时性受影响:需要对数据的一致性,时效性进行评估,进而确定是否要缓存或设定缓存的过期时间,比如个性化的数据是否值得缓存?
-
缓存介质带来的不可靠性:一般使用内存做缓存的话,若机器故障,如何保证缓存的高可用?可考虑对缓存进行分布式做成高可用,同时,需要接受这种不可靠不安全会给数据带来的问题,在异常情况下进行补偿处理,定期持久化等方式。
-
缓存的数据使得更难排查问题:因为缓存命中是随着访问随时变化的,缓存的行为难以重现,使得出现BUG很难排查。
-
进程内缓存可能会增加GC压力:在具有垃圾收集功能的语言中(如Java),大量长寿命的缓存对象会增加垃圾收集的时间和次数。
所以,在使用缓存之前我们需要对数据进行分类,对访问行为进行预估,思考哪些数据需要缓存,缓存时需要采用什么策略?这样,我们才不被缓存所困扰,才能规避这些问题。
四、缓存的适用场景
在实际应用中,我们需要对数据进行分类,才能更好的使用缓存以及一些策略来辅助。比如:哪些为冷热数据?哪些数据量很大,读取会严重影响IO?哪些数据查多改少(日志数据,爬虫数据)?哪些数据又是经过很复杂的计算得到的结果(这些珍贵的数据需要好好保存利用)?等等。
- 情况一:缓存数据比较稳定
如:邮政编码,省市区编码,地域区块,归档的历史数据,这类信息适合通过多级缓存Redis来减少数据库的压力。
- 情况二:瞬间产生高并发的场景
如:春晚抢红包,双十一活动,整点秒杀等,存在瞬间的流量洪峰,可以通过多级缓存防止Redis击穿和穿透。
- 情况三:一定程度上允许数据不一致
如:库存,余额这种需要强一致性的数据,在分布式系统中需要分布式事务控制,但是如果允许数据段时间的有差别,可以使用先使用缓存,最后利用RocketMQ或定时任务实现最终一致。
总结
大流量下的多级缓存设计,大致有五大策略,从用户请求开始到数据库依次是:HTTP 缓存,CDN 缓存,Ngginx负载均衡缓存,Cache进程内缓存,Redis分布式缓存,其中:
- CDN 缓存和 HTTP 缓存是好搭档,他们主要缓存静态数据,将静态资源放在距离用户最近的地方;
- Nginx 负载均衡器缓存相对稳定的资源,需要服务协助工作(如:设置一个缓存更新服务,定期把应用服务器中相对稳定的信息更新到 Nginx 本地缓存中),毕竟负载均衡才是Nginx的本职工作;
- Cache 进程内缓存,效率高,但容量有限制,只有性能要求极高,又不需要数据强一致性的场景才适合使用。进程内缓存需要注意数据一致性的问题,利用 RocketMQ 可以实现数据最终一致。
- 分布式缓存容量大,能力强,牢记三个性能算法并且防范三个缓存风险(缓存穿透,缓存击穿,缓存雪崩)。