问题来源
你只要用缓存,就可能会涉及到缓存与数据库双存储双写,你只要是双写,就一定会有数据一致性的问题。因为配置信息缓存在内存中,而内存时无法感知到数据在数据库的修改。 这样就会造成数据库中的数据与缓存中数据不一致的问题。
那么你如何解决一致性问题?
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; String shopJson = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { Shop shop = JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } Shop shop = getById(id); if (shop==null) { return Result.fail("店铺不存在!"); } stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); 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不能为空!"); } updateById(shop); 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