在项目中,因为缓存具有高性能、高并发的特性而被大家广泛的使用。但是常常我们会遇到一个问题:当需要更新数据库的时候怎么保证缓存里的数据和数据库里面的数据始终保持一致呢?

这个问题就是著名的数据库和缓存的双写一致性问题

为了不让内容显得空洞,帮助一些没有对此问题没有一点直观概念的读者,建立起对该问题的直观概念。我先赘述一下问题发生的情况

下面代码是我们在项目中最常见的缓存的使用方式:

Object cache = redisTemplate.getValue(key);
if(Objects.nonNull(cache){
    return cache;
Object result = dbService.queryDb();
redisTemplate.putValue(cache);
return result;

流程图如下:

但是有时候,我们需要更新数据库的数据,而且这些数据在缓存中也存在,我们该怎么办呢?

什么情况下会导致缓存不一致呢?

我们可以做一个简单的排列组合,把所有能实现的方式列举出来然后在逐一采用画图和伪代码的方式讨论各种方案的可行性

无非以下几种方案:

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

      //1、更新缓存
      int id = 3;
      redisService.update(3,"张三"));
      //2、更新数据库
      dbService.update(3,"张三");
    

    这种情况显而易见,该方式不可行,原因:如果①更新缓存成功,将缓存里id为3的数据改为了张三,但是这个时候由于各种原因②来不及执行或者执行失败,那么数据库中的id为3的数据没有被改为张三,而用户查询的数据是张三,这就造成了数据不一致

    先更新数据库,后更新缓存

      //1、更新数据库
      int id = 3;
      dbService.update(3,"张三");
      //2、更新缓存
     redisService.update(3,"张三"));
    

    这种情况也是显而易见的,该方式不可行(设置缓存过期时间除外)。原因: ① 更新数据库事务提交成功,由于各种更新缓存失败,并且该缓存没有设置过期时间,那么用户读取的数据将一直是旧数据。这也会造成数据不一致

    ② 开启数据库更新事务未提交,更新缓存成功后事务提交失败,如果未设置缓存的过期时间,那么用户将会一致读取脏数据。这也会造成数据的不一致

    先删除缓存,后更新数据库

    // 1、删除缓存
    int  id = 3;
    redisService.delete(3)
    // 2、更新数据库
    dbService.update(3,"张三");
    

    该方案需要讨论读写串行执行和读写并发执行两种情况:

    该方式在串行执行的情况下,讨论如下:

    ① 删除缓存成功,更新数据库失败;业务出错,下次用户查询的时候,仍然是从数据库中最新的数据,所以缓存和数据库数据一致 ② 删除缓存失败;业务出错,缓存中的数据仍然是旧数据,而且数据库中的数据没有被更新也是旧数据,所以缓存和数据库数据一致 ③ 删除缓存成功,更新数据库成功;数据一致

    综上所述:先删除缓存,后更新数据库的方案在串行执行的情况下,能保证数据的一致性

    该方式在并发执行的情况下,讨论如下:

    ① 线程A删除缓存成功,线程B在线程A更新数据库之前,查询了数据库,将旧数据"李四",存入缓存,线程A再更新数据库数据为"张三",这时数据库和缓存的数据不一致

    ② 线程A删除缓存成功,线程B在线程A更新数据之后,查询数据库。线程B查询的将会是最新的数据,然后在数据放入缓存中,这时数据库和缓存中的数据一致 ③ 线程A删除缓存失败,业务出错就不会执行更新数据库操作,缓存和数据库中的数据仍然是旧数据,数据是一致的

    综上所述:先删除缓存,后更新数据库的方案在并发执行的情况下,能保证不能保证数据的一致性

    先更新数据库,后删除缓存

    // 1、更新数据库
    int  id = 3;
    dbService.update(3,"张三");
    // 2、删除缓存
    boolean result = redisService.delete(3);
    if(!result){
    

    该方案需要讨论读写串行执行和读写并发执行两种情况:

    该方式在串行执行的情况下,讨论如下:

    ① 更新数据库成功,删除缓存成功;数据一致 ② 更新数据库失败,业务出错,不会删除缓存,数据库和缓存中的数据仍然是旧数据,数据保持一致 ③ 更新数据库成功,删除缓存失败,这时可以通过数据库事务回滚。保证了的数据的一致性

    综上所述:先更新数据库,后删除缓存的方案在串行执行的情况下,能保证数据的一致性

    该方式在并发执行的情况下,讨论如下:

    ① 线程A开启更新数据事务,在删除缓存成功之后事务未提交之前此时线程B查询数据,发现缓存为空,此时由于线程A未提交事务,查询数据库中的数据为旧数据,将旧数据放入缓存中后线程A提交事务修改数据库为新数据,就导致了数据不一致

    Begin
    updateDb
    delete redis success
    commit
    

    ② 线程A开启更新数据事务,在删除缓存失败之后事务为回滚事前此时线程B查询缓存中的旧值,数据库回滚后也是旧值。数据一致

    Begin
    updateDb
    delete redis error
    roback
    

    ③ 线程A开启更新数据事务并且提交成功,删除缓存失败。造成数据库和缓存数据不一致

    Begin
    updateDb
    commit
    delete redis error
    

    综上所述:先更新数据库,后删除缓存的方案在并发执行的情况下,不能保证数据的一致性

    由第二部分的讨论结果,可以率先排除前两种操作方案及先更新数据库,后更新缓存先更新缓存,后更新数据库,因为对于串行执行而言都不能保证数据的一致性,从理论的角度上排除该方案。那么下面重点从后两种方案讨论一下解决方案。

    设置缓存超时时间

    这种方法是通用的也是最简单,但是如果过期时间设置过长会导致数据的不一致性时间延长;如果设置的过短,又会频繁的查询数据库,使缓存形同虚设。

    分布式锁(读写串行化)

    无论是先删除缓存,后更新数据库;还是先更新数据,后删除缓存;在串行执行的情况下都是能保证数据的一致性。所以我们就是要实行读写串行化

    分布上场景下,最常用的便是分布式锁的方案,伪代码如下:

    加锁(key)
    更新数据库
    
    加锁(key)
    

    这种解决方案,又引入了新的概念(分布式锁),我们知道程序中加锁是一件非常消耗性能的操作,与我们引入缓存来提升性能的初衷背离。但是它能保证数据的强一致性。

    在对数据的一致性要求很高,但是能牺牲性能的情况下的可以使用。

    针对先更新数据库,后删除缓存的方案的第三种代码顺序,可以采用异步补偿的方式实现最终一致性

    该方案通过异步队列的形式实现了最终一致性,但是有引入了新的队列中间件。建议使用JDK自带的队列实现该异步补偿(在JVM进程内完成异步补偿)而不是用RabbitMQ这类网络中间件。

    在最后可以通过记录相关日志后,通过认为接入的方式完成补偿和数据同步。

    如果您对我的文章感兴趣,可以关注我的个人微信公众号哦,将会第一时间同步最新的技术博客~~ SmileJosiah 小小码 @慧择网

    粉丝