聊一聊缓存和数据库不一致性问题的产生及主流解决方案以及扩展的思考

6,127 阅读29分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第3天,点击查看活动详情

写文一方面是为了自己复习面试,另外就是想让大家和我一起卷吧(哈哈说笑了,是希望大家有所收获吧)

我也不敢说什么一文详解本地缓存和分布式缓存,本文就是针对事实,用图文并茂的方式,可运行的代码案例结合常见的面试题,一点点的推导和分析发生的问题。

文章中针对可能会出现的问题,都附带了一副彩图,来让大家对问题具有更深刻的理解记忆

希望各位小伙伴都能够满载而归~

全文大致9000字上下,并且具有推导性,所以在看本文前,建议能腾出较多的空闲时间,摸鱼看的话,可能会略微有一点点割裂感,可以先收藏,哈哈~

文章大纲: image.png

一、缓存

1、缓存的基本知识

1)什么是缓存?

为了系统性能的提升,我们一般都会将部分数据放入缓存中,加速访问。而DB只承担数据落盘工作。

最简单的理解就是缓存是挡在 DB 前面的一层,为DB遮风挡雨。

架构中最经典的一句话:“没有什么是加一层不能解决的,如果加一层不行,就再加一层”。

2)什么样的数据适合放入缓存?

精简为四字就是:读多写少

  • 访问量很大,需要使用缓存来承担一部分压力(读多、写少)
  • 即时性要求高,能承受一定时间内的数据不一致性。
  • 较长时间不会改变的数据,如后台管理的菜单列表,商品分类列表等等。

3)使用缓存后会产生什么样的问题?

  • 缓存与数据库双写不一致
  • 缓存雪崩、缓存穿透
  • 缓存并发竞争

4)缓存的使用流程

image.png

这只是一个非常简单的流程介绍,实际中还是有不少值得思考的地方。

2、使用Map模拟本地缓存

所谓的缓存,其实就是一个位于应用程序与数据库之间的一层,作用就是减少访问数据库的次数,以提高服务性能。

单机服务下,一些较小,并且是单线程中用到的到数据,使用本地 Map 来存储也不是不可以。

如果是学习过 ThreadLocal的小伙伴,就可能见过ThreadLocalMap 这个Map,一般而言,ThreadLocal都是用来存储本次请求中一些信息(例如:当前请求中登录用户信息),方便在整个请求过程中使用,不过往往它都是一次性的~

我下面的案例只是简单的模拟一下本地缓存,并不实用,为解释大致的含义而写。

 /**
  * <p>
  * 分类菜单 服务实现类
  * </p>
  *
  * @author Ning Zaichun
  * @since 2022-09-07
  */
 @Slf4j
 @Service
 public class LocalMenuServiceImpl implements ILocalMenuService {
 ​
     /**
      * 本地缓存
      * 最开始的话,拿 HashMap 模拟
      * 但 HashMap 它是一个非线程安全类集合,
      * 进一步又改为使用 ConcurrentHashMap,多线程下安全的集合
      */
     private Map<String, Object> localCacheMap = new ConcurrentHashMap<String, Object>();
 ​
     private static final String LOCAL_MENU_CACHE_KEY = "local:menu:list";
 ​
     @Autowired
     private MenuMapper menuMapper;
 ​
 ​
     @Override
     public List<MenuEntity> getLocalList() {
         //1、判断本地缓存中是否存在
         List<MenuEntity> menuEntityList = (List<MenuEntity>) localCacheMap.get(LOCAL_MENU_CACHE_KEY);
         //2、本地缓存中有,就从缓存中拿
         if (menuEntityList == null) {
             //3、如果缓存中没有,就重新查询数据库
             log.info("缓存中没有,查询数据库,重新构建缓存");
             menuEntityList = menuMapper.selectList(new QueryWrapper<MenuEntity>());
             //4、从数据库查询到结果后,重新放入缓存中
             localCacheMap.put(LOCAL_MENU_CACHE_KEY, menuEntityList);
             return menuEntityList;
         }
         log.info("缓存中有直接返回");
         //5、将结果返回
         return menuEntityList;
     }
 ​
     /**
      * 更新操作
      *
      * @param menu
      * @return
      */
     @Override
     public Boolean updateLocalMenuById(MenuEntity menu) {
         //1、删除本地缓存数据
         localCacheMap.remove(LOCAL_MENU_CACHE_KEY);
         System.out.println("清空本地缓存===>");
         //2、更新数据库,根据id更新数据库中实体信息
         return menuMapper.updateById(menu) > 0;
     }
 ​
 }

问题:

并不实用,存在较多问题,存储数据量较小,并发能力较弱等等。


软件架构中一直流传着这么一句话:

"没有什么是加一层解决不了的,如果加一层解决不了,就再加一层"。

所以就将缓存抽取出来,架构演变成如下图:

image.png

二、集成 Redis 做缓存 and Redis 的使用

这里准确点说应当是集成 Redis 做编程式的缓存,而非大家常见的集成 Spring-Cache 利用注解做缓存。

我从上至下大致会说到的下列几个知识点:

  • Redis 的简单使用
  • Redis 序列化机制
  • 更改 Redis 默认序列化机制
  • 使用 Redis 做编程式的缓存
  • 简单讲解了 Redis 的两个连接工厂 JedisLettuce

2.1、前期准备

添加 Redis 的相关依赖

 <dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-data-redis</artifactId>
 </dependency>

使用SpringBoot这么久以来,看到某个stater,可以放心的推测,它大概率会有一个xxxxAutoConfiguration的自动配置类

image.png

在这里可以看到它给我们自动注入了RedisTemplateStringRedisTemplate两个常用的操作模板类。

配置yml文件:

 spring:
   application:
     name: springboot-cache
   redis:
     host: 192.168.1.1
     password: 000415 #有就写,木有则省略

2.2、Redis 的简单使用

在说其他的之前,我们先来看看 Redis 常用的一些命令,从浅到深

 /**
  * @description:
  * @author: Ning Zaichun
  * @date: 2022年09月21日 22:01
  */
 @RunWith(SpringRunner.class)
 @SpringBootTest(classes = {ApplicationCache.class})
 public class RedisTest {
 ​
 ​
     @Autowired
     private StringRedisTemplate stringRedisTemplate;
 ​
     /**
      * set key value 命令 使用
      */
     @Test
     public void test1() {
         // set key value 往redis 中set 一个值
         stringRedisTemplate.opsForValue().set("username", "宁在春");
         // get key : 从redis中根据key 获取一个值
         System.out.println(stringRedisTemplate.opsForValue().get("username"));
         //out:宁在春
 ​
         // del key: 从redis 中删除某个key
         stringRedisTemplate.delete("username");
         System.out.println(stringRedisTemplate.opsForValue().get("username"));
     }
 ​
     /**
      * setnx  key value : 如果 key 不存在,则设置成功 返回 1 ;否则失败 返回 0
      */
     @Test
     public void test2() {
         Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", "1");
         System.out.println(lock);
         Boolean lock2 = stringRedisTemplate.opsForValue().setIfAbsent("lock", "1");
         System.out.println(lock2);
         //true
         //false
     }
 ​
     /**
      * set  key value nx ex : 如果 key 不存在,则设置值和过期时间 返回 1 ;否则失败 返回 0
      * nx、ex 都为命令后面的参数
      * 更为详细命令的解释:https://redis.io/commands/set/
      * 这个命令也常常在分布式锁中出现,悄悄为后文铺垫一下
      */
     @Test
     public void test3() {
         Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock:nx:ex", "lock", 30L, TimeUnit.SECONDS);
         System.out.println(lock);
         Boolean lock2 = stringRedisTemplate.opsForValue().setIfAbsent("lock:nx:ex", "lock", 30L, TimeUnit.SECONDS);
         System.out.println(lock2);
         //true
         //false
     }
 ​
 ​
     /**
      * 上述的三个测试,都是基础的 set 命令的,但 redis 中也有很多其他的数据结构,如 list、set、map 等等
      * list测试,可将列表视为队列(先进先出)
      * 想要详细了解,请查看:https://redis.io/docs/data-types/lists/
      * 【注意注意注意:还有很多方法没有测试,大家私下可以多用用】
      */
     @Test
     public void test4() {
         ListOperations<String, String> opsForList = stringRedisTemplate.opsForList();
         //从列表左边进行插入
         opsForList.leftPush("left:key", "1");
         opsForList.leftPush("left:key", "2");
         opsForList.leftPush("left:key", "3");
         opsForList.leftPush("left:key", "4");
         opsForList.leftPush("left:key", "5");
         //从列表右边进行插入
         opsForList.rightPush("left:key", "10");
         opsForList.rightPush("left:key", "9");
         opsForList.rightPush("left:key", "8");
         opsForList.rightPush("left:key", "7");
         opsForList.rightPush("left:key", "6");
 ​
         //按规定范围取出数据 ,也可以取出单个数据 leftPop rightPop ,按这样的方式取出来,也就代表从列表中删除了。
         List<String> stringList = opsForList.range("left:key", 1, 10);
 ​
         stringList.forEach(System.out::print);
         //out::4321109876
     }
 ​
     /**
      * set 测试,set 数据结构是唯一字符串(成员)的无序集合
      * 想要详细了解,请查看:https://redis.io/docs/data-types/sets/
      * 【注意注意注意:还有很多方法没有测试,大家私下可以多用用】
      */
     @Test
     public void test5() {
         SetOperations<String, String> opsForSet = stringRedisTemplate.opsForSet();
 ​
         opsForSet.add("set:key","宁在春","1","1","2","3","开始学习");
 ​
         Set<String> members = opsForSet.members("set:key");
         members.forEach(System.out::print);
         // 3宁在春21开始学习
 ​
         System.out.println("");
         // 从 set 集合中删除 value 为 1 的值
         opsForSet.remove("set:key","1");
 ​
         Set<String> members2 = opsForSet.members("set:key");
         members2.forEach(System.out::print);
         //2宁在春3开始学习
         // 注意:取出来的时候,不一定是插入顺序
     }
 ​
     /**
      * Redis 哈希 测试,
      * Redis 哈希是结构为字段值对集合的记录类型。您可以使用散列来表示基本对象并存储计数器分组等。
      * 想要详细了解,请查看:https://redis.io/docs/data-types/hashes/
      * 【注意注意注意:还有很多方法没有测试,大家私下可以多用用】
      * 应用场景:可以存储登录用户的相关信息
      */
     @Test
     public void test6() {
         HashOperations<String, Object, Object> opsForHash = stringRedisTemplate.opsForHash();
         opsForHash.put("hash:key","username","宁在春");
         opsForHash.put("hash:key","school","xxxx学校");
         opsForHash.put("hash:key","age","3");
 ​
         Object username = opsForHash.get("hash:key", "username");
         System.out.println(username);
         //宁在春
         username="宁在春写的这篇文章还不错,值得一赞";
         // 更新某一个值数据
         opsForHash.put("hash:key","username",username);
         Object username2 = opsForHash.get("hash:key", "username");
         System.out.println(username2);
         //宁在春写的这篇文章还不错,值得一赞
 ​
         // 删除某一条数据
         opsForHash.delete("hash:key","age");
 ​
         // 展示某个key下所有的 hashKey
         Set<Object> keys = opsForHash.keys("hash:key");
         keys.forEach(System.out::println);
         //username
         //school
 ​
         // 展示某个key下所有 hashKey 对应 Value值
         List<Object> hashKeyValues = opsForHash.values("hash:key");
         hashKeyValues.forEach(System.out::println);
         //宁在春写的这篇文章还不错,值得一赞
         //xxxx学校
 ​
 ​
         HashMap<String, String> map = new HashMap<>();
         map.put("name","nzc");
         map.put("address","china");
         opsForHash.putAll("hash:key2",map);
 ​
         List<Object> values = opsForHash.values("hash:key2");
         values.forEach(System.out::println);
         //china
         //nzc
 ​
     }
 ​
     @Autowired
     private RedisTemplate<Object,Object> redisTemplate;
 ​
 ​
     /**
      *  上面的操作都是操作字符串,但是大家在使用的过程中都知道,我们大都是存放到Redis中的数据,都是将某个对象直接放入Redis中
      *
      *  redisTemplate 的操作和 stringRedisTemplate 的操作都是一样的,只是内部存放的不同罢了
      */
     @Test
     public void test7() {
         Map<String, Student> map = new HashMap<>();
         Student s1 = new Student();
         s1.setSchool("xxxx1");
         s1.setUsername("ningzaichun1号");
         s1.setAge(3);
         Student s2 = new Student();
         s2.setSchool("xxxx2");
         s2.setUsername("ningzaichun2号");
         s2.setAge(5);
         map.put("user:1",s1);
         map.put("user:2",s2);
         redisTemplate.opsForHash().putAll("student:key",map);
 ​
 ​
         List<Object> values = redisTemplate.opsForHash().values("student:key");
         values.forEach(System.out::println);
         //Student(username=ningzaichun1号, school=xxxx1, age=3)
         //Student(username=ningzaichun2号, school=xxxx2, age=5)
     }
 ​
     /**
      * 虽然大家可以在Java 程序中看到取出来的值是正常的,
      * 但是在平时开发和测试的时候,我们还是需要借助 Redis 的可视化工具来查看的,
      * 你会发现,我们采用默认的序列化机制(JDK序列化机制),在Redis可视化软件中,会无法直接查看的,
      * 都是转码之后的数据: \xac\xed\x00\x05t\x00\x06user:2
      * 图片见下面第二张图
      */
     @Test
     public void test8() {
         Map<String, Student> map = new HashMap<>();
         Student s1 = new Student();
         s1.setSchool("xxxx1");
         s1.setUsername("ningzaichun1号");
         s1.setAge(3);
         Student s2 = new Student();
         s2.setSchool("xxxx2");
         s2.setUsername("ningzaichun2号");
         s2.setAge(5);
         map.put("user:1",s1);
         map.put("user:2",s2);
         redisTemplate.opsForHash().putAll("student:key",map);
 ​
         List<Object> values = redisTemplate.opsForHash().values("student:key");
         values.forEach(System.out::println);
         //Student(username=ningzaichun1号, school=xxxx1, age=3)
         //Student(username=ningzaichun2号, school=xxxx2, age=5)
     }
 }
 ​

list数据类型演示 leftPushrightPush命令时的效果图:

image.png

使用 Redis 默认序列化机制,往Redis中存储对象的结果展示:

image.png

可以看到在可视化界面中,他们的值都被序列化成这么一串字符串了,不便于数据的查看。

将 Redis 的默认序列化机制,改成什么样的才合适呢?

改成 JSON 格式的序列化机制最佳,既方便查看,也支持各种各样的程序。

2.3、Redis的序列化机制

Redis 的默认序列化机制是JDK序列化机制,这点可以在下图的源码中看出。

image.png

从源码中也可以看出,如果没有设置序列化机制,则defaulatSeralizer = new JdkSerializationRedisSerializer() ,可以明显看出,使用的就是JDK 的序列化机制。

JDK默认序列化机制并非不能使用,只是它具有一定的局限性

  • 它只适用于Java项目,对其他语言编写的项目不兼容,如Go或者PHP
  • 在Redis的可视化页面,无法进行较好的展示

我们需要将 Redis 的默认序列化机制改为JSON格式,一方面兼容性较高,另一方面在可视化界面也较好查看。

在修改之前,先看一眼默认的JdkSerializationRedisSerializer类吧。

public class JdkSerializationRedisSerializer implements RedisSerializer<Object>

我们可以看到它也是实现了RedisSerializer接口,点进接口去,在IDEA中按下ctrl+H可以查看整个类从上至下的树结构。

我们来可以看看它的实现类有哪些,有没有已经实现JSON相关的序列化机制的实现类。

image.png

从接口实现树上可以看到,有三个可以直接转换为JSON序列化的实现类

  • GenericJackson2JsonRedisSerializer
  • FastJsonRedisSerializer
  • Jackson2JsonRedisSerializer

这三者都是可以的,具体的区别,用法搜索或者看一下源码就会大概懂的使用了~,不是我的讨论重点。

我这里直接使用的是Jackson2JsonRedisSerializer,作用就是序列化object对象为json字符串。

2.4、更改Redis的默认序列化机制

那么如何更改勒?

在之前也已经看到在RedisAutoConfiguration中已经帮我们注入了RedisTemplete和StringTemplete,我们现在要更改他们的设置,所以就改为手动注入。

RedisTemplete中的afterPropertiesSet方法中可以看到当RedisSerializer<?> defaultSerializer;当为 null 时,默认使用JdkSerializationRedisSerializer的序列机制。

那么更改就很简单啦~

创建一个 MyRedisConfig 配置类,将RedisTemplete和StringTemplete改为手动注入,在注入RedisTemplete时,手动set一个Jackson2JsonRedisSerializer类即可。

 /**
  * @description:
  * @author: Ning Zaichun
  * @date: 2022年09月06日 23:21
  */
 @Configuration
 public class MyRedisConfig {
 ​
     @Bean
     public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
         RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
 ​
 ​
         Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer(Object.class);
 ​
         ObjectMapper objectMapper = new ObjectMapper();
         objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
         objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
 ​
         serializer.setObjectMapper(objectMapper);
 ​
         //设置value 值的序列化
         redisTemplate.setValueSerializer(serializer);
         //key的序列化
         redisTemplate.setKeySerializer(new StringRedisSerializer());
 ​
         // set hash  hashkey 值的序列化
         redisTemplate.setHashKeySerializer(new StringRedisSerializer());
         // set hash value 值的序列化
         redisTemplate.setHashValueSerializer(serializer);
 ​
         redisTemplate.setConnectionFactory(redisConnectionFactory);
         redisTemplate.afterPropertiesSet();
         return redisTemplate;
     }
 ​
     @Bean
     public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
         return new StringRedisTemplate(redisConnectionFactory);
     }
 }
 ​

测试: 还是之前一样的代码

 /**
     * 虽然大家可以在Java 程序中看到取出来的值是正常的,
 * 但是在平时开发和测试的时候,我们还是需要借助 Redis 的可视化工具来查看的,
 * 你会发现,我们采用默认的序列化机制(JDK序列化机制),在Redis可视化软件中,会无法直接查看的,
 * 都是转码之后的数据: \xac\xed\x00\x05t\x00\x06user:2
     * 图片见下面第二张图
     */
     @Test
     public void test8() {
     Map<String, Student> map = new HashMap<>();
     Student s1 = new Student();
     s1.setSchool("xxxx1");
     s1.setUsername("ningzaichun1号");
     s1.setAge(3);
     Student s2 = new Student();
     s2.setSchool("xxxx2");
     s2.setUsername("ningzaichun2号");
     s2.setAge(5);
     map.put("user:1",s1);
     map.put("user:2",s2);
     redisTemplate.opsForHash().putAll("student:key",map);
 ​
     List<Object> values = redisTemplate.opsForHash().values("student:key");
     values.forEach(System.out::println);
     //Student(username=ningzaichun1号, school=xxxx1, age=3)
     //Student(username=ningzaichun2号, school=xxxx2, age=5)
 }

效果图:

image.png

2.5、RedisConnectionFactory

想了想,都写到这里来了,还是说说RedisConnectionFactory(Redis连接工厂)吧。

SpringBoot 对于 Redis 连接工厂的实现有两个,一个是SpringBoot 2.0 默认使用的`LettuceConnectionFactory,另一个是早期就出现的JedisConnectionFactory

首先得说明,两者是有区别的。

Jedis

  • Jedis 是一个优秀的基于 Java 语言的 Redis 客户端
  • Jedis 在实现上是直接连接 Redis-Server,在多个线程间共享一个 Jedis 实例时是线程不安全的,如果想要在多线程场景下使用 Jedis,需要使用连接池,每个线程都使用自己的 Jedis 实例,当连接数量增多时,会消耗较多的物理资源。

Lettuce

  • SpringBoot 2.0 及之后版本 Redis 的默认连接工厂
  • Lettuce则完全克服了其线程不安全的缺点;
  • Lettuce是一个可伸缩的线程安全的 Redis客户端,支持同步、异步和响应式模式。多个线程可以共享一个连接实例,而不必担心多线程并发问题。
  • 它基于优秀 Netty NIO 框架构建,支持 Redis 的高级功能,如 Sentinel,集群,流水线,自动重新连接和 Redis 数据模型。

一些关于 Lettuce的使用,大家可以看看这篇文章

初探 Redis 客户端 Lettuce:真香! 作者: 博客园-vivo互联网技术

三、缓存的三大面试常客

3.1、缓存穿透

缓存穿透是指用户在不断访问一个缓存和数据库中都没有的数据

缓存无法命中,从而导致一直请求数据库,流量过大就会导致数据库的崩溃.

如发起为id为 -1 的数据或id为特别大不存在的数据,这时的用户往往可能是恶意攻击者,这种恶意攻击会导致数据库压力非常大,扛不住的结果就是宕机。

image.png

解决方案:

1、缓存穿透是指查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也没有,如果我们不存储这个空数据,那么持续的访问就会导致我们的数据库压力倍增,此时我们就可以将空结果(null)存入到缓存中并且设置一个较短的过期时间

2、接口层增加校验,如用户鉴权校验,编写一些特殊数据的校验,预防这样的事故的发生。如将id<=0的查询请求直接拒绝掉。

image.png

3.2、缓存雪崩

Redis挂掉了,请求全部走数据库

如一个系统,每天的高峰期是每秒5000个请求,Redis缓存在的时候,可以差不多抗住,但是Redis的机器突然网络出问题了,完全访问不了。

那么此时每秒5000的请求都会直接打到数据库上,数据扛住了没啥事,扛不住了就是GG啦。

有哪些方案可以来预防和处理这样的故障呢?

image.png

从三个角度讨论:

1、部署角度:实现 Redis 的高可用,主从+哨兵,Redis集群。

2、应用程序角度:

  • 本地缓存 + 限流&降级

  • 允许的话,设置热点数据永远不过期

  • 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生

    (但实际而言,这一点很多时候其实不做的,因为如果加上随机时间后,再碰撞又该如何呢?)

3、恢复角度:Redis 的 RDB+AOF组合持久化策略,方便redis宕机后及时恢复数据

image.png

浅谈一下限流和降级的好处:

  • 限流会限制最高的访问人数,保证系统的正常运行,不会崩溃。
  • 服务降级,会返回用户一些合适的提示信息,对于用户而言,刷新个四五次还是有可能访问成功的。
  • 都可以保证系统的运行,不至于让系统崩溃。

3.3、缓存击穿

缓存击穿和缓存穿透其实非常类似,但是缓存击穿说的是数据在缓存中没有,但是在数据库中有的数据。

看到过的面试题中,最常举例的场景就是:

Redis 中大某一个热点key在突然失效,并且此时正处于高并发期间,导致流量全部打到数据库上,造成数据库极大的压力。我们通常将这样的事件称之为缓存击穿

image.png

其实读懂问题,解决方案也很好说明:

  1. 设置热点数据不过期;
  2. 第一时间去数据库获取数据填充到redis中,并且在请求数据库时需要加锁,避免所有的请求都直接访问数据库,一旦有一个请求正确查询到数据库时,将结果存入缓存中,其他的线程获取锁失败后,让其睡眠一会,之后重新尝试查询缓存,获取成功,直接返回结果;获取失败,则再次尝试获取锁,重复上述流程。

流程图:大致如下

image.png 大致流程就是这样的~

四、缓存与数据库的一致性问题

先谈问题,再谈理论,最后说如何去实现。

需要一点点耐心阅读,为了减轻大家的理解和记忆负担,图文并茂

咱就说,别慌!!!

一切设计都是基于业务的,所以不同的场景会产出不同的最佳实践

暂无共有的最佳实践,下面的讨论也是如此。

希望大家友善踊跃交流,谢谢


数据库和缓存的数据不一致问题,大都是产生在更新数据时。

在更新的时候,操作缓存和数据库无疑就是以下四种可能之一:

  • 先更新缓存,再更新数据库
  • 先更新数据库,再更新缓存
  • 删除缓存,再更新数据库
  • 更新数据库,再删除缓存

一个一个分析,为什么会产生数据不一致问题?

4.1、先更新缓存,再更新数据库

操作流程大致如下:问题出现在第四个操作上

image.png

如果我成功更新了缓存,但是在执行更新数据库的那一步,服务器突然宕机了,那么此时,我的缓存中是最新的数据,而数据库中是旧的数据

脏数据就因此诞生了,并且如果我缓存的信息(是单独某张表的),而且这张表也在其他表的关联查询中,那么其他表关联查询出来的数据也是脏数据,结果就是直接会产生一系列的问题。

4.2、先更新数据库,再更新缓存

先更新数据库,再更新缓存,其实还是存在类似的问题。

image.png

只有等到缓存过期之后,才能访问到正确的信息。那么在缓存没过期的时间段内,所看到的都是脏数据。

从上面两张图中,大家也能看出,无论咋样,只要执行第二步时失败了,就必然会产生脏数据。

思考:如果如果如果两步都能执行成功?能保证数据一致性吗?

其实也不能,因为还有Java常考的并发

4.3、并发情况下的思考

如果上面的两小节,两步操作都能成功,在并发情况下是怎么样的呢?

image.png

换成是先更新数据库,再更新缓存,也是一样的。

image.png

在这里可以看到当执行时序被改变,那么就必然会产生脏数据

看到这里,也许学过 Java 锁知识的小伙伴可能会说,咱们可以加锁啊,这样就不会产生这样的问题啦~

在这里确实可以加锁,以保证用户的请求顺序,来达到数据一致性。


虽然加锁确实可以通过牺牲一些性能来保证一定数据一致性,但我还是不推荐更新缓存的方式。

原因如下:

  1. 首先加入缓存的主要作用是提高系统性能。

  2. 其次更新缓存的代价并不低。

    • 复杂场景下:比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据并进行运算,才能计算出缓存最新的值的。
    • 可能一些场景是需要这样的。
  3. 缓存利用率问题。一个频繁更新的缓存,它是否会被频繁的访问呢?

    • 一个缓存在很短的时间内,更新10次,20次或者更多,但是实际访问次数只有1、2次,这其实也是一种浪费。
    • 如果采用删除缓存就不会这样,删除了缓存,那么就只会等到有人要使用缓存的时候,才会重新查询数据,放入缓存中。这其实也是懒加载的思想,等到要使用了,再加载。

当然业务场景确实有这样的场景,这么使用也未免不可, 一切都要实事求是,而并非空谈。

接着我们再思考思考:难道先删除缓存,再更新数据库,或者是先更新数据库,再删除缓存就没有问题了吗?

4.4、先删除缓存,再更新数据库

这种方式在没有高并发的情况下,是可能保持数据一致性的。

image.png

如果只有第一步执行成功,而第二步失败,那么只有缓存中的数据被删除了,但是数据库没有更新,那么在下一次进行查询的时候,查不到缓存,只能重新查询数据库,构建缓存,这样其实也是相对做到了数据一致性。

但如果是处于读写并发的情况下,还是会出现数据不一致的情况:

image.png

执行完成后,明显可以看出,1号用户所构建的缓存,并不是最新的数据,还是存在问题的~

4.5、先更新数据库,再删除缓存

如果更新数据库成功了,而删除缓存失败了,那么数据库中就会是新数据,而缓存中是旧数据,数据就出现了不一致情况。

image.png

和之前一样,如果两段代码都执行成功,在并发情况下会是什么样呢

image.png

还是会造成数据的不一致性。

但是此处达成这个数据不一致性的条件明显会比起其他的方式更为困难

  • 时刻1:读请求的时候,缓存正好过期
  • 时刻2:读请求在写请求更新数据库之前查询数据库,
  • 时刻3:写请求,在更新数据库之后,要在读请求成功写入缓存前,先执行删除缓存操作。

这通常是很难做到的,因为在真正的并发开发中,更新数据库是需要加锁的,不然没一点安全性~

一定程度上来讲,这种方式还是解决了一定程度上的数据不一致性问题的。

4.6、小小总结

1、无论选择下列那种方式

  • 先更新缓存,再更新数据库
  • 先更新数据库,再更新缓存
  • 先删除缓存,再更新数据库
  • 先更新数据库,再删除缓存

如果是在多服务或是并发情况下,其实都有可能产生数据不一致性。

不过在这四种选择中,平常都会优先考虑后两种方式。并且市面上对于这后两种选择,也已经有一些解决方案。

在谈解决方案之前,我们先看看需要解决的问题

  1. 我们要如何保证这两段代码一起执行成功

  2. 【先删除缓存,再更新数据库】在读写并发时,会产生一个缓存旧数据,而数据库是新数据的问题,这该如何解决呢?

  3. image.png

  4. 加锁可以解决并发情况下出现的不一致问题吗

关于第三点讲解,在下一篇关于本地锁到Redis分布式锁中讲解。

4.7、关于数据一致性的补充

简单说,只要使用缓存,那么必然就会产生缓存和数据库数据不一致的问题。

在这首先我们要明确一个问题,就是我们的系统是否一定要做到“缓存+数据库”完全一致性?是否能够接受偶尔的数据不一致性问题?能够接受最长时间的数据不一致性?

强一致性

如果缓存和数据库要达到数据的完全一致,那么就只能读写都加锁,变成串行化执行,系统吞吐量也就大大降低了,一般不是必须达到强一致性,不采用这样的方式。

并且实在过于要求强一致性,会采用限流+降级,直接走MySQL,而不是特意加一层 Redis 来处理。

弱一致性(最终一致性)

一般而言,大都数项目中,都只是要求最终一致性,而非强一致性。

最终一致性是能忍受一定时间内的数据不一致性的,只要求最后的数据是一致的即可。

例如缓存一般是设有失效时间的,失效之后数据也会保证一致性,或者是下次修改时,没有并发,也会让数据回到一致性等等。

五、数据一致性解决方案

所谓的解决方案,其实大都也就是解决之前我们提出来的几个问题~

5.1、如何保证这两段代码一起执行成功

要想第二段代码成功执行,那么重试是必不可少的啦

重试的思想,在学习Java的道路会遇到很多次的哈,

1)引子

像如果学习过Java中锁相关知识的朋友,应该会记得自旋锁和互斥锁~

自旋锁:一种是没有获取到锁的线程就一直循环等待判断该资源是否已经释放锁,它不用将线程阻塞起来(NON-BLOCKING);

互斥锁:把自己阻塞起来,等待重新调度请求。

自旋锁的思想其实也就是一个while(true)一直重试罢了。

还有使用过openfegin的朋友会知道,它在发送请求时,也包含有一个重试机制,很多高可用的场景,都会加上重试~

2)重试

但是重试存在的问题,也有很多,需要重试几次呢?重试的间隔时间是多少呢?重试再失败该如何补偿呢?在重试的过程中,如果程序宕机,重试也就丢失啦

看到这些你有没有头大,有的话,就对了,认真思考每一个点,你都会发现很多其他的知识,这往往比老老实实的学习更有效。

我们如果仍然像锁机制或者是openfeign的机制一样,采取同步重试的方式的话,是解决不了问题的,如同步重试是可能会失败的,如果一直失败,则会一直占用线程资源,导致其他用户的请求无法正常被执行。

应该很容易想到,同步的对立面就是异步,异步重试,交由别人来做这件事情,自己不用去管这件事情即可。

谈到异步,并且是第三方来做的,最快想到的无疑就是消息队列啦~

3)消息队列-异步

如果学习过消息队列的朋友,应该很快就能get到,或者自己思考到这一点;

如果没有学习过的话,我觉得学习消息队列还是非常有必要的一件事情。

我们可以把第二步操作交由消息队列去做,达到一个异步重试的效果。并且引入消息队列来实现,代价并非想象中的那么大。

当然大家也会说,如果发送消息也失败呢?

有这种可能,但真的不算高,另外消息队列自身是很好的支持高可用的。

  1. 首先消息队列在高并发的场景下,可以毋庸置疑的说是一个非常重要的组件啦,所以引入消息队列以及维护消息队列,其实都不能算是额外的负担。

  2. 其次消息队列具有持久化,即使项目重启也不会丢失。

  3. 最后消息队列自身可以实现可靠性

    • 保证消息成功发送,发送到交换机;
    • 保证消息成功从交换机发送至队列;
    • 消费者端接收到消息,采用手动ACK确认机制,成功消费后才会删除消息,消费失败则重新投递~

图:

image.png

(说明:消息队列的内部可靠机制就没有再详细画了)

4)Canal 订阅日志实现

消息队列虽然已经比较简单,但是仍然要手动的进行代码的编写,以及写一个消费者来进行监听,可以说还是比较麻烦,每个地方都还要引入消息队列,发送一个消息~,有没有办法省去这一步呢?有的勒,偷懒的人大有人在勒

现有的解决方案中,可以使用 alibaba 的开源组件 Canal,订阅数据库变更日志,当数据库发生变更时,我们可以拿到具体操作的数据,然后再去根据具体的数据,去删除对应的缓存。

当然Canal 也是要配合消息队列一起来使用的,因为其Canal本身是没有数据处理能力的。

相应的流程图大致变成下列这样:

image.png

优点:

  • 算的上彻底解耦了,应用程序代码无需再管消息队列方面发送失败问题,全交由 Canal来发送。

缺点:

  • 引入了Canal中间件,需要一定的维护成本,需要实现高可用的话,也需考虑集群等,架构也会进一步变得复杂。

具体的代码实现,还是需要各位朋友去进行一番搜索啦。

在本文中,我更多的是针对Redis方面的学习,关于这部分的内容以及实现,我也只是通过八股文和一些文章观摩,并没有进行深入的研究,说来实在惭愧,还请各位见谅。

5.2、延时双删策略

问题:【先删除缓存,再更新数据库】在读写并发时,会产生缓存是旧数据,而数据库是新数据的问题,这该如何解决呢?

image.png (图片说明:上图为产生数据不一致性的情况)

延时双删流程图

image.png

解决这样的问题,其实最好的方式就是在执行完更新数据库的操作后,先休眠一会儿,再进行一次缓存的删除,以确保数据一致性,这也就是市面上给出的主流解决方案--延时双删

相信大家在诸多面试八股文中,也常常会看到这个吧~

但是更加深入的思考“延时”两字,这个延时到底延时多久合适呢?有什么评判依据吗?

首先延迟删除的时间需要大于 1号用户执行流程的总时间

即:【1号用户从数据库读取数据+写入缓存】时间

但是要说具体是多长,这无法给出一个准确答复,只能经过不断的压测和实验,预估一个大概的时间,尽可能的去降低发生数据不一致的概率罢了。

补充:并发问题的解决,最常用的方式无疑就是加锁,那到底是加什么锁呢?在分布式系统中,对于并发,加的无疑就是分布式锁。


写到这里已经感觉有点长了,分布式锁的演进,打算另外开一篇文章

总结

能够看到这里的话,那咱们就一起再看一遍文章大纲吧,看看你是否已经理解啦~

image.png

后记

本文参考于水滴与银弹(很强的一个大佬,同名公众号)大佬文章《缓存和数据库一致性问题,看这篇就够了》 大家感兴趣可以去关注关注大佬的公众号。

虽然写完也有再次阅读,但不可避免会出现疏忽或遗漏,如在阅读中发现任何问题,请及时联系我(留言、私信、微信群、微信nzc_wyh都可,备注掘金),一定会在第一时间进行修正,非常非常感谢各位,能够读到此处。

其实写下这篇文章的我,也并不知道这篇文章有没有让你有所收获,究竟是满载而归,还是竹篮打水一场空,我也不知。

但无论是哪一种,我都希望这里能留下属于你的痕迹(留言或者私信啦~)

写的不好的话,我下次就再改改啦~

写的还行的话,就请给予一些正向反馈吧,也让我收获收获属于工作日的快乐吧~

希望有吧,有的话记得给我来个赞~

写于 2022 年 10 月 19 日晚,作者:宁在春,你也可以叫我春春~