微服务下分布式事务模式的详细对比
作为 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:一个稍好一点的办法是先提交本地事务,然后再发布消息。在本地事务提交之后和消息发布之前这里有很小的概率会出现故障。但即便是出现这样的情况,你也可以把服务设计成幂等的并对操作进行重试。这意味着会再次提交本地事务并发布消息。如果你能控制下游的消费者并且确保它们是幂等的,那么这种方式就是行之有效的。总体而言,这是一个很好的实现方案。
实现协同式架构的各种实现方式都限制每个服务都要通过本地事务写入到单一的数据源中,而不能写入到其他的地方中。我们看一下,如何在避免双重写入的情况下实现这一点。
假设服务 A 接收到一个请求并要对数据库 A 进行写入操作,除此之外不再操作其他的数据源。服务 B 周期性地轮询服务 A 并探测新的变更。当它读取到变更时,服务 B 会基于变更更新自己的数据库,并且会更新索引或时间戳来标记获取到了变更。这里的关键在于,这两个服务只对自己的数据库进行写入操作,并以本地事务的形式进行提交。如图 7 所示,这种方式可以描述为服务协同(service choreography),或者我们也可以用非常古老的数据管道的术语来对其进行描述。至于可供选用的实现方案就更有趣了。
通过轮询实现的服务协同
对于服务 B 来说,简单的场景就是连接到服务 A 的数据库并读取服务 A 的表。但是,业界会尽量避免共享数据表这种级别的耦合,原因在于:服务 A 的实现和数据模型的任意变更都可能干扰到服务 B。我们可以对这种场景做一些改进,例如使用发件箱(Outbox)模式,为服务 A 提供一个表作为公开接口。这个表可以只包含服务 B 所需的内容,它可以设计得易于查询和跟踪变更。如果你觉得这还不够好的话,进一步的改进方案是让服务 B 通过 API 管理层查询服务 A 的所有变化,而不是直接连接数据库 A。
从根本上来讲,所有的这些变种形式都有一个相同的缺点:服务 B 需要不断地轮询服务 A。这种方式会给系统带来不必要的持续负载,或者在接收变更时存在不必要的延迟。轮询微服务的变更并不是常见的做法,那么我们看一下如何进一步改善这个架构。
在改进协同式架构时,有一种方式很有吸引力,那就是引入像 Debezium 这样的工具,它使用数据库 A 的事务日志执行变更数据捕获(change data capture,CDC)。这种方式如图 8 所示。
通过变更数据捕获实现的服务协同
Debezium 可以监控数据库的事务日志,执行必要的过滤和转换,并将相关的变更投递到 Apache Kafka 的主题中。这样的话,服务 B 就可以监听主题中的通用事件,而不是轮询服务 A 的数据库或 API。我们通过这种方式,将数据库轮询转换成了流式变更,并且在服务间引入了一个队列,这样会使得分布式系统更加可靠、可扩展,而且为新的使用场景会引入其他消费者提供了可能性。Debezium 提供了一种优雅的方式来实现发件箱模式,能够用于基于编排式和协同式的 Saga 模式实现。
这种方式的一个副作用在于,服务 B 有接收到重复消息的可能性。这可以通过实现幂等的服务来解决,可以在业务逻辑层面来解决,也可以使用技术化的去重器(deduplicator,比如 Apache ActiveMQ Artemis 的重复消息探测或者 Apache Camel 的幂等消费者模式)。
事件溯源(event sourcing)是另外一种服务协同的实现模式。在这种模式下,实体的状态会被存储为一系列的状态变更事件。当有新的更新时,不是更新实体的状态,而是往事件的列表中追加一个新的事件。往事件存储中追加新的事件是一个原子性的操作,会在一个本地事务中完成。如图 9 所示,这种方式的好处在于,对于消费数据更新的其他服务来讲,事件存储的行为也是一个消息队列。
通过事件溯源实现的服务协同
在我们样例中,如果要转换成使用事件溯源的话,要把客户端的请求存储在一个只能进行追加操作的事件存储中。服务 A 可以通过重放(replay)事件重新构建当前的状态。事件存储需要让服务 B 也订阅相同的更新事件。通过这种机制,服务 A 使用其存储层作为与其他服务的通信层。尽管这种机制非常整洁,解决了当有状态变更时可靠地发布事件的问题,但是它引入了一种很多开发人员所不熟悉的编程风格,并且围绕状态重建和消息压缩,会引入额外的复杂性,这需要专门的存储。
不管使用哪种方式来检索数据变更,协同式的模式都解耦了写入,能够实现独立的服务可扩展性,并提升系统整体的弹性。这种方式的缺点在于,决策流是分散的,很难发现全局的分布式状态。要查看一个请求的状态需要查询多个数据源,这对于服务数量众多的场景来说是一个挑战。表 4 总结了这种方式的优点和缺点。
表 4:协同式的优点和缺点
在协同式模式中,没有一个中心化的地方可以查询系统的状态,但是会有一个服务的序列,以便于在分布式系统中传播状态。协同式模式创建了一个处理服务的序列化管道,所以我们能够知道当一个消息到达整个过程的特定步骤时,它肯定已经通过了前面的所有步骤。如果我们能够放松这个限制,允许独立地处理这些步骤的话,情况又会怎样呢?在这种场景下,服务 B 在处理一个请求的时候,根本不用关心服务 A 是否已经处理过它。
在并行管道的方式中,我们会添加一个路由服务,该服务接收请求,并在一个本地事务中通过消息代理将请求转发至服务 A 和服务 B。如图 10 所示,从这个步骤开始,两个服务可以独立、并行地处理请求。
通过并行管道进行处理
尽管这种模式很容易实现,但是它只适用于服务之间没有时间约束的场景。例如,服务 B 不管服务 A 是否已经处理过该请求,它都能够对请求进行处理。同时,这种方式需要一个额外的路由服务,或者客户端知道服务 A 和服务 B,从而能够给它们发送消息。
这种方式有一种轻量级的替代方案,被称为“监听自身(listen to yourself)”模式,在这里,其中有个服务会同时担任路由。在这种替代方式下,当服务 A 接收到一个请求时,它不会写入到自己的数据库中,而是将请求发送至消息系统中,而消息的目标是服务 B 以及服务 A 本身。图 11 阐述了这种模式。
监听自身模式
在这里,不写入数据库的原因在于避免双重写入。当进入消息系统之后,消息会在完全独立的事务上下文中进入服务 B,也会重新返回服务 A。通过这样一个曲折的处理流程,服务 A 和服务 B 就可以独立地处理请求,并写入到各自的数据库中了。
表 5:并行管道的优点和缺点
从本文的论述中,你可能已经猜到,在微服务架构中,处理分布式事务并没有正确或错误的模式。每种模式都有其优点和缺点。每种模式都能解决一些问题,但是反过来又会产生其他的问题。图 12 中的图表简单总结了我所阐述的各种双重写入模式的主要特征。
双重写入模式的特征
不管你采用哪种方式,都要阐述和记录决策背后的动机,以及该选择在架构上所带来的长期影响。你还需要得到从长期实现和维护该系统的团队那里获取支持。在这里,我根据数据一致性和可扩展性特征来组织和评估本文所描述的各种方法,如图 13 所示。
各个双重写入模式的数据一致性和可扩展性特征
我们从可扩展性强、可用性高的方法到可扩展性差、可用性低的顺序来评估各种方法。
如果你的步骤在时间上是解耦的,那么采用并行管道的方法来运行是很合适的。有可能你只能在系统的某些部分使用这种模式,而不是在整个系统中。接下来,假设步骤间存在时间方面的耦合性,特定的操作和服务必须要在其他的服务前执行,那么你可以考虑采用协同式的方式。借助协同式的服务,我们可以创建一个可扩展的、事件驱动的架构,在这里消息会通过一个去中心化的协同化过程在服务和服务之间流动。在这种情况下,使用 Debezium 和 Apache Kafka 的发件箱模式实现(如 Red Hat OpenShift Streams for Apache Kafka)特别有趣,而且越来越受欢迎
如果协同式模式不是很合适,你需要一个负责协调和决策的中心点,那么可以考虑采用编排式模式。这是一个流行的架构,有基于标准的和自定义的开源实现。基于标准的实现可能会强迫你使用某些事务语义,而自定义的编排式实现则允许你在所需的数据一致性和可扩展性之间进行权衡。
如果你沿着图示再往左走的话,那么很可能你对数据一致性有非常强烈的需求,而且对它所需的重大权衡有充分的思想准备。在这种情况下,针对特定数据源,通过两阶段提交的分布式事务是可行的,但是在专门为可扩展性和高度可用性设计的动态云环境中,它很难可靠地实现。如果是这样的话,那么你可以直接采用比较老式的模块化单体方式,同时伴以从微服务运动中学到的实践。这种方式可以确保高的数据一致性,但代价是运行时和数据源的耦合。
结论
在具有数十个服务的大型分布式系统中,并不会有一个适用于所有场景的方式,我们需要将其中的几个方法结合起来,应用于不同的环境中。我们可能会将几个服务部署在一个共享的运行时上,以满足对数据一致性的特殊需求。我们可能会选择两阶段的提交来与支持 JTA 的遗留系统进行集成。我们可能会编排复杂的业务流程,并让其余的服务使用协同式模式和并行处理。总而言之,你选择什么策略并不重要,重要的是基于正确的原因,精心选择一个策略,并执行它。
相关文章