关于一致性,你该知道的事儿(下)

前言

上篇文章讲了一些关于较为底层的一致性内容,这篇文章上升一个层次,讨论讨论应用层面的一些一致性内容。

底层给我们提供了实现数据逻辑一致性的一些保证,但是由于上层应用多种多样,需求也各不相同,有些系统宁愿吞吐量降低也不允许不一致的情况发生,而有的系统则可以接受一定程度的不一致,但是需要保证一定的高可用和高并发(因此这些系统可能采没有实现事务功能的数据库系统)。 因此,要实现整个应用的一致性,上层系统还需要注意很多东西。下面是几点引起应用不一致的几个常见场景。


一、并发修改单个对象

和数据库一样,并发操作是引起不一致的一个重要场景,但是大多数web服务场景的应用不可避免的要支持并发(一个一个串行执行的请求任务在遇到io阻塞时效率会低到哭的)。

在这里插入图片描述

比如说,很多业务场景都会遇到read-modfy-write的逻辑,即先从数据库中读取要操作的数据D,然后根据用户请求对数据进行更新,成为D’, 然后将更新后的值写入到数据库中。

很常见的一个例子就是多个人更新同一个文档, 如果多个请求同时到来,需要进行这种“read-modify-write”的操作,设计不当有可能会造成后者的更新不包括前者修改后的数据,因此导致前者的修改丢失(更新丢失)的情形。

再比如说一种情况,一个群组,每加入一个人,就需要在群资料里的总人数中进行加1操作,如果使用普通的“read-modify-write”操作,并发场景下很有可能会出现更新数目不准确状况(两次+1操作被因为冲突变成了一次)。

有一些方式可以应用到上述场景来一定程度上的应对上述问题。

1.1 原子写操作

很多数据库提供了原子更新操作,将“read-modify-write”的逻辑下沉到数据库层面,可以解决某些场景的更新丢失问题。

比如说mongo提供单个document级别的原子操作。如果我们需要更新的数据是在同一个document中,那么使用mongo可以避免更新丢失(注意,这里是针对单个document情况,对于复杂的业务逻辑需要更新多个document,mongo只保证了每个document的原子性,而不保证整个update操作的原子性)。

redis提供的大部分单个命令操作时原子性的, 有些场景可以利用redis的原子操作实现一些防止并发冲突的功能,比如说原子自增,序列号分发等。

1.2 显示加锁

有些数据库(比如说mysql)提供了对返回的结果集加锁的功能。所以,应用程序可以根据请求对查询的结果集加锁,显示锁定待更新的对象。当其他的请求尝试读取对象的时候,必须等待当前请求的执行队列完成。

Select * from page 
where name = "modify_page"
for update;     //for update 指示数据库对返回的所有数据行进行加锁

锁是一把双刃剑,虽然好用易理解,但是用得不好往往会引起效率的降低,使用宜谨慎。

1.3 原子的TestAndSet

有些数据库支持原子性的testAndSet操作,即只有当前值没有被其他人修改时才执行更新写入操作。

比如说对于两个用户同时需要更新一篇文档,只有当前页面从上次读取出后没有发生变化,才会执行当前的更新操作;
如下:

update page 
set content = "new content"
where id = 1234 and content = "old content"

1.4 版本号机制

有这样一个场景,比如说要提供一个简单的kv存储系统给客户,客户通过调用接口来操作这些kv值。 但是有一个需求,客户端需要明确知道调用是否有并发冲突,即"我调用的时候要么成功,要么失败。但是不允许有人和我一起调用成功"。

这种kv存储怎么设计呢?直接使用已有的kv组件(如redis)肯定是不行的,因为它无法防止并发冲突,虽然redis可以使用单个线程执行客户端发来的请求(串行化请求),但是它会"悄咪咪"的把客户的请求都执行了,当返回结果给客户端时,客户端也不知道自己的请求是不是穿插了其他的请求。

在这里插入图片描述
有其他的解决方案不?

可以参考版本控制的类似思想来解决这个问题。每个kv数据要保存一个版本号代表当前数据的版本 dataVersion, 每次操作完数据之后,dataVersion自增。同时当客户端请求的时候,让其带一个请求的版本号reqVersion(版本号可以是一个中心的版本分发器,也可以让app层或者redis返回时response携带,但必须是全局唯一的),只有reqVersion 和dataVersion 匹配时(比如说reqVersion=dataVersion+1),操作可以进行,否测返回客户端并发冲突。

这样当同时多个客户端请求时,如果携带相同的reqVersion来请求,只有一个可以成功,其他的将返回并发操作失败。如下图所示。

在这里插入图片描述
(上述请求假设req1先到达服务端)

这种方式从某种程度上和上面的TestAndSet有点像,都是先Test,符合某种条件时才进行Set; 不一样的一点是把判断的条件移到了客户端(让客户端请求时携带),这样让客户端能感知到并发的处理结果。其实如果不考虑判断的条件放在何处,TestAndSet和版本号机制本质上都是属于乐观锁的方式,只有在更新时才判断条件是否满足,是否有其他的线程更改了条件。

二、 多个相关对象的一致性

当现在很多的互联网应用开始划分业务为各个微服务的时候,各个微服务之间的联系就变得繁多了起来。很多时候,一个上游的请求会导致下游多个服务的调用;一个业务逻辑需要同时(这里的同时指的不是时间上的同时)修改多个对象,这就需要保证这多个数据对象的一致性。

一个很常见的业务场景就是订单支付,如下图所示。一个订单请求涉及到下游多个服务和对应数据对象的修改,。请求之后这些数据对象要保证一致性,不能订单数据为已支付,但是库存数据没修改。
在这里插入图片描述

我们要达成多个数据对象一致性(专业点叫做分布式事务),要么一起提交修改,要么都不提交修改的最终效果,这有哪些方式呢?

一般有常见的有几种方式(我用的还不多,在此简单介绍):

2.1 最大努力实现

最简单的一种方式就是重试策略。当要修改的其中某个数据对象不成功的时候(因为网络超时、或者机器宕机等原因),就重新发起请求,不断重试,直到重试成功或者达到最大的重试次数。

本质上来说,这种方式不一定能达到多个数据对象的一致性,因此只能算作最大努力实现。但对于一些一致性要求没那么高的场景,如果上层的应用设计的合理,还是可以使用这种方式的,毕竟执行失败是少数情况,很多时候retry几下就可以成功的。

2.2 2PC && TCCC

要么一起提交修改,要么都不提交修改。是不是和我们上面讨论的2PC协议有点类似?

没错,2PC也是实现这种分布式事务一种经典协议(只不过之前说它是在分布式数据库层面,现在讨论的是在业务应用层面),通过"Parepare”—>“Commit/Rollback"两个阶段来实现多个数据对象的一致性。

上文中有论述,这里就不重复了。 这里介绍一个在2PC在业务层面的一个变种,TCCC。

TCC是 Try-Confirm-Cancel 的简称,如其名字中所表述的,它的执行过程分为3个阶段:

  1. Try : 检测预留留资源, 对应于2PC的Prepare阶段
  2. Confirm: 真正的业务操作提交, 对应2PC的Commit阶段
  3. Cancel: 预留留资源释放, 对应2PC的Rollback阶段

如下图所示:
在这里插入图片描述
从某成程度上说,TCC是2PC在业务层间的套用,可以实现最终的一致性。但是2PC存在的问题,它也存在,而且这种方式对业务的侵入较强,会带来一定的开发量。

2.3.基于可靠消息的一致性方案

还有一种基于可靠消息的一致性方案,通过消息中间件自身提供的异步+持久化+重试的策略保证(当然也不是完全保证)消息一定会被消息的订阅方消费。 那么只需要保证业务操作(修改其中的某个数据对象)和消息的发送(传递修改其他对象的指令)是事务性的即可。

如下图所示为基于消息中间件的一致性方案,通过【消息发送方】执行本地事务(修改某个数据对象)和发送消息到服务端(也就是消息中间件服务)的一个类2PC过程来实现某种程度上的原子性。

首先消息发送方会发送一个“半事务消息”,然后再执行本地事务,根据本地事务执行的结果,来给消息服务端再发送一个commit或rollback的确认消息。只有服务端收到commit消息后,才会真正的发送消息给订阅方。
在这里插入图片描述

这种方式使用消息中间件的方式解耦了修改两个对象数据的过程,对性能的损耗和业务的入侵更小。现在很消息中间件都实现了事务消息的功能,可以很好的帮上层业务实现多个对象的一致性问题。

2.4.Saga事务

还有一种应用于长事务的Saga方案,通过将长事务拆分为多个本地短事务来执行,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。(有点类似于数据库中的undo操作,出有问题了就逆向操作)。

如下图所示:
在这里插入图片描述
在这里插入图片描述
从上文介绍的几种应对多个相关联对象的一致性方案来看,很多方案或多或少都能看到重试的影子(第2、3哥方案中间过程也依赖于重试),或多或少也都有点加锁的味道。 一般来说,有锁就影响并发,影响性能。 在性能、可用性和一致性方面,具体采用哪种方案还是要看具体的业务场景和需求。

三、 多个副本的双写一致性

如前所述,除了数据库系统给我们提供的多副本机制,我们还会遇到不同异构层次涉及的多个副本,具体来说是缓存系统涉及到的多个副本。在应用中常见的就是类似 本地内存-> redis缓存->数据库系统这种,我们为了提供系统的读写性能,把一部分常用的数据缓存到更快访问的介质之上,对用上层应用来说这个过程也涉及到一致性的相关问题。 虽然一般来说这种方式不会要求多么强的一致性,但是不同的操作顺序也会对一致性有不同的影响。

这里简要讨论一下【Redis缓存—>数据库】这种缓存架构,应用层不同的操作顺序带来的不同结果。

对于【Redis缓存—>数据库】这种缓存架构方式,读取方式肯定是先从Redis中读取,如果Redis不存在,再从数据库中读取。

但是写入更新就有好几种方式了,按照缓存更新的时机,分为写入时更新,或者读取时更新

这么一说下来,就有如下几种操作方式了。

3.1 写入时更新

写入时更新分为【更新缓存–>更新数据库】 和 【更新数据库–>更新缓存】两种方式。这两种方式都会有并发冲突带来的不一致现象。比如说第一种方式吧,A、B两个进程并发来一套上述的流程。
在这里插入图片描述
【更新缓存–>更新数据库】

最终发生了Redis和数据库中数据不一致的情况。

第二种方式也是一样,都会产生这种A1->B1->B2->A2(A1:表示A进程执行第1个操作)的问题。
在这里插入图片描述
【更新数据库–>更新缓存】


我们这里没考虑执行失败,或者宕机的情况,如果考虑这种情况的话,第一种方式要比第二种方式影响更大些,因为在第一种方式里,如果Redis更新成功了,但是数据库失败了,数据就不仅仅是不一致的问题,而是产生了脏数据,缓存毕竟是缓存,我们最终要是要以数据库中的数据为准。

3.2 读取时更新

【删除缓存–>更新数据库】和【更新数据库–> 删除缓存】是两种读取时更新的方式,这两方式先删除缓存,然后下一次读取的时候就可以从数据库中读取更新的数据。这两种方式相当于是把写写冲突造成的不一致转移到了读写上。比如说下面的并发场景:

在这里插入图片描述
【删除缓存–>更新数据库】

在这里插入图片描述
【更新数据库–> 删除缓存】

这两种方式也会造成最终的缓存Dc和数据库Db不一致。相比来说,第四种方式要比第三种方式发生不一致的概率更小点,因为更新缓存的速度要远远大于更新数据库,第四种方式中ClientA 把数据库都更新了,缓存也删了,ClientB还没有更新缓存,这种情况不能说没有,但是概率上要少些。

但是采用删缓存有一个缓存穿透问题需要考虑:就是删除了缓存之后要防止突然大量的并发请求到数据库中。

上述只是简单讨论了一下,实际的现实的情况要更复杂(比如说哪个过程执行失败了),也更灵活(有些场景不需要太高的一致性),需要具体问题,具体分析。

3.3 另外一种方式-订阅数据库变更日志

这里更新【数据库-缓存】的问题,除了上面提到的双写方式还可以使用订阅数据库变更日志的方式,把数据库的变更看成一种变更数据流,然后把这种变更流apply到缓存中。如下图所示。
在这里插入图片描述

上图中还可以省略MQ,直接发送变更日志到更新缓存服务中。这种订阅数据库变更的方式从某种程度上把并发请求给强行串起来了,更新的事件不在通过应用程序来下发,而是通过数据库来下发了。

这里仅简要提一点,详细的可以参阅【1】。


四、后记

一致性是个大问题,这两篇文章从单机和分布式的角度,从数据库和应用层面,大概梳理了一致性的相关内容。内容有点多, 因此很多内容只是简单过了个囫囵吞枣。这两篇文章主要是想通过梳理一下一致性的相关内容,来对编程过程中涉及到的一致性有个大概的认识(知道是怎么回事儿,算是属于哪个分类,该往哪个方向考虑问题),以后遇到一致性问题不至于 卖虾米不拿秤-抓瞎。

但是如果从更高的层次来看,这两篇文章的很多内容其实非常相似,抽象的看,研究的可能就是一个东西,只是在实际中被用到了不同的场景,因而有些变化。因此如果能从宏观上来看这些内容,会对一致性有更深的理解(当然,我现在还没到这个程度)。

最后总结以一张“一致性全家图”来结束这两篇关于一致性的文章。
在这里插入图片描述


参考

【1】《DDIA》
【2】 挑战大型系统的缓存设计——应对一致性问题
【3】 不就是分布式事务,这下彻底清楚了😎
【4】 一致性问题与分布式事务
【5】 TCC分布式事务,最终一致性分布式事务
【6】Seata-go: Simple Extensible Autonomous Transaction Architecture(Go version)
【7】关于一致性,你该知道的事儿(上)
【8】Using logs to build a solid data infrastructure (or: why dual writes are a bad idea)
【9】基于日志的同步数据一致性和实时抽取

  • 11
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值