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


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

1.双写模式

双写模式:每次修改数据库的数据后,然后在更新redis中的数据,使用了两次写操作,称为双写模式

双写模式存在的问题:高并发下有可能会有脏数据

场景:

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

原理如下图:
在这里插入图片描述


2.失效模式

失效模式:每次修改数据库的数据后,删除redis中缓存的数据,当有redis查询请求时,会先去数据库查询,然后更新到redis中,后续继续请求redis。更新时删除缓存数据,称为失效模式

失效模式存在的问题:同样会存在脏数据问题

场景一:先改数据库,再删redis

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

场景二:先删redis,再改数据库!

  1. 以减库存为例,假设库存量为100。线程A要减库存,会先删除redis数据,再对数据库中的库存量执行减 1 操作。
  2. 当线程A删除完redis数据, 准备执行减 1 操作时,cpu时间片被线程B抢占
  3. 线程B要查询库存,此时查到的还是原来的库存量(100),因为此时线程A还没来得及执行减 1 操作。然后线程B把原来的库存量100更新到redis
  4. 线程B执行完毕后,cpu时间片又回到线程A手中,线程A继续执行减 1 操作,执行完毕,数据库库存量为99,与redis中的100不相等,数据不一致!

2.1 延迟双删

        延迟双删是在失效模式的基础上,在删除reids缓存时,让程序睡眠几十毫秒,再次执行删除缓存操作,可有效预防失效模式中缓存不一致问题,但是并不推荐。因为数据不一致问题本来就是极少情况发生的,如果使用延时双删,那么大部分正常的请求都会被阻塞几十毫秒,系统性能下降,显然得不偿失!


3.缓存一致性解决方案

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

  1. 对于并发很小的数据,比如个人信息、用户数据等。这些数据在使用双写或者失效模式后,由于并发量小,根本不需要考虑缓存一致性问题。可以给缓存数据加上过期时间,每隔一段时间触发读操作的主动更新即可!
  2. 如果并发量很高,但业务上能容忍短时间的缓存数据不一致,比如商品名称,商品分类三级菜单等。为缓存数据加上过期时间依然可以解决大部分业务对于缓存的要求。
  3. 如果并发量很高,且无法容忍数据不一致,比如库存。可以使用分布式锁来保证一致性!但也不用读写操作都加一把重量级的分布式锁,使用轻量级读写锁即可,通过添加读写锁保证写数据时读写都阻塞,仅读数据时相当于无锁!
  4. 还有一种方案,但代价也挺大。方案:所有的对库存的增、删、改、查操作都通过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的值,区分读写逻辑,读写锁与lock一样,都是可重入锁!

①:如果加的是读锁,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操作流程图
在这里插入图片描述

总结:

        以上我们针对的都是读多写少的情况加入缓存提高性能,如果写多读多的情况又不能容忍缓存数据不一致,那就没必要加缓存了,可以直接操作数据库! 放入缓存的数据应该是对实时性、一致性要求不是很高的数据。切记不要为了用缓存,同时又要保证绝对的一致性做大量的过度设计和控制,增加系统复杂性!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值