1

- 谈起缓存,大家都不陌生。

- 特别是对于国内的互联网行业来说,高并发问题是一个绕不过的坎。

而其中最常见的解决方案就是 —— 缓存。

/前言/

and Introduction

缓存其实概念很简单,即临时存储一些文件;但其究竟是怎么去运作,在使用中应该注意什么,却值得我们去琢磨。今天我们就来谈谈,缓存的那些事儿。

/ 我们身边的那些缓存/

and my life

计算机中缓存的身影

从硬件的角度来说,缓存就是可以进行高速数据交换的存储器。从我们使用的每台计算机开始剖析,到处都充满着缓存的身影。最经典的莫过于CPU层面的多级缓存:计算机的处理速度之所以可以发展的越来越快,缓存在其中占着重中之重。从性能最高的寄存器、到稍逊一筹的多级缓存、主存,最后到了外部的机械硬盘。计算机总是优先从读写性能最好的存储器中来读写数据。

对于上图,我们以读书来举例说明:当我在读书时,书(文件)捧在手里(寄存器), 我最近频繁阅读的书则放在书桌上(缓存),这样当我想读的时候可以直接从书桌上(缓存里)拿而不是再从图书馆(硬盘)取。当然书桌上只能放有限几本书(缓存大小有限)。我更多的书在书架上(内存)。如果书架上没有的书,就去图书馆(磁盘)。则一个整体的顺序为:当我想读书时,如果要读的书手里没有,那么去书桌上找,如果书桌上没有,去书架上找,如果书架上没有,去图书馆去找。对应到硬件上即若寄存器没有,则从缓存中取,缓存中没有,则从内存中取到缓存,如果内存中没有,则先从磁盘读入内存,再读入缓存,再读入寄存器。

常用中间件中缓存的身影

回归到我们的日常开发中,我们用到的中间件,比如数据库、RocketMQ、ES等等都有着缓存的身影:

数据库缓存以最常见的MySQL为例,MySQL拿到一个查询请求后,会先到查询缓存中查看是否有缓存过这条SQL(可能会以key-value对的形式:key是查询的语句,value是查询的结果)。若缓存命中,则会直接返回查询结果;若未命中,就会继续后面的执行阶段。执行完成后,执行结果会被存入查询缓存中。从理论上看,这样确实效率会比较高,但实际中不建议使用。其原因是对于常用的业务表,查询缓存的失效非常频繁,只要我们对表有更新,缓存就会被清空,只有一些纯配置的表,才比较适合。甚至在MySQL 8.0以后,查询缓存功能被移除了。

RocketMQ缓存:RocketMQ中,在Page Cache(页缓存)机制的预读取作用下,Consume Queue文件的读性能几乎接近读内存,即使在有消息堆积情况下也不会影响性能。Page Cache是OS对文件的缓存,用于加速对文件的读写。一般来说,程序对文件进行顺序读写的速度几乎接近于内存的读写速度,主要原因就是由于OS使用Page Cache机制对读写访问操作进行了性能优化,将一部分的内存用作Page Cache。对于数据的写入,OS会先写入至Cache内,随后通过异步的方式由pdflush内核线程将Cache内的数据刷盘至物理磁盘上。对于数据的读取,如果一次读取文件时出现未命中Page Cache的情况,OS从物理磁盘上访问读取文件的同时,会顺序对其他相邻块的数据文件进行预读取。

ES缓存:分为Query Cache、Request Cache、Fielddata Cache。Query Cache是对一个查询中包含的过滤器执行结果进行缓存,比如我们常用的term,terms,range过滤器都会在满足某种条件后被缓存,但是bol过滤器不会,它的子term会。Request Cache是针对分片结果集的缓存,缓存的key是查询DSL字符串。Fielddata Cache是专门针对分词的字段在query-time(查询期间)的数据结构的缓存。当我们第一次在一个分词的字段上执行聚合、排序或通过脚本访问的时候就会触发该字段Fielddata Cache的加载。

/ 从架构的发展看缓存的使用/

and structure

随着系统架构的不断演化,缓存的使用也在不停的变化。在不同的架构中,缓存有着不同的使用方式。

单体架构

在单体架构中,使用的比较多的是本地缓存、缓存组件(Map,ConcurrentHashMap)以及第三方的组件库如Ehcache、OScache。在最初的业务场景下,我们主要用本地缓存来缓存数据库的一些数据,随着业务的发展我们也会缓存一下配置信息或通用信息,本地缓存的数据就会产生一些差异,这种情况下集中式缓存memcache就开始兴起,数据一致性要求较高的数据就会维护到集中式缓存中。

集群架构

在集群架构中,随着业务的发展也会有不同的使用方式,如上图所示我们可以看到,所有的业务都访问同一个集中式缓存,这种情况下业务是混合进行部署的,业务划分就相对不是很清楚。

我们再看到下图,每种业务都使用独立的缓存,从业务层将缓存进行了隔离,防止互相影响。

除了集中式缓存,我们也可以在业务中使用本地缓存,这种情况下对本地缓存和集中式缓存的一致性没有太大的要求,不过我们都知道随着业务发展,本地缓存的一致性是势在必行的,那么这种情况下我们可以对缓存数据进行监听,同步更新到本地缓存,如下所示。

/ 缓存的分类 /

and categorys

主要分为两大类,客户端缓存和服务端缓存。

客户端缓存

(1) 页面缓存:

一种是页面自身对某些元素或全部元素进行缓存。

另一种服务端将静态页面或者动态页面进行缓存,然后给客户端使用。

(2) 浏览器缓存:

(3) App缓存:

Android

轻量级缓存框架:ASimpleCache

iOS:

常用缓存框架:SDWebImage、NSCache

服务端缓存

(1) 数据库缓存:

数据库缓存是数据库自身的缓存机制,上文也提到过了,sql执行过程中query cache阶段。

(2) 平台级缓存:

平台级缓存就是带有缓存功能的专用库,或者具有缓存特性的框架。常用的有Ehcache、Guava Cache。

(3) 应用级缓存:

当平台级缓存不能满足系统性能要求的时候,需要开发者通过代码来实现缓存机制。常用的有redis、Memcached。

/缓存常见问题以及解决方式/

problems and solutions

缓存虽然有着其独特的优势和好处,但是并不意味着可以滥用,缓存的适用也会存在常见的问题。谈起缓存的使用我们最关注的就是两点:数据的一致性和缓存的命中率。

下面就来说说常见的缓存使用带来的问题和一些解决手段。

问题1. 缓存穿透

这个问题其实是缓存命中率的问题,外部可以非法请求一个缓存和DB中必定不存在的数据,让其击穿缓存直接打到DB,不断的大流量攻击就可以让DB瘫痪。这个问题只要使用缓存就一定存在,也是一个在生产中比较危险的问题。

​常用的解决手段有:

(1) 空数据设置缓存key:

当请求第一次穿过缓存,查询到DB返回空数据时,我们可以短暂的将空值也进行缓存。当然缓存时间不宜过长,比如给个3秒的缓存,这样做可以防止短时间内的高流量穿透缓存。这个方案的缺点也比较明显,如果短时间大量请求不同的key,那么预防效果就会大打折扣,并且也会高度占用内存空间,要知道缓存的代价是非常昂贵的。

(2) 业务逻辑前置过滤:

我们可以在业务逻辑中,根据合适的业务场景来主动判断过滤掉一些非法请求。比如根据ID查询商品信息,对入参的商品ID不大于0的,或者大于一个你当前业务根本不可能达到的数值,我们就可以当做非法请求过滤掉。这个方法也有缺点,不适用广泛的业务场景,无法做到通用化,并且需要对于业务场景有个合理的判断,才可以过滤掉非法请求。

(3) Redis的布隆过滤器:

布隆过滤器的原理是,当一个元素被加入集合时,通过K个Hash函数将这个元素映射成一个位数组中的K个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的基本思想。将非法请求的key放入布隆过滤器中,下次再来请求时我们从布隆过滤器中就可以判断是不是非法的,从而避免缓存的穿透。而且布隆过滤器的存储空间利用的非常之小,因此可以存储大量的key。当然布隆过滤器其的缺点就是存在误判的可能。

问题2. 缓存击穿

缓存击穿和缓存穿透从字面来看十分相似,但是现象确实不甚相同。缓存击穿是指:在缓存数据失效的瞬间,有着大量的请求打入,从而没有命中缓存直接打入DB的过程。同样的,这其实也是一个缓存命中率的问题,如何提高缓存数据的命中率?

常用的几种方案:

(1) 缓存的预热:

针对预测高峰流量做的防护处理,有效防止突发的热点key击穿现象(还没有缓存)。比如零点活动,等活动开始根据请求自行实现数据缓存,此时已经为时已晚。这种方法需要对于高流量的精准预测才可以起到作用,因此适用范围只有特定的业务场景。

(2) 针对热key失效时间点,随机均匀分布:

往往热key都是在同一个时间点(短暂的时间段)创建的,如果固定有效时长,则失效将在同一个时间点,当失效发生时,缓存击穿的可能性很大。我们可以在业务要求的失效时间点上,加上随机时长(范围),可以分散热key失效时间点,这样可以有效的增加一部分key的缓存命中率,从而防止全部击穿。

(3) 针对高并发热点key击穿,逻辑失效异步更新:

对于高并发的热点key,我们做逻辑失效。当数据失效时我们依然返回失效数据,只是异步的用线程去更新缓存。这样可以保证无论何时都有缓存替我们抵抗大流量的冲击,缺点是部分返回的数据是尚未更新的旧数据,所以如果业务场景对数据的实时性要求不是特别严格的话就可以使用这个方法。

(4) 多级缓存、限流排队:

回归到本质上来,缓存击穿的问题本质是一个缓存数据命中率的问题。如何保证数据的命中率,我们很直接的一个做法就是采用多级缓存的设计。比如我们可以设置三级缓存,首先去一级缓存中拿、一级缓存没有命中就从二级缓存拿,最后才从三级缓存获取。这样的做法虽然大大提高了缓存数据的命中率,但是缺点和上面有个一样,那就是数据一致性的问题。

除此以外,我们可以底层DB做好限流排队,当缓存被击穿时,我们底层有排队限流,这样也可以防止瞬时流量打入DB,当然这已经算是最后采取的方案了。

问题3. 缓存数据的一致性

缓存虽然有着天然的读写性能优势,可以帮助我们抗住大量的请求,提高系统的性能。但是,一旦数据做了缓存,那么一定就会面临着另一个问题:数据一致性的问题。对于数据一致性的处理,需要根据不同的业务场景来看。如果业务场景需要很高的数据一致性,那么我们就需要所有操作缓存的地方统一做收口、更改的时候统一做更新,这样可以保证严格的一致性。如果业务场景没有那么高的一致性要求,我们可以给定缓存数据的失效时间,失效后主动更新缓存。

/ 缓存常用的案例/

and examples

随着系统架构的不断演化,缓存的使用也在不停的变化。在不同的架构中,缓存有着不同的使用方式。

(1) 文案转换

对于一些热门的常用的文案,我们会放到了缓存中降低数据库压力。

(2) 商品信息

对于一些人气热度很高的商品,我们会将其主要的信息存储在缓存中以进行快速访问。

(3) 安全控制

对于一些需要控制访问次数的地方,我们也会通过缓存来实现。

/ 写在最后/

and the ending

在日常的业务中,缓存是必不可少的,如何正确使用缓存是我们必须要了解的,随着业务不断的发展,我们会遇到各种缓存的问题,只有不断学习才能解决这些未知的坑,期待与你共同进步。

- END -

关注我们
【得物技术】

文 | 银河舰队


得物技术
846 声望1.5k 粉丝