缓存

Posted by 令德湖周杰伦 on 01-23,2024

1. 缓存是什么

把读写速度【慢】介质中的数据保存在读写速度【快】的介质中,从而提高读写速度,减少时间消耗。缓存是一个相对的概念,要有对比的对象才能更好的理解缓存。

  • 读写速度:CPU高速缓存 > 内存 > 磁盘
  • 相对于磁盘来说内存就是一种缓存,为了方案理解,本文的缓存是指内存,和数据库(磁盘)进行对比说明。

2. 缓存的使用场景和操作

2.1 场景

缓存主要还是用来提高系统的读性能,带来更高的并发,通常应用于读多写少的业务场景:

  1. 排行榜
  2. 帖子的阅读量
  3. 热门商品
  4. 等等

2.2 读缓存

缓存一般是通过(key, value)的方式进行操作,key代表缓存的唯一键,value缓存的具体业务信息。

读缓存的步骤:

  1. 根据key查询缓存中是否存在对应的value
  2. 如果hit,那么直接返回
  3. 如果miss,那么查询数据库返回,同时并把返回的结果放在缓存中

2.3 写缓存

当数据发生变化时,需要对缓存中的数据和数据库的数据进行更新。

缓存更新的2种方式:

  1. 更新缓存:数据不但写入数据库,还会写入缓存
  2. 淘汰缓存:数据只会写入数据库,不会写入缓存,只会把数据淘汰掉

如何选择:

  • 主要取决于“更新缓存的复杂度”,如果更新缓存比较的逻辑比较复杂建议直接淘汰缓存
  • 淘汰缓存操作简单,并且带来的副作用只是增加了一次cache miss

数据库和缓存的操作顺序:

  1. 先写数据库,再更新缓存
  2. 先写缓存,再淘汰数据库

使用缓存可以更好的保护数据库,提高系统的并发性能,但写缓存的过程中可能会引起数据库和缓存中的数据不一致,需要去考虑和解决。

3.数据库和缓存数据一致性问题

3.1 为什么会出现数据不一致的情况

数据不一致的本质原因是:

  1. 原子性:写缓存和写数据库的操作不是原子的
  2. 多线程:并发读写带来的数据不一致

3.2 原子性问题分析

控制变量,假设目前是单线程,先分析原子性问题

3.2.1 先写数据库,再更新缓存

  1. 第一步写数据库操作成功
  2. 第二步更新缓存失败
  3. 出现DB中是新数据,cache中是旧数据,数据不一致

3.2.2 先淘汰缓存,再写数据库

  1. 第一步淘汰缓存成功
  2. 第二步写数据库失败
  3. 出现cache清了,数据也没更新,那么数据一致,只会引发一次Cache miss。

结论:单线程环境下,先淘汰缓存,再写数据库

3.3 多线程分析

在分布式环境下,多线程访问服务,可能会出现如下情况:

  1. thread1 先淘汰缓存,del key
  2. thread2 读缓存,key不存在从db中取值value
  3. thread2 将value写入了缓存(key,value)
  4. thread1 再写数据库 更新value 为 value1

即在thread1 进行【写缓存操作】的过程中,thread2进行【读缓存操作】,并把脏数据写入了缓存,导致最后cache和db中的数据不一致

如果数据库做了主从同步,那么可有可能出现数据不一致的情况:

  1. thread1 先淘汰缓存,del key
  2. thread1 写数据库(主库)value1
  3. thread3 读缓存,key不存在从数据库中(从库)取值value
  4. thread3 将value写入了缓存(key,value)
  5. 从主延时同步完成,从库中同步主库的value1

在【主延时同步的延时内】,thread3进行【读缓存操作】,并把脏数据写入了缓存,导致最后cache和db中的数据不一致

结论:通过上面分析,要想使cache和db的数据保持「完全实时一致性」在分布式和多线程的环境中是办不到到的,除非是在单机环境和同步请求的环境下,
但这样会使服务的性能和并发极具下降。所以我们通常的解决方案是保证「缓存和db数据的最终一致性」

3.4 缓存双淘汰法

保证缓存和db数据的最终一致性,相应的会失去完全实时性,只需要保证在一个时间段后数据是一致的即可,这个时间段可能1s,也可能是2s,这个需要根据实际情况来决定。

3.4.1 思路和步骤

核心的解决思路就是:异步在规定时间内再次淘汰调缓存,这个方法付出的代价是,缓存会增加1次cache miss(代价几乎可以忽略)

缓存的写步骤:

  1. 淘汰缓存成功
  2. 写数据库失败
  3. 在指定时间后,异步淘汰缓存

3.4.2 异步淘汰缓存的几种方式

  1. 异步线程,定时淘汰,简单粗暴
  2. 发送定时mq,消费消息后淘汰,可以做成通用的解决方案,但引入了新的依赖
  3. 消费binlog,定时淘汰缓存,可以做成通用的解决方案,但引入了新的依赖

指定时间的选择:

  • 在业务能接受和容忍的时间内
  • 大于主从延时的时间

3.5 其他方案

缓存双淘汰法是基于“先淘汰缓存,再写数据库”思路来的。当然可以基于“先写数据库,再更新缓存”的思路来,那么就需要引入事务和分布式锁来完成

整体步骤:

  1. 先写数据库
  2. 在写入数据库所在的事务中,插入一条记录到任务表。该记录会存储需要更新的缓存 KEY 和 VALUE
  3. 异步,定时任务每秒扫描任务表,更新到缓存中,之后删除该记录
  4. 这里也会出现并发的情况,需要根据key做分布锁,决定插入任务表中的顺序,因为任务表的顺序决定了最后缓存的是多少

4.缓存穿透

指查询「一个一定不存在」的数据,由于数据库中没有该数据的记录,所以也不会同步到缓存中,这样每次请求都会到 DB 去查询,当流量变大时,存在打垮数据的风险。

4.1 原因

缓存穿透的原因主要有:

  • 查询不存在的数据:当查询一个不存在于缓存中的数据时,缓存无法命中,请求会直接访问数据库。
  • 恶意请求:恶意请求会通过各种方式绕过缓存,直接访问数据库。

4.2 解决方案

4.2.1 缓存空对象

将在DB中不存在的数据,给一个特殊标记缓存起来,并设置合适的过期时间
优点:逻辑简单
缺点:浪费较大的内存空间

4.2.2 布隆过滤器

在缓存的前面,加一层布隆过滤器,存储对应的KEY是否存在

步骤:

  1. 从布隆过滤器查询key,如果存在,查询缓存即可(走读缓存3步即可)
  2. 如果不存在,直接返回

由于布隆过滤器的特性:

  • 使用小内存可以记录海量信息(使用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,并更新到缓存
步骤:

  1. 获取分布式锁,如果超时或者获取失败,抛出异常,查询失败
  2. 获取分布式锁成功后,重新判断缓存是否存在,如果不存在,查询DB,并更细到缓存
  3. 获取分布式锁成功后,重新判断缓存,如果存在,代表之前的线程已经更新成功了,直接返回该缓存即可

6.2.2 手动过期

缓存上从不设置过期时间,功能上将过期时间存在key对应的value里
步骤如下:

  1. 查询缓存,如果发现对应的value未过期,直接返回
  2. 如果value过期了,那么直接返回,同时使用后端一个异步线程去查询DB,同时更新缓存。

6.2.3 预加载

在缓存失效前,通过定时任务或者后台线程提前加载热点数据到缓存中,避免热点数据缓存失效时的突然访问峰值。

6.2.4 降级策略

最后,如果缓存失效可以降级,使用本地内存,或者返回一个默认值,来保证系统的稳定性,避免由于缓存击穿导致的系统崩溃。