浅析微服务下保证事务数据一致性的几种方案

1,533 阅读20分钟

PS:这篇文章如果暂时看不太明白,可能是因为我在写博客的时候因为篇幅原因省略介绍了一些知识导致你在一些环节上出现逻辑断层,需要仔细再思考思考;也可能是因为我表述不当产生歧义;无论是何种原因,欢迎评论区交流或者通过我写这篇博客的参考数据与博客进行系统学习~如果对你有帮助还是麻烦点点赞,我好喜欢三级的那个蓝色啊,二级的蓝色不好看😂

「本文已参与好文召集令活动 点击查看: 后端、大前端双赛道投稿,2万元奖池等你挑战!

1. 从本地事务到分布式事务的演变

如依赖mysql的传统的单体式应用可以通过mysql基于锁+MVCC实现本地事务的ACID特性对上层应用提供一致的数据视图,而不会因为多个事务并发读写而产生脏读、脏写、更新丢失等问题;

本文会贯穿一个相对简单的创建订单的业务场景进行讨论:

  • 存在一个订单服务-OrderService,通过createOrder()对外提供创建订单的功能;
  • 依赖ConsumerService的consumerVerify()进行消费者信用额度的扣除,如果余额>订单金额,则创建成功,否则对外返回额度认证失败;
  • 依赖AccountingService的accountAuth()对于当日所有Order的金额进行核算,如果未超出约定的限制额度,则创建成功,否则当前订单创建失败;

对于单体服务而言,因为方法中的OrderService、ConsumerService、AccoutingService依赖的不同表在同一个数据库中,通过@Transactional(rollbackFor = Exception.class)注解修饰createOrder方法使其成为事务方法,因此不会出现多个事务并发访问导致的数据不一致问题,并且创建订单失败后抛出异常便可以直接使得事务回滚;

上述业务的部署如下所示:

image.png

当时当由于业务逐渐复杂,数据量增多,单体架构不再可以支撑高并发的流量,此时对于数据库可以通过两种方式-垂直分表、水平分表;

  • 水平分表:对表的行进行拆分。因为表的行数超过几百万行时,就会变慢,这时可以把一张的表的数据拆成多张表来存放。水平拆分,有许多策略,例如,取模分表,时间维度分表等。这种场景下,虽然我们根据特定规则分表了,我们仍然可以使用本地事务,因为我们可以将对于相同数据的读写路由到相同的数据库实例上;

  • 垂直分表:对表的列进行拆分。垂直分表则会将原有表中的数据列按照领域的维度拆分为多个表,并且多个表会部署在不同微服务对应的不同的数据库中,但是这多个表之间可能仍然存在数据一致性的要求,而在分布式场景中每个数据库只能够保证自身本地事务的ACID特性,并且微服务间只可以通过网络通信了解到其他事务的执行状态,因此需要提供额外的机制负责维护微服务间的分布式事务;

因为单体架构->微服务架构的过程中,原本的本地进程调用->远程过程调用/消息队列通信,因此对于服务可用性、数据一致性的讨论需要加入网络这个因素;

CAP理论定义了在发生网络分区的网络问题时,我们只可以保证服务的可用性与数据的强一致性这两个特性的其中一个,因此一般考虑网络问题都会在可用性与一致性之间进行取舍;

BASE理论即通过弱化了数据的强一致性,转变为数据的最终一致性,来保证服务的可用性。

下文便开始介绍分布式事务的几种模式:

2. 2PC(XA)、TCC模式

XA与TCC是基于二阶段提交、同步网络通信的数据强一致性解决方案,因为其是保证数据的强一致性,因此会因为网络分区导致服务的不可用;

具体流程如下图所示:

image.png

  • 第一个阶段-准备阶段:事务管理器会远程调用当前分布式事务所有参与者的prepare()方法用于锁定事务相关数据、记录事务相关日志便于事务回滚;
  • 第二个阶段-提交阶段:如果所有的参与者返回prepare()方法执行成功,那么事务管理器便会远程调用所有参与者的commit方法进行事务的提交;否则调用cancel()方法进行事务的回滚;

2.1 二阶段提交模式的问题

  • 一些数据库与消息代理并不支持回滚:如流行的消息代理KafkaRabbitMQ等并不支持XA的事务回滚,这也意味着在2PC中即使其他服务回滚了,已经打入消息代理的信息却不能够撤回,而这会导致数据的不一致性;

  • 同步通信的性能问题: 由协调者节点同步通信维护分布式事务的状态,依赖参与者节点本地事务维护了节点间数据的强一致性;但是在分布式事务运行的整个过程中,本地事务一直处于未提交的状态、对应的业务数据处于锁定状态,因此在高并发或者说该分布式事务涉及到的参与者节点较多时,会降低系统可承载的并发量;因此最直观的优化的方案为同步->异步,并基于消息事件进行实现;即下文要讲述的可靠事件模式;

  • 同步阻塞问题: 上述2PC整个流程是同步的,事务管理者(协调者)必须等待每一个资源管理者(参与者)返回操作结果后才能进行下一步操作,这样就非常容易造成同步阻塞问题;因此3PC将准备阶段拆分为预备和准备阶段用于在预备阶段提前发现挂掉的节点而提前终止事务,并引入了超时机制解决协调者因为网络因素一直处于阻塞状态的问题;

  • 引入超时机制的问题: 因为网络延迟不固定的原因,可能存在协调者节点发起prepare()调用的网络包因为网络延迟在超时时间内尚未到达服务节点,因此协调者节点会发起cancel()调用用于事务回滚,因此可能存在cancel()包已经到达而prepare()包尚未到达的情况,因此服务需要支持空回滚;但是更可靠的方式是设置过期时间>>网络通信时间,并设置基于网络平均延迟的网络包指数级避让重发的机制。

  • 协调者单点故障问题: 二阶段提交亦会有单点故障的问题,虽然3PC通过引入超时机制,并将二阶段提交的准备过程拆分成两个步骤,但是仍然无法避免协调者的单点故障问题;而基于服务协同的可靠事件模式可以有效避免单点故障问题。

3. 可靠事件模式

可靠事件模式是基于队列实现服务协同通信的最终一致性解决方案;与2PC将分布式事务分为准备/提交两个阶段不同,可靠事件模式则是将一个分布式长事务拆分成多个事务,通过向消息队列投递事件异步触发事务流程;

以下图为例,OrderService接收到客户端的createOrder请求后,便会创建Order对象并生成OrderCreated事件投递到消息代理中;而ConsumerService向消息代理订阅了OrderCreated事件,并且对应的事件处理器为consumerVerify()方法,处理成功便会向消息代理中投递ConsumeVerified事件;而AccoutingService向消息代理订阅了ConsumeVerified事件,对应的事件处理器为accountAuth()...

image.png

可靠事件模式通过消息队列进行事务流程的传递,虽然避免了2PC同步带来的性能问题,但是又会带来一些新的问题。

3.1 补偿事务

与2PC不同,可靠事件模式下的分布式事务涉及到的多个服务的本地事务,都是在自身事件处理器处理完成后便直接提交的,不会等待协调者节点的commit/cancel指令后再进行提交/回滚,而这会不仅产生隔离性的问题(会在4.1节一起讲述),还需要我们提供一种对于已提交事务的事务回滚机制-补偿事务

我们将分布式事务中涉及到的多个本地事务分为:可补偿事务、关键事务、可重复事务。

  • 可补偿事务:意味着在分布式事务中,该本地事务后的其他本地事务可能会失败,因此如果后续事务失败,则需要对该事务进行补偿;
  • 关键性事务:意味着在分布式事务中,该本地事务后的其他本地事务即使失败也不会影响流程,如一些用于日志记录的本地事务;
  • 可重复事务:意味着该事务总可以最终成功。

因此如果分布式事务中第n+1个事务失败了,并且该事务不是可重复事务,则需要对前n个事务对数据库产生的影响进行回滚。

如当调用AccountingService时,判断当前订单不可以创建,则AccountingService会投递AccoutingAuthFailed事件至消息队列,而ConsumerService则需要订阅该事件,并关联自身对应的补偿事务方法用于回滚对信用额度进行的变更。

3.2 可靠事件模式要解决的问题

3.2.1 发送方:执行业务代码与发送事件消息的原子性问题

一般为了避免消息先发送成功而业务代码执行失败的情况,我们会选择在业务逻辑执行完成后再发送事件消息,但是发送事件消息至消息队列也并不是一定成功的。因此需要提供一个机制保证该事件最终一定会发送到对应的消息队列中,即事务性消息

事务性消息的实现一般是通过发送事件的服务维护额外的事件表,在发送消息前将发送的事件消息内容与类型写入对应的事件表中,使得业务代码与发送事件都可以通过数据库本地事务保证一致性,即最终状态满足事务原子性要求,最后再通过额外的消息中继服务获取事件表的事件信息并保证事件发布的可靠性;

image.png

接着的问题在于消息中继如何通过事件表中的信息获取到要发送的事件消息;

  • 消息的筛选:因此我们需要给事件基于流程进行状态的建模,一个事件可能处于未发送、已发送到消息代理、消息代理已发送到消费者、消费者已成功消费并进行ACK这几个流程中,因此事务在事件表中也对应存在几个状态,并通过status状态进行标识。

  • 消息的获取:消息中继可以通过 sql轮询 获取服务对应事件表中处于未发送状态的消息,并进行发布;但是显然这样会使得消息发布的速度被数据库查询的效率所约束,并且轮询在一定程度上会加重数据库的负担;我们除了可以在对应的事件表中获取信息,还可以通过读取数据库的事务日志来反映事件表的变化,因此我们可以通过日志拖尾模式进行异步优化,通过消息中继服务读取数据库事务日志并进行过滤来获取要发送的事件消息,而不是直接访问数据库表;此外,这样做的好处还在于解耦合,消息中继通过依赖事务日志而不是表,使得其不再只可以工作在关系模型的数据库上,也可以工作在诸如redis等的NoSQL数据库上。

至此,我们分析了如何保证业务代码的执行与消息发布的原子性,主要是通过写入事件表来使得消息的发送加入本地事务;不过这样使得服务所在数据库需要额外承担事件表的数据成本,也会使得业务的承载能力降低,因此我们可以通过分化出单独的事件服务,用于维护不同主题的事件表并进行事件的发布。

3.2.2 消息队列:事件消息顺序性问题

因为事件消息不总是可以并发的,它们之间可能会存在因果顺序,因此消息队列需要保证其发送消息的顺序与其接收到消息的顺序一致。

因为诸如kafka等消息队列,一般都会通过复制与分区的手段实现高可用,而对于同一个主题下的消息,可能处于不同的分区中,而不同分区的消息之间是没有全局顺序的,但是分区自身内的消息是满足全序的,因此消费者在发送消息时需要指定基于事件类型的分区策略,使得消息队列可以将相同的事件类型的消息按照全序发送至消费者端进行处理,从而满足事件间的因果关系。

3.2.3 消费方:消费幂等性的问题

因为诸如kafka等消息队列只提供了消息至少成功发送一次的保证,即一条事务消息可能因为超时机制使得消费方收到多次,因此消费方需要保证接收到多次事件消息产生的影响与接收到一次一致-幂等性;

  • 最直观的解决方案为,消费方的事件处理方法实现接口幂等,一般通过业务对象状态进行判断是否已经处理过该事件消息,但是会使得所有代码冗余了幂等保证的切面代码,因此最好通过额外的机制进行消息的拦截;
  • 优化的方法即为在该事件消息被事件处理方法处理前提供额外的机制进行判断当前事件是否可以被处理,消费者可以通过维护额外的事件处理表用于记录已经被处理过的event_id,如果已经被处理过则进行丢弃;但是需要考虑消费者处理消息中途失败的场景,此时可以依赖消息队列的超时机制,如果消息队列到达超时时间后仍未接收到消费者ACK消息,则进行消息的重新发送,但此时需要绕过幂等性的判重逻辑,因此消费者在因为宕机等原因重启后要将事件处理表中未完成处理的消息状态进行回滚。

4. 编排式Saga模式

Saga模式除了可以实现上述的基于服务间协同的可靠事件模式,还可以实现基于编排器的编排式事件模式;

Saga的编排器与2PC中的协调者节点功能类似,但实现的方式不同,2PC是基于请求/响应的通信方式,而Saga是基于命令/异步响应

对于每个暴露给客户端的调用方法,比如createOrder,实现编排式Saga的分布式事务框架都会为该方法编排对应的分布式事务涉及到的本地事务流程,并进行集中控制。

具体流程如下图所示,注意对比与基于服务之间协同的可靠事件模式的异同:

image.png

createOrder()方法依赖于ConsumerService与AccoutingService,对应的CreateOrderSaga便会通过消息队列向ConsumerService发送OrderCreated事件,ConsumerService进行处理后会将答复事件信息回复到CreateOrderSaga回复通道中交由Saga进行处理;Saga判断响应结果决定是需要触发回滚or继续向下执行分布式事务,如果响应结果正确则会接着通过消息队列向AccoutingService发送AccoutingAuth事件并等待回复...

而如何确保Saga编排器、依赖服务与消息队列间的可靠通信已经在3.2节详细叙述过,接着我们将叙述隔离性问题。

4.1 基于消息队列异步通信带来的隔离性问题

编排式Saga和可靠事件模式,本质上都是通过引入消息队列实现服务间异步通信;与XA或者TCC等二阶段提交等同步通信协议不同,Saga并没有严格区分准备、提交、回滚阶段,而是每个服务在处理事件时,通过事务性消息机制会直接提交当前本地事务,而不会等到其他相关事务完成,即分布式事务涉及到的本地事务可能处在未开始、未提交、已提交、已回滚的不同状态下,因此整个分布式事务涉及到的数据可能处在数据不一致的状态。

举个脏读的栗子:

脏读的本地事务中的定义为:一个事务读取到了一个还没有提交事务的更新;类似的,在分布式事务中即为在一个分布式事务中读取到了另一个还没有完成的分布式事务的更新;

  • 用户发起了一个CreateOrder的请求,该账单的金额为100,OrderService发布OrderCreated事件;
  • ConsumerService判断当前用户账号信用额度为120>100,因此进行扣除后剩余20,并通过事件表与消息中继机制进行ConsumerVerified事件的回复等待AccoutingService进行消费;
  • 此时如果该用户又发起了一个CreateOrder请求,账单金额为10,那么即使上一个分布式事务还未提交,ConsumerService会返回20,而不是120,这便造成了脏读的问题;

当然上述讲述的脏读可能直观上好像并没有什么危害,但脏读带来的问题一般为违反数据间的约束性要求,从而打破了数据视图应该满足的一致性,比如下述的例子会讲到因为脏读引起的写冲突-更新丢失问题

  • 第二个分布式事务中ConsumerService会对该用户剩余的20信用额度进行扣减,此时该用户剩余信用额度为10;
  • 第一个分布式事务因为AccoutingService失败,触发事务回滚机制,如果分布式事务是基于数据库Undo日志进行回滚,那么ConsumerService进行回滚后,该用户的信用额度会重新变为120,而这便造成了第二个分布式事务创建的Order对于信用额度-10更新丢失了。

上述便是分布式场景中最常见的并发读写导致的脏读、更新丢失的问题;我们可以通过增加语义锁、自定义事务回滚方式进行修改;

4.1.1 语义锁

在单体数据库中,为了避免多个事务并发读写产生的问题,结合使用了MVCC与锁机制;最直观的是行锁、表锁、意向锁等锁机制,可以让并发的事务达到局部串行以避免冲突的效果;

而二阶段提交的方式便是借助于数据库本地事务的锁特性,来实现隔离性;但是显然基于消息队列与事件的模式不行,因为每个本地事务是独自提交的,因此需要在应用层实现一把语义锁,提供锁信息使得第二个分布式事务知道有另一个事务在操作其关心的数据,从而避免并发读写的冲突问题;

在应用层,一般可以通过聚合根业务对象的状态作为锁信息;如对于上述的Order对象,可以对该对象进行状态机建模:

image.png

状态机建模的一般分为对象的状态以及引起对象状态变更的操作,依据这个业务流程,画出了上述的状态流转图,基于上图我们可以实现基于状态的语义锁,在ConsumerService调用consumerVerify()进行信用额度扣除前,先查看当前Consumer业务对象中是否有处于创建流程中的订单,如果有则需要进行阻塞等待直到上一个订单创建完成或创建失败并完成回滚;

4.1.2 自定义回滚方式

上文中我们提到依赖数据库Undo日志进行事务回滚可能会导致更新丢失的问题,因此我们需要自定义回滚方式-可交换更新

可交换更新的方式可以理解为,用户请求创建信用额度为100的Order订单,但是当前事务因为AccountingService失败,使得ConsumerService需要对当前用户的信用额度进行回滚,那么最简单的方式是依据事务日志,对于当前用户的信用额度进行+100即可;如此上述场景中的第二个事务的更新丢失的问题便不会出现;

使用该策略的前提是Service对于业务对象的操作存在一个对应的可交换操作,比如说借50元可以通过还50元消除借50元这个动作产生的影响。

5. 总结与参考

本文主要分析了基于二阶段提交、协调者同步通信的2PC,接着通过2PC的性能与缺乏容错导致单点故障的问题引出基于消息队列实现的异步通信的可靠事件模式,最后再介绍了基于协调者+消息队列实现的命令/异步响应通信方式的编排式Saga方案。

主要参考: 《DDIA》、《分布式架构设计模式》

一文讲透微服务下如何保证事务的一致性

分布式事务 Seata Saga 模式首秀以及三种模式详解

Choerodon猪齿鱼平台中的微服务数据一致性解决方案

美团团购订单系统优化记