写在前面
Redis因为其自身高性能的数据读取能力,因此会经常被应用到缓存的场景中,本文就一起看下Redis当做缓存使用时的特点,问题,以及需要注意的点。
1:缓存的架构模式
从架构模式上来看缓存系统可以分为旁路缓存,内嵌缓存,分别来看下。
1.1:旁路缓存
旁路缓存在系统架构中所处的位置如下图:
数据读取方式如下:
1:当缓存中有数据时直接读取缓存数据
2:当缓存中没有数据时,从后端存储中读取数据,并将数据写入到缓存中
数据写入方式如下:
1:同时写入缓存和后端存储,保持数据的强一致性
2:数据仅写入后端存储,并是缓存失效
这种方式由应用程序负责管理缓存,即需要在应用程序中写缓存管理相关代码,可能代码如下:
本文我们要分析的Redis作为缓存使用就是旁路缓存。
1.2:内嵌缓存
内嵌缓存在系统架构中所处的位置如下图:
数据读取方式如下:
1:当缓存中有数据时,则直接返回数据
2:当缓存中没有数据时,直接从后端存储中读取数据(透读),缓存数据,并返回数据
数据写入方式:
1:数据写入缓存后,缓存以同步(透写)的方式将数据写入到后端存储
2:数据写入缓存后,缓存以异步(后写)的方式将数据写入到后端存储
我们的计算机系统使用的缓存就是内嵌缓存,因为CPU,内存,磁盘的数据存取速度差别很大,所以需要采用缓存机制,三者的时间级别如下图:
为了避免因为获取下层数据拖慢整个过程,CPU提供了LLC内嵌缓存,用来加速从内存中获取数据,内存提供了page cache用来加速从磁盘中获取数据,此时结构如下图:
2:缓存的特征
缓存系统主要有以下两个特征:
1:缓存是一个快速子系统,能够提供快速的数据存取服务
2:缓存系统的容量大小一定小于后端慢速系统,即不可能将
对于以上两个特点,1Redis是满足的,2因为Redis提供了数据淘汰策略,所以也是满足的,所以Redis是适合作为缓存系统来使用的。
3:缓存的类型
只读缓存,读写缓存。
3.1:只读缓存
仅仅提供数据读取,如果是数据写入的话,则直接写到后端存储中,缓存中的数据直接失效,下次从后端存储查询后再写到缓存中,如下图:
3.2:读写缓存
读写缓存是,缓存也接收更新,有如下两种方式:
同步直写:即更新缓存后同时更新后端存储
异步写回:即只更新缓存,后续再更新后端存储
这两种方式如下图:
同步只写会降低程序的性能,但是保证数据不丢失和一致性,异步写回,可能会导致数据丢失,但是可以提高更新速度,从而提高程序性能。
4:缓存满了怎么办
缓存满了,到底是缓存真的满了,还说说是我们设置的内存太小了!所以在看缓存满了怎么办
这个问题之前,首先要来看下如何设置一个合适的缓存大小。
4.1:设置多大的容量
设置容量我们可以参考八二原理
,即百分之八十的访问量是由百分之二十的数据贡献的,如下图:
图中展示的蓝色区域就是20%的数据贡献的80%的访问量,而粉色区域就是剩余的80%的数据贡献的20的访问量,即蓝色区域面积:粉红色区域面积=8:2
,当然这只是统计学意义上的一个概念,和我们实际的业务场景还是有一定偏差的,所以结合不同的业务场景,这个20%可以是一个范围,这个范围是15%到30%
,兼顾访问性能和内存带来的开销,那么具体应该怎么做呢?我们觉得可以这样,比如10G一共有10G的数据,我们可以先设置为20%,如2G,然后观察其缓存命中率 ,如果命中率特别高,比如达到了95%,则我们就可以适当的降低内存,但是如果缓存命中率不很理想,比如70%,则我们就需要适当的调高内存,一般能达到90%左右就是一个比较理想的状态了。
比如确定了内存为4g则可以通过命令config set maxmemory 4gb
来动态调整。那么,缓存大小已经确定了,当缓存满了该怎么办呢?这就需要看下缓存的淘汰策略了。
4.2:缓存的淘汰策略
redis当前一共提供了8中淘汰策略,通过配置项maxmemory-policy
来配置,默认是noeviction
,其含义是不淘汰,这种策略当缓存满时如果写入数据,会因为无法写入数据而直接返回错误,这里的noeviction也正是redis提供的8中淘汰策略的一种。对于这8中淘汰策略,我们可以按照是不淘汰数据,淘汰数据划分,对于淘汰数据又可以按照从设置了过期时间的数据中淘汰,从所有的数据中淘汰,如下:
不淘汰数据:noeviction
淘汰数据:
从设置了过期时间的数据中淘汰
volatile-random
volatile-lru
volatile-lfu
volatile-ttl
从所有的数据中淘汰
allkeys-random
allkeys-lru
allkeys-lft
可以看到从设置了过期时间的数据中淘汰
的淘汰策略相比于从所有的数据中淘汰
仅仅多了一个基于过期时间的ttl策略。接下来我们分别看下每种淘汰策略具体的工作方式。
4.2.1:noeviction
不淘汰数据,当空间不足无法写入新数据时,停止对外服务,直接给写入请求返回错误。
4.2.2:volatile-random
从设置了过期时间的数据中随机删除。
4.2.3:volatile-lru
从设置了过期时间的数据中通过lru算法删除,这里介绍下lru(least recent used)算法,该算法通过一个队列维护数据,队列头叫做MRU端(most recent used),对列尾叫LRU(least recent used)端,当某个数据被访问或新写入时,就会被移动到MRU,其他数据后移,如下图:
但是实际应用时,因为Redis的数据较多,如果对全部数据维护一个大的lru链表的话,会带来额外的空间开销,因此,Redis对该算法进行了优化,具体是首先在RedisObject的元数据中维护lru属性作为数据最近被访问的时间,当需要淘汰数据时,随机的选择的N(通过参数max-samples配置)
个数据,然后选择其中lru值最小的进行删除。
4.2.4:volatile-lfu
类似于lru,但是在RedisObject的元数据中又多维护了一个访问次数的信息counter,当淘汰数据时也是随机选择N个数,但是淘汰数据的策略是,优先淘汰访问次数最少的,如果是访问次数相同的话,则淘汰最近一次访问时间最早的(这和lru是一致)
。
4.2.5:volatile-ttl
从设置了过期时间的数据中选择一个最接近过期时间的删除。
4.2.6:allkeys-random
从所有数据中随机选择一个删除。
4.2.7:allkeys-lru
对所有数据使用lru算法删除,同4.2.3:volatile-lru
。
4.2.8:allkeys-lfu
同4.2.4:volatile-lfu
,不同之处是从所有的数据中选择。
4.2.9:怎么选
allkeys-lru:业务数据有明显的冷热数据区分,让最常访问的数据保留下来,提升访问性能。
allkeys-random:业务数据没有明显的冷热数据区分,访问比较均匀
volatle-lru:存在所有用户一定会查看的数据,比如全站的通知,置顶的新闻等,对于这些数据可以不设置过期时间,这样永远不会被删除,其他数据设置过期时间通过lru淘汰
5:如何解决缓存一致性问题
在分析这个问题之前,我们要先看下这里的一致性是什么,主要以下两方面:
1:缓存和数据库中都有数据,二者数据必须一直,且都是最新数据
2:缓存中没有数据时,数据库中的数据必须是最新数据
以上两种的任何一种情况发生我们就说出现了数据不一致。我们知道,Redis一般作为旁路缓存使用,而具体的缓存类型又可以分为只读缓存和读写缓存,关于数据一致性的问题,我们也需要从这两种缓存类型切入进行分析。
5.1:读写缓存的数据一致性问题
我们先来回顾下读写缓存的特点,读写缓存即更新时先更新缓存(目的是提高更新速度)
,对于后端存储的更新有以下的两种方式:
同步直写策略:写缓存时同步更新后端存储
异步写回策略:写缓存后,异步的写后端存储
其中同步写回策略不会有数据一致性的问题,但是需要引入事务,保证二者操作的原子性。对于异步写回策略,如果是异步更新后端存储失败,则会出现数据一致性问题。所以,对于读写缓存要想解决数据一致性问题,可以采用同步直写策略
,或者是增加重试机制。
5.2:只读缓存的数据一致性问题
注意以下分析,基于业务接受短时间内读取到不一致的数据,对于业务不接受任何数据不一致的场景,就需要考虑锁等待。
对于只读缓存我们需要将操作分成两类来进行分析,即新增数据,和删改数据。
5.2.1:新增数据
新增数据时,会直接将数据写到后端存储,此时缓存中是没有数据的,符合缓存中没有数据时,数据库中的数据必须是最新数据
的条件,因此此时不存在数据一致性问题。
5.2.2:删改数据
对于删改数据,我们可以分为以下不同的场景来分析。
- 先更新数据库成功再删除缓存失败
这种情况是因为缓存没有删除,导致数据库和缓存数据不一致,出现问题的根本原因是后更新缓存失败,因此我们只需要加入重试机制再次尝试删除缓存就行了,具体的可以通过消息队列来实现重试机制,如下图:
- 先删除缓存成功后更新数据库失败
这种情况因为数据库没有更新,所以就会出现了数据一致性问题,如下图:
那么此时,怎么办呢?出现问题的根本原因是后更新数据库失败,因此我们只需要加入重试机制再次更新数据库就行了。
- 先删除缓存成功后更新数据库也成功
这种情况出现数据一致性的场景是,在线程A删除缓存后,更新数据库前的时间段,线程B要获取缓存值,此时线程B发现缓存中没有对应的值,因此就会从数据库中读取,而此时读取到的是还没有更新的值,而后线程B会将该值重新写到缓存中,最后线程A有更新数据库,最终导致缓存中是老值,而数据库中的是新值,此时二者不一致,就导致了数据一致性问题,这个过程可以参考下图:
出现数据不一致的原因是红框中的操作,此时,我们只需要在一段时间后删除红框中的写入就可以了,即线程A操作完成后sleep一段时间,再一次删除缓存,这个sleep的时长可以通过计算完成数据库读取+写入到缓存
的时间估算出来。此时伪代码可能如下:
- 先更新数据成功后删除缓存也成功
这种情况,假设线程A更新数据库后,还没有来得及删除缓存,此时如果是有线程B来读取缓存值,会从缓存中读取到旧值,但是当线程A最终删除缓存后,后续不会有数据一致性的问题,所以这种场景不存在数据一致性的问题。
6:缓存雪崩,缓存击穿,缓存穿透
这3个问题都是因为无法通过缓存获取数据,进而导致请求大量挤压到数据库系统,造成数据库的巨大压力,甚至导致数据库的宕机,进而引发线上事故,接下来分别看下。
6.1:缓存雪崩
缓存雪崩的定义是大量数据无法在缓存中获取,进而导致应用层将请求发送到数据库,给数据库造成巨大的压力。一般缓存雪崩由两方面原因造成,首先是大量的key在同一时间过期,其次是redis实例宕机,针对这两种情况来分别看下如何处理。
6.1.1:大量key同时过期
这种情况参考下图:
针对大量key同时过期造成的缓存雪崩,我们可以从事前预防和事后补救两方面来进行分析。
- 事前预防
既然是大量key同时过期造成,我们只需要注意在程序中不要设置给大量的key设置相同的过期时间就可以了,具体的做法是,设置过期时间时通过增加一段时间的随机值,比如1~3分钟
。
这种事前预防是业务无损的,实际应用中可以放心的使用。
- 事后补救
服务降级,即如果是查询的是核心数据则允许从数据库直接获取,如果查询的是非核心数据则直接返回错误。如下图:
服务降级的思想就是丢车保帅,断尾自救。
注意:这种方式是业务有损的,实际应用还是要格外小心!
6.1.2:缓存实例宕机
从事前预防和事后补救两方面来分析。
- 事前预防
配置主从节点的高可用集群,当主节点宕机后,从节点直接切换为主节点继续提供服务(可通过哨兵 实现)。 - 事后补救
服务熔断和服务限流,其中服务熔断是访问缓存的客户端不再访问缓存而是直接返回错误,如下图:
服务限流是,只允许一定比例的请求执行,剩余的请求直接返回,如下图:
6.2:缓存击穿
缓存击穿是一些热点数据因为过期被删除导致请求挤压到数据库,如下图:
这种情况解决方案是,对于会频繁访问的热点数据不要设置过期时间。
6.3:缓存穿透
缓存穿透不同于缓存雪崩和缓存击穿,后二者都是缓存中没有数据,但是在数据库中有数据,但是缓存穿透是在缓存中和数据库中都没有数据,但是效果是一样的,都是请求全部到数据库,给数据库造成巨大的压力,但因为数据库中没有数据,所以其影响要比缓存雪崩和缓存击穿更严重,参考下图:
造成缓存穿透的原因可能如下:
1:程序错误,导致同时删除了缓存和数据库中的数据
2:恶意攻击,故意访问不存在的数据
对于缓存穿透有如下几种方案。
6.3.1:直接缓存约定值
对于缓存中没有数据库中也没有的值,我们可以和业务方来约定一个值代表这种情况,比如notExitsInCacheAndDb
,当返回该值时业务方就知道发生了缓存穿透,可以做特殊处理,并且该约定值也会被缓存到缓存中,下次可以使用。
6.3.2:基于布隆过滤器判断数据库是否存在
直接查询数据库判断是否存在的成本较高,会增加数据库的压力,此时,这个判断过程可以通过布隆过滤器完成,布隆过滤器的工作原理是,使用若干个bit的0和1来表示该值是否存在,而具体是哪几个位则通过对应个数的哈希函数获取,如hash(key)%bit数
就是对应的bit的位置,当这若干个bit位有一个为0时,则说明肯定不存在,工作原理可以参考下图:
因此,我们只需要将数据库中的值,维护到这个bit数组中,后续判断时直接通过布隆过滤器来判断,如果是不存在就不用通过数据库来查询了,减轻了数据库的压力。
6.3.3:恶意请求检测
程序中可以通过参数等信息来判断是否为恶意请求,如果是恶意请求则直接过滤,这种方式和业务关系密切,需要好好考虑下如何做,不然可能出现误杀的情况。
7:缓存污染
7.1:什么是缓存污染
访问次数很少的数据,一直保留在缓存中,占用缓存空间的情况就是缓存污染。即不会或几乎不会再次被访问的数据一只保留在缓存中。
7.2:如何解决缓存污染
缓存污染是不应该继续保留在缓存中数据留在缓存中,因此我们需要通过一定的手段来将其淘汰出缓存,而淘汰缓存就需要用到缓存的淘汰策略,因此这里一定的手段就是配置合适缓存淘汰策略了,接下来我们就针对每种缓存淘汰策略看下其是否适合用来解决缓存污染问题。
- noeviction
该策略在缓存满时不会淘汰数据,因此不能用来解决缓存污染问题。
- volatile-random
随机删除,可能删除经常访问的数据,当然也可能删除造成缓存污染的数据,但是随机性太强,也不能用来解决缓存污染问题,并且是只会针对设置了过期时间的数据,如果是没有设置过期时间的话,则无法处理。
- volatile-lru
如果是都设置了过期时间的话,适合用来解决缓存污染的问题。但是有一种情况不适合,即存在大批量的key单次扫描的情况,此时大量的key的访问时间都很新,也就无法淘汰,但是访问次数都很少,这种场景就需要考虑lfu算法的淘汰策略。
- volatile-lfu
如果是都设置了过期时间的话,适合用来解决缓存污染问题。
- volatile-ttl
正常数据的过期时间并不能反映出其是否是造成缓存污染的数据,所以不适合用来解决缓存污染问题,但是如果是业务上清晰的知道哪些数据使用的时间长,哪些数据使用的时间短,并基于此设置了对应有效期的话,则适合用来解决缓存污染的问题。
- allkeys-lru
类似于volatile-lru,但也无法解决大批量key单次扫描的问题,也需要使用lfu。
- allkeys-lfu
适合用来解决缓存污染问题。
- allkeys-random
随机删除数据,作用有限,不能用来解决缓存污染问题。
写在后面
参考文章列表:
缓存的几种架构模式 。