1. 缓存是什么
把读写速度【慢】介质中的数据保存在读写速度【快】的介质中,从而提高读写速度,减少时间消耗。缓存是一个相对的概念,要有对比的对象才能更好的理解缓存。
- 读写速度:CPU高速缓存 > 内存 > 磁盘
- 相对于磁盘来说内存就是一种缓存,为了方案理解,本文的缓存是指内存,和数据库(磁盘)进行对比说明。
2. 缓存的使用场景和操作
2.1 场景
缓存主要还是用来提高系统的读性能,带来更高的并发,通常应用于读多写少的业务场景:
- 排行榜
- 帖子的阅读量
- 热门商品
- 等等
2.2 读缓存
缓存一般是通过(key, value)的方式进行操作,key代表缓存的唯一键,value缓存的具体业务信息。
读缓存的步骤:
- 根据key查询缓存中是否存在对应的value
- 如果hit,那么直接返回
- 如果miss,那么查询数据库返回,同时并把返回的结果放在缓存中
2.3 写缓存
当数据发生变化时,需要对缓存中的数据和数据库的数据进行更新。
缓存更新的2种方式:
- 更新缓存:数据不但写入数据库,还会写入缓存
- 淘汰缓存:数据只会写入数据库,不会写入缓存,只会把数据淘汰掉
如何选择:
- 主要取决于“更新缓存的复杂度”,如果更新缓存比较的逻辑比较复杂建议直接淘汰缓存
- 淘汰缓存操作简单,并且带来的副作用只是增加了一次cache miss
数据库和缓存的操作顺序:
- 先写数据库,再更新缓存
- 先写缓存,再淘汰数据库
使用缓存可以更好的保护数据库,提高系统的并发性能,但写缓存的过程中可能会引起数据库和缓存中的数据不一致,需要去考虑和解决。
3.数据库和缓存数据一致性问题
3.1 为什么会出现数据不一致的情况
数据不一致的本质原因是:
- 原子性:写缓存和写数据库的操作不是原子的
- 多线程:并发读写带来的数据不一致
3.2 原子性问题分析
控制变量,假设目前是单线程,先分析原子性问题
3.2.1 先写数据库,再更新缓存
- 第一步写数据库操作成功
- 第二步更新缓存失败
- 出现DB中是新数据,cache中是旧数据,数据不一致
3.2.2 先淘汰缓存,再写数据库
- 第一步淘汰缓存成功
- 第二步写数据库失败
- 出现cache清了,数据也没更新,那么数据一致,只会引发一次Cache miss。
结论:单线程环境下,先淘汰缓存,再写数据库
3.3 多线程分析
在分布式环境下,多线程访问服务,可能会出现如下情况:
- thread1 先淘汰缓存,del key
- thread2 读缓存,key不存在从db中取值value
- thread2 将value写入了缓存(key,value)
- thread1 再写数据库 更新value 为 value1
即在thread1 进行【写缓存操作】的过程中,thread2进行【读缓存操作】,并把脏数据写入了缓存,导致最后cache和db中的数据不一致
如果数据库做了主从同步,那么可有可能出现数据不一致的情况:
- thread1 先淘汰缓存,del key
- thread1 写数据库(主库)value1
- thread3 读缓存,key不存在从数据库中(从库)取值value
- thread3 将value写入了缓存(key,value)
- 从主延时同步完成,从库中同步主库的value1
在【主延时同步的延时内】,thread3进行【读缓存操作】,并把脏数据写入了缓存,导致最后cache和db中的数据不一致
结论:通过上面分析,要想使cache和db的数据保持「完全实时一致性」在分布式和多线程的环境中是办不到到的,除非是在单机环境和同步请求的环境下,
但这样会使服务的性能和并发极具下降。所以我们通常的解决方案是保证「缓存和db数据的最终一致性」
3.4 缓存双淘汰法
保证缓存和db数据的最终一致性,相应的会失去完全实时性,只需要保证在一个时间段后数据是一致的即可,这个时间段可能1s,也可能是2s,这个需要根据实际情况来决定。
3.4.1 思路和步骤
核心的解决思路就是:异步在规定时间内再次淘汰调缓存,这个方法付出的代价是,缓存会增加1次cache miss(代价几乎可以忽略)
缓存的写步骤:
- 淘汰缓存成功
- 写数据库失败
- 在指定时间后,异步淘汰缓存
3.4.2 异步淘汰缓存的几种方式
- 异步线程,定时淘汰,简单粗暴
- 发送定时mq,消费消息后淘汰,可以做成通用的解决方案,但引入了新的依赖
- 消费binlog,定时淘汰缓存,可以做成通用的解决方案,但引入了新的依赖
指定时间的选择:
- 在业务能接受和容忍的时间内
- 大于主从延时的时间
3.5 其他方案
缓存双淘汰法是基于“先淘汰缓存,再写数据库”思路来的。当然可以基于“先写数据库,再更新缓存”的思路来,那么就需要引入事务和分布式锁来完成
整体步骤:
- 先写数据库
- 在写入数据库所在的事务中,插入一条记录到任务表。该记录会存储需要更新的缓存 KEY 和 VALUE
- 异步,定时任务每秒扫描任务表,更新到缓存中,之后删除该记录
- 这里也会出现并发的情况,需要根据key做分布锁,决定插入任务表中的顺序,因为任务表的顺序决定了最后缓存的是多少
4.缓存穿透
指查询「一个一定不存在」的数据,由于数据库中没有该数据的记录,所以也不会同步到缓存中,这样每次请求都会到 DB 去查询,当流量变大时,存在打垮数据的风险。
4.1 原因
缓存穿透的原因主要有:
- 查询不存在的数据:当查询一个不存在于缓存中的数据时,缓存无法命中,请求会直接访问数据库。
- 恶意请求:恶意请求会通过各种方式绕过缓存,直接访问数据库。
4.2 解决方案
4.2.1 缓存空对象
将在DB中不存在的数据,给一个特殊标记缓存起来,并设置合适的过期时间
优点:逻辑简单
缺点:浪费较大的内存空间
4.2.2 布隆过滤器
在缓存的前面,加一层布隆过滤器,存储对应的KEY是否存在
步骤:
- 从布隆过滤器查询key,如果存在,查询缓存即可(走读缓存3步即可)
- 如果不存在,直接返回
由于布隆过滤器的特性:
- 使用小内存可以记录海量信息(使用BitMap来记录信息)
- 存在误判
- 不能删除数据
- 需要提前构建
优点:缓存空间占用小
缺点:存在漏网之鱼(存在误判的原因),
结论:布隆过滤器可以过滤掉无效的查询直接到数据库,减少数据库的压力,实际要根据业务场景来选择,如果在海量数据,且对内存有要求的前提下,推荐使用布隆过滤器,如果使用对准确率有要求,那么推荐使用第一种方案,当前也可以2种方案同时使用。
5.缓存雪崩
缓存雪崩指的是在某个时间点,缓存中的大部分或全部数据同时失效,导致大量的请求直接落到数据库上,从而引发数据库的压力过大,甚至崩溃。这种情况通常发生在缓存中的数据在同一时间段内过期,或者由于某种原因导致缓存失效。
5.1 原因
缓存雪崩的原因主要有:
- 缓存过期时间设置不合理:如果大量的缓存在同一时间段内过期,就会导致大- 量请求直接访问数据库。
- 缓存服务器故障:当缓存服务器发生故障,无法提供服务时,所有的请求都会直接访问数据库。
- 热点数据集中:如果某些热门数据的缓存失效时间相近,可能会导致大量请求同时访问数据库
5.2 解决方案
5.2.1 设置合理的缓存时间
合理设置缓存的过期时间,避免大量缓存同时失效。
5.2.2 分散缓存失效时间
将热门数据的缓存失效时间分散开,避免集中在同一时间段失效。
5.2.3 缓存高可用
- 通过使用缓存集群和备份服务器等机制,提高缓存的可用性,减少单点故障的风险
- 引入本地内存
- DB做好限流
6. 缓存击穿
缓存击穿指的是一个热点数据的缓存失效,导致大量请求直接落到数据库上,造成数据库压力过大,影响系统性能。与缓存雪崩不同,缓存击穿指的是某个特定的数据失效,而不是全部数据。
6.1 原因
缓存击穿的原因主要有:
- 热点数据缓存失效:当某个热点数据的缓存失效时
- 高并发请求:大量请求同时访问数据库,容易造成缓存击穿
6.2 解决方案
6.2.1 互斥锁
当缓存失效时,查询DB前,使用分布式锁,保证有且仅有一个线程去查询DB,并更新到缓存
步骤:
- 获取分布式锁,如果超时或者获取失败,抛出异常,查询失败
- 获取分布式锁成功后,重新判断缓存是否存在,如果不存在,查询DB,并更细到缓存
- 获取分布式锁成功后,重新判断缓存,如果存在,代表之前的线程已经更新成功了,直接返回该缓存即可
6.2.2 手动过期
缓存上从不设置过期时间,功能上将过期时间存在key对应的value里
步骤如下:
- 查询缓存,如果发现对应的value未过期,直接返回
- 如果value过期了,那么直接返回,同时使用后端一个异步线程去查询DB,同时更新缓存。
6.2.3 预加载
在缓存失效前,通过定时任务或者后台线程提前加载热点数据到缓存中,避免热点数据缓存失效时的突然访问峰值。
6.2.4 降级策略
最后,如果缓存失效可以降级,使用本地内存,或者返回一个默认值,来保证系统的稳定性,避免由于缓存击穿导致的系统崩溃。