1、引言
本文主要介绍在使用缓存过程中经常会遇到的几个问题:缓存击穿、缓存雪崩、缓存穿透,以及其解决方案。之后会对缓存穿透的解决方案之一布隆过滤器,进行详细讲解。
2、缓存击穿
缓存击穿是一个失效的热点Key被并发集中访问,导致请求全部打在数据库上。实际情况是,这个热点key在缓存中不存在,但是在数据库中存在,这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库取数据,引起数据库压力瞬间增大,造成过大压力。如下图:
解决方案:
1)热点key缓存不失效:对热点key可以设置永不过期;
2)使用互斥锁或堵塞队列:这样可以控制数据库的线程访问数,减小数据库的压力,但也会让系统吞吐率有所下降。实现流程如下:
- 阻塞当前 Key 的请求;
- 从后端存储恢复数据;
- 在Key 对应的Value 还未恢复的过程中, 如果有其他请求继续获取该 Key, 同样阻塞该请求;
- 当key 从后端恢复后,依次唤醒该 Key 对应阻塞的请求;
3)热点数据由代码来手动管理,缓存击穿是仅针对热点数据被自动失效才引发的问题,对于这类数据,可以直接由开发者通过代码来有计划地完成更新、失效,避免由缓存策略来自动管理。
4)接口限流与熔断,降级。重要的接口一定要做好限流策略,防止用户恶意刷接口,同时要降级准备,当接口中的某些服务不可用时候,进行熔断,失败快速返回机制。
3、缓存雪崩
缓存雪崩是指缓存中数据大批量过期,而查询数据量巨大,导致数据库瞬时压力过载从而拒绝服务甚至是宕机。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
分析:造成缓存雪崩的关键在于在同一时间大规模的key失效。诱因可能是:
- 大量热点数据设置了相同或相近的过期时间;
- 缓存组件不可用,比如redis宕机了。
解决方案:
1)打散缓存失效时间:主要是通过对key的TTL增加随机数去尽量规避,过期时间则需要根据业务场景去设置;
2)使用多级缓存;
3)兜底逻辑使用熔断机制,防止过多请求同时打到DB。在触发熔断机制后返回预先配置好的兜底数据,减少过多请求压倒DB导致服务不可用。
4)组件高可用:
- 对于redis这样的缓存组件,可以搭建Redis集群(集群模式或哨兵模式),提高Redis的可用性,尽量规避单点故障导致缓存雪崩;
- Redis基于一个Master主节点多Slave从节点的模式和Redis持久化机制,将一份数据保持在多个实例中,从而实现增加副本冗余量,又使用哨兵机制实现主备切换, 在master故障时,自动检测,将某个slave切换为master,最终实现Redis高可用;
- 提高数据库的容灾能力,可以使用分库分表,读写分离的策略。
4、缓存穿透
缓存穿透是指用户查询数据库没有的数据,缓存中自然也不会有。那先查缓存再查数据相当于进行了两次无效操作。大量的无效请求将给数据库带来极大的访问压力,甚至导致其过载拒绝服务。下图中红色箭头标出了每一次用户请求到来都会经过的两次无效查询。
分析:造成缓存穿透的原因可能包括:
1)空数据查询(黑客攻击),空数据查询通常指攻击者伪造大量不存在的数据进行访问(比如不存在的商品信息、用户信息);
2)缓存污染(网络爬虫),缓存污染通常指在遍历数据等情况下冷数据把热数据驱逐出内存,导致缓存了大量冷数据而热数据被驱逐。
解决方案:
1)接口层增加校验,如用户鉴权校验,id做基础校验等,id<=0的直接拦截;
2)缓存空值,当碰到查询结果为空的key时,放一个空值到缓存中,下次再访问就知道此key是无效的,避免无效查询数据库,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也无法使用),这种方案需要花费额外的空间来存储空值。
3)对于空数据查询,使用布隆过滤器高效判断key是否存在,流程图如下:
4)对于缓存污染,关键点是能识别出只访问一次或者访问次数很少的数据,然后使用淘汰策略去删除冷数据。
5、布隆过滤器
5.1 布隆过滤器的思想
缓存穿透解决方案之一是使用 布隆过滤器,而其原理就是判断数据是否存在redis里,以决定是否需要将请求打到缓存。
那么,如果想要判断一个元素是不是在一个集合里,一般想到的方法是将所有元素保存起来,然后通过比较进行确定。链表、树等数据结构都是这种思路。但是随着集合中元素的增加,我们需要的存储空间越来越大,检索速度也越来越慢(O(n),O(logn))。
不过还有一种叫作散列表(又叫哈希表,Hash table)的数据结构。它可以通过一个Hash函数将一个元素映射成一个位阵列(Bit array)中的一个点。这样一来,我们只要看看这个点是不是1就可以知道集合中有没有它了。这就是布隆过滤器的基本思想。
5.2 什么是布隆过滤器?
布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。
优点:
- 相比于其它的数据结构,布隆过滤器在空间和时间方面都有巨大的优势。布隆过滤器存储空间和插入/查询时间都是常数;
- Hash函数相互之间没有关系,方便由硬件并行实现;
- 不需要存储元素本身,在某些保密要求非常严格的场合有优势;
缺点:
- 误算率:随着存入的元素数量增加,误算率随之增加。常见的补救办法是建立一个小的白名单,存储那些可能被误判的元素。但是如果元素数量太少,则使用散列表足矣;
- 删除困难:一般情况下不能从布隆过滤器中删除元素。
5.3 布隆过滤器构建
布隆过滤器使用了上面的思想,即利用哈希表这个数据结构,通过一个Hash函数将一个元素映射成一个位阵列(Bit array)中的一个点(每个点只能表示0或者1),这样一来,我们只要看看这个点是不是1就知道在集合中有没有它了。
但是在哈希冲突的情况下,我们无法使用一个哈希函数来判断一个元素是否存在于集合之中,解决方法也简单,就是使用多个哈希函数,如果其中有一个哈希函数判断该元素不在集合中(元素经过Hash之后映射在位阵列中的点为0),那肯定就不在。如果它们都判断存在,那也有一定可能性它们都在说谎,不过这要比只用一个哈希函数来判断“一个元素存在于集合之中”的可靠性要高很多。这种多个Hash组成的数据结构就叫Bloom Filter。
我们以两个字符串str1、str2为例,判断其是否存在于集合中,如上图所示:
1)首先,对str1进行三次哈希,确定其在位阵列中的位置。三次哈希,对应的下标分别是1、5、8,将原始数据从0变为1;
2)对str2,进行三次哈希,确定其在位阵列中的位置。三次哈希,对应的下标分别是7、8、99,将原始数据从0变为1;
Hash规则:如果在Hash后,原始位是0的话,将其从0变为1;如果这一位数本身已经是1的话,则保持不变。
5.4 布隆过滤器使用
和初始化的过程类似,当要从缓存中查询一个key时,首先要判断这个key是否存在。过程如上图:
1)使用三个哈希函数对key计算哈希值;
2)在布隆过滤器位阵列中查找访问对应的位置,0或1;
3)判断三个值,只要有一个不是1,那么我们认为数据是不存在的;
注意:布隆过滤器只能精确判断数据不存在的情况,而对于存在,我们只能说是可能存在,因为存在哈希冲突的情况,当然这个概率非常低。
5.5 误算率降低方法
随着存入的元素数量增加,误算率随之增加。常见的降低办法包括:
1)建立一个小的白名单,存储那些可能被误判的元素;
2)增加二进制位数组的长度,这样经过hash后数据会更加的离散化,出现冲突的概率会显著降低;
3)增加Hash的次数,变相的增加数据特征,特征越多,冲突的概率越小;
5.6 如何处理数据删除
初始化后的布隆过滤器,可以直接拿来使用。但是如果原始数据删除了怎么办?布隆过滤器位阵列如何维护?如果直接删除,因为里面有Hash冲突的情况,可能会导致误删。
解决方案:
方案1:开发定时任务,每隔几个小时,自动创建一个新的布隆过滤器,替换老的,有点CopyOnWriteArrayList
的味道
方案2:布隆过滤器增加一个等长的数组,存储计数器,主要解决冲突问题,每次删除时对应的计数器减一,如果结果为0,更新主数组的二进制值为0。