14、缓存双写一致性之更新策略探讨

1、一致性,谈谈你的理解

1.1、如果redis中有数据

需要和数据库中的值相同

1.2、如果redis中无数据

数据库中的值要是最新值

2、缓存按照操作来分,有细分2种

2.1、只读缓存

2.2、读写缓存

同步直写策略:写缓存时也同步写数据库,缓存和数据库中的数据一致;

对于读写缓存来说,要想保证缓存和数据库中的数据一致,就要采用同步直写策略

什么时候同步直写?

小数据,某条、某一小撮热点数据,

要求立刻变更,可以前台服务降级一下后台马上同步直写

什么时候异步缓写?

1.正常业务,马上更新 mysql,可以在业务上容许出现1个小时后 redis起效

2.出现异常后,不得不将失败的动作重新修补,不得不借助 kafka或者rabbitmq等消息中间件,实现解耦后重试重写。

3、数据库和缓存一致性的几种更新策略

3.1、挂牌报错,凌晨升级

单线程,这样重量级的数据操作最好不要多线程

3.2、目的

总之,我们要达到最终一致性!

给缓存设置过期时间,是保证最终一致性的解决方案。

我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说

如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓

存,达到一致性,切记以mysql的数据库写入库为准。

上述方案和后续落地案例是调研后的主流+成熟的做法,但是考虑到各个公司业务系统的差距,不是100%绝对正确,

不保证绝对适配全部情况,请同学们自行酌情选择打法,合适自己的最好。

3.3、我们讨论3种更新策略

3.3.1、先更新数据,再更新缓存

异常问题

1.先更新 mysql的某商品的库存,当前商品的库存是100,更新为99个。

2.先 mysql更新修改为99成功,然后更新 redis

3.此时假设异常出现,更新 redis失败了,这导致 mysql里面的库存是99 而redis里面的还是100

4.上述发生,会让数据库里面和缓存 redis里面数据不一致,读到脏数据

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

3.3.2.1、异常问题

1.A线程先成功删除了 redis里面的数据,然后去更新 mysql,此时 mysql正在更新中,还没有结束。(比如网络延时)

B突然出现要来读取缓存数据。

public void deleteOrderData(Order order) {
    try {
        //1.线程A先成功删除redis缓存
        jedis.del(order.getId() + "");
        //2.线程A再更新mysql
        orderDao.update(order);
        //暂停20秒钟,其他业务逻辑导致耗时延时,20是随便乱写的,只是为了讲解技术方便
        try {
            TimeUnit.SECONDS.sleep(20);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    } catch (Exception e) {
        e.printStackTrace();
    }

}

2.此时 redis里面的数据是空的,B线程来读取,先去读redis里数据(已经被A线程 delete掉了),此处出来2个问题:

2.1.B从 mysql获得了旧值

B线程发现 redis里没有(缓存缺失)马上去mysq里面读取,从数据库里面读取来的是旧值。

2.2.B会把获得的旧值写回 redis

获得旧值数据后返回前台并回写进 redis(刚被A线程删除的旧数据有极大可能又被写回了)

public Order selectOrderData(Order order){
    try{
        //1.先去redis里面查找,找到返回数据找不到去mysql查找
        String result = jedis.get(order.getId()+"");
        if(result!=null){
            return (Order)JSON.parse(result);
        }else {
            order= orderDao.getOrderById(order.getId());
            //2.线程B会将从mysql查找到的旧数据写回到redis
            jedis.set(order.getId()+"",order.toString());
            return order;
        }
    }catch (Exception e){
        e.printStackTrace();
    }
    return null;
}

低并发 写回旧值

高并发 缓存击穿

3.A线程更新完 mysql,发现 redis里面的缓存是脏数据,A线程直接懵逼了,o(T)

两个并发操作,一个是更新操作,另一个是查询操作,A更新操作删除缓存后,B查询操作没有命中缓存,B先把老数据读出来

后放到缓存中,然后A更新操作更新了数据库。

于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。

4.总结流程:

(1)请求A进行写操作,删除缓存后,工作正在进行中......A还没有彻底更新完

(2)请求B开工,查询 redis发现缓存不存在

(3)请求B继续,去数据库查询得到了 mysql中的旧值

(4)请求B将旧值写入redis缓存

(5)请求A将新值写入数据库

上述情况就会导致不一致的情形出现。

时间

线程A

线程B

出现的问题

t1

请求A进行写操作,删除缓存后,工作正在进行中...

t2

1 缓存中读取不到,立刻读mysql,由于A还没有对mysql更新完,读到的是旧值。

2 还把从mysql读取的旧值写回了redis

1 A还没更新完mysql,导致B读到了旧值

2 线程B遵守回写机制,把旧值写回redis,导致其它请求读取的还是旧值,A白干了

t3

更新mysql数据库的值,over

redis是被B写回的旧值,mysql是被A更新的新值。出现了数据不一致的问题。

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

如果数据库更新失败,导致B线程请求再次访问缓存时,发现 redis里面没数据,缓存缺失,再去读取 mysql时,从数据库中读取到旧值

3.3.2.2、解决方案

3.3.2.2.1、先复习阿里内部缓存击穿的方案

多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个互斥锁来锁住它。

其他的线程走到这一步拿不到锁就等着等第一个线程查询到了数据,然后做缓存。

后面的线程进来发现已经有缓存了,就直接走缓存。

双端检锁

3.3.2.2.2、采用延迟双删策略

加上sleep的这段时间,就是为了让线程B能够先从数据库读取数据,再把缺失的数据写入缓存,

然后,线程A再进行删除。所以,线程A sleep的时间,就需要大于线程B读取数据再写入缓存的时间。

这样一来,其它线程读取数据时,会发现缓存缺失,所以会从数据库中读取最新值。因为这个方案会在第一

次删除缓存值后,延迟一段时间再次进行删除,所以我们也把它叫做“延迟双删”。

public void deleteOrderData(Order order) {
    try {
        //1.线程A先成功删除redis缓存
        jedis.del(order.getId() + "");
        //2.线程A再更新mysql
        orderDao.update(order);
        //暂停20秒钟,其他业务逻辑导致耗时延时,20是随便乱写的,只是为了讲解技术方便
        try {
            TimeUnit.SECONDS.sleep(20);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //再次删除
        jedis.del(order.getId() + "");
    } catch (Exception e) {
        e.printStackTrace();
    }

}

3.3.2.2.3、双删方案面试题

① 这个删除该休眠多久呢

线程 A sleep的时间,就需要大于线程B读取数据再写入缓存的时间。

这个时间怎么确定呢?

在业务程序运行的时候,统计下线程读数据和写缓存的操作时间,自行评估自己的项目的读数据业务逻辑的耗时,

以此为基础来进行估算。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上加百毫秒即可。

这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

② 当前演示的效果是mysql单机,如果mysql主从读写分离架构如何?

(1)请求A进行写操作,删除缓存

(2)请求A将数据写入数据库了,

(3)请求B查询缓存发现,缓存没有值

(4)请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值

(5)请求B将旧值写入缓存

(6)数据库完成主从同步,从库变为新值,上述情形就是数据不一致的原因。还是使用双删延时策略。

只是,睡眠时间修改为在主从同步的延时时间基础上,加几百ms

③ 这种同步淘汰策略,吞吐量降低怎么办?

public void deleteOrderData(Order order) {
    try {
        //1.线程A先成功删除redis缓存
        jedis.del(order.getId() + "");
        //2.线程A再更新mysql
        orderDao.update(order);
        //暂停20秒钟,其他业务逻辑导致耗时延时,20是随便乱写的,只是为了讲解技术方便
        try {
            TimeUnit.SECONDS.sleep(20);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        CompletableFuture.supplyAsync(()->{
            //3.将第二次删除作为异步的。自己起一个线程,异步删除
            //这样,写的请求就不用沉睡一段时间后,再返回。这么做加大吞吐量
            return  jedis.del(order.getId() + "");
        }).whenComplete((t,u)->{
            System.out.println("-------t:"+t);
            System.out.println("-------u:"+u);
        }).exceptionally(e->{
            System.out.println("-------e:"+e.getMessage());
            return 44L;
        }).get();

    } catch (Exception e) {
        e.printStackTrace();
    }

}

 

3.3.3、先更新数据库,再删除缓存(一般用这个)

3.3.3.1、异常问题

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

时间

线程A

线程B

出现的问题

t1

删除数据库中的值

t2

缓存中立刻命中,此时B读取的是缓存旧值

A还没有来得及删除缓存的值,导致B缓存命中读到旧值

t3

更新缓存的数据,over

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

假如缓存删除失败或者来不及,导致请求再次访问redis时缓存命中,读取到的是缓存旧值

3.3.3.2、业务指导思想

论文: Cache-Aside pattern - Azure Architecture Center | Microsoft Docs

知名社交网站facebook也在论文《Scalling Memcache at Facebook》中提出 https://www.usenix.org/system/files/conference/nsdi13/nsdi13-final170_update.pdf

我们上面的canal也是类似的思想,上述的订阅binlog程序在mysql中有现成的中间件叫canal,可以完成订阅binlog日志的功能

3.3.3.3、解决方案

 14、缓存双写一致性之更新策略探讨_第1张图片

1.可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用 Kafka/RabbitMQ等)

2.当程序没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。

3.如果能够成功地删除或更新我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了,否则还需要再次进行重试

4.如果重试超过的一定次数后还是没有成功,我们就需要向业务层发送报错信息了,通知运维人员。

3.3.4、不能用

先更新缓存,再更新数据这种策略 X

3.4、小总结

方案2和方案3用哪个?利弊如何

在大多数业务场景下,我们会把 Redis作为只读缓存使用。假如定位是只读缓存来说,

理论上我们既可以先删除缓存值再更新数据库,也可以先更新数据库再删除缓存,但是没有完美方案,两害相衡趋其轻的原则

个人建议是,优先使用先更新数据库,再删除缓存的方案。理由如下:

1.先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力,严重导致打满mysql

2.如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。

多补充一句:如果使用先更新数据库,再删除缓存的方案

如果业务层要求必须读取一致性的数据,那么我们就需要在更新数据库时,先在 Redis缓存客户端暂存并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性。

一图总结:

操作顺序

是否有并发请求

潜在问题

现象

应对方案

先删除缓存值,

再更新数据库

缓存删除成功,但数据库更新失败

应用从数据库读到旧数据

重试数据库更新

缓存删除后,尚未更新数据库,有并发读请求

并发请求从数据库读到旧值,并且更新到缓存,导致后续请求都读取旧值

延迟双删

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

数据库更新成功,但缓存删除失败

应用从缓存读到旧数据

重试缓存删除

数据库更新成功后,尚未删除缓存,有并发请求

并发请求从缓存中读到旧值

等待缓存删除完成,期间会有不一致数据短暂存在

 

 

你可能感兴趣的