我是靠谱客的博主 内向老鼠,最近开发中收集的这篇文章主要介绍关于缓存一致性问题的思考一、引言二、双写一致性问题三、缓存的典型使用方式四、如何保证缓存的最终一致性五、不用过分放大缓存不一致问题六、总结,觉得挺不错的,现在分享给大家,希望可以做个参考。

概述

一、引言

缓存一致性问题是实际工作中很少很少遇见但面试过程中经常出现的一个问题。本文主要谈一下自己对缓存一致性问题的一些思考,而不是面试科普文。

二、双写一致性问题

根据 CAP 原理,分布式系统在可用性、一致性和分区容错性上无法兼得,通常由于分区容错无法避免,所以一致性和可用性难以同时成立。

常见的redis+mysql的场景也是一个典型的分布式场景。
如果需要保证两者数据的强一致性,那么就需要加分布式锁和全局事务,导致读写并发降低;
如果需要保证两者的数据可用性,那么在redis和mysql的数据同步过程中,就必然存在数据的不一致问题。
数据可用性简单来说就是在数据不一致的情况下还可以被查询到,对外提供服务

持久化层和缓存层的一致性问题也通常被称为双写一致性问题,“双写”意为数据既在数据库中保存一份,也在缓存中保存一份。对于一致性来说,包含强一致性和弱一致性,强一致性保证写入后立即可以读取,弱一致性则不保证立即可以读取写入后的值,而是尽可能的保证在经过一定时间后可以读取到,在弱一致性中应用最为广泛的模型则是最终一致性模型,即保证在一定时间之后写入和读取达到一致的状态。对于应用缓存的大部分场景来说,追求的则是最终一致性,少部分对数据一致性要求极高的场景则会追求强一致性。

这里我个人认为,过分追求缓存和数据库的强一致性是非常不明智的,首先是技术实现复杂,难度大,给项目增加了额外的风险,而且会导致并发性能降低。
本来引入缓存是为了提高并发,现在由于要保证强一致性,增加分布式事务和分布式锁,反而会导致并发降低,那么就失去了引入缓存的意义。

三、缓存的典型使用方式

Cache-Aside 是应用最为广泛的一种缓存策略。下面的图示展示了它的读写流程,来看看它是如何保证最终一致性的。

  1. 在读请求中,首先请求缓存,若缓存命中( cache hit ),则直接返回缓存中的数据;若缓存未命中( cache miss ),则查询数据库并将查询结果更新至缓存,然后返回查询出的数据( demand-filled look-aside )。
  2. 在写请求中,先更新数据库,再删除缓存(write-invalidate)。
    在这里插入图片描述

四、常见缓存使用的问题

1、为什么是删除,而不是更新缓存?
我们以先更新数据库,再删除缓存来举例。
如果是更新的话,那就是先更新数据库,再更新缓存。

举个例子:如果数据库1小时内更新了1000次,那么缓存也要更新1000次,但是这个缓存可能在1小时内只被读取了1次,那么这1000次的更新有必要吗?

反过来,如果是删除的话,就算数据库更新了1000次,那么也只是做了1次缓存删除,只有当缓存真正被读取的时候才去数据库加载。

2、先删除缓存,再更新数据库的问题?
如果先删除缓存再更新数据库,那么在执行删除缓存到更新数据库的中间时间,如果出现查询请求,就会将数据库中的旧值保存到缓存中,从而导致缓存和数据库的数据不一致。
实际项目中,往往查询请求的并发会高于更新请求。
(删除缓存 + 更新数据库)的过程中出现(查询数据库+更新旧值到缓存)的概率极大
所以一般不会采用先删除缓存,再更新数据库的方式。

在这里插入图片描述

3、先更新数据库,再删除缓存的问题?
如果采用先更新数据库再删除缓存,比如更新请求A,那么除非这时出现像查询请求B的情况,刚好在更新之前查询到数据库的旧值,还没来的急更新到缓存,请求A就执行完成了更新数据库和删除缓存的操作。这样请求B中完成缓存更新后,就导致缓存和数据库的数据不一致了。
但是实际过程中,数据库的查询请求一般都比更新请求快。
(查询数据库+更新缓存的时间间隔)往往都是小于(更新数据库+删除缓存的耗时),
并且由于更新请求A没有先删除缓存,查询请求B大概率会命中缓存(缓存过期丢失除外),而不会去查库更新缓存。
所以采用先更新数据库再删除缓存的处理方式出现缓存不一致情况的概率极低

在这里插入图片描述

四、如何保证缓存的最终一致性

1、设置缓存的过期时间

每次放入缓存的时候,设置一个过期时间,比如5分钟,以后的操作只修改数据库,不操作缓存,等待缓存超时后从数据库重新读取。

如果对于一致性要求不是很高的情况,可以采用这种方案。

这个方案还会有另外一个问题,就是如果数据更新的特别频繁,不一致性的问题就很大了。

在实际生产中,我们有一些活动的缓存数据是使用这种方式处理的。

因为活动并不频繁发生改变,而且对于活动来说,短暂的不一致性并不会有什么大的问题。

2、延迟双删

延时双删的方案的思路是,为了避免更新数据库的时候,其他线程从数据库中读取到旧的数据更新到缓存中,需要在更新完数据库之后,再sleep一段时间,然后再次删除缓存。

sleep的时间要对业务读写缓存的时间做出评估,sleep时间大于读写缓存的时间即可。

流程如下:

  1. 线程1删除缓存,然后去更新数据库
  2. 线程2来读缓存,发现缓存已经被删除,所以直接从数据库中读取,这时候由于线程1还没有更新完成,所以读到的是旧值,然后把旧值写入缓存
  3. 线程1根据估算的时间sleep,由于sleep的时间大于线程2读数据+写缓存的时间,所以缓存被再次删除。
  4. 如果还有其他线程来读取缓存的话,就会再次从数据库中读取到最新值。

在这里插入图片描述
在这里插入图片描述

延时双删常用步骤有 4 个,参考下面伪代码:

@Transactional(rollbackFor = Exception.class)
void  update_data(String key,Order obj){
    del_cache(key)     # 删除 redis 缓存数据。
    update_db(obj)     # 更新数据库数据。
    logic_sleep(_time) # 当前逻辑延时执行。
    del_cache(key)     # 删除 redis 缓存数据。
}

大家认为上面的这个伪代码正确吗?
1、由于有更新数据的操作,这里添加了事务控制,使用的是方法级别的事务注解@Transactional,这就导致更新数据库的方法update_db(obj)只有在方法执行完成才会执行commit操作,提交数据库的更新。虽然代码看上去是在更新数据库后延迟执行的第二次删除缓存的操作,但是实际上两次缓存都是在更新操作前执行的。

2、那如果将事务控制在update_db方法中呢?
这就会导致删除缓存操作如果执行失败,数据库的更新操作不能回滚,缓存中仍然是旧的数据。这里就涉及到一个分布式事务的问题,暂时不展开讨论,默认删除缓存操作一定成功。

3、延迟双删中,在更新数据库操作前,执行的第一次删除缓存操作的意义在哪?
只要第一次删除缓存的操作和更新数据库操作之间,有其他查询请求未命中缓存,就会查询到数据库的旧值,并更新到缓存中。
而删除缓存的速度是非常快的,所以在延迟双删过程中,第一次缓存删除操作,基本没什么作用。

缓存的意义就是为了提高查询速度,如果数据库值的更新操作都没有完成,这时候的提前执行缓存删除操作毫无意义。
在这里插入图片描述

4、为什么要进行延迟删除?
进行延时删除的核心原因是为了让更新数据库操作之前进行的查询请求先完成缓存更新操作。
也就是等缓存的更新操作都执行完毕后,再执行缓存删除操作,从而保证数据的最终一致性。
在这里插入图片描述
5、延时删除会不会降低更新操作的并发能力?
这里的一个优化方法是更新数据库完成后,调用一个异步方法,再该方法内部进行延时等待+删除缓存的操作,这样就可以保障更新操作的并发响应速度。

说明
延迟双删应该是网上解决缓存一致性问题看到的最多的解决方案。
但有很多细节痛点都没有仔细考虑。
比如最基本的一条,如何保证先完成数据库的更新操作(进行了commit),在执行删除缓存的操作?是不是有很多人犯了伪代码中同样的错误。

3、采用消息队列

先更新数据库,成功后往消息队列发消息,消费到消息后再删除缓存,借助消息队列的重试机制来实现,达到最终一致性的效果。
在这里插入图片描述
这个解决方案其实问题更多。
1、引入消息中间件之后,问题更复杂了,怎么保证消息不丢失更麻烦
2、就算更新数据库和删除缓存都没有发生问题,消息的延迟也会带来短暂的不一致性,不过这个延迟相对来说还是可以接受的

4、基于数据库日志( MySQL binlog )增量解析、订阅和消费

鉴于上述方案对业务代码具有一定入侵性,所以需要一种更加优雅的解决方案,让缓存删除失败的补偿机制运行在背后,尽量少的耦合于业务代码。一个简单的思路是通过后台任务使用更新时间戳或者版本作为对比获取数据库的增量数据更新至缓存中,这种方式在小规模数据的场景可以起到一定作用,但其扩展性、稳定性都有所欠缺。

一个相对成熟的方案是基于 MySQL 数据库增量日志进行解析和消费,这里较为流行的是阿里巴巴开源的作为 MySQL binlog 增量获取和解析的组件 canal (类似的开源组件还有 Maxwell、Databus 等)。canal sever 模拟 MySQL slave 的交互协议,伪装为 MySQL slave ,向 MySQL master 发送 dump 协议,MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal sever ),canal sever 解析 binary log 对象(原始为 byte 流),可由 canal client 拉取进行消费,同时 canal server 也默认支持将变更记录投递到 MQ 系统中,主动推送给其他系统进行消费。在 ack 机制的加持下,不管是推送还是拉取,都可以有效的保证数据按照预期被消费。当前版本的 canal 支持的 MQ 有 kafka 或者 RocketMQ 。另外, canal 依赖 zookeeper 作为分布式协调组件来实现 HA ,canal 的 HA 分为两个部分:

为了减少对 MySQL dump 的请求压力,不同 canal server 上的 instance 要求同一时间只能有一个处于运行状态,其他的 instance 处于 standby 状态;
为了保证有序性,对于一个 instance 在同一时间只能由一个 canal client 进行 get/ack 等动作;

在这里插入图片描述

五、不用过分放大缓存不一致问题

真的不用过分放大缓存不一致的问题。

遇到技术问题,首先应该评估他会带来什么负面影响,是否有必要解决,解决方案的投入和收益

在我看来缓存一致性问题更多的情况下只是一个面试问题,完全算不上是技术问题,更谈不上是什么业务问题。

核心关键点:

  • 业务操作尽量不使用缓存数据
  • 查询操作中才考虑使用缓存数据

把握住这两个关键点,这样就能保证业务操作不受缓存一致性问题的影响,保证所有业务操作正常进行。
而大部分系统对查询操作的数据的实时性要求和感知其实并不是明显。
再简单给缓存数据设置一下过期时间,就能满足大多数场景下数据查询的实时性要求。
完全没有必要把问题搞的过分复杂化。针对那些实时性要求高且变更频率快的数据,完全没有必要添加缓存,直接查数据库反而是更好的方式。

在这里插入图片描述


六、总结

1、介绍了缓存一致性问题是怎么产生的。
2、缓存一致性问题本质上是数据库和缓存之间数据的最终一致性问题。通常可以采用以下办法实现缓存的最终一致性:

  • 添加缓存过期时间
  • 延迟删除
  • 消息队列
  • 监听数据库binlog更新缓存

3、不用过分放大缓存不一致的问题,把握关键点:业务操作中避免使用缓存,保证业务功能不受缓存不一致的影响;查询操作添加缓存提高响应速度。大部分系统对查询数据的实时性要求一般都能接受一定的容忍度。
针对更新频繁又要保证较高的数据一致性的场景,最好重新考虑使用缓存的必要性。

4、最推荐的组合方式是:添加缓存过期时间 + 延迟删除。实现简单能满足大部分项目需求。

最后

以上就是内向老鼠为你收集整理的关于缓存一致性问题的思考一、引言二、双写一致性问题三、缓存的典型使用方式四、如何保证缓存的最终一致性五、不用过分放大缓存不一致问题六、总结的全部内容,希望文章能够帮你解决关于缓存一致性问题的思考一、引言二、双写一致性问题三、缓存的典型使用方式四、如何保证缓存的最终一致性五、不用过分放大缓存不一致问题六、总结所遇到的程序开发问题。

如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(60)

评论列表共有 0 条评论

立即
投稿
返回
顶部