1.缓存穿透
什么是缓存穿透?
我们使用 redis 大部分情况都是通过 key 查询对应的值,假如发送的请求传进来的 key 是不存在redis 中的,那么就查不到缓存,查不到缓存就会去数据库查询。假如有大量这样的请求,这些请求像“穿透”了缓存一样直接打在数据库上,这种现象就叫做缓存穿透。
分析:
关键在于在 redis 查不到 key 值,这和缓存击穿有根本的区别,区别在于缓存穿透的情况是传进来的 key 在 redis 中是不存在的。假如有黑客传进大量的不存在的 key ,那么大量的请求打在数据库上是很致命的问题,所以在日常开发中要对参数做好校验,一些非法的参数,不可能存在的key就直接返回错误提示,要对调用方保持这种“不信任”的心态。
解决方案:
- 把无效的 key 存进 redis 中。如果 redis 查不到数据,数据库也查不到,我们把这个 key 值保存进 redis ,设置 value=”null” ,当下次再通过这个 key 查询时就不需要再查询数据库。但这种处理方式肯定是有问题的,假如传进来的这个不存在的 key 值每次都是随机的,那存进 redis 也没有意义,会造成无意义的内存消耗,并且如果某个之前访问过的 key 此时设置了对应的值,那么就会造成 redis 和数据库的短暂不一致。
- 使用布隆过滤器。布隆过滤器的作用是某个 key 不存在,那么就一定不存在,它说某个 key 存在,那么很大可能是存在(存在一定的误判率)。于是我们可以在缓存之前再加一层布隆过滤器,在查询的时候先去布隆过滤器查询 key 是否存在,如果不存在就直接返回。
布隆过滤器的一种实现:
- 初始化一个较大的数组,用来存放二进制 0 或 1。一开始数组中数据都为 0,当一个 key 来了之后经过 3 次 hash 计算,得到三个下标 index,将数组中这三个下标对应的数据从 0 改为 1,这样的话数组中三个位置就能标明一个 key 的存在。
- 当然这种实现也是有误判率的,应该说布隆过滤器的误判率不可能为 0。如果我们想要减少误判率,就得增加数组的长度,但这样会造成更多的内存消耗。
2.缓存雪崩
什么是缓存雪崩?
当某一个时刻出现大规模的缓存失效的情况,那么就会导致大量的请求直接访问数据库,导致数据库压力巨大,如果在高并发的情况下,可能瞬间就会导致数据库宕机。这时候如果运维马上又重启数据库,马上又会有新的流量让数据库宕机,这就是缓存雪崩。
分析:
造成缓存雪崩的关键在于在同一时间大规模的 key 失效。为什么会出现这个问题呢,有几种可能,第一种可能是 redis 宕机,第二种可能是采用了相同的过期时间。搞清楚原因之后,那么有什么解决方案呢?
解决方案:
- 在原有的失效时间上加上一个随机值,比如 1-5 分钟随机。这样就避免了因为采用相同的过期时间导致的缓存雪崩。
如果真的发生了缓存雪崩,有没有什么兜底的措施?
- 使用熔断机制。当流量到达一定的阈值时,就直接返回“系统拥挤”之类的提示,防止过多的请求访问数据库。至少能保证一部分用户是可以正常使用,其他用户多刷新几次也能得到结果。
- 提高数据库的容灾能力,可以使用分库分表,读写分离的策略。
- 为了防止Redis宕机导致缓存雪崩的问题,可以搭建Redis集群,提高Redis的容灾性。
3.缓存击穿
什么是缓存击穿?
其实跟缓存雪崩有点类似,缓存雪崩是大规模的 key 失效,而缓存击穿是一个热点的 key,有大并发集中对其进行访问,突然间这个 key 失效了,导致大并发全部访问到数据库上,导致数据库压力剧增,这种现象就叫做缓存击穿。
分析:
关键在于某个热点的 key 失效了,导致大并发集中访问在数据库上。所以要从两个方面解决,第一是否可以考虑热点 key 不设置过期时间,第二是否可以考虑降低访问在数据库上的请求数量。
解决方案:
- 如果业务允许的话,对于热点的 key 可以设置永不过期的 key。
- 使用互斥锁。如果缓存失效的情况,只有拿到锁才可以查询数据库,降低了在同一时刻访问在数据库上的请求,防止数据库宕机。当然这样会导致系统的性能变差。
- 逻辑过期,当发现缓存中数据已经过期,先获取互斥锁,然后新建一个线程去进行缓存更新(数据同步)。在缓存更新的过程中如果收到获取数据的请求,先返回已过期的旧数据,保证高性能。
4.缓存污染
什么是缓存污染?
Linux (实现两个 LRU 链表)和 MySQL (划分两个区域)通过改进传统的 LRU 数据结构,避免了预读失效带来的影响。
但是如果还是使用「只要数据被访问一次,就将数据加入到活跃 LRU 链表头部(或者 young 区域)」这种方式的话,那么还存在缓存污染的问题。
当我们在批量读取数据的时候,由于数据被访问了一次,这些大量数据都会被加入到「活跃 LRU 链表」里,然后之前缓存在活跃 LRU 链表(或者 young 区域)里的热点数据全部都被淘汰了,如果这些大量的数据在很长一段时间都不被访问的话,那么整个活跃 LRU 链表(或者 young 区域)就被污染了。
缓存污染会带来什么问题?
缓存污染带来的影响就是很致命的,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I/O,系统性能就会急剧下降。
以 MySQL 举例子,Linux 发生缓存污染的现象也是类似。
当某一个 SQL 语句扫描了大量的数据时,在 Buffer Pool 空间比较有限的情况下,可能会将 Buffer Pool 里的所有页都替换出去,导致大量热数据被淘汰了,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I/O,MySQL 性能就会急剧下降。
注意, 缓存污染并不只是查询语句查询出了大量的数据才出现的问题,即使查询出来的结果集很小,也会造成缓存污染。
比如,在一个数据量非常大的表,执行了这条语句:
select * from t_user where name like "%xiaolin%";
可能这个查询出来的结果就几条记录,但是由于这条语句会发生索引失效,所以这个查询过程是全表扫描的,接着会发生如下的过程:
- 从磁盘读到的页加入到 LRU 链表的 old 区域头部;
- 当从页里读取行记录时,也就是页被访问的时候,就要将该页放到 young 区域头部;
- 接下来拿行记录的 name 字段和字符串 xiaolin 进行模糊匹配,如果符合条件,就加入到结果集里;
- 如此往复,直到扫描完表中的所有记录。
经过这一番折腾,由于这条 SQL 语句访问的页非常多,每访问一个页,都会将其加入 young 区域头部,那么原本 young 区域的热点数据都会被替换掉,导致缓存命中率下降。那些在批量扫描时,而被加入到 young 区域的页,如果在很长一段时间都不会再被访问的话,那么就污染了 young 区域。
怎么避免缓存污染造成的影响?
前面的 LRU 算法只要数据被访问一次,就将数据加入活跃 LRU 链表(或者 young 区域),这种 LRU 算法进入活跃 LRU 链表的门槛太低了!正是因为门槛太低,才导致在发生缓存污染的时候,很容易就将原本在活跃 LRU 链表里的热点数据淘汰了。
所以,只要我们提高进入到活跃 LRU 链表(或者 young 区域)的门槛,就能有效地保证活跃 LRU 链表(或者 young 区域)里的热点数据不会被轻易替换掉。
Linux 操作系统和 MySQL Innodb 存储引擎分别是这样提高门槛的:
- Linux 操作系统:在内存页被访问第二次的时候,才将页从 inactive list 升级到 active list 里。
- MySQL Innodb:在内存页被访问第二次的时候,并不会马上将该页从 old 区域升级到 young 区域,因为还要进行停留在 old 区域的时间判断:
- 如果第二次的访问时间与第一次访问的时间在 1 秒内(默认值),那么该页就不会被从 old 区域升级到 young 区域;
- 如果第二次的访问时间与第一次访问的时间超过 1 秒,那么该页就会从 old 区域升级到 young 区域;
提高了进入活跃 LRU 链表(或者 young 区域)的门槛后,就很好了避免缓存污染带来的影响。
在批量读取数据时候,如果这些大量数据只会被访问一次,那么它们就不会进入到活跃 LRU 链表(或者 young 区域),也就不会把热点数据淘汰,只会待在非活跃 LRU 链表(或者 old 区域)中,后续很快也会被淘汰。