当前位置: 首页 > 科技观察

微服务系统数据一致性总结

时间:2023-03-15 11:07:08 科技观察

大家好,我是看山。从单体架构到分布式架构,从单体架构到微服务架构。系统之间的交互越来越复杂,系统之间数据交互的量级也呈指数增长。作为一个系统,我们需要保证逻辑的自洽性和数据的自洽性。数据自洽性有两个要求:不管是什么代码,数据都能验证自己的准确性,即数据之间不矛盾。所有数据准确无误,符合预期。为了实现这两点,就需要实现数据的一致性。为了达到一致性需要使用事务。需要注意的是,本文设计的数据一致性并不是维护多个数据副本之间的数据一致性,而是维护系统间业务数据的一致性。本地事务在早期的系统中,我们可以通过关系数据库事务来保证数据的一致性。这个事务有四个基本元素:ACID。A(原子性):整个事务中的所有操作要么全部完成要么全部失败,不可能停滞在中间的某个环节。如果事务执行过程中出现错误,则会回滚(Rollback)到事务开始前的状态,就好像事务从未执行过一样。C(Consistency,一致性):事务可以封装状态变化(除非是只读的)。事务必须始终使系统保持一致状态,无论在任何给定时间有多少并发事务。I(Isolation,隔离):隔离地执行事务,使它们看起来是系统在给定时间执行的唯一操作。如果有两个事务同时运行并执行相同的功能,事务隔离将确保每个事务都被认为是系统中唯一使用该系统的事务。此属性有时称为序列化,为了防止事务操作之间的混淆,请求必须被序列化或序列化以便一次只有一个请求针对相同的数据。D(Durability,持久性):事务完成后,事务对数据库所做的更改将永久保存在数据库中,不会回滚。这四个元素是关系数据库的基础。不管系统多么复杂,只要我们使用同一个关系数据库,我们就可以使用事务来保证数据的一致性。基于对关系数据库的信任,我们可以认为本地事务是可靠的,并且在开发过程中不需要额外的工作。从架构上看,关系数据库也是一个独立的系统,关系数据库与应用的关系也是分布式的。那么我们先来看看这个简单的分布式系统是如何实现ACID的。首先,A(原子性)和D(持久性)是密不可分的两个属性:原子性保证事务的所有操作要么完成要么全部失败,不可能停滞在中间的某个环节;持久化保证一旦一个事务完成,该事务对数据库所做的修改将永久保存在数据库中,修改的内容不会因任何原因被撤销或丢失。众所周知,数据必须写入磁盘才能保证持久化。它仅存储在内存中。一旦系统崩溃或主机断电,数据就会丢失。所以,关键是“写入磁盘”,实现原子性和持久化,但在这个动作中有一个中间状态:写入。因此,现代关系型数据库通常采用追加日志记录的方式。修改数据所需的所有信息(包括修改什么数据,数据物理位于哪个内存页和磁盘块,什么值改成什么值等)以顺序追加的形式记录到磁盘.只有当所有的日志记录都放到磁盘上,数据库看到日志中代表事务提交成功的“提交记录”,才会根据日志上的信息修改真正的数据。修改完成后,在日志中添加一条“结束记录”,表示事务已经被持久化。这种事务实现方式称为“commitlog”。对于本地事务,我们可以通过日志来保证事务的原子性和持久性。如果多个事务访问同一个资源怎么办?作为程序员,我们都知道,多个线程/进程访问同一个资源,这个资源叫做critical。资源,解决临界资源占用冲突的方法很简单,就是加锁。关系型数据库为我们准备了三种锁:写锁:同一时间只有一个事务可以写数据,所以写锁又叫排它锁。数据被write加锁后,其他事务不能写数据,也不能给它加读锁(注意不能加读锁,但可以读数据)。读锁:多个事务同时可以对数据加读锁,所以读锁又叫共享锁。数据库加读锁后,数据不能加写锁。RangeLock:对一个数据范围加写锁,这个范围内的数据不能写入。也可以算作写锁的批处理行为。根据这三种锁的不同组合,我们可以实现四种不同的事务隔离级别:序列化(Serializable):写入时加写锁,读时加读锁,读写时加范围锁。范围锁定。RepeatableRead:写时加写锁,读时加读锁,读写范围时不加锁。这样,当读取相同范围的数据时,返回的结果是不同的,即幻读(PhantomRead)。ReadCommitted(读提交):写时加写锁,读时加读锁,读完后立即释放读锁。这样,同一个事务会多次读取同一个数据,得到的结果是不同的,即不可重复读(Non-RepeatableRead)。未提交读(ReadUncommitted):写时加写锁,读时不加锁。这样,另一个未提交的事务写入的数据就会被读取,即脏读(DirtyRead)。随着系统规模的不断扩大,全球交易的业务量不断增加。单体应用已经不能满足需求了,我们会拆分系统,再拆分数据库。这时在同一个请求中,会同时访问多个数据库。为了解决这种情况下的数据一致性问题,X/Open组织在1991年(我还年轻的时候)提出了一套X/OpenXA事务处理架构。XA的核心内容是定义了全局事务管理器(TransactionManager,用于协调全局事务)和本地资源管理器(ResourceManager,用于驱动本地事务)之间的通信接口。在多个资源管理器(ResourceManager)之间形成沟通桥梁,通过协调多个数据源的一致动作,实现全局事务的统一提交或统一回滚。支持XA架构的是两阶段提交协议(2PC,TwoPhaseCommitmentProtocol)。在这个协议中,最关键的一点是多个数据库的活动由事务协调器组件控制。具体分为5个步骤:应用调用事务管理器中的commit方法。事务管理器将联系事务中涉及的每个数据库,并通知它们准备好提交事务(这是第一阶段的开始)收到准备好提交的事务通知后,数据库必须确保它提交事务当它被要求这样做时,或者当它被要求这样做时回滚事务。如果数据库无法准备事务,它会向事务管理器作出否定响应。事务管理器收集来自每个数据库的所有响应。在第二阶段,事务管理器将事务的结果通知给每个数据库。如果任何数据库做出否定响应,事务管理器将向事务中涉及的所有数据库发送回滚命令。如果数据库响应积极,事务管理器将指示所有资源管理器提交事务。一旦通知数据库提交,后续事务就不会失败。通过对第一阶段的积极响应,每个资源管理器已确保如果稍后被告知提交事务,事务将不会失败。2PC两阶段提交协议的实现很简单,但是有几个明显的缺陷:资源管理器回复,允许资源管理器Downtime,但是资源管理器在等待事务管理器命令时不能做超时处理。一旦宕机的不是某个资源管理器,而是事务管理器,那么所有的资源管理器都会受到影响。如果事务管理器还没有恢复,没有正常发送Commit或者Rollback命令,那么所有的资源管理器都必须一直等待下去。性能问题:在二阶段提交过程中,所有的资源管理器相当于被绑定成一个统一的调度整体。期间需要两次远程服务调用,数据持久化三次(准备阶段写重做日志,事务管理器做)。状态持久化,提交阶段写入日志中的CommitRecord),整个过程会一直持续到资源管理器集群中最慢的处理操作结束,这决定了二阶段提交的性能通常很差。一致性风险:虽然commit阶段很短,但确实是一个危险期。如果事务管理器在下发prepare命令后根据各个资源管理器发回的信息确定事务状态可以提交,则事务管理器会先持久化事务状态并提交自己的事务。如果不能通过网络向所有资源管理器下达Commit命令,则(事务管理器的)部分数据会被提交,但(资源管理器的)部分数据不会提交,也没有办法回滚,导致数据不一致的问题。如果你能找到问题,你就能想到解决方案。我们高中老师说过,只要意识不滑坡,办法总是比困难多。因此,开发了三阶段提交协议(3PC,ThreePhaseCommitmentProtocol),可以缓解准备阶段的单点问题和性能问题。该协议将2PC中的准备阶段拆分为CanCommit和PreCommit,并将提交阶段重命名为DoCommit。CanCommit是一个查询阶段,让各个资源管理器根据自己的情况判断事务是否可以完成。3PC的本质是通过查询。如果大家都说可以做,那么成功的可能性就很大,减少了在准备阶段直接锁定资源的需要。由于事务失败和回滚的概率较小,在三阶段提交中,如果事务管理器在PreCommit阶段后宕机,即资源管理器不等待DoCommit消息,则默认操作策略为提交事务而不是回滚事务或者继续等待,这相当于避免了事务管理器出现单点问题的风险。3PC分布式事务说到分布式事务,就不得不提到CAP理论:任何分布式系统只能满足一致性(Consistency)、可用性(Availability)和分区容错性(Partitiontolerance)两点。顾及者。CAP理论一致性:数据在任何时候在任何分布式节点中看到的是符合预期的。可用性:系统不间断地提供服务的能力。可用性是根据可靠性和可服务性计算的比例值。可靠性通过平均故障间隔时间(MTBF)来衡量;可维护性通过平均修复时间(MTTR)来衡量。可用性衡量系统可以正常使用的时间占总时间的比例。公式为:A=MTBF/(MTBF+MTTR)。分区容错(PartitionTolerance):在分布式环境中,部分节点由于网络原因相互失去联系后,系统仍能正确提供服务。CAP的理论定义经过数次修改,修改后的定义本质上是一样的,但逻辑上更加严谨。为了更好的理解,本文使用最容易被公众接受和理解的定义。既然CAP不能兼顾两者,那么我们来看看如果其中一个链路缺失会发生什么情况:选择CA并放弃P:即我们认为网络是可靠的,不会出现分区。这种可靠性意味着节点之间不会出现网络延迟或中断等情况,显然不成立。选择CP放弃A:这个就是放弃availability。为了保证数据的一致性,一旦网络出现异常,节点间的信息同步时间可以无限延长。CP组合的使用一般用在对数据质量要求高的场合,即要保证数据完全一致,在网络完全恢复之前暂时不提供服务,可能会持续不确定的时间,特别是当系统已经显示出高延迟或网络故障导致连接丢失时。ChooseAPtogiveupC:表示一旦发生网络分区,服务优先可用,数据一致性放弃。这是目前分布式系统的主流选择,因为网络本身链接不同地区的服务器,网络不可靠,所以不能丢弃P。同时,我们实现分布式系统以提高可用性。这是我们的宗旨,不能放弃。这里需要再次说明,我们选择AP放弃C并不是放弃数据一致性,而是暂时放弃强一致性(StrongConsistency),而是选择弱一致性,即最终一致性(EventualConsistency):所有系统中的数据副本经过一段时间后,最终可以达到一致状态。这里所说的时间段,也是用户可以接受的时间段。最终一致性还有一个理论支撑:BASE理论(不得不说,理论界的缩写真牛逼,ACID是酸,CAP是帽子,BASE是碱),内容主要包括:BasicallyAvailable:当系统在发生不可预见的故障时,允许丢失一些可用性。例如,允许响应时间增加,允许一些非关键接口降级或熔断。软态(SoftState):软态也称为弱态,与硬态相对。意思是允许系统中的数据有一个中间状态,相信这个中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间同步数据。EventuallyConsistent:最终一致强调系统中的所有数据副本经过一段时间的同步后,最终能够达到一致的状态。所以,最终一致性的本质是系统需要保证最终的数据能够一致,而不需要实时保证系统数据的强一致性。在工程实践中,最终一致性有五种类型,它们会结合使用来实现最终一致性:因果一致性:如果节点A在更新某个数据后通知节点B,则节点B对该数据的后续访问和修改是基于A更新后的值。同时,对于与A节点没有因果关系的节点C的数据访问没有这样的限制。读取你的写入(Readyourwrites):节点A更新一条数据后,它总是可以访问自己更新的最新值,而看不到旧值。会话一致性(Sessionconsistency):会话一致性将对系统数据的访问过程框定在一个会话中,系统可以保证在同一个有效会话中“读什么写”的一致性,也就是说,执行一次更新操作后,客户端始终可以在同一个会话中读取数据项的最新值。单调读一致性:如果一个节点从系统中读取了某个数据项的某个值,系统不应该为该节点的任何后续数据访问返回一个更旧的值。单调写一致性(Monotonicwriteconsistency):一个系统必须能够保证来自同一个节点的写操作是顺序执行的。一致性关系模型有了理论之后,再来说说实现最终一致性的几种模式。可靠事件模式可靠事件模式属于事件驱动架构:当事件发生时,例如更新业务实体,服务向消息代理发布事件。消息代理将事件推送到订阅事件的服务。当订阅这些事件的服务接收到事件时,它们可以完成自己的业务并可能触发更多的事件发布。让我们用一个例子来解释这种模式。用户下单成功后,订单系统需要通知库存系统减少库存。可靠的事件模式订单系统根据用户的操作完成订单操作。此时,同一个本地事务用于保存订单信息和写入事件。另一个消息服务会轮询事件表,将状态为“进行中”的事件以消息的形式发送给消息服务。如果发送失败,因为是轮询任务,会在下次轮询时再次发送。(这里有一些优化点,为了简化模型,本例不再展开)消息服务向订阅订单消息的库存服务发送订单成功消息,库存服务开始处理。这时候就会出现这样一种集中的情况:库存服务扣除库存成功,消息服务收到成功响应。消息服务将响应结果返回给订单服务,订单服务中的事件接收者将事件修改为“已完成”。库存服务扣除库存失败,消息服务收到处理失败响应。此时消息服务会再次向库存服务发送消息,直到获得成功响应。如果失败的次数达到阈值,则可以通知警报以进行人工干预。当消息服务向订单服务返回结果时,发生故障,订单服务没有收到成功的响应。这时,事件轮询逻辑会再次将事件发送给消息服务。这样库存服务会反复收到扣减库存的消息,所以要求库存服务是幂等的。库存服务发现消息已经处理完毕,直接返回成功。这种依靠不断重试来保证可靠性的方案被称为“尽力而为交付”(Best-EffortDelivery),这也是“可靠”一词的来源。可靠事件模式还有一种更常见的形式,叫做“Best-Effort1PC”,指的是使用连续重试的方式(不限于消息服务)来提示其他关联业务的完成在同一个分布式事务中。找到最有可能出错的方法是事先对出错的概率进行先验评估,这样我们就可以知道哪个块最有可能出错。找到核心业务的方法就是找到那种只要它成功,其他业务就一定成功的业务。这里再补充两个概念:业务异常:业务逻辑错误,如账户余额不足、商品库存不足等技术异常:非业务逻辑产生的异常,如网络连接异常、网络超时等TCC模式TCC(Try-Confirm-Cancel)是一种业务侵入式事务方案,需要将业务处理拆分为“预留业务资源”和“确认/释放消费资源”两个子流程。该服务协调和调度不同业务系统的子流程。分为以下三个阶段:Try:在尝试执行阶段,完成所有业务可执行检查(保证一致性),并预留所有需要的业务资源(保证隔离性)。Confirm:确认执行阶段,不做任何业务检查,直接使用Try阶段准备好的资源完成业务处理。Confirm阶段可能会重复执行,需要幂等。取消:取消执行阶段,释放Try阶段预留的业务资源。Cancel阶段可能会重复执行,需要幂等。TCC模式1.订单系统创建交易,生成交易ID(作为标识请求幂等性的标识),通过活动管理器记录活动日志。2、进入Try阶段,调用账户系统检查账户余额是否充足。如果足够,则冻结所需的数量。此时账户余额是一个非常关键的资源,需要通过排他锁或者乐观锁来保证冻结操作的安全性。调用库存系统查看商品库存是否充足。如果足够,锁定所需的库存,并锁定仓库操作以确保安全。3、如果所有业务退货成功,记录活动日志为Confirm,进入Confirm阶段:调用账户系统,扣除扣减冻结金额调用库存系统扣减锁定库存4、如果第3步的所有操作都是完成,交易宣告结束。如果第3步出现异常,将根据活动日志中的记录重复Confirm操作,即进行besteffort投递。因此,各个业务系统的Confirm操作需要做到幂等。5、如果第2步有任何一方失败(包括业务异常和技术异常),记录活动日志为Cancel,进入Cancel阶段:调用账户系统,解除冻结金额调用库存系统,解除锁定库存6.Step5如果所有步骤都完成,则交易失败。如果第5步出现异常(包括业务异常和技术异常),则根据活动日志中的记录重复Cacel操作,即best-effortdelivery。所以各个业务系统的Cancel操作也需要做到幂等。是不是觉得TCC和2PC很像?两者的区别在于TCC位于业务代码层面,是一个白盒,而2PC是位于基础设施层面,是一个黑盒。因此TCC具有更高的灵活性,可以根据需要调整资源锁定的粒度。TCC可以在业务执行过程中预留资源,解决了可靠事件模式的资源隔离问题。但是,TCC有两个明显的缺点:TCC将基础设施层的逻辑上移到业务代码,对业务的侵入性强,需要更高的开发成本,增加开发成本,以及相应的维护成本,开发人员的素质,等等,都会有更高的要求。TCC要求资源可以被锁定、占用或释放,但是有些资源属于外部系统,没有办法实现锁定。鉴于以上两个缺点,让我们看看SAGA能否弥补。SAGA模式SAGA在英文中的意思是“长篇故事,长篇叙事,一连串的事件”。SAGA模型的提出比分布式事务的概念要早得多(又是前辈的五体推崇)。它起源于1987年普林斯顿大学的HectorGarcia-Molina和KennethSalem在ACM发表的一篇论文♂。本文提出了一种提高“LongLivedTransaction”运行效率的方法。大致思路是将一个大的交易分解成一系列可以交错的子交易集合,以后发展成分布式环境。将大型交易分解为一系列本地交易的设计模式。在一些文章中,这种模式被称为业务补偿模式。SAGA是交易形式的描述,业务补偿是交易行为的描述。本质是一样的。SAGA模式有两种实现方式:ForwardRecovery:顺序执行每个子事务。如果一个子事务失败,它会一直重试操作直到成功,然后继续执行下一个子事务。例如,用户下单支付成功,则必须扣除库存。反向恢复(BackwardRecovery):依次执行每个子事务。如果一个子事务执行失败,则执行该子事务的补偿操作(为避免技术异常导致的失败,补偿操作需要幂等),然后倒序执行对有的子事务的补偿操作成功了。这种批量操作一般是可以取消的,比如旅游预订,需要买机票、酒店预订、车票。如未能购票,可取消机票及酒店。SAGA模式根据这两种实现,SAGA可以分为两部分:子事务(NormalTransactions):将一个大事务拆分成若干个小事务,将整个事务T分解为n个子事务,命名为T1,T2,...,Tn。每个子事务都应该或可以被认为是原子的。如果分布式事务能够正常提交,那么它对数据的影响(最终一致性)应该等同于Ti的连续顺序提交。CompensatingTransactions:每个子交易对应的补偿动作,命名为C1,C2,...,Cn。子事务和补偿动作需要满足一些条件:Ti和Ci必须对应补偿动作Ci必须执行成功,即需要实现尽力而为的交付。Ti和Ci需要幂等。文末小结本文主要总结了本地事务、全局事务、最终一致性实现数据自一致性。重点关注最终一致性的中心化模式:可靠事件模式、TCC模式、SAGA模式等。数据一致性一直是个难题。随着微服务的发展,数据一致性变得更加困难。不要害怕困难。只要不放弃,总会解决的。本文转载自微信公众号《看山的小屋》,可通过以下二维码关注。转载本文请联系看山小屋公众号。