返回
Featured image of post Redis 缓存一致性问题解决方案

Redis 缓存一致性问题解决方案

Redis 缓存一致性问题解决方案

由于数据库的操作和缓存的操作不可能在一个事务中,也就势必会出现数据库写入失败,缓存不能更新,缓存写入失败的补偿机制。具体我们应该怎么做呢?

在现在的系统架构中,缓存的地位可以说是非常高的。因为在互联网的时代,请求的并发量可能会非常高,但是关系型数据库对于高并发的处理能力并不是非常强,而缓存由于是在内存中处理,并不需要磁盘的IO,所以非常适合于高并发的处理,也就成为了各个系统中必不可少的一部分了。

不过,由此产生的问题也是非常多的,其中一个就是如何保证数据库和缓存之间的数据一致性。

由于数据库的操作和缓存的操作不可能在一个事务中,也就势必会出现数据库写入失败,缓存不能更新,缓存写入失败的补偿机制。具体我们应该怎么做呢?

我们先看一个最常见的读缓存的例子

上图的读取缓存的方式是比较常见的方式,读取数据没什么问题,但是要保证数据一致性就是出现问题了。

只考虑双写的数据一致情况下。由于不同的写入方式,可能带来的结果也就是不同的。通常情况下,我们都有哪些写入数据并刷新缓存的方式呢?

方法一、先更新数据库,在更新缓存

下图这种方案是最简单的实现方法,使用这种方案,只要保证数据库写入成功后,刷新缓存就可以,代码简单、维护简单。但是多线程情况下无法保证数据一致性。

st=>start: 开始
db=>operation: 写入数据库
isSuccess=>condition: 是否成功
ck=>operation: 写入缓存
e=>end: 结束

st->db->isSuccess(yes)->ck->e
isSuccess(no)->e

例如:我们现在同时有两个请求会操作同一条数据,一个是请求A,一个是请求B。请求A需要先执行,请求B后执行,那么数据库的记录就是请求B执行后的记录。

但是,由于一些网络原因或者其他情况,最终执行的顺序可能就变成了:

dbA=>operation: 请求A update数据库
dbB=>operation: 请求B update数据库
ckA=>operation: 请求A update缓存
ckB=>operation: 请求B update缓存

dbA->dbB->ckB->ckA
   /**
     * 模拟两个线程同时修改, key1。 线程A线修改数据库,但是在修改redis之前。线程B抢先修改了缓存。所以导致数据库和redis数据不一致问题
     * 请求A update数据库
     *          |
     *          V
     * 请求B update数据库
     *          |
     *          V
     * 请求B update 缓存
     *          |
     *          V
     * 请求A update 缓存
     *
     */
    public String doubleWriteCache(){
        new Thread(() ->{
            int count = 0;
            RedisRecord record = new RedisRecord();
            record.setId(1);
            record.setRedisKey("key1");
            record.setRedisValue("111");
            System.out.println("线程A修改数据库");
            if (record.getId() == null){
                count = redisRecordMapper.insert(record);
            }else {
                count = redisRecordMapper.updateById(record);
            }
            try {
                Thread.sleep(5000);
                System.out.println("线程A修改缓存");
                if (count > 0){
                    // 成功写入缓存
                    redisUtil.set(record.getRedisKey(), record.getRedisValue());
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }, "threadA").start();

        new Thread(() ->{
            int count = 0;
            RedisRecord record = new RedisRecord();
            record.setId(1);
            record.setRedisKey("key1");
            record.setRedisValue("222");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("线程B修改数据库");
            if (record.getId() == null){
                count = redisRecordMapper.insert(record);
            }else {
                count = redisRecordMapper.updateById(record);
            }
            if (count > 0){
                // 成功写入缓存
                System.out.println("线程B修改缓存");
                redisUtil.set(record.getRedisKey(), record.getRedisValue());
            }
        }, "threadA").start();
        return "ok";
    }

这样的结果会导致:

  1. 数据库和缓存中的数据不一致,从而缓存中的数据就成为了脏数据。
  2. 写入操作多于读操作,就会频繁的刷新缓存,但是这些数据根本没有被读过。这样就会浪费服务器的资源。

因此,这种双写方式很难保证数据一致性,不建议使用。

方法二、先删除缓存再更新数据库

由于上述方式存在的问题,那么我们就考虑,能不能先删除缓存,在更新数据库,这样,在更新数据库的前后,由于缓存中没有数据了,请求就会穿透到数据库直接读取数据然后放入缓存,这样,缓存就不会被频繁的刷新了。

于是,我们就设置了一个新的执行顺序:

st=>start: 开始
rmCk=>operation: 删除缓存数据
wDb=>operation: 写入数据数据库
e=>end: 结束
st->rmCk->wDb->e

不过,这样一来,新问题又出现了。有两个请求,一个请求A,一个请求B,请求A去写数据,请求B去读数据。当并发量高的时候,就会出现以下情况:

dbA=>operation: 请求A进行写操作,删除缓存
ckB=>operation: 请求B查询发现缓存不存在
dbB=>operation: 请求B去数据库查询得到旧值,并写入缓存
ckA=>operation: 请求A将新的值写入数据库


dbA->ckB->dbB->ckA

这是,脏数据又出现了。如果我们没有设置缓存的过期时间,那么在下一次下入数据前,脏数据就会一直的存在。针对这种脏数据出现的情况,我们决定在写入数据后,增加一点延时,再删除一次数据,于是就有了方法三。


    /**
     * 方法二、先删除缓存再更新数据库
     * 有两个请求,一个请求A,一个请求B,请求A去写数据,请求B去读数据。
     * 当并发量高的时候,就会出现以下情况:
     * 请求A进行写操作,删除缓存
     *          |
     *          V
     * 请求B查询发现缓存不存在
     *          |
     *          V
     * 请求B去数据库查询得到旧值,并写入缓存
     *          |
     *          V
     * 请求A将新的值写入数据库
     */
    public String deleteCacheWriteDB(){
        new Thread(() ->{
            // 先删除缓存在修改数据库
            boolean flag = redisUtil.remover("key1") == 1;
            System.out.println("redis删除: "+flag);
//             删除后卡住了
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 修改数据
            RedisRecord record = new RedisRecord();
            record.setId(1);
            record.setRedisKey("key1");
            record.setRedisValue("1222");
            System.out.println("线程A修改数据库");
            if (record.getId() == null){
                redisRecordMapper.insert(record);
            }else {
                redisRecordMapper.updateById(record);
            }
        }, "threadA").start();

        new Thread(() ->{
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            String key = "key1";
            RedisRecord redisRecord = null;
            String json = redisUtil.get(key);
            if (!StringUtils.isEmpty(json)){
                redisRecord = JSONUtil.toBean(json, RedisRecord.class);
            }
            if (redisRecord == null){
                redisRecord = redisRecordMapper.selectOne(new LambdaQueryWrapper<RedisRecord>().eq(RedisRecord::getRedisKey, key));
            }
            if (redisRecord != null){
                // 成功写入缓存
                System.out.println("线程B修改缓存");
                redisUtil.set(redisRecord.getRedisKey(), redisRecord);
            }
            System.out.println(redisRecord);
        }, "threadA").start();
        return "ok";
    }

方法三、延时双删

st=>start: 开始
rmCk=>operation: 删除缓存数据
wDb=>operation: 写入数据数据库
rmCk2=>operation: 等待一秒删除缓存数据(异步)
e=>end: 结束
st->rmCk->wDb->rmCk2->e

使用延时双删的策略,就能够很好的解决之前我们应该并发所引起的数据不一致的情况。那是不是延时双删就完全没有问题呢?不。

我们来假设一个场景,就是我们做了读写分离,那么使用延时双删可能问出现什么情况呢?

1=>operation: 请求A进行写操作,删除缓存
2=>operation: 请求A将数据写入数据库了
3=>operation: 请求B查询缓存发现,缓存没有值
4=>operation: 请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值
5=>operation: 请求B将旧值写入缓存
6=>operation: 数据库完成主从同步,从库变为新值。


1->2->3->4->5->6

糟糕,又出现数据不一致了。

然后在看看性能如何,由于需要延时,如果是同步执行,性能必定很差,所以第二次删除只有做成异步,避免影响性能。那异步执行删除就会出现新问题,如果异步线程执行失败了,那么旧数据就不会被删除,数据不一致又出现了。

不行,我们需要向一个一劳永逸的办法,单纯的双删还是不可靠。

 /**
     * 方法三、延时双删
     * 延时双删可以解决以上的问题,但是如果项目涉及到数据库主从那么也会出现一下问题:
     * 请求A进行写操作,删除缓存 -> 请求A将数据写入数据库了 -> 请求B查询缓存发现,缓存没有值 -> 请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值 -> 请求B将旧值写入缓存 -> 数据库完成主从同步,从库变为新值。
     * 糟糕,又出现数据不一致了。
     * 然后在看看性能如何,由于需要延时,如果是同步执行,性能必定很差,所以第二次删除只有做成异步,避免影响性能。那异步执行删除就会出现新问题,如果异步线程执行失败了,那么旧数据就不会被删除,数据不一致又出现了。
     * 不行,我们需要向一个一劳永逸的办法,单纯的双删还是不可靠。
     *
     */
    @Inclusion
    @GetMapping("delayedDoubleDeletion")
    public boolean delayedDoubleDeletion(){
        new Thread(() ->{
            // 先删除缓存在修改数据库
            System.out.println(redisUtil.remover("key1") == 1);
            //             删除后卡住了
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 修改数据
            RedisRecord record = new RedisRecord();
            record.setId(1);
            record.setRedisKey("key1");
            record.setRedisValue("1222");
            System.out.println("线程A修改数据库");
            if (record.getId() == null){
                redisRecordMapper.insert(record);
            }else {
                redisRecordMapper.updateById(record);
            }
            try {
                Thread.sleep(500);
                System.out.println(redisUtil.remover("key1") == 1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "threadA").start();

        new Thread(() ->{
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            String key = "key1";
            RedisRecord redisRecord = null;
            String json = redisUtil.get(key);
            if (!StringUtils.isEmpty(json)){
                redisRecord = JSONUtil.toBean(json, RedisRecord.class);
            }
            if (redisRecord == null){
                redisRecord = redisRecordMapper.selectOne(new LambdaQueryWrapper<RedisRecord>().eq(RedisRecord::getRedisKey, key));
            }
            if (redisRecord != null){
                // 成功写入缓存
                System.out.println("线程B修改缓存");
                redisUtil.set(redisRecord.getRedisKey(), redisRecord);
            }
            System.out.println(redisRecord);
        }, "threadA").start();
        return true;
    }

方法四、队列删除缓存

图片
图片

我们在把数据更新到数据库后,把删除缓存的消息加入到队列中,如果队列执行失败,就再次加入到队列执行直到成功为止。

这样,我们就能够有效的保证数据库和缓存的数据一致性了,不管是读写分离还是其他情况,只要队列消息能够保证安全,那么缓存就一定会被刷新。

当然,根据这个方案,我们还可以进一步优化。因为这里我们的缓存刷新时基于业务代码的,也就是说,业务代码和缓存刷新的耦合度很高。有没有办法能够把缓存刷新独立出来,不基于业务代码执行呢?

方法五、binlog订阅删除缓存

为了保证业务代码的独立性,我们可以通过订阅binlog日志的方式来刷新缓存。我们先启动mysql的binlog日志,然后如下图方式设计流程:

图片
图片

通过binlog的订阅,我们就把业务代码和缓存刷新的非业务代码独立开来。代码量小了,也方便维护了。程序员们也不需要去关心什么时候应该刷新缓存,是不是需要刷新缓存。

当然,实战中,我们还有很多不同的业务场景,可能需要的数据一致性同步方案也不同,这里也只算是一个案例。

转载:缓存(本地缓存、分布式缓存)与数据库之间的数据一致性问题?