专栏名称: 互联网后端架构
主要介绍Java后端架构。其中也会掺杂一些前端、GO、Python、Linux,目标:全栈工程师!---好像很牛叉的样子 ^-^
目录
相关文章推荐
美团技术团队  ·  空降香港!美团无人机率先在港启航 ·  2 天前  
美团技术团队  ·  美团大模型北斗实习计划2025——全球顶尖科 ... ·  2 天前  
架构师之路  ·  DeepSeek开源V3/R1架构设计思路, ... ·  3 天前  
架构师之路  ·  总有人问,出海怎么用DeepSeek满血版( ... ·  4 天前  
51好读  ›  专栏  ›  互联网后端架构

微服务下分布式事务模式的详细对比

互联网后端架构  · 公众号  · 架构  · 2021-12-14 08:18

正文

更多内容关注微信公众号:fullstack888

作为 Red Hat 咨询架构师,我有幸参与了大量客户项目。虽然每个客户都面临自己特有的挑战,但是我发现其中有一些共同点。大多数项目都想知道如何协调对多个记录系统的写入。要回答这个问题,一般会涉及长篇累牍的解释,包括双重写入(dual write)、分布式事务、现代化的替代方案以及每种方式可能出现的故障情况和缺点。这样做通常会让客户意识到,将单体应用拆分为微服务架构是一个漫长和复杂的过程,而且通常都需要权衡。


本文不会深入介绍事务的细节,而是总结了向多个数据源协调写入操作的主要方式和模式。我知道,你可能对这些方法有过美好或糟糕的经验。但是实践中,在正确的环境和正确的限制条件下,这些方法都能很好地工作。技术领导者要为自己的环境选择最好的方式。

双重写入的问题


关于你是否会面临双重写入的问题有一个简单的指标,那就是预期要不要向多个记录系统进行写入操作。这样的需求可能并不明显,在分布式系统设计的过程中,它可能会以不同的方式进行表述。比如说:

  • 你已经为每项工作选择了最佳工具,现在在一个业务事务中,你必须要更新一个 NoSQL 数据库、一个搜索索引和一个缓存。

  • 你所设计的服务必须要更新自己的数据库,同时还要把变更相关的信息以通知的形式发送给另一个服务。

  • 你的业务事务跨越了多个服务的边界。

  • 你可能需要以幂等的方式实现服务操作,因为服务的消费者必须要重试失败的调用。


在本文中,我们将会使用一个很简单的示例场景来评估在分布式事务中处理双重写入的各种方法。我们的场景是一个客户端应用,它会在发生变更操作的时候,调用一个微服务。服务 A 要更新自己的数据库,但是它还要调用服务 B 进行写入操作,如图 1 所示。至于数据库的实际类型以及服务与服务之间进行交互的协议,这些对于我们的讨论都无关紧要,因为问题都是一样的。



微服务中的双重写入问题


我们简要解释一下为什么这个问题没有简单的解决方案。如果服务 A 写入到了自己的数据库,然后发送一个通知到队列中供服务 B 使用(我们将这种方式称为 local-commit-then-publish ),这样应用依然有可能无法可靠地运行。当服务 A 写入到自己的数据库,然后发送消息到队列时,依然有很小的概率发生这样的事情,即应用在提交到数据库后,且在第二个操作之前,发生了崩溃,这样的话,就会使系统处于一个不一致的状态。如果消息在写入到数据库之前发送的话(我们将这种方式称为 publish-then-local-commit ),有可能出现数据库写入失败,或者服务 B 接收到事件的时候,服务 A 还没有提交到数据库,这会出现时效性问题。不管是出现哪种情况,这种场景都会涉及到对数据库和队列的双重写入问题,这就是我们要探讨的核心问题。在下面的章节中,我们将会讨论针对这一长期存在的挑战目前已有的各种解决方案。

模块化单体


将应用程序开发为模块化单体看起来像一种权宜之计(hack),或是架构演化的一种倒退。但是,我发现它在实践中能够很好地运行。它不是一种微服务的模式,而是微服务规则的一个例外情况,能够非常严谨地与微服务相结合。如果强写入一致性是驱动性的需求,甚至要比独立部署和扩展微服务的能力更重要时,那么我们就可以采用模块化单体的架构。


采用单体架构并不意味着系统设计得很差或者是件坏事。它并不说明任何质量相关的问题。顾名思义,这是一个按照模块化方式设计的系统,它只有一个部署单元。需要注意,这是一个精心设计和实现的模块化单体,这与随意创建并随时间而不断增长的单体是不同的。在精心设计的模块化单体架构中,每个模块都遵循微服务的原则。每个模块会封装对其数据的所有访问,但是操作是以内存方法调用的方式进行暴露和消费的。

模块化单体的架构


如果采用这种方式的话,我们必须要将两个微服务(服务 A 和服务 B)转换成可以部署到共享运行时的库模块(library module)。然后,让这两个微服务共享同一个数据库实例。因为服务是在一个通用的运行中编写和部署的,所以它们可以参与相同的事务。鉴于这些模块共享同一个数据库实例,所以我们可以使用本地事务一次性地提交或回滚所有的变更。在部署方法方面也有差异,因为我们希望模块以库的方式部署到一个更大的部署单元中,并参与现有的事务。


即便是在单体架构中,也有一些方式来隔离代码和数据。例如,我们可以将模块隔离成单独的包、构建模块和源码仓库,这些模块可以由不同的团队所拥有。通过将表按照命名规则、模式、数据库实例,甚至数据库服务器的方式进行分组,我们可以实现数据的部分隔离。图 2 的灵感来源于 Axel Fontaine 关于伟大的模块化单体的演讲,它阐述了应用中不同的代码和数据隔离级别。



应用程序的代码和数据隔离级别


拼图的最后一块是使用一个运行时和一个包装器服务(wrapper service),该服务能够消费其他的模块并将其纳入到现有事务的上下文中。所有的这些限制使模块比典型的微服务耦合更紧密,但是好处在于包装器服务能够启动一个事务、调用库模块来更新它们的数据库,并且以一个操作的形式提交或回滚事务,而不必担心部分失败或最终一致性的问题。


在我们的样例中,如图 3 所示,我们将服务 A 和服务 B 转换为库,并将它们部署到一个共享的运行时中,或者也可以将其中的某个服务作为共享运行时。数据库的表也共享同一个数据库实例,但是它会被拆分为一组由各自的库服务管理的表。



具有共享数据库的模块化单体

模块化单体的优点和缺点


在有些行业中,这种架构的收益远比其他地方所看重的更快的交付以及更快的变更节奏重要得多。表 1 总结了模块化单体架构的优点和缺点。


表 1:模块化单体架构的优点和缺点


分布式事务通常是最后的方案,通常会在如下的情况下使用:

  • 当对不同资源的写入操作不允许最终一致性时;

  • 当我们必须要写入到不同种类的数据源时;

  • 当我们需要确保对消息的处理有且仅有一次,而且无法重构系统以实现操作的幂等性时;

  • 当与第三方黑盒系统或实现了两阶段提交规范的遗留系统进行集成时。


在这些情况下,如果可扩展性不是重要的关注点的话,我们可以考虑将分布式事务作为一种可选方案。

实现两阶段提交架构


两阶段提交技术要求我们有一个分布式事务管理器(如Narayana)和一个可靠的事务日志存储层。我们还需要能够兼容DTP XA的数据源,以及能够参与分布式事务的相关的 XA 驱动,比如 RDBMS、消息代理和缓存。如果你足够幸运有合适的数据源,但是运行在一个动态环境中,比如Kubernetes,那么你还需要有一个像 operator 这样的机制,以确保分布式事务管理器只有一个实例。事务管理器必须是高可用的,并且必须能够访问事务日志。


就实现而言,你可以尝试使用Snowdrop Recovery Controller,它使用Kubernetes StatefulSet模式来实现单例,并使用持久化卷来存储事务日志。在这个类别中,我还包含了适用于 SOAP Web 服务的Web Services Atomic Transaction(WS-AtomicTransaction)等规范。所有这些技术的共同点在于它们实现了 XA 规范,并且有一个中心化的事务协调器。


在我们的样例中,如图 4 所示,服务 A 使用分布式事务提交所有的变更到自己的数据库中,并且会提交一条消息到队列中,这个过程中不会出现消息的重复和丢失。类似的,服务 B 可以使用分布式服务来消费消息,并在同一个事务中提交至数据库 B,这个过程中也不会出现任何的重复数据。或者,服务 B 也可以选择不使用分布式事务,而是使用本地事务并实现幂等的消费者模式。在本节中,一个更合适的例子是使用 WS-AtomicTransaction 在一个事务中协调对数据库 A 和数据库 B 的写入,并完全避免最终一致性。但是,现在这种方式已经不太常见了。



跨数据库和消息代理的二阶段提交

两阶段提交架构优点和缺点


两阶段提交协议所提供的保障与模块化单体中的本地事务类似,但有些例外情况。因为这里有两个或更多的独立数据源参与到原子更新之中,所以它们可能会以不同的方式失败并阻塞整个事务。但是,由于存在一个中心化的协调者,相对于我下面将要讨论的其他方式,我们还是能够很容易地发现分布式系统的状态。


表 2:两阶段提交的优点和缺点

编排式


对于模块化单体来讲,我们会使用本地事务,这样我们始终能够知道系统的状态。对基于两阶段提交的分布式事务,我们也能保证状态的一致性。唯一的例外情况是事务协调者出现了不可恢复的故障。但是,如果我们想要减弱一致性的需求,而希望能够了解整个分布式系统的状态,并且能从一个地方对其进行协调,那么我们该怎么处理呢?


在这种情况下,我们可以考虑采取一种编排(orchestration)的方式,在这里,某个服务会担任整个分布式状态变更的协调者和编排者。编排者服务有责任调用其他的服务,直至它们达到所需的状态,或者在它们出现故障的时候执行纠正措施。编排者使用它的本地数据库来跟踪状态变更,并且要负责恢复与状态变更的所有故障。

实现编排式架构


编排式技术最流行的实现是 BPMN 规范的各种具体实现,比如jBPM和Camunda。对这种系统的需求并不会因为微服务或 Serverless 这样的极度分布式架构的出现而消失,相反,这种需求还会增加。为了证明这一点,我们可以看一下较新的有状态编排引擎,它们没有遵循什么规范,但是却提供了类似的有状态行为,比如 Netflix 的Conductor、Uber 的Cadence和 Apache 的Airflow。像 Amazon StepFunctions、Azure Durable Functions 和 Azure Logic Apps 这样的 Serverless 有状态函数也属于这个类别。还有一些开源库允许我们实现有状态的协调和回滚行为,如 Apache Camel 的Saga模式实现和 NServiceBus 的Saga功能。许多实现 Saga 模式的自定义系统也属于这一类。



编排两个服务的分布式事务


在我们的示例图中,我们让服务 A 作为有状态的编排者,负责调用服务 B 并在需要的时候通过补偿操作从故障中恢复。这种方式的关键特征是,服务 A 和服务 B 有本地事务的边界,但是服务 A 有协调整个交互流程的知识和责任。这也是为什么它的事务边界会接触到服务 B 的端点。在实现方面,我们可以使用同步的交互,就像上图所示,也可以在服务之间使用消息队列(在这种情况下我们也可以使用两阶段提交)。

编排式的优点和缺点


编排式是一种最终一致的方法,它可能会涉及到重试和回滚才能使分布式系统达到一致的状态。虽然避免了对分布式事务的需求,但是编排的方式要求参与的服务提供幂等的操作,以防协调者必须进行重试操作。参与的服务还必须要提供恢复端点,以防协调者决定执行回滚并修复全局状态。这种方式的最大优点是,能够仅通过本地事务就能驱动那些可能不支持分布式事务的异构服务达到一致的状态。协调者和参与的服务只需要本地事务即可,而且始终能够通过协调者查询系统的状态,即便它目前可能处于部分一致的状态。在下面我所描述的其他方式中,是不可能实现这一点的。

表 3:编排式的优点和缺点

协同式


从迄今为止的讨论中,我们可以看到,一个业务操作可能会导致服务间的多次调用,并且一个业务事务完成端到端的处理所需的时间是不确定的。为了管理这一点,编排式(orchestration)模式会使用一个中心化的控制器服务,它会告诉参与者该做什么。


编排式的一种替代方案就是 协同式(choreography) ,在这种风格的服务协调中,参与者在交换事件时没有一个中心化的控制点。在这种模式下,每个服务会执行一个本地事务并发布事件,从而触发其他服务中的本地事务。系统中的每个组件都要参与业务事务工作流的决策,而不是依赖一个中心化的控制点。在历史上,协同式方式最常见的实现就是使用异步消息层来进行服务的交互。图 6 说明了协同式模式的基本架构。



通过消息层进行服务协同化

具有双重写入的协同式


为了实现基于消息的服务协同,我们需要每个参与的服务执行一个本地事务,并通过向消息基础设施发布一个命令或事件,以触发下一个服务。同样的,其他参与的服务必须消费一个消息并执行本地事务。从本质上来讲,这就是在一个较高层级的双重写入问题中又出现了另一个双重写入的问题。当我们开发一个具有双重写入的消息层来实现协同式模式的时候,我们可以把它设计成跨本地数据库和消息代理的一个两阶段提交。在前面,我们曾经介绍过这种方式。另外,我们也可以采用 publish-then-local-commit 或 local-commit-then-publish 模式:

  • Publish-then-local-commit 我们可以先尝试发布一条消息,然后再提交本地事务。 虽然这种方案听起来不错,但是它有一些切实的挑战。 举例来说,在很多时候,我们需要发布一个由本地事务所生成的 ID,而这个 ID 此时还没有生成,因此无法发布。 另外,本地事务有可能会失败,但是我们无法回滚已经发布的消息。 这种方式缺乏“读取自己的写入”的语义,因此对于大多数场景来说,这并不是合适的方案。

  • Local-commit-then-publish 一个稍好一点的办法是先提交本地事务,然后再发布消息。 在本地事务提交之后和消息发布之前这里有很小的概率会出现故障。 但即便是出现这样的情况,你也可以把服务设计成幂等的并对操作进行重试。 这意味着会再次提交本地事务并发布消息。 如果你能控制下游的消费者并且确保它们是幂等的,那么这种方式就是行之有效的。 总体而言,这是一个很好的实现方案。

无双重写入的协同式

实现协同式架构的各种实现方式都限制每个服务都要通过本地事务写入到单一的数据源中,而不能写入到其他的地方中。我们看一下,如何在避免双重写入的情况下实现这一点。







请到「今天看啥」查看全文