目录
- 一、什么是缓存与数据库双写不一致性
- 二、常见保证高并发下双写一致性方案
- 2.1、延迟双删(不可靠)
- 2.2、分布式读写锁(可靠)
- 2.3、MQ异步消费(不可靠)
- 2.4、订阅数据库变更日志(不可靠)
- 三、总结
- 3.1、如何选择一个合适的方案
- 3.1.1、先更新数据库再删除缓存(普通一致性)
- 3.1.2、延时双删、MQ异步消费、订阅数据库变更日志(高级一致性)
- 3.1.3、分布式读写锁(终极一致性)
- 3.2、其它方案
- 3.2.1、商品信息更新后手动刷新缓存
- 3.2.2、通过延时消息实现异步删除缓存
一、什么是缓存与数据库双写不一致性
当数据发生更新时,我们不仅要更新数据库数据,还要更新缓存。如果缓存和数据库都更新的话,就会存在以下两个问题,是先更新数据库还是先更新缓存,在单线程执行没有异常的情况下无论是谁先谁后,缓存和数据库都可以保持一致性,但是异常的情况或者并发操作下都有可能会出现双写不一致性的情况,这里会重点说明高并发下如何保证Redis缓存与数据库双写一致性。
-
单线程双写不一致示例
对于这种情况其实很好解决,因为是在一个数据库事务中别做缓存修改,直接做缓存删除就行了,数据库更新后将对应缓存删除即可,在查询操作中在进行缓存,就算缓存删除失败或者删除成功后其它业务出现异常也不影响。
-
高并发双写不一致示例
一般情况下我们都是先更新数据库然后删除缓存,在查询时如果查询到数据则将数据写入缓存。在并发操作时这里可以看到,在T2线程查询数据库写入缓存时可能会存在查询到数据准备写入缓存时,T3线程正好在这期间将新数据写入数据库并且删除缓存,那么这里T2线程写入缓存的数据就属于脏数据。
二、常见保证高并发下双写一致性方案
对于并发几率很小的数据(如单个用户维度的订单数据、用户数据等),这种几乎不用考虑这个问题,很少会发生缓存不一致,可以给缓存数据加上过期时间,每隔一段时间触发读的主动更新即可,就算并发很高,如果业务上能容忍短时间的缓存数据不一致(如商品名称,商品图片等),缓存加上过期时间依然可以解决大部分业务对于缓存的要求。
在代码实现中一般情况下我们都是先更新数据库然后删除缓存,只有确保数据库数据更新成功才能去动缓存,这应该是大部分人所使用的方式,这里也会围绕这个流程做说明,有几个常见可行的方法延迟双删、分布式锁、MQ异步消费、订阅数据库变更日志…,像什么先删除缓存在更新数据库这类型的方法是没有意义的还是会有问题,这里针对这几个方法做说明。
2.1、延迟双删(不可靠)
延迟双删理论上也是不可靠的,只是能在一些特定情况下降低出现双写不一致性的情况,比如3个线程同时进入T2线程先查询数据库拿到结果准备写缓存时,T1线程拿到CPU执行权开始执行修改数据库逻辑,修改好后延时0.5S在删除缓存,在这期间T2线程执行写入缓存操作这个写入数据就是脏数据,0.5S后T1线程将缓存删除,先忽略T3线程这样T4线程在进行查询时缓存中就是没有值的会重新查询数据库获取最新值,不过这个逻辑很脆弱,T3线程可能因为一些情况导致一直没有执行,在T4线程查询数据库后突然就开始执行了,这个时候T3线程执行了修改逻辑和删除缓存逻辑T4线程才把之前查询的数据写入缓存那么这个数据就是脏数据,又出现缓存不一致问题。
2.2、分布式读写锁(可靠)
如果不能容忍缓存数据不一致,可以通过加读写锁保证并发读写,读读共享、读写互斥、写写互斥,这样可以保证业务的双写一致,但是这样做Redis性能开销会比较大每个查询请求都要调用Redis进行锁判断,而且要控制好锁避免出现等待时间过长和死锁问题,这里提供两个业务实现流程各有优缺点可以根据具体业务选择。
- 流程一:获取锁失败,轮询等待获取锁成功。
- 流程二:获取锁失败,执行查询流程但是不将数据写入缓存
2.3、MQ异步消费(不可靠)
通过MQ异步消费和延时双删其实是类似的都是只能解决一部分问题,例如我这里举例的,3个线程都是并行的,T1线程修改数据库修改成功后发送MQ消息,T2线程查询商品信息写入缓存写入的是脏数据,先不看T3线程,在T2线程写入缓存后MQ消费到消息将缓存给删除了,那么后面再有查询过来就能获取到最新的数据,现在看看T3线程,在查询商品信息准备写入缓存这时MQ消费到消息先删除缓存,然后T3线程在写入缓存,这个时候缓存中又是脏数据了,和延时双删差不多。
2.4、订阅数据库变更日志(不可靠)
在MySQL中修改一条数据,MySQL 就会产生一条变更日志(Bin Log),我们可以订阅这个日志,获取到具体的操作数据,然后再根据这条日志数据,去删除对应的缓存,订阅变更日志比较比较成熟的开源中间件,比如阿里的 canal,通过canal订阅到Bin Log然后将数据发送给MQ交给对应消费者处理,canal只做数据采集不做业务处理,这种方式又和MQ异步消费类似了,无非就是将数据变更通过canal来采集然后发送给MQ还是会存在MQ异步消费一样的问题。
三、总结
这里介绍了一些方案,除了加锁其它方案都不能完全保证双写一致性问题,其实还有别的方案也能保证一致性问题,要结合业务设计这里不深入,以上我们针对的都是读多写少的情况加入缓存提高性能,如果写多读多的情况又不能容忍缓存数据不一致,那就没必要加缓存了,可以直接操作数据库。放入缓存的数据应该是对实时性、一致性要求不是很高的数据。切记不要为了用缓存,同时又要保证绝对的一致性做大量的过度设计和控制,增加系统复杂性。
3.1、如何选择一个合适的方案
不同的缓存策略对于不同的应用场景和需求可能会产生不同的影响,因此需要根据具体情况选择合适的缓存策略和组合方式。
3.1.1、先更新数据库再删除缓存(普通一致性)
一般情况下我们写代码肯定都是先更新数据库才会去删除缓存的,后面两个方案也基于先更新数据库才会去删除缓存,当然不同业务可能不同,我们这里的商品信息肯定是先更新数据库才会去删除缓存,这样是能保证最低限度的一致性问题的,适合并发不高读多写少的业务,并且实现简单不依赖其它中间件。
3.1.2、延时双删、MQ异步消费、订阅数据库变更日志(高级一致性)
延时双删、MQ异步消费、订阅数据库变更日志这三个方案的目的其实都是为了提升一致性的措施,在有条件的情况下是可以使用的,如果考虑极端情况其实还是还存在问题,并且实现起来会稍微麻烦一些,MQ异步消费、订阅数据库变更日志还需要依赖外部中间件增加系统复杂度导致出现一些不可预期问题。
3.1.3、分布式读写锁(终极一致性)
使用分布式读写锁是能完全保证一致性的,如果业务数据一致性要求非常高那可以考虑使用分布式读写锁,如果一致性要求并没有很高其实使用锁来解决并不是一个很合适的方式,比如商品信息只做展示使用,并且只有后台编辑时才会改变,那么查询时也进行锁判断其实增加了很多不必要的开销,每一次查询请求都要调用Redis进行加锁,在使用分布式读写锁解决一致性问题时一定要做好压力测试,避免线上资源不足。
3.2、其它方案
所有方案都需要结合业务,不同业务的最优方案都不同,这里列举一些实际案例的解决方案。
3.2.1、商品信息更新后手动刷新缓存
无论那种方案,其实手动刷新还是挺靠谱的,后台管理设计一个批量刷新商品信息缓存功能,当商品信息变更后如果担心缓存没有更新成最新商品信息或者实际看到数据就是老数据,完全可以使用手动刷新来解决这个问题,选择需要刷新缓存的商品批量刷新即可。
3.2.2、通过延时消息实现异步删除缓存
其实这个设计原理就是结合了延时双删、MQ异步消费、订阅数据库变更日志,在我们商品信息更新后将缓存删除,并且在投递一个延时消息或者订阅数据库变更日志投递一个延时消息,假设延时3s执行,消费者3s后收到这个消息将缓存再删除一次,这期间就算将老数据插入了缓存中,在这一次3s延时消费中也能将这个缓存删除,如何还要考虑极限情况那就延时时间在长一点5s 10s 15s …,因为是MQ异步消费不会影响主业务并且商品信息修改频次不会很高,而且只是刷新缓存操作也很快,这个方案是完全可行的,只是延时时间要根据业务和实际情况把关好。