Redis-商品查询缓存03-实现商铺缓存与数据库双写一致

  1. 1. 问题来源
  2. 2. Cache Aside Pattern
    1. 2.1. 最初级的缓存不一致问题及解决方案
      1. 2.1.1.
  3. 3. 实现商铺和缓存与数据库双写一致
  4. 4. 解决方案比较:
  5. 5. 面试题:如何保证缓存与数据库的双写一致性?

问题来源

​ 你只要用缓存,就可能会涉及到缓存与数据库双存储双写,你只要是双写,就一定会有数据一致性的问题。因为配置信息缓存在内存中,而内存时无法感知到数据在数据库的修改。 这样就会造成数据库中的数据与缓存中数据不一致的问题。

那么你如何解决一致性问题?

Cache Aside Pattern

最经典的缓存+数据库读写的模式,就是 Cache Aside Pattern。

  • 读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
  • 更新的时候,先更新数据库,然后再删除缓存。

为什么是删除缓存,而不是更新缓存?

原因很简单,很多时候,在复杂点的缓存场景,缓存不单单是数据库中直接取出来的值。

比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据并进行运算,才能计算出缓存最新的值的。

另外更新缓存的代价有时候是很高的。是不是说,每次修改数据库的时候,都一定要将其对应的缓存更新一份?也许有的场景是这样,但是对于比较复杂的缓存数据计算的场景,就不是这样了。如果你频繁修改一个缓存涉及的多个表,缓存也频繁更新。但是问题在于,这个缓存到底会不会被频繁访问到?

举个栗子,一个缓存涉及的表的字段,在 1 分钟内就修改了 20 次,或者是 100 次,那么缓存更新 20 次、100 次;但是这个缓存在 1 分钟内只被读取了 1 次,有大量的冷数据。实际上,如果你只是删除缓存的话,那么在 1 分钟内,这个缓存不过就重新计算一次而已,开销大幅度降低。用到缓存才去算缓存。

其实删除缓存,而不是更新缓存,就是一个 lazy 计算的思想,不要每次都重新做复杂的计算,不管它会不会用到,而是让它到需要被使用的时候再重新计算。像 mybatis,hibernate,都有懒加载思想。查询一个部门,部门带了一个员工的 list,没有必要说每次查询部门,都里面的 1000 个员工的数据也同时查出来啊。80% 的情况,查这个部门,就只是要访问这个部门的信息就可以了。先查部门,同时要访问里面的员工,那么这个时候只有在你要访问里面的员工的时候,才会去数据库里面查询 1000 个员工。

最初级的缓存不一致问题及解决方案

问题:先更新数据库,再删除缓存。如果删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据就出现了不一致。

解决思路:先删除缓存,再更新数据库。如果数据库更新失败了,那么数据库中是旧数据,缓存中是空的,那么数据不会不一致。因为读的时候缓存没有,所以去读了数据库中的旧数据,然后更新到缓存中。

不管是先写MySQL数据库,再删除Redis缓存;还是先删除缓存,再写库,都有可能出现数据不一致的情况。举一个例子:

1.如果删除了缓存Redis,还没有来得及写库MySQL,另一个线程就来读取,发现缓存为空,则去数据库中读取数据写入缓存,此时缓存中为脏数据。

2.如果先写了库,在删除缓存前,写库的线程宕机了,没有删除掉缓存,则也会出现数据不一致情况。

因为写和读是并发的,没法保证顺序,就会出现缓存和数据库的数据不一致的问题。

https://pdai.tech/md/db/nosql-redis/db-redis-x-cache.html#%E6%95%B0%E6%8D%AE%E5%BA%93%E5%92%8C%E7%BC%93%E5%AD%98%E4%B8%80%E8%87%B4%E6%80%A7

实现商铺和缓存与数据库双写一致

核心思路如下:

修改ShopController中的业务逻辑,满足下面的需求:

根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间

根据id修改店铺时,先修改数据库,再删除缓存

修改重点代码1:修改ShopServiceImpl的queryById方法

设置redis缓存时添加过期时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. 从 redis 查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断 redis里是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3. 存在, 直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4. 不存在,根据 id 查询数据库
Shop shop = getById(id);
// 5. 不存在,返回错误
if (shop==null) {
return Result.fail("店铺不存在!");
}
// 6. 数据库存在,写入redis(缓存)
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7. 返回
return Result.ok(shop);
}

修改重点代码2

代码分析:通过之前的淘汰,我们确定了采用删除策略,来解决双写问题,当我们修改了数据之后,然后把缓存中的数据进行删除,查询时发现缓存中没有数据,则会从mysql中加载最新的数据,从而避免数据库和缓存不一致的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if (id==null) {
return Result.fail("店铺id不能为空!");
}
// 1. 更新数据库
updateById(shop);
// 2. 删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}

解决方案比较:

https://blog.csdn.net/best_luxi/article/details/122308586

面试题:如何保证缓存与数据库的双写一致性?

https://cloud.tencent.com/developer/article/1438140#:~:text=%E4%B8%80%E8%88%AC%E6%9D%A5%E8%AF%B4%EF%BC%8C%E5%A6%82%E6%9E%9C%E5%85%81%E8%AE%B8%E7%BC%93%E5%AD%98%E5%8F%AF%E4%BB%A5%E7%A8%8D%E5%BE%AE%E7%9A%84%E8%B7%9F%E6%95%B0%E6%8D%AE%E5%BA%93%E5%81%B6%E5%B0%94%E6%9C%89%E4%B8%8D%E4%B8%80%E8%87%B4%E7%9A%84%E6%83%85%E5%86%B5%EF%BC%8C%E4%B9%9F%E5%B0%B1%E6%98%AF%E8%AF%B4%E5%A6%82%E6%9E%9C%E4%BD%A0%E7%9A%84%E7%B3%BB%E7%BB%9F%E4%B8%8D%E6%98%AF%E4%B8%A5%E6%A0%BC%E8%A6%81%E6%B1%82,%E2%80%9C%E7%BC%93%E5%AD%98%2B%E6%95%B0%E6%8D%AE%E5%BA%93%E2%80%9D%20%E5%BF%85%E9%A1%BB%E4%BF%9D%E6%8C%81%E4%B8%80%E8%87%B4%E6%80%A7%E7%9A%84%E8%AF%9D%EF%BC%8C%E6%9C%80%E5%A5%BD%E4%B8%8D%E8%A6%81%E5%81%9A%E8%BF%99%E4%B8%AA%E6%96%B9%E6%A1%88%EF%BC%8C%E5%8D%B3%EF%BC%9A%E8%AF%BB%E8%AF%B7%E6%B1%82%E5%92%8C%E5%86%99%E8%AF%B7%E6%B1%82%E4%B8%B2%E8%A1%8C%E5%8C%96%EF%BC%8C%E4%B8%B2%E5%88%B0%E4%B8%80%E4%B8%AA%E5%86%85%E5%AD%98%E9%98%9F%E5%88%97%E9%87%8C%E5%8E%BB%E3%80%82