缓存使用系列

实际工作中经常用到Redis作为缓存,只要用到缓存,就会碰到缓存穿透、缓存雪崩和缓存击穿。在看了很多博文之后记录一下关于缓存的问题,每个博文有各自的观点,这里做一下总结和个人的理解。

1. 缓存穿透

在大多数互联网应中,缓存是不可或缺的组件,缓存的使用方式如下:

  1. 当业务系统发起某一个查询请求时,首先判断缓存中是否有该数据;
    1.1 如果缓存中存在,则直接返回数据
  2. 缓存层不命中,查询数据库
  3. 然后返回数据,若无返回空结果,有则将结果写入缓存层同时返回结果(存储层和缓存层应有同步措施,保证缓存了最新的数据)缓存穿透.png

1.1 什么是缓存穿透

在实际的项目中,我们查询数据通常先从缓存中读取,如果缓存存在直接返回。不存在就查询数据库,然后返回结果。如果查询的数据一直不存在,就会一直查询DB,这样缓存就没有作用,当流量很大的时候,DB有可能挂掉。
简单而言就是:请求了大量根本不存在的数据,导致所有的请求直接请求到后端DB上,造成DB异常。

1.2 为什么会发生缓存穿透

发生缓存穿透的原因很多,一般为两种:

  1. 恶意攻击和爬虫造成大量空命中
  2. 业务自身代码或者数据出现问题

1.3 如何发现

通常使用日志、打点等,记录调用总量、缓存命中数、存储命中数,如果短时间内空命中非常多,可能存在缓存穿透。

1.4 缓存穿透解决方法

1.4.1 缓存空对象

在第3步中储存层不命中后,将空对象保存到缓存层中(使用特定key来表示),之后的访问将从缓存层中去查询获取到,从而保护了存储层。
这么做也会带来两个问题:

  1. 对空值缓存意味着需要额外的空间来存放键值,如果是恶意攻击会导致严重的内存占用。有效的措施是对这类数据设置一个较短的过期时间,让其自动剔除(redis对于过期的key采用定时加惰性删除的机制,定时删除不保证一定会清除过期key,除非主动访问)。
  2. 缓存层和存储层如果没有同步措施会存在一段时间的窗口不一致,如果设置了过期时间为5分钟,这时存储层添加或者修改了这个数据,此时缓存层和数据层消息就不一致了。我们可以利用消息系统或者其他方式清除掉缓存层中的空对象。

1.4.2 布隆过滤器拦截

一种可行的方案是使用布隆过滤器,将所有存在的key使用布隆过滤器提前保存起来。业务系统首先去查询布隆过滤器中是否存在该key,如果不存在直接返回,因为数据库中也不存在该数据,缓存中也不会存在。
布隆过滤器.png

其中5.向bitmap添加数据和缓存非空数据取决于业务的需求,我们一般会定时或者批量更新,而不是每次都触发更新。使用布隆过滤器并不适合强时效和高一致性的场景,布隆过滤器无法处理删除数据,重新生成bitmap将耗费一定时间,而且使用前要预热bitmap。

1.4.3 两种方式的对比

解决方式 适用场景 维护成本
缓存空对象 1.数据命中不高
2.数据频繁变化实时性高
1.代码维护简单
2.需要过多的缓存空间
3.可能存在数据不一致
布隆过滤器 1.数据命中不高
2.数据相对固定实时性低
1.代码维护复杂
2.缓存占用空间少

2. 缓存雪崩

2.1 什么是缓存雪崩

在实际应用中,通过合理的设置缓存,大部分请求都通过缓存层返回,有效的保护了存储层,但是由于缓存层出现问题(比如宕机或者缓存超时集体失效),所有或者超出存储层极限的调用都会直接到达存储层,从而导致存储层也挂掉。

2.2 如何预防缓存雪崩

2.2.1 保证缓存层服务高可用

面对这种情况我们一般引入集群,通过负载均衡算法分解请求,Redis Sentinel和Redis Cluster暂时还没有用过,TODO:负载均衡算法和Redis集群部分改日填坑。

2.2.2 依赖隔离组件为后端限流并降级

基础组件不是永远可靠的,在实际过程中经常碰到基础服务出现问题,小则过载,大则宕机,一些过载恢复机制会随机将请求发送到过载节点,通过返回结果来判断过载服务是否恢复,至于是谁来做重试要看业务的需求。
降级在高并发系统中是常见的:比如个性推荐服务中,如果个性化需求不能提供服务了,可以降级补充热点数据,不至于造成前端页面空白。
至于hystrix我目前并没有用过,现在组件都是微服务了,基础服务和业务都有自己线程池管理方式,改日再看情况填坑。

3. 缓存击穿(热点key失效)

这里我看了几篇博文,发现缓存击穿和热点key失效说的是一回事。缓存雪崩和缓存击穿的区别是,缓存雪崩针对的是大量key,击穿针对很少的几热点个key产生大量请求,可以看作是雪崩中的的特例,但请求量会非常大。

3.1 缓存击穿的危害

当以下两个问题同时出现,可能会对系统造成致命的危害:

  1. 这个key是热点key(例如重要的新闻等),短时间内的访问量可能非常大。
  2. 缓存的构建需要一定时间,比如要经过复杂运算、多次IO、数据库访问、依赖其它接口等等(比如游戏的赛季排行榜)。

于是就会碰到致命问题,缓存失效的瞬间有大量的线程来构建缓存(如下图,直接盗图),造成后端负载加大,甚至系统崩溃。
缓存击穿.png

3.2 缓存击穿的解决方案

我们尽量使用较少的线程来构建缓存(甚至是一个)

3.2.1 使用互斥锁

互斥锁的解决思路比较简单,就是同一时间只让一个线程来构建线程,其他线程等待缓存构建完,再从缓存中获取数据就可以。

互斥锁分单机和分布式环境。单机环境使用编程语言提供的锁来处理便可,分布式环境使用分布式锁就可以,Redis和zookeeper都可以实现分布式锁,如何实现改日再填坑(TODO:分布式锁)
原文都给了互斥锁的实现,但是这个锁是有问题的,而且是有发生死锁的可能性,想一想为什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
String get(String key) {
String value = redis.get(key);
if (value == null) {
if (redis.setnx(key_mutex, "1")) {
// 设定一个时间,防止db.get()方法产生死锁,要保证这个时间大于db.get()的执行时间
redis.expire(key_mutex, 3 * 60)
value = db.get(key);
redis.set(key, value);
redis.delete(key_mutex);
} else {
//其他线程休息50毫秒后重试
Thread.sleep(50);
get(key);
}
}
}

原因:Redis事务和传统数据库事务定义不同,setnx和expire这两个操作并不能保证原子性,如果setnx成功之后代码没有执行就会一直锁住,原博主没有详细解释产生死锁的原因。

3.2.2 永不过期

  1. Reis上不再设计过期时间,保证“物理”不过期
  2. 过期时间放在key对应的value里,如果过期了,先返回旧值,后台通过异步线程构建缓存,然后更新,也就是“逻辑”上过期(Java的CopyOnWrite非常与之类似,TODO:CopyOnWrite容器源码分析)

这种方式对于性能非常友好,几乎不会造成请求的阻塞,但是不适用强一致性的数据读取,因为其余线程可能读到的是老的数据。

3.2.3 两种方式的对比

解决方案 优点 缺点
互斥锁 1.思路简单
2.保证强一致性
1.代码复杂度增大
2.存在死锁的风险
3.存在线程池阻塞的风险
永不过期 1.异步构建,不会阻塞线程池
1.不保证一致性
2.代码复杂度增大(每个value都要维护一个timekey)
3.占用一定的内存空间(每个value都要维护一个timekey)。

参考资料