跳至主要內容

初识缓存


来源:稀土掘金社区深入理解缓存原理与实战设计小林Coding,Seven进行了部分补充完善

在服务端开发中,缓存常常被当做系统性能扛压的不二之选。在实施方案上,缓存使用策略虽有一定普适性,却也并非完全绝对,需要结合实际的项目诉求与场景进行综合权衡与考量,进而得出符合自己项目的最佳实践。

缓存使用的演进

现有这么一个系统:

一个互动论坛系统,用户登录系统之后,可以在论坛上查看帖子列表、查看帖子详情、发表帖子、评论帖子、为帖子点赞等操作。

系统中所有的配置数据与业务数据均存储在数据库中。随着业务的发展,注册用户量越来越多,然后整个系统的响应速度也越来越慢,用户体验越来越差,用户逐渐出现流失。

本地缓存的牛刀小试

为了挽救这一局面,开发人员需要介入去分析性能瓶颈并尝试优化提升响应速度,并很快找到响应慢的瓶颈在数据库的频繁操作,于是想到了使用缓存来解决问题。

于是,开发人员在项目中使用了基于接口维度的短期缓存,对每个接口的请求参数(帖子ID)与响应内容缓存一定的时间(比如1分钟),对于相同的请求,如果匹配到缓存则直接返回缓存的结果即可,不用再次去执行查询数据库以及业务维度的运算逻辑。

JAVA中有很多的开源框架都有提供类似的能力支持,比如Ehcache或者Guava CacheCaffeine Cache等,可以通过简单的添加注解的方式就实现上述需要的缓存效果。比如使用Ehcache来实现接口接口缓存的时候,代码使用方式如下(这里先简单的演示下,后续的系列文档中会专门对这些框架进行深入的探讨):

@Cacheable(value="UserDetailCache", key="#userId")
public UserDetail queryUserDetailById(String userId) {
    UserEntity userEntity = userMapper.queryByUserId(userId);
    return convertEntityToUserDetail(userEntity);
}

将上面的本地缓存策略改动后重新上线,整体的响应性能上果然提升了很多。本地缓存的策略虽然有效地提升了处理请求的速度,但新的问题也随之浮现。有用户反馈,社区内的帖子列表多次刷新后会出现内容不一致的情况,有的帖子刷新之后会从列表消失,多次刷新后偶尔会出现。

其实这就是本地缓存在集群多节点场景下会遇到的一个很常见的缓存漂移现象:

因为业务集群存在多个节点,而缓存是每个业务节点本地独立构建的,所以才出现了更新场景导致的本地缓存不一致的问题,进而表现为上述问题现象。

集中式缓存的初露锋芒

为了解决集群内多个节点间执行写操作之后,各节点本地缓存不一致的问题,开发人员想到可以构建一个集中式缓存,然后所有业务节点都读取或者更新同一份缓存数据,这样就可以完美地解决节点间缓存不一致的问题了。

业界成熟的集中式缓存有很多,最出名的莫过于很多人都耳熟能详的Redis,或者是在各种面试中常常被拿来与Redis进行比较的Memcached。也正是由于它们出色的自身性能表现,在当前的各种分布式系统中,Redis近乎已经成为了一种标配,常常与MySQL等持久化数据库搭配使用,放在数据库前面进行扛压。比如下面图中示例的一种最简化版本的组网架构:

开发人员对缓存进行了整改,将本地缓存改为了Redis集中式缓存。这样一来:

  1. 缓存不一致问题解决:解决了各个节点间数据不一致的问题。
  2. 单机内存容量限制解决:使用了Redis这种分布式的集中式缓存,扩大了内存缓存的容量范围,可以顺便将很多业务层面的数据全部加载到Redis中分片进行缓存,性能也相比而言得到了提升。

似乎使用集中式缓存已经是分布式系统中的最优解了,但是现实情况真的就这么简单么?也不尽然

多级缓存的珠联璧合

在尝到了集中式缓存的甜头之后,暖心的程序员们想到要彻底为数据库减压,将所有业务中需要频繁使用的数据全部同步存储到Redis中,然后业务使用的时候直接从Redis中获取相关数据,大大地减少了数据库的请求频次。但是改完上线之后,发现有些处理流程中并没有太大的性能提升。缘何如此?只因为对集中式缓存的过分滥用!分析发现这些流程的处理需要涉及大量的交互与数据整合逻辑,一个流程需要访问近乎30次Redis!虽然Redis的单次请求处理性能极高,甚至可以达到微秒级别的响应速度,但是每个流程里面几十次的网络IO交互,导致频繁的IO请求,以及线程的阻塞唤醒切换交替,使得系统在线程上下文切换层面浪费巨大

那么,要想破局,最常规的手段便是尝试降低对集中式缓存(如Redis)的请求数量,降低网络IO交互次数。而如何来降低呢? —— 又回到了本地缓存!集中式缓存并非是分布式系统中提升性能的银弹,但我们可以将本地缓存与集中式缓存结合起来使用,取长补短,实现效果最大化。如图所示:

上图演示的也即多级缓存的策略。具体而言:

  • 对于一些变更频率比较高的数据,采用集中式缓存,这样可以确保数据变更之后所有节点都可以实时感知到,确保数据一致;
  • 对于一些极少变更的数据(比如一些系统配置项)或者是一些对短期一致性要求不高的数据(比如用户昵称、签名等)则采用本地缓存,大大减少对远端集中式缓存的网络IO次数。

这样一来,系统的响应性能又得到了进一步的提升。

通过对缓存使用策略的一步步演进,我们可以感受到缓存的恰当使用对系统性能的帮助作用。

无处不在的缓存

缓存存在的初衷,就是为了兼容两个处理速度不一致的场景对接适配的。在我们的日常生活中,也常常可以看到“缓存”的影子。比如对于几年前比较盛行的那种带桶的净水器(见下图),由于净水的功率比较小,导致实时过滤得到纯净水的水流特别的缓慢,用户倒一杯水要等2分钟,体验太差,所以配了个蓄水桶,净水机先慢慢的将净化后的水存储到桶中,然后用户倒水的时候可以从桶里快速的倒出,无需焦急等待 —— 这个蓄水桶,便是一个缓存器

编码源于生活,CPU高速缓存设计就是这一生活实践在计算机领域的原样复制。缓存可以说在软件世界里无处不在,除了我们自己的业务系统外,在网络传输操作系统中间件基础框架中都可以看到缓存的影子。如:

  1. 网络传输场景。比如ARP协议,基于ARP缓存表进行IP与终端硬件MAC地址之间的缓存映射。这样与对端主机之间有通信需求的时候,就可以在ARP缓存中查找到IP对应的对端设备MAC地址,避免每次请求都需要去发送ARP请求查询MAC地址。
  2. MyBatis的多级缓存MyBatis作为JAVA体系中被广泛使用的数据库操作框架,其内部为了提升处理效率,构建了一级缓存二级缓存,大大减少了对SQL的重复执行次数。
  3. CPU中的缓存CPU内存之间有个临时存储器(高速缓存),容量虽比内存小,但是处理速度却远快于普通内存。高速缓存的机制,有效地解决了CPU运算速度内存读写速度不匹配的问题。

缓存的使用场景

缓存作为互联网类软件系统架构与实现中的基石般的存在,不仅仅是在系统扛压或者接口处理速度提升等性能优化方案,在其他多个方面都可以发挥其独一无二的关键价值。

降低自身CPU消耗

如前面章节中提到的项目实例,缓存最典型的使用场景就是用在系统的性能优化上。而在性能优化层面,一个经典的策略就是“空间换时间”。比如:

  • 在数据库表中做一些字段冗备

比如用户表T_User和部门表T_Department,在T_User表中除了有个Department_Id字段与T_Department表进行关联之外,还额外在T_User表中存储Department_Name值。这样在很多需要展示用户所属部门信息的时候就省去了多表关联查询的操作。

  • 对一些中间处理结果进行存储

比如系统中的数据报表模块,需要对整个系统内所有的关联业务数据进行计算统计,且需要多张表多来源数据之间的综合汇总之后才能得到最终的结果,整个过程的计算非常的耗时。如果借助缓存,则可以将一些中间计算结果进行暂存,然后报表请求中基于中间结果进行二次简单处理即可。这样可以大大降低基于请求触发的实时计算量。

在“空间换时间”实施策略中,缓存是该策略的核心、也是被使用的最为广泛的一种方案。借助缓存,可以将一些CPU耗时计算的处理结果进行缓存复用,以降低重复计算工作量,达到降低CPU占用的效果。

减少对外IO交互

上面介绍的使用缓存是为了不断降低请求处理时对自身CPU占用,进而提升服务的处理性能。这里我们介绍缓存的另一典型使用场景,就是减少系统对外依赖请求频次。即通过将一些从远端请求回来的响应结果进行缓存,后面直接使用此缓存结果而无需再次发起网络IO请求交互。

对于服务端而言,通过构建缓存的方式来减少自身对外的IO请求,主要有几个考量出发点:

  1. 自身性能层面考虑,减少对外IO操作,降低了对外接口的响应时延,也对服务端自身处理性能有一定提升。
  2. 对端服务稳定性层面考虑,避免对端服务负载过大。很多时候调用方和被调用方系统的承压能力是不匹配的,甚至有些被调用方系统可能是不承压的。为了避免将对端服务压垮,需要调用方缓存请求结果,降低IO请求。
  3. 自身可靠性层面而言,将一些远端服务请求到的结果缓存起来,即使远端服务出现故障,自身业务依旧可以基于缓存数据进行正常业务处理,起到一个兜底作用提升自身的抗风险能力

在分布式系统服务治理范畴内,服务注册管理服务是必不可少的,比如SpringCloud家族的Eureka,或者是Alibaba开源的Nacos。它们对于缓存的利用,可以说是对上面所提几点的完美阐述。

Nacos为例:

除了上述的因素之外,对一些移动端APP或者H5界面而言,缓存的使用还有一个层面的考虑,即降低用户的流量消耗,通过将一些资源类数据缓存到本地,避免反复去下载,给用户省点流量,也可以提升用户的使用体验(界面渲染速度快,减少出现白屏等待的情况)。

提升用户个性化体验

缓存除了在系统性能提升或系统可靠性兜底等场景发挥价值外,在APP或者web类用户侧产品中,还经常被用于存储一些临时非永久的个性化使用习惯配置或者身份数据,以提升用户的个性化使用体验。

  • 缓存cookiesession等身份鉴权信息,这样就可以避免用户每次访问都需要进行身份验证。
  • 记住一些用户上次操作习惯,比如用户在一个页面上将列表分页查询设置为100条/页,则后续在系统内访问其它列表页面时,都沿用这一设置。
  • 缓存用户的一些本地设置,这个主要是APP端常用的功能,可以在缓存中保存些与当前设备绑定的设置信息,仅对当前设备有效。比如同一个账号登录某个APP,用户希望在手机端可以显示深色主题,而PAD端则显示浅色主体,这种基于设备的个性化设置,可以缓存到设备本身即可。

业务与缓存的集成模式

如前所述,我们可以在不同的方面使用缓存来辅助达成项目在某些方面的诉求。而根据使用场景的不同,在结合缓存进行业务逻辑实现的时候,也会存在不同的架构模式,典型的会有旁路型缓存穿透型缓存异步型缓存三种。

旁路型缓存

旁路型缓存模式中,业务自行负责与缓存以及数据库之间的交互,可以自由决定缓存未命中场景的处理策略,更加契合大部分业务场景的定制化诉求。

由于业务模块自行实现缓存与数据库之间的数据写入与更新的逻辑,实际实现的时候需要注意下在高并发场景的数据一致性问题,以及可能会出现的缓存击穿缓存穿透缓存雪崩等问题的防护。

旁路型缓存是实际业务中最常使用的一种架构模式,在后面的内容中,我们还会不断的涉及到旁路缓存中相关的内容。

穿透型缓存

穿透型缓存在实际业务中使用的较少,主要是应用在一些缓存类的中间件中,或者在一些大型系统中专门的数据管理模块中使用。

一般情况下,业务使用缓存的时候,会是先尝试读取缓存,在尝试读取DB,而使用穿透型缓存架构时,会有专门模块将这些动作封装成黑盒的,业务模块不会与数据库进行直接交互。如下图所示:

这种模式对业务而言是比较友好的,业务只需调用缓存接口即可,无需自行实现缓存与DB之间的交互策略。

异步型缓存

还有一种缓存的使用模式,可以看作是穿透型缓存的演进异化版本,其使用场景也相对较少,即异步型缓存。其主要策略就是业务侧请求的实时读写交互都是基于缓存进行,任何数据的读写也完全基于缓存进行操作。此外,单独实现一个数据持久化操作(独立线程或者进程中执行),用于将缓存中变更的数据写入到数据库中。

这种情况,实时业务读写请求完全基于缓存进行,而将数据库仅仅作为一个数据持久化存储的备份盘。由于实时业务请求仅与缓存进行交互,所以在性能上可以得到更好的表现。但是这种模式也存在一个致命的问题:数据可靠性!因为是异步操作,所以在下一次数据写入DB前,会有一段时间数据仅存在于缓存中,一旦缓存服务宕机,这部分数据将会丢失。所以这种模式仅适用于对数据一致性要求不是特别高的场景。

缓存的优秀实践

缓存持久化存储的一个很大的不同点就是缓存的定位应该是一种辅助角色,是一种锦上添花般的存在。

缓存也是一把双刃剑,基于缓存可以大幅提升我们的系统并发承压能力,但稍不留神也可能会让我们的系统陷入灭顶之灾。所以我们在决定使用缓存的时候,需要知晓缓存设计与使用的一些关键要点,才可以让我们在使用的时候更加游刃有余。

可删除重建

可删除重建,这是缓存与持久化存储最大的一个差别。缓存的定位一定是为了辅助业务处理而生的,也就是说缓存有则使用,没有也不会影响到我们具体的业务运转。此外,即使我们的缓存数据除了问题,我们也可以将其删除重建。

这一点在APP类的产品中体现的会比较明显。比如对于微信APP的缓存,就有明确的提示说缓存可以删除而不会影响其功能使用:

同样地,我们也可以去放心的清理浏览器的缓存,而不用担心清理之后我们浏览器或者网页的功能会出现异常(最多就是需要重新下载或者重建缓存数据,速度会有一些慢)。

相同的逻辑,在服务端构建的一些缓存,也应该具备此特性。比如基于内存的缓存,当业务进程重启后,应该有途径可以将缓存重建出来(比如从MySQL中加载数据然后构建缓存,或者是缓存从0开始基于请求触发而构建)。

缓存可靠性

在分布式系统盛行的今天,尤其是在一些用户体量比较大的互联网业务系统里面,缓存充当着扛压屏障的作用。当前各互联网系统可以抗住动辄数万甚至数十万的并发请求量,缓存机制功不可没。而一旦缓存出现问题,对系统的影响往往也是致命的。所以在缓存的使用时必须要考虑完备的兜底与灾难应对策略。

热点数据与淘汰策略

大部分服务端使用的抗压型缓存,为了保证缓存执行速度,普遍都是将数据存储在内存中。而受限于硬件与成本约束,内存的容量不太可能像磁盘一样近乎无限的去随意扩容使用。对于实际数据量极其庞大且无法将其全部存储于缓存中的时候,需要保证存储在缓存中的有限部分数据要尽可能的命中更多的请求,即要求缓存中存储的都是热点数据

那么就会存在一个不得不面对的问题:当数据量超级大而缓存的内存容量有限的情况下,如果容量满了该怎么办

缓存实现的时候,必须要有一种机制,能够保证内存中的数据不会无限制增加 —— 也即数据淘汰机制数据淘汰机制,是一个成熟的缓存体系所必备的基础能力。这里有个概念需要厘清,即数据淘汰策略与数据过期是两个不同的概念。

  • 数据过期,是缓存系统的一个正常逻辑,是符合业务预期的一种数据删除机制。即设定了有效期的缓存数据,过期之后从缓存中移除。
  • 数据淘汰,是缓存系统的一种“有损自保”的降级策略,是业务预期之外的一种数据删除手段。指的是所存储的数据没达到过期时间,但缓存空间满了,对于新的数据想要加入缓存中时,缓存模块需要执行的一种应对策略。

把缓存当做一个容器,试想一下,一个容器已满的情况下,继续往里面放东西,可以有什么应对之法?无外乎两种:

  1. 直接拒绝,因为满了,放不下了。
  2. 从容器里面扔掉一些已有内容,然后腾挪出部分空间出来,将新的东西放进来。

进一步地,当决定采用先从容器中扔掉一些已有内容的时候,又会面临一个新的抉择,应该扔掉哪些内容?实践中常用的也有几种方案:

  1. 一切随缘,随机决定。从容器中现有的内容中随机扔掉剔除一些。
  2. 按需排序,保留常用。即基于LRU策略,将最久没有被使用过的数据给剔除掉。
  3. 提前过期,淘汰出局。对于一些设置了过期时间的记录,将其按照过期时间点进行排序,将最近即将过期的数据剔除(类似让其提前过期)。
  4. 其它策略。自行实现缓存时,除了上述集中常见策略,也可以根据业务的场景构建业务自定义的淘汰策略。比如根据创建日期、根据最后修改日期、根据优先级、根据访问次数等等。

一些主流的缓存中间件的淘汰机制大都也是遵循上述的方案来实现的。比如Redis提供了高达6种不同的数据淘汰机制,供使用方按需选择,将有限的空间仅用来存储热点数据,实现缓存的价值最大化。

缓存雪崩、击穿、穿透

缓存雪崩、击穿、穿透

关注缓存的一致性保证

在高并发类的系统中进行数据更新的时候,缓存与数据库的数据一致性问题,是一个永远无法绕过的话题。对于基于旁路型缓存的大部分业务而言,数据更新操作,一般可以组合出几种不同的处理策略,具体看这篇文章缓存和数据库一致性问题

  • 先更新缓存,再更新数据库
  • 先更新数据库, 再更新缓存
  • 先删除缓存,再更新数据库
  • 先更新数据库,再删除缓存

由于大部分数据库都支持事务,而几乎所有的缓存操作都不具有事务性。所以在一些写操作并发不是特别高且一致性要求不是特别强烈的情况下,可以简单的借助数据库的事务进行控制。比如先更新数据库再更新缓存,如果缓存更新失败则回滚数据库事务。

然而在一些并发请求特别高的时候,基于事务控制来保证数据一致性往往会对性能造成影响,且事务隔离级别设置的越高影响越大,所以也可以采用一些其它辅助策略,来替代事务的控制,如重试机制、或异步补偿机制、或多者结合方式等。

比如下图所示的这种策略:

上图的数据更新处理策略,可以有效地保证数据的最终一致性,降低极端情况可能出现数据不一致的概率,并兜底增加了数据不一致时的自恢复能力。

seven97官方微信公众号
seven97官方微信公众号