业务场景: 生活点评项目的商品缓存
项目地址: https://github.com/Ray2310/Evaluate-System

商品缓存

在客户端与数据库之间加上一个Redis缓存,先从Redis中查询,如果没有查到,再去MySQL中查询,同时查询完毕之后,将查询到的数据也存入Redis,这样当下一个用户来进行查询的时候,就可以直接从Redis中获取到数据

image.png

缓存模型和思路

标准的操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入Redis。
image.png

缓存的更新策略

三种策略的比较
image.png

场景

**低一致性需求: **使用内存淘汰机制,例如店铺类型的查询缓存
高一致性需求: 主动更新,并且使用超时剔除作为兜底方案。例如店铺的详情查询

数据库和缓存不一致带来的问题

  • 由于我们的缓存数据源来自数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在,其后果是
    • 用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等

那么如何解决这个问题呢?

有如下三种方式:

  1. Cache Aside Pattern 人工编码方式:**缓存调用者在更新完数据库之后再去更新缓存**,也称之为**双写方案**
  2. Read/Write Through Pattern:**缓存与数据库整合为一个服务,由服务来维护一致性。**调用者调用该服务,无需关心缓存一致性问题。**但是维护这样一个服务很复杂**,市面上也不容易找到这样的一个现成的服务,开发成本高
  3. Write Behind Caching Pattern:**调用者只操作缓存,其他线程去异步处理数据库,最终实现一致性。但是维护这样的一个异步的任务很复杂**,需要实时监控缓存中的数据更新,其他线程去异步更新数据库也可能不太及时,而且缓存服务器如果宕机,那么缓存的数据也就丢失了

数据库和缓存不一致采用什么方案

综上所述,在企业的实际应用中,还是方案一最可靠,但是方案一的调用者该如何处理呢?

  • 如果采用方案一,假设我们每次操作完数据库之后,都去更新一下缓存,但是如果中间并没有人查询数据,那么这个更新动作只有最后一次是有效的,中间的更新动作意义不大,所以我们可以把缓存直接删除,等到有人再次查询时,再将缓存中的数据加载出来
  • 对比删除缓存与更新缓存
    • 更新缓存:每次更新数据库都需要更新缓存,无效写操作较多
    • 删除缓存:更新数据库时让缓存失效,再次查询时更新缓存
  • 如何保证缓存与数据库的操作同时成功/同时失败
    • 单体系统:将缓存与数据库操作放在同一个事务
    • 分布式系统:利用TCC等分布式事务方案
  • 先操作缓存还是先操作数据库?我们来仔细分析一下这两种方式的线程安全问题
  • 先删除缓存,再操作数据库

删除缓存的操作很快,但是更新数据库的操作相对较慢,如果此时有一个线程2刚好进来查询缓存,由于我们刚刚才删除缓存,所以线程2需要查询数据库,并写入缓存,但是我们更新数据库的操作还未完成,所以线程2查询到的数据是脏数据,出现线程安全问题

  • 先操作数据库,再删除缓存

线程1在查询缓存的时候,缓存TTL刚好失效,需要查询数据库并写入缓存,这个操作耗时相对较短(相比较于上图来说),但是就在这么短的时间内,线程2进来了,更新数据库,删除缓存,但是线程1虽然查询完了数据(更新前的旧数据),但是还没来得及写入缓存,所以线程2的更新数据库与删除缓存,并没有影响到线程1的查询旧数据,写入缓存,造成线程安全问题

  • 虽然这二者都存在线程安全问题,但是相对来说,后者出现线程安全问题的概率相对较低,所以我们最终采用后者先操作数据库,再删除缓存的方案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//todo 更新数据库删除缓存
@Override
@Transactional //添加事务
public Result update(Shop shop) {
//1. 更新数据库
updateById(shop);
if(shop.getId() == null){
return Result.fail("店铺id不能为空!!!");
}
Long id = shop.getId();
String key = CACHE_SHOP_KEY + id;
//删除缓存
stringRedisTemplate.delete(key);
return Result.ok();
}

缓存穿透问题

指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

两种解决思路

  1. **缓存空对象 **
    • 优点:实现简单,维护方便
    • 缺点:额外的内存消耗,可能造成短期的不一致
  • image.png
  1. 布隆过滤
    • 优点:内存占用啥哦,没有多余的key
    • 缺点:实现复杂,可能存在误判
  • image.png

思路分析

  • 缓存空对象思路分析:当我们客户端访问不存在的数据时,会先请求redis,但是此时redis中也没有数据,就会直接访问数据库,但是数据库里也没有数据,那么这个数据就穿透了缓存,直击数据库。但是数据库能承载的并发不如redis这么高,所以如果大量的请求同时都来访问这个不存在的数据,那么这些请求就会访问到数据库,简单的解决方案就是哪怕这个数据在数据库里不存在,我们也把这个这个数据存在redis中去(这就是为啥说会有额外的内存消耗),这样下次用户过来访问这个不存在的数据时,redis缓存中也能找到这个数据,不用去查数据库。可能造成的短期不一致是指在空对象的存活期间,我们更新了数据库,把这个空对象变成了正常的可以访问的数据,但由于空对象的TTL还没过,所以当用户来查询的时候,查询到的还是空对象,等TTL过了之后,才能访问到正确的数据,不过这种情况很少见罢了

  • 布隆过滤思路分析:布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,根据哈希思想去判断当前这个要查询的数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库里一定会存在这个数据,从数据库中查询到数据之后,再将其放到redis中。如果布隆过滤器判断这个数据不存在,则直接返回。这种思想的优点在于节约内存空间,但存在误判,误判的原因在于:布隆过滤器使用的是哈希思想,只要是哈希思想,都可能存在哈希冲突

总结

  • 缓存穿透产生的原因是什么?
    • 用户请求的数据在缓存中和在数据库中都不存在,不断发起这样的请求,会给数据库带来巨大压力
  • 缓存穿透的解决方案有哪些?
    • 缓存null值
    • 布隆过滤
    • 增强id复杂度,避免被猜测id规律(可以采用雪花算法)
    • 做好数据的基础格式校验
    • **加强用户权限校验 **
    • 做好热点参数的限流

缓存雪崩问题

缓存雪崩是指在同一时间段,大量缓存的key同时失效,或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力

  • 解决方案
    • 给不同的Key的TTL添加随机值,让其在不同时间段分批失效
    • 利用Redis集群提高服务的可用性(使用一个或者多个哨兵(Sentinel)实例组成的系统,对redis节点进行监控,在主节点出现故障的情况下,能将从节点中的一个升级为主节点,进行故障转义,保证系统的可用性。 )
    • 给缓存业务添加降级限流策略
    • 给业务添加多级缓存(浏览器访问静态资源时,优先读取浏览器本地缓存;访问非静态资源(ajax查询数据)时,访问服务端;请求到达Nginx后,优先读取Nginx本地缓存;如果Nginx本地缓存未命中,则去直接查询Redis(不经过Tomcat);如果Redis查询未命中,则查询Tomcat;请求进入Tomcat后,优先查询JVM进程缓存;如果JVM进程缓存未命中,则查询数据库)

缓存击穿问题

缓存击穿也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,那么无数请求访问就会在瞬间给数据库带来巨大的冲击

  • 举个不太恰当的例子:一件秒杀中的商品的key突然失效了,大家都在疯狂抢购,那么这个瞬间就会有无数的请求访问去直接抵达数据库,从而造成缓存击穿

缓存击穿问题解决思路

解决缓存击穿的方法可以是设置热点数据永不过期,或者使用互斥锁(即在缓存失效的时候,不是立即加载数据库查询结果到缓存,而是先使用锁或者其他同步机制保证只有一个请求查询数据库并加载到缓存)。

常见的解决方案有两种

  1. **互斥锁**
  2. **逻辑过期**
  • 逻辑分析:假设线程1在查询缓存之后未命中,本来应该去查询数据库,重建缓存数据,完成这些之后,其他线程也就能从缓存中加载这些数据了。但是在线程1还未执行完毕时,又进来了线程2、3、4同时来访问当前方法,那么这些线程都不能从缓存中查询到数据,那么他们就会在同一时刻访问数据库,执行SQL语句查询,对数据库访问压力过大

  • 解决方案一:互斥锁
  • 利用锁的互斥性,假设线程过来,只能一个人一个人的访问数据库,从而避免对数据库频繁访问产生过大压力,但这也会影响查询的性能,将查询的性能从并行变成了串行,我们可以采用tryLock方法+double check来解决这个问题
  • 线程1在操作的时候,拿着锁把房门锁上了,那么线程2、3、4就不能都进来操作数据库,只有1操作完了,把房门打开了,此时缓存数据也重建好了,线程2、3、4直接从redis中就可以查询到数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
  //todo 方法二:  基于互斥锁方式解决缓存击穿问题
public Result queryWithMutex(Long id) throws InterruptedException {
String key = CACHE_SHOP_KEY + id;
// 1. 从redis中查询商铺的缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断redis中是否存在该id的商户
if(StrUtil.isNotBlank(shopJson)){
//3. 如果存在: 返回商户的信息
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
if(shopJson != null){
return null;
}
Shop shopN = null;
//未命中---------尝试获取互斥锁--------
String lockKey = LOCK_SHOP_KEY + id;
//1. 判断获取互斥锁是否成功
boolean isLock = tryLock(lockKey);
//2.失败休眠,成功就获取
if (!isLock){
Thread.sleep(50);
queryWithMutex(id); //递归重试获取互斥锁
}
//!获取互斥锁成功
shopN = getById(id);
if(shopN == null){
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
// 4.2数据库中 商户如果存在就将商户信息写入redis ,超时删除30min
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shopN),30, TimeUnit.MINUTES);
unLock(lockKey);
//6. 返回商户信息
return Result.ok(shopN);
}
//todo 获取锁
boolean tryLock(String key){
//setIfAbsent 该方法的作用是只有当键不存在时,才会设置键的值,并且设置一个过期时间,以避免死锁的情况发生。
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.MINUTES);
return BooleanUtil.isTrue(aBoolean);
}
//todo 释放锁
void unLock(String key){
stringRedisTemplate.delete(key);
}
  • 解决方案二:逻辑过期方案
  • 方案分析:我们之所以会出现缓存击穿问题,主要原因是在于我们对key设置了TTL,如果我们不设置TTL,那么就不会有缓存击穿问题,但是不设置TTL,数据又会一直占用我们的内存,所以我们可以采用逻辑过期方案
  • 我们之前是TTL设置在redis的value中,注意:这个过期时间并不会直接作用于Redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断当前数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的进程他会开启一个新线程去进行之前的重建缓存数据的逻辑,直到新开的线程完成者逻辑之后,才会释放锁,而线程1直接进行返回,假设现在线程3过来访问,由于线程2拿着锁,所以线程3无法获得锁,线程3也直接返回数据(但只能返回旧数据,牺牲了数据一致性,换取性能上的提高),只有等待线程2重建缓存数据之后,其他线程才能返回正确的数据
  • 这种方案巧妙在于,异步构建缓存数据,缺点是在重建完缓存数据之前,返回的都是脏数据

对比逻辑删除和互斥锁

  • 互斥锁方案:由于保证了互斥性,所以数据一致,且实现简单,只是加了一把锁而已,也没有其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁的情况,就可能死锁,所以只能串行执行,性能会受到影响
  • 逻辑过期方案:线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构缓存数据,但是在重构数据完成之前,其他线程只能返回脏数据,且实现起来比较麻烦

image-20240226201001533