评论

收藏

[NoSQL] redis专题:数据库和redis缓存一致性解决方案

数据库 数据库 发布于:2021-07-08 11:01 | 阅读数:325 | 评论:0

  
  文章目录



    • 1.双写模式
    • 2.失效模式
    • 3.缓存一致性解决方案


  redis缓存和数据库都保存了数据信息,当我们更新了数据库的数据时,应该如何保证redis和数据库的数据同步呢?当前比较常用的是双写模式和失效模式。

1.双写模式
  双写模式:每次修改数据库的数据后,然后在更新redis中的数据,使用了两次写操作,称为双写模式
  双写模式存在的问题:高并发下有可能会有脏数据
  场景:

  • 线程A在修改数据库之后、更新缓存之前,由于其他原因,cpu时间片被线程B抢到。
  • 线程B改库、刷缓存一气呵成。
  • 此时线程A再拿到cpu,执行更新缓存操作,那么此时缓存中的数据更新的就是线程A的脏数据,
    但其实我们想要的是线程B最新修改的数据,这样就出现了双写模式下不一致的情况,产生了脏数据!
  原理如下图:
DSC0000.png

2.失效模式
  失效模式:每次修改数据库的数据后,删除redis中缓存的数据,当有redis查询请求时,会先去数据库查询,然后更新到redis中,后续继续请求redis。更新时删除缓存数据,称为失效模式
  失效模式存在的问题:同样会存在脏数据问题
  场景一:先改数据库,再删redis!

  • 线程A修改数据库数据,并删除了缓存中对应的数据。
  • 线程B要获取数据,发现缓存中没有,就去数据库查到线程A修改后的数据,然后准备更新缓存,但在更新之前,由于其他原因,cpu时间片被线程C抢到了,
  • 此时线程C也修改了数据库数据,并删除已经为空的缓存。
  • 然后cpu又被线程B抢到,线程B继续执行更新缓存操作,此时缓存中更新的是线程A修改后的数据,但其实我们想要的是线程C修改后的数据,这就产生了数据不一致的情况!!
  场景二:先删redis,再改数据库!

  • 以减库存为例,假设库存量为100。线程A要减库存,会先删除redis数据,再对数据库中的库存量执行减 1 操作。
  • 当线程A删除完redis数据, 准备执行减 1 操作时,cpu时间片被线程B抢占
  • 线程B要查询库存,此时查到的还是原来的库存量(100),因为此时线程A还没来得及执行减 1 操作。然后线程B把原来的库存量100更新到redis
  • 线程B执行完毕后,cpu时间片又回到线程A手中,线程A继续执行减 1 操作,执行完毕,数据库库存量为99,与redis中的100不相等,数据不一致!
  2.1 延迟双删
  延迟双删是在失效模式的基础上,在删除reids缓存时,让程序睡眠几十毫秒,再次执行删除缓存操作,可有效预防失效模式中缓存不一致问题,但是并不推荐。因为数据不一致问题本来就是极少情况发生的,如果使用延时双删,那么大部分正常的请求都会被阻塞几十毫秒,系统性能下降,显然得不偿失!

3.缓存一致性解决方案
  如上所示,无论是双写模式还是失效模式,都无法完美解决缓存一致性问题!但在不同的业务场景对数据一致性的要求也不同,并非所有场景都需要数据强一致性,我们要根据实际业务场景来分析,具体解决方案如下所示:

  • 对于并发很小的数据,比如个人信息、用户数据等。这些数据在使用双写或者失效模式后,由于并发量小,根本不需要考虑缓存一致性问题。可以给缓存数据加上过期时间,每隔一段时间触发读操作的主动更新即可!
  • 如果并发量很高,但业务上能容忍短时间的缓存数据不一致,比如商品名称,商品分类三级菜单等。为缓存数据加上过期时间依然可以解决大部分业务对于缓存的要求。
  • 如果并发量很高,且无法容忍数据不一致,比如库存。可以使用分布式锁来保证一致性!但也不用读写操作都加一把重量级的分布式锁,使用轻量级读写锁即可,通过添加读写锁保证写数据时读写都阻塞,仅读数据时相当于无锁!
  • 还有一种方案,但代价也挺大。方案:所有的对库存的增、删、改、查操作都通过nginx路由到集群中某一台固定的机器上,在这台机器上定义一个内存队列。然后对库存的操作类型进行判断,每一个增、删操作放入队列,读操作不放入(防止队列元素过多)。然后从队列中逐个取出增、删操作并执行,读操作需要等到队列中没有增、删操作时才可执行! 这种方案也可以阻止并发带来的数据不一致,但却降低了系统可用性!
  3.1 redisson读写锁的底层原理
//获取读写锁
    RReadWriteLock readWriteLock = redisson.getReadWriteLock("123");
    
    //写锁
    RLock writeLock = readWriteLock.writeLock();
    writeLock.lock();
    System.out.println(111);
    writeLock.unlock();
    
//读锁
    RLock readLock = readWriteLock.readLock();
    readLock.lock();
    System.out.println(111);
    readLock.unlock();
  redisson的readWriteLock其实和redisson的lock差不多,只不过加了个mode标识。底层的lua脚本根据mode的值,区分读写逻辑
  ①:如果加的是读锁,mode = read,并发读数据不阻塞
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
    //增加mode属性
                "local mode = redis.call('hget', KEYS[1], 'mode'); " +
                "if (mode == false) then " +
                  "redis.call('hset', KEYS[1], 'mode', 'read'); " +
                  "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                  "redis.call('set', KEYS[2] .. ':1', 1); " +
                  "redis.call('pexpire', KEYS[2] .. ':1', ARGV[1]); " +
                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                  "return nil; " +
                "end; " +
                //如果mode为读,其他的读不阻塞
                "if (mode == 'read') or (mode == 'write' and redis.call('hexists', KEYS[1], ARGV[3]) == 1) then " +
                  "local ind = redis.call('hincrby', KEYS[1], ARGV[2], 1); " + 
                  "local key = KEYS[2] .. ':' .. ind;" +
                  "redis.call('set', key, 1); " +
                  "redis.call('pexpire', key, ARGV[1]); " +
                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                  "return nil; " +
                "end;" +
                "return redis.call('pttl', KEYS[1]);",
            Arrays.<Object>asList(getName(), getReadWriteTimeoutNamePrefix(threadId)), 
            internalLockLeaseTime, getLockName(threadId), getWriteLockName(threadId));
  ②:如果加的是写锁,mode = write,并发写数据,其他读写都阻塞
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
    //增加mode属性
              "local mode = redis.call('hget', KEYS[1], 'mode'); " +
              "if (mode == false) then " +
                  "redis.call('hset', KEYS[1], 'mode', 'write'); " +
                  "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                  "return nil; " +
                "end; " +
                //如果mode为写,判断haxists,使其他的读写都阻塞
                "if (mode == 'write') then " +
                  "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                    "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + 
                    "local currentExpire = redis.call('pttl', KEYS[1]); " +
                    "redis.call('pexpire', KEYS[1], currentExpire + ARGV[1]); " +
                    "return nil; " +
                  "end; " +
                "end;" +
                "return redis.call('pttl', KEYS[1]);",
            Arrays.<Object>asList(getName()), 
            internalLockLeaseTime, getLockName(threadId));
  3.2 使用Canal解决缓存一致性问题
  Canal使我们的业务代码只关注于数据库的交互,不用管redis缓存的问题,因为Canal可以订阅mysql数据库的每一次更新,只要mysql数据库有更新,Canal就会把数据同步到redis
  使用Canal注意:

  • ①:mysql要开启binlog日志,才能被Canal所监控
  • ②:使用Canal在业务代码中执行修改缓存就可以
  • ③:使用Canal需要额外增加Canal中间件,加重系统复杂度。
  Canal操作流程图
DSC0001.png
  总结:
  以上我们针对的都是读多写少的情况加入缓存提高性能,如果写多读多的情况又不能容忍缓存数据不一致,那就没必要加缓存了,可以直接操作数据库! 放入缓存的数据应该是对实时性、一致性要求不是很高的数据。切记不要为了用缓存,同时又要保证绝对的一致性做大量的过度设计和控制,增加系统复杂性!
  

  
关注下面的标签,发现更多相似文章