译自:Distributed transaction patterns for microservices compared
作为Red Hat的顾问架构师,曾有幸参与过无数个客户项目。每个客户都存在各自的挑战,但我发现其中存在一定的共性。其中,客户最想了解的一件事情是如何在多个记录系统中协调写操作。解答这个问题通常需要耐心地解释双写、分布式事务、替代方案、可能的故障场景以及各个方式的缺点等等。这时候客户通常会意识到将一体式应用切分为微服务是一个漫长且艰难的过程,需要一定的取舍。
本文不会深入讨论事务系统,概括了在协调写入多个资源时会用到的主要方法和模式。过去,你可能对其中一种或多种方法有良好或不好的体验,但在合适的上下文、合适的限制条件下,这些方法都能够发挥其各自的优点。
双写问题
可以预见,在需要写入多个记录系统时可能会遇到双写问题。该需求可能不够明确,在分布式系统设计过程中可以以不同的方式来表达该需求,例如:
- 你已经为每个任务选择了合适的工具,现在需要更新NoSQL数据库、查询索引以及单个业务事务的缓存
- 你设计的服务需要更新其数据库,并向其他服务发送此次变更
- 你可能有跨多个服务边界的业务事务
- 由于用户会重试失败的调用,因此你不得不实现幂等服务操作
本文中使用了一个简单的场景来评估在分布式事务中处理双写的多种方式,该场景中,一个客户端应用会调用一个微服务。在图1中,A 服务需要更新其数据库,但同时需要调用B服务来执行一个写操作。在我们的讨论中,不关注数据库的类型以及服务到服务交互所使用的协议。
如果A服务写入数据库,然后向队列中给B服务发送通知(将这种方式称为本地提交-然后发布(local-commit-then-publish ) ),但这种方式有一定概率无法保证可靠性。当A服务写入其数据库,然后向队列发送消息,A服务有一定概率在提交后且发送消息前发送崩溃,导致系统处于不一致状态。如果在写入数据库前发送消息(将这种方式称为发布-然后本地提交( publish-then-local-commit)),但此时仍然有一定概率发生数据库写入失败或时序问题(在A服务提交到数据库前,B服务接收到了该事件)。上述两种场景都涉及对数据库和队列的双写,这也是下面需要探究的核心问题。在下面章节中,我将介绍几种方法来应对这种一直都存在的挑战。
一体式模块
将应用作为一体式模块进行开发,听起来可能在开架构演进的倒车,但实际上这种方式并没有什么问题。它不是微服务模式,但可以看作是微服务的例外,可以谨慎地与微服务组合在一起。当对强一致性的写入需求大于微服务的独立部署和扩展时,就可以考虑采用一体式模块架构。
使用一体式架构并不意味着系统不好或缺乏设计。顾名思义,它传达了使用一个开发单元、以模块方式进行设计的系统。注意,这是有意设计和实现的一体式模块,而非随时间意外导致的一体式的后果。在有目的性的一体式模块架构中,每个模块都会遵循微服务原则,每个模块都封装了所有对其数据的访问操作,并以内存方法调用的方式来暴露和消费操作。
一体式架构
使用这种方式,必须要将两个微服务(A服务和B服务)转化为可以部署到一个共享运行时的模块库。然后这两个微服务就可以共享相同的数据库实例。由于服务以库的形式部署到相同的运行时中,因此就可以让这两个服务参与到相同的事务中。由于模块共享相同的数据库实例,因此可以使用一个本地事务一次性提交或回滚所有操作。由于我们期望在更大规模的部署中以库来部署服务,并参与到现有的事务中,因此在部署方法上也会存在一定的差异。
即使在一体式架构中,也有办法隔离代码和数据。例如,可以将模块划分到不同的包、构建模块和源代码库中,并由不同的团队负责。可以根据命名规范、schemas、数据库实例或数据库服务器来对表进行分组,以此来隔离部分数据。图2描述了应用中不同的代码和数据隔离级别,灵感来自Axel Fontaine的主题演讲: 宏伟的一体式模块。
最后看下如何在一个现有的事务中加入一个运行时以及封装好的(可以使用其他模块的)服务。相比典型的微服务,所有这些限制使得模块之间的耦合更加紧密,但好处是,封装的服务可以启动一个事务,并调用模块来(在一个操作中)执行数据的更新、提交或事务回滚,而无需担心局部故障或最终一致性。
如图3所示,我们将A服务和B服务转换为模块,并部署到一个共享的运行时中(或使用其中的一个服务作为共享的运行时)。数据库表也共享了同一个数据库实例,但对表进行了分组隔离,并由不同的库服务管理。
一体式模块的优劣势
在一些行业中,该架构带来的好处要远比快速交付和快速变更更加重要。表1概括了一体式模块架构的优劣势:
表1:一体式模块的优劣势
优势 | 使用本地事务来保证数据一致性、读写一致性、回滚等,事务语义比较简单 |
劣势 | 1. 共享运行时下无法进行独立部署和模块扩展,且无法进行故障隔离 2. 数据库中的表的逻辑隔离性不强,后续可能会发展为一个共享的集成层 3. 需要在开发阶段协调模块的耦合性和共享事务上下文,这样增加了服务间的耦合性 |
举例 | 1. 运行时,如 Apache Karaf 和 WildFly,它们允许模块化和动态部署服务 2. Apache Camel的 direct 和direct-vm 组件,它们允许通过内存调用暴露操作,并支持通过JVM进程保留事务上下文3. Apache Isis是一个很好的一体式模块架构的例子。它通过为你的Spring Boot 应用自动生成UI和REST API来支持领域驱动的应用开发 4. Apache OFBiz是另一个一体式模块和面向服务的架构(SOA)的例子。它是一个全面的企业资源规划系统,拥有数百个表格和服务,可以自动化企业业务流程,其模块化架构可以让开发者快速理解并进行定制。 |
分布式事务通常是最后的手段,用于:
- 当写入不同的资源时无法达到最终一致性
- 当需要写入各种各样的数据源
- 当需要处理一次性消息,但无法对系统进行重构来实现幂等的操作
- 当集成了实现二阶段提交规范的第三方黑盒系统或遗留系统
在上述场景中,如果不考虑可扩展性,我们可能会使用考虑分布式事务。
实现二阶段提交架构
二阶段提交需要一个分布式事务管理器(如Narayana),以及一个可靠的存储层来保存事务日志。你可能会用到可参与分布式事务的(带相关XA驱动的)兼容DTP XA的数据源,如RDBMS、消息代理和缓存等。如果正好有一个可用的数据源,但运行在一个动态环境中,如kubernetes,你还需要一个类operator的机制来保证只能存在一个分布式事务管理器。事务管理器必须是高可用的,且能够一直访问事务日志。
在实现时,可以参考用了kubernetes有状态模式的Snowdrop Recovery Controller,它实现了单例模式,并使用PV来保存事务日志。这种类型中,还可以为SOAP web服务引入如Web Services Atomic Transaction这样的规范。这些技术的共同点是它们都实现了XA规范,并有一个中央事务协调器。
图4中,A服务使用分布式将所有的变更提交到其数据库,然后将消息发送到一个队列,期间不会有消息重复或消息丢失。类似地,B服务使用分布式事务(在一条事务中)来消费消息并提交到数据库B,且不会有数据重复。或者B服务可以不使用分布式事务,转而使用本地事务,并实现幂等消费模式。更正式一点,可以在一条事务中使用WS-AtomicTransaction协调A数据库和B数据库的写入,从而避免最终一致性,但目前这种方式比较少见。
二阶段提交的优劣势
二阶段提交协议提供了类似一体式模块中的本地事务保证,但也有例外。由于原子更新中涉及到两个或多个不同的数据源,数据源可能因各种原因产生故障或阻塞事务。但由于中央协调器的存在,相比其他方式(后面讨论),可以方便地发现分布式系统的状态。
表2:二阶段提交的优劣势
优势 | 1:标准方式,使用开箱即用的事务管理器以及数据源 2:强数据一致性 |
劣势 | 1:可扩展性限制 2:当事务管理器故障时可能会导致恢复失败 3:支持的数据源有限 4:动态环境中需要存储和单例模式 |
举例 | 1: Jakarta Transactions API 2:WS-AtomicTransaction 3:JTS/IIOP 4:eBay’s GRIT 5:Atomikos 6:Narayana 7:消息代理,如Apache ActiveMQ 8:实现了XA规范的关系型数据源,内存数据库如Infinispan |
编制(Orchestration)
一体式模块中,使用本地事务来了解系统的状态。而在基于二阶段提交协议的分布式事务中,需要保证状态的一致性,唯一例外是当事务协调器故障时可能会发生无法恢复的失败。但如果我们想降低一致性需求,同时仍然需要了解整体分布式系统的状态并从一个地方进行协调,这时可以考虑使用编制模式,使用其中一个服务作为协调器和整体分布状态变更的协调者。编制器服务负责调用其他服务,直到达到期望的状态或在故障时采取正确的动作,编制器使用它的本地数据库来跟踪状态变更,并负责恢复与状态变更有关的故障。
实现编制架构
最有名的编制技术的实现是BPMN规范实现,如 jBPM 和Camunda项目。对这类系统的需求并没有随着分布式架构(如微服务或无服务)而消亡,反而在增加。可以看下最新的有状态编制引擎,它们并没有遵循这类规范,但却提供了相似的有状态行为,如Netflix的Conductor, Uber的Cadence, 和 Apache的Airflow。无服务有状态功能,如Amazon StepFunctions, Azure Durable Functions, 和Azure Logic Apps都属于这类。此外还有很多开源库,可以帮助实现有状态协调和回滚行为,如Apache Camel的Saga 模式实现和NServiceBus Saga
图5展示了将A服务作为有状态协调器,负责调用B服务,并在需要时通过补偿操作执行故障恢复。这种方法的关键特性是A服务和B服务都有本地事务边界,但A服务了解并负责编制整体交互流程,这也是为什么其边界会涉及B服务的后端。在实现方面,可以设置同步交互(如图所示),或在服务间使用消息队列(这种情况下也可以使用二阶段提交)。
编制的优劣势
编制是一种可能会涉及重试和回滚来让分布式系统达到最终一致状态的方法,编制要求参与的服务能够提供幂等操作来让协调器重试某个操作。参与的服务必须提供可恢复的后端,这样协调器可以通过回滚来恢复整体状态。这种方式的最大好处是能够通过本地事务让可能不支持分布式事务的各种服务达到一致性状态。协调器和参与的服务仅需要本地事务,且总能够通过查询协调器了解到系统的状态(即使是部分一致的状态)。
表3:编制的优劣势
优势 | 1. 在各种分布式组件中协调状态 2. 不需要XA事务 3.可以在协调器层面了解到分布式状态 |
劣势 | 1. 复杂的分布式编程模型 2. 参与的服务可能要提供幂等补偿操作 3. 最终一致性 4. 补偿操作也可能无法执行故障恢复 |
举例 | 1. jBPM 2. Camunda 3. MicroProfile Long Running Actions 4. Conductor 5. Cadence 6. Step Functions 7. Durable Functions 8. Apache Camel Saga pattern implementation 9. NServiceBus Saga pattern implementation 10. The CNCF Serverless Workflow specification 11. Homegrown implementations |
编排(Choreography)
可以看到,到目前为止,单个业务操作可能会涉及多个服务间的调用,且端到端的业务事务处理并没有明确的时间。为了处理这种场景,编制模式使用一个中央控制器服务来告诉参与者应该做什么。
编制的替代方案是编排,它也是一种服务协调模式,但不需要中央控制点来协调参与者之间的事件交互。这种模式下,每个服务会执行本地事务,然后发布事件并触发其他服务的本地事务。由系统中参与的每个组件决定业务事务的工作流(而不会依赖中央控制点)。在过去,服务间交互时经常会使用异步消息层来实现编排方式。图6展示了编排模式架构。
编排下的双写
为了让基于消息的编排能够正常工作,每个参与的服务需要执行一个本地事务并通过向消息设施中发布命令或事件来触发下一个服务。类似地,其他参与的服务需要消费消息并执行本地事务,其本身就是在更高级别的双写问题中的双重写问题。当通过开发一个带双写的消息层来实现编排方式时,需要将其设计为一个跨本地数据库和消息代理的二阶段提交,或者可以使用 分布-然后本地提交 或 本地提交-然后发布 的模式:
- 发布-然后本地提交:首先尝试发布一个消息,然后提交到本地事务。虽然这种方式听起来不错,但实际中会有很多挑战。例如,你可能需要发布一个本地事务提交时生成的ID,但这种方式下无法首先获取到这个ID。且本地事务可能会失败,但无法回滚已发布的消息。这种方式缺乏读写一致语义,大多数场景下并不适用。
- 本地提交-然后发布:首先提交到本地事务,然后发布消息。这种方式在本地事务提交之后且消息发布前有很小的概率会出现故障。但即使这样,你也可以通过让服务实现幂等和重试来解决这种问题,即重新提交本地事务并发布消息。如果你可以控制下游消费者并使其幂等时,就可以考虑使用这种方式(同时也是一个不错的选项)。
无双写的编排
各种实现了编排的架构都会限制每个服务只能用本地事务写入单个数据源。下面看下如何在无双写场景下工作。
假设A服务接收到请求,并写入A数据库。B服务周期性轮询服务A并检测新的变更。当它读取到变更时,B服务会使用此次变更更新其数据库以及对应的索引或时间戳。此时两个服务仅会使用本地事务写入各自的数据库并进行提交。图7展示了这种方式,也可以称为服务编排,或称之为使用了良好的旧数据流水线。
最简单的场景下,B服务会连接到A服务的数据库,并读取由A服务负责的表。业界会尝试使用共享表来避免这种耦合,但这种情况下,任何A服务的实现变更都有可能会影响到B服务。我们可以对这种场景做稍许优化,如使用发件箱模式,给A服务分配一张表,作为公共接口。这张表仅包含B服务需要的内容,且易于查询和跟踪变更。如果还不够好,可以让B服务通过API管理层来查询变更(而不通过直接连接A数据库)。
从根本上讲,上述方式都有相同的缺点:B服务必须要不断轮询A服务。这样会导致不必要的、持续的系统负载以及在获取变更时的不必要的延迟。通过轮询微服务来获取变更并不简单,下面看下如何来优化这种架构。
带Debezium的编排
一种提升编排架构的方式是使用像Debezium这样的工具,这样就可以使用A数据库的事务日志来捕获数据变更(CDC)。图8展示了这种方式。
Debezium可以监控数据库的事务日志,并向一个Apache Kafka topic中投递相关的变更。使用这种方式时,B服务只需要监听topic中的普通事件,而无需轮询A服务的数据库或使用APIs。取消使用轮询数据库的方式来获取变更流,并在服务间引入队列,使得分布式系统更可靠、可扩展,并为后续在新场景中引入新客户提供了可能性。使用Debezium 为基于编制或编排的Sagq模式实现了发件箱模式。
这种方式的副作用是B服务可能会接收到重复的消息。可以通过在业务逻辑层实现幂等或通过去重器(如Apache ActiveMQ Artemis的消息去重探测或Apache Camel的幂等消费模式)来解决。
带事件源的编排
事件源是另一种服务编排实现。这种方式下,会使用一系列状态变更事件来保存一个实体的状态。当实体更新时,不会更新实体的状态,而会将新事件附加到事件列表中。将新事件附加到事件存储是一个在本地事务中完成的原子操作。这种方式的好处是事件存储的行为类似消息队列,可以为其他服务提供事件消费的能力。
在我们的例子中,当转为使用事件源时,需要将客户请求存储到一个仅支持附加的事件存储中。A服务可以通过回放事件来修复当前状态。事件源也需要允许B服务订阅这些事件。使用这种机制,A服务可以将其存储层作为与其他服务的交互层。这种方式非常简洁,并解决了状态变更时可靠发布事件的问题,它引入了一种新的、很多开发者不熟悉的编程风格,并为状态恢复和消息压缩上带来了额外的复杂度,需要特定的数据存储。
编排的优劣势
除了用于检索数据变更的机制,编排方式还解耦了写操作,允许独立扩展服务,并提升了整体系统的可靠性。这种方式的缺点是使用了去中心化的决策流,且很难发现全局的分布式状态。如果要在大规模服务中发现查询了多个数据源的请求状态可能会比较困难。
表4:编排的优劣势
优势 | 1. 实现和交互解耦 2. 不需要事务协调器 3. 提升了扩展性和恢复能力 4. 近实时交互 5. 使用Debezium或类似工具时系统的开销比较小 |
劣势 | 1. 系统的全局状态和协调逻辑分散到了所有参与者中 2. 最终一致性 |
举例 | 1. Homegrown database or API polling implementations. 2. The outbox pattern 3. Choreography based on the Saga pattern 4. event sourcing 5. Eventuate 6. Debezium 7. Zendesk's Maxwell 8. Alibaba's Canal 9. Linkedin's Brooklin 10. Axon Framework 11. EventStoreDB |
并行流水线
编排模式中没有中心点来请求系统状态,但服务的状态会在分布式系统中进行传播。编排创建了一系列用于处理服务的流水线,因此当一个消息达到一个整个流程中的特定的步骤时,说明它已经完成了前面的步骤。但如果我们解除这个限制并独立处理所有的步骤会怎么样?这种场景下,B服务可能会直接处理一个请求,而不关心该请求是否已经被A服务处理。
在并行流水线中,我们增加了一个路由服务来接受请求,并在单个本地事务中通过消息代理将其转发到A服务和B服务。从这步开始,两个服务都可以独立且并行处理请求。
这种模式很容易实现,它仅适用于服务之间没有时间绑定的情况。例如,无论A服务是否处理了相同的请求,B服务都可以处理该请求。而且,这种方式需要一个额外的路由服务或一个同时了解A服务和B服务的客户端来转发消息。
Listen to yourself
Listen to yourself是一种轻量替代方式,其中一个服务作为路由器。使用这种替代方式,当A服务接收到一个请求时,不需要将其写入数据库,只需要将该请求发布到消息系统,最终该消息会转发给B服务和A服务本身。图11 展示了这种模式。
未写入数据库的原因是避免双写,一旦一个消息进入消息系统,后续会将该消息发送给B服务,且可以在一个完全隔离的事务上下文中,将消息反送给A服务。随着加处理流程的扭曲,A服务和B服务可能会独立处理请求并写入各自的数据库。
并行流水线的优劣势
表5:并行流水线的优劣势
优势 | 简单,并行处理下的可扩展架构 |
劣势 | 需要解耦服务间的时间绑定,且难以了解到全局系统状态 |
举例 | Apache Camel的multicast 和splitter(并行处理) |
如何选型分布式事务策略
正如你看到的,在微服务架构中处理分布式事务时并不存在正确或错误的模式。每种模式都有其优劣势。每种模式都解决了一些问题,但同时又引入了其他问题。图12给出了上文讨论过的双写模式下的主要特性。
不管选择那种方式,你需要解释和记录决策背后的动机以及对选择的长期架构后果负责,还可能需要从实施和维护系统从团队中获得支持。图13给出了根据其数据一致性和可扩展性属性得出的评估结果。
下面根据可扩展性和可用性从高到低对各种方法进行评估。
高:并行流水线和编排
如果你的步骤暂时是解耦的,那么可以选择并行流水线方法来运行这些步骤。你可以在系统的某一部分(而不是整个系统)中采用这种模式。下面,假设处理步骤中存在时间耦合,且特定操作和服务必须以一定顺序执行,此时你可能会考虑使用编排方式。使用服务编排,可以创建一个可扩展的、事件驱动架构,消息在去中心化的编排流程中流转。这种场景下,可以使用Debezium 和 Apache Kafka来实现发件箱模式。
中:编制和二阶段提交
如果编排不合适,你可能需要一个中央点来负责协调和做出决策,此时可以考虑编制。这是一个比较流行的架构,可以使用标准的和自定义开源实现。但标准的实现可能会强制你使用特定的事务语义,使用自定义的编制实现可以在期望的数据一致性和可扩展性之间进行权衡。
低:一体式模块
到这一步,说明你可能对数据一致性有非常强的要求。在这种情况中,使用二阶段提交的分布式事务可以在某些特定数据源下工作,但它们很难在(为可扩展性和高可用性设计的)动态云环境上保证可靠性。此时,你可能会使用一体式模块方式,这种方式保证的数据的高度一致性,但运行时和数据源是耦合的。