图片来自宝途网【原创稿件】今天就来分析一下工作中常见的MySQL和Redis数据一致性问题。什么是数据一致性?一致性意味着数据保持一致。在分布式系统中,可以理解为多个节点中数据的值是一致的。一致性可以分为强一致性和弱一致性。强一致性可以理解为在任何时刻,所有节点中的数据都是相同的。在同一时间点,你在A节点获取的值应该和B节点获取的值相同。弱一致性包括很多不同的实现。目前,最终一致性在分布式系统中被广泛实现。所谓最终一致性,并不是保证任意时刻任意节点上的同一条数据都是相同的,而是随着时间的推移,不同节点上的同一条数据总是在向收敛的方向变化。也可以简单理解为经过一段时间后,节点之间的数据最终会达到一致状态。目前,大多数互联网公司都进行了数据库拆分和面向服务(SOA)的微服务。在这种情况下,完成某个业务功能可能需要跨多个服务操作多个数据库(包括关系数据库和非关系数据库)。这涉及到要操作的资源位于多个资源服务器上,应用需要保证对多个资源服务器的数据的操作要么全部成功,要么全部失败,所以我们必须保证不同资源服务器的数据一致性。那么数据一致性有哪几种类型呢?我这里会给他一个具体的分类,方便大家在什么场景下需要实现数据一致性来实现数据一致性。①跨库数据一致性如果数据库中的数据量比较大,或者预计以后数据量会很大,就会分库分表存储。也就是说同一张表的数据可能存放在不同的库中。这个时候,分布式场景下的数据一致性问题也存在。②微服务拆分现在的互联网公司都是采用微服务架构。服务被拆分成许多不同的独立系统。系统通过网络进行通信,每个服务都有自己独立的数据库。比如一个应用同时操作多个库,这样的应用的业务逻辑一定非常复杂,这对开发者来说是一个很大的挑战。应该拆分成不同的独立服务来简化业务逻辑。拆分后,独立的服务通过RPC框架进行远程调用,相互通信。此时,上图描述的架构对应了两个对应的分布式事务处理点:多个服务之间的事务处理(一个服务调用多个服务)多数据源事务处理(一个服务访问多个数据源)ServiceA来完成某个功能,需要直接操作数据库,需要同时调用ServiceB和ServiceC,ServiceB同时操作两个数据库,ServiceC也操作一个库。有必要确保这些跨服务对多个数据库的操作要么成功,要么失败。事实上,这可能是最典型的数据一致性场景。③根据不同类型的数据存储数据的一致性还有一种场景是同时操作不同类型的数据库,但是同时需要满足不同数据库的数据一致性问题。缓存数据一致性基本意思是:如果缓存中有数据,那么缓存数据的值就等于数据库中的值。但是,基于缓存中有数据,“一致性”可以包括以下两种情况:如果缓存中有数据,那么缓存数据的值就等于数据库中的值(两者都必须是最新值,本文将“旧值一致”归类为“不一致状态”)。缓存中没有数据,所以数据库中的值就相当于最新的值(当有请求查询数据库时,会将数据写入缓存中,然后就会变成上面的“一致”状态)。数据不一致:缓存的数据值与数据库中的值不相等;缓存或数据库中有旧值,导致其他线程读取旧数据。本文将带大家详细了解缓存一致性是如何实现的,以及缓存一致性的原理是什么。数据不一致及对策根据是否接收到写请求,缓存可以分为读写缓存和只读缓存:只读缓存:只用于缓存中的数据查找,即“更新数据库+删除”可以使用缓存”策略。读写缓存:需要对缓存中的数据进行增删改查,即可以采用“更新数据库+更新缓存”的策略。①对于只读缓存Read-onlycache:新增数据时,直接写入数据库;更新(修改/删除)数据时,先删除缓存。后面访问增删改查的数据时,会出现缓存未命中,然后查询数据库更新缓存。添加数据时,写入数据库;访问数据时,缓存缺失,检查数据库,更新缓存(始终处于“数据一致”状态,不会出现数据不一致)。在更新(修改/删除)数据时,会出现一个时序问题:更新数据库和删除缓存的顺序(这个过程会造成数据不一致)。在更新数据的过程中,可能会出现以下问题:没有并发请求,其中一个操作失败。在并发请求下,其他线程可能会读取旧值。因此,为了实现数据的一致性,需要保证两点:在没有并发请求的情况下,保证步骤a和b能够成功执行。并发请求下,在步骤a和b之间的间隔内,避免或消除其他线程的影响。接下来,我们针对有/没有并发的场景分析和使用不同的策略。②无并发在没有并发请求的情况下,在更新数据库和删除缓存值的过程中,由于操作被拆分成了两步,所以很有可能会出现“第一步成功,第二步失败”的情况”。由于步骤1和步骤2是在单线程中串行执行的,所以不太可能出现“步骤2成功,步骤1失败”的情况。Deletethecache,thenupdatedatabase:先更新数据库,再删除缓存:因此,如果先删除缓存,再更新数据库,则删除缓存成功,但更新数据库失败,这样请求就无法命中缓存,读取数据库的旧值。一致性问题。如果先更新数据库,再删除缓存,则更新数据库成功,但删除缓存失败,从而请求命中缓存,读取命中缓存的旧值,出现也是一个一致性问题。那么它的解决方案是什么?消息队列+异步重载试试。无论采用哪种执行时机,都可以在执行步骤1时将步骤2的请求写入消息队列。当步骤2失败时,可以使用重试策略来“补偿”失败的操作。③在高并发情况下使用上述策略后,可以保证单线程/非并发场景下的数据一致性。但是在高并发场景下,由于数据库层面的读写并发,会造成数据库与缓存数据不一致的问题(本质是后发生的读请求先返回).(1)先删除缓存,再更新数据库。假设线程1删除缓存值后,由于网络延迟等原因无法更新数据库。这时候线程2开始读取数据的时候,会发现缓存丢失了,然后查询数据库。当线程2从数据库中读取数据并更新缓存时,线程1开始更新数据库。此时缓存中的数据是旧值,而数据库中是最新值,造成“数据不一致”。本质是本应晚发生的“线程2-读请求”在“线程1-写请求”之前执行并返回。所以对于这类问题,我们的解决策略是:设置缓存过期时间+延迟双删除:通过设置缓存过期时间,如果出现上述清除缓存失败的情况,缓存过期后,读取request仍然可以从DB中读取最新的数据并更新缓存,这样可以减少数据不一致的影响。虽然一定时间范围内的数据存在差异,但可以保证数据的最终一致性。另外,也可以通过延迟双删来保证:线程1更新数据库值后,让其休眠一小段时间,保证线程2先从数据库读取数据,再将缺失的数据写入缓存,然后,线程1再次删除。后来其他线程读取数据时,发现缓存不见了,就从数据库中读取最新的值。redis.delKey(X)db.update(X)Thread.sleep(N)redis.delKey(X)sleep时间:业务程序运行时,统计线程读取数据和写入缓存的运行时间,基于此估计。(2)先更新数据库,再删除缓存。如果线程1更新了数据库中的值,但在删除缓存值之前,线程2开始读取数据。这时线程2查询缓存时,发现缓存命中,就会直接从缓存中读取旧值。本质也是,本应在后面发生的“2线程读取请求”在“1线程删除缓存”之前执行并返回。或者,在“先更新数据库,再删除缓存”的方案下,“读写分离+主从库延迟”也会导致不一致。上述问题的解决方案如下:延迟消息:根据经验向队列发送“延迟消息”,延迟删除缓存,同时控制主从库的延迟,减少不一致的概率越多越好。订阅binlog,异步删除:利用数据库的binlog异步剔除key,使用工具(canal)将收集的binlog日志发送给MQ,再通过ACK机制确认删除缓存。将删除消息写入数据库:通过比对数据库中的数据来确认删除。先更新数据库再删除缓存,这样可能会导致请求访问数据库时因为缺少缓存,给数据库造成压力,这就是缓存穿透的问题。对于缓存穿透的问题,可以使用缓存空结果和布隆过滤器来解决。Locking:更新数据时,加写锁;查询数据时,加读锁,保证两步操作的“原子性”,使操作串行执行。“原子性”的本质是什么?不可分割性只是外在表现。其本质是要求多个资源之间的一致性,运行的中间状态是外界不可见的。建议优先考虑“先更新数据库,再删除缓存”的执行顺序。主要有两个原因:先删除缓存值再更新数据库,可能会因为缺少缓存而请求访问数据库,对数据库造成压力。业务应用中读数据库和写缓存的时间有时很难预估,导致延迟双删的休眠时间设置不好。④对于读写缓存读写缓存:在缓存中进行增删改查,并采用相应的回写策略将数据同步到数据库。同步直写:使用事务保证缓存和数据更新的原子性,失败重试(如果Redis自身失败,会降低服务的性能和可用性)。异步回写:写入缓存时,不同步写入数据库,当数据从缓存中淘汰后,再回写到数据库(如果在写回数据库之前缓存失效,则数据会丢失)这个策略在秒杀领域见过,业务层直接操作缓存中的闪购商品库存信息,过一段时间再写回数据库。一致性:同步直写>异步回写,因此,对于读写缓存,保持数据强一致性的主要思路是:使用同步直写,同步直写也有两个时序问题:更新数据库和更新缓存。无并发:在高并发的情况下,有四种场景会造成数据不一致:场景1和场景2的解决方案是:将请求的读取记录保存到缓存中,对比延迟的消息,之后进行业务补偿发现不一致。场景3和4的解决方案是:对于写请求,需要配合分布式锁使用。当一个写请求进来时,对于同一个资源的修改操作,先加一个分布式锁,保证只有一个线程同时更新数据库和缓存;未获得锁的线程将操作放入队列,延迟处理。这样就保证了多个线程对同一个资源的操作顺序,保证了一致性。其中,分布式锁的实现可以使用以下策略:乐观锁:使用版本号和updatetime;只允许较高版本覆盖缓存中的较低版本。Watch实现Redis乐观锁:Watch监听Rediskey的状态值,创建Redis事务,key+1,执行事务,key修改则回滚。setnx:获取锁:set/setnx;释放锁:del/lua。Redisson分布式锁:以Redis的hash结构为存储单元,以业务指定的名字为key,以随机的UUID和threadID为feld,最后存储锁的个数为value,即线程安全的。⑤强一致性策略上述策略只能保证数据的最终一致性。要实现强一致性,最常见的方案是2PC、3PC、Paxos、Raft等共识协议,但它们的性能往往比较差,而且这些方案也比较复杂,需要考虑各种容错问题。如果业务层需要读数据的强一致性,可以采用以下策略:暂存并发读请求:在更新数据库时,先将并发读请求暂存到Redis缓存客户端,等到数据库更新完毕,缓存值被删除,然后读取数据以保证数据的一致性。序列化:读写请求进入队列,工作线程从队列中取出任务顺序执行。修改服务连接池,建模id选择服务连接,可以保证相同数据的读写落在同一个后端服务上级。修改数据库的DB连接池,通过取模id选择DB连接,可以保证同一个数据的读写在数据库层面是串行的。使用Redis分布式读写锁:将缓存清除和更新数据库表放入同一个写锁中,与其他读请求互斥,防止其间产生旧数据。读写互斥、写写互斥、读写共享,可以满足读多写少场景的数据一致性,也能保证并发。而过期时间是根据逻辑平均运行时间和响应超时时间来确定的。作者:JackHu简介:水滴健康基础设施高级技术专家编辑:陶佳龙征稿:有意投稿或寻求报告,请联系editor@51cto.com【原创稿件请注明原作者及来源为.com]
