作者 | Anjana Fernando
策划 | 田晓旭
当从一个单体系统转向微服务架构(microservice architecture, MSA)时,处理分布式系统带来的复杂性是一个挑战。事务处理是其中的首要核心问题。在一个 Web 应用程序中使用本地事务完成的典型数据库事务,现在是一个复杂的分布式事务问题。在本文中,我们将讨论造成这种情况的原因、可能的解决方案以及使用 MSA 开发安全事务性软件系统的最佳实践。
1数据库事务:入门
在我们旧的单体应用中,我们用数据库事务来实现全部或全无的数据操作,同时保证数据一致性。我们主要使用 ACID 事务,这些可以在关系型数据库系统中见到。下面是快速回顾:
我们可以想象使用如下所示的 ACID 事务,将资金从一个人的账户转移到另一个人的账户。
SQL
BEGIN TRANSACTION
UPDATE ACCOUNT SET BALANCE=BALANCE-AMOUNT WHERE ID=1;
UPDATE ACCOUNT SET BALANCE=BALANCE+AMOUNT WHERE ID=2;
END TRANSACTION
这里,我们将个体的借贷操作包装在一个 ACID 事务中。这避免了不一致的情况,例如,如果资金从一个人的账户中取出,但没有存入另一个人的账户中,就会从系统中丢失。这是一个清晰、直接的解决方案,我们将在需要时继续编写这样的代码。
我们习惯于在任何需要的时候使用 ACID 事务。对于处理需求被保存在单个数据库服务器的大部分典型用户而言,这个模型很好。但是对于需要随着数据访问、存储性能和读写规模等需求的增长而扩充系统的用户而言,这种架构很快就会崩溃。对于这些用户,有两种方法来扩展数据存储:
在水平扩展中,一个数据存储拥有一个集群中的多个节点,事情就会变得有点儿复杂。由于数据驻留在物理隔离的服务器上,因此出现了一系列新的挑战。CAP 定理解释了这一点,在分布式数据存储中以下属性只可以实现其中两点。
根据这一点,不可能同时拥有一致性、可用性和分区容差。如果我们考虑可能发生的情况,我们就能直观地理解这种行为。
为了在写入数据时保持一致性,我们需要向所有副本服务器同时写入数据。然而,如果网络中有分区,我们就不能这么做,因为我们那时候只能访问某些服务器。在那种情况下,如果我们想要在保持一致性的同时容忍这些分区,我们就不能让用户从数据存储中读取这些不一致的数据。那意味着我们需要停止响应用户请求,使数据存储不再可用(一致性和分区容差)。
另一个场景会使得数据存储继续工作,即保持数据存储的可用性,但会使得数据存储不再保持一致性。(可用性和分区容差)。
最后一种场景是系统不能容忍分区,使得可以保持一致性和可用性。在这种情况下,为了拥有很强的一致性(线性化),我们仍然需要使用诸如两段提交(2PC)等事务协议来执行不同副本数据库服务器节点之间的数据操作。在 2PC 中,参与的数据库的操作是由一个事务协调器以如下两段执行的:
除了上述用于伸缩性的数据库副本场景,2PC 还用于在不同类型的系统(如数据库服务器和消息代理)之间执行事务。然而,我们通常避免使用 2PC,因为在分布式参与者中增加锁争用,会导致性能低下并妨碍伸缩性。
实际上,计算机网络是不可靠的,我们应该预期它们会在某个时间存在网络分区。因此,我们通常看到数据库系统优先考虑可用性或一致性,即 AP 或 CP。一些系统允许用户调整这些参数,来使其获得高可用性或者选择一致性级别。这在 Amazon 的 DynamoDB 和 Apache Cassandra 的数据库系统中已有提供。然而,它们通常被归类为 AP 系统,因为它们没有严格的 CAP 级别的一致性。Cassandra 的轻量事务支持使用 Paxos 共识协议来实现线性化的一致性,但是这很少使用,因为它的机制会导致非常低的性能,这是可以预见的。
2可伸缩性对数据一致性的影响
当我们研究 CAP 定理的权衡时,如果我们重视高可用性和高性能与可伸缩性,那我们就必须在数据一致性上做出妥协。CAP 的数据一致性影响 ACID 的隔离性。如果分布式系统中的节点之间的数据不一致,这意味着存在事务隔离问题,而且我们会看到脏数据。因此,在这种情况下,我们必须接受现实,找到一种没有 ACID 事务和完全的 CAP 一致性的工作方式。
这种包含最终一致性的事务一致性模型也被称为 BASE(BasicallyAvailable,Soft State,andEventually Consistent),它促进了拥有最终一致性的可用性。软状态意味着,数据可能为了最终一致性而在之后变化。大多数 NoSQL 数据库遵循这种方案,它们不提供任何 ACID 事务功能,而是聚焦于可伸缩性。
在许多案例中,最终一致性是可以接受的,因为没有要求严格的数据一致性。例如,域名系统(DNS)就是基于一个最终一致性模型。许多中间缓存包含 DNS 条目。如果某个人更新了一个 DNS 条目,这些条目不会被立即更新,而是在本地条目的缓存超时之后才做 DNS 查询。由于 DNS 条目的更新并不频繁,为每个名称解析执行新的 DNS 查询是一种过度操作,而且会成为网络性能的主要瓶颈。因此,在 DNS 中有一条过时的条目对于用户来说是可以容忍的。同样地,在许多其它现实场景中,我们也会这么做。我们将实施其它解决方法来检测这类过时数据或者不一致性,并在那时采取适当的措施,而不是悲观地使所有操作都完全一致,从而导致性能严重受损。
在我们的分布式数据存储场景中,一致性的级别取决于我们正在实现的案例。让我们再详细了解下各种一致性级别,从最强的一致性到最弱的一致性。
严格的可序列化
在严格的可序列化中,多个对象操作应该在所有副本中原子性地发生,同时保持实时顺序。实时顺序意味着,对于每个人都共享的全球时钟来说,客户端执行操作的顺序相同。这些分组的对象操作表示单独的事务。
这是实现 ACID 事务的隔离性方面所必需的。因此,如果我们需要工作负载具有 ACID 事务中的典型行为,那么在我们的分布式数据存储中就需要严格的可序列化。
线性化
在这种一致性模型中,单个对象在所有副本中的操作应该是原子的。当一个客户端看到在一个副本中完成了一个操作,任何连接到其它副本中的客户端应该看到相同的操作。另外,发生在对象上的操作的顺序,所有客户端看到的顺序应该相同,类似于相同的实时排序。
什么时候需要这种模型?假设我们有三个客户端或进程。进程 A 向数据存储写入了一个对象值。进程 B 接收到一些外部事件,提示它读取前面所说的对象值。在读取这个值之后,它向进程 C 发送了一条消息也去读取这个值并进行决策。进程 B 认为,它读取的对象值至少是它所拥有的最新值,而不是一个比较旧的值。为了确保这种行为,分布式数据存储必须提供线性化一致性保证,从而确保进程 A 所做的更新会立即被所有其它副本同时看到。
即使我们配置 Cassandra 数据库为使用基于仲裁的读 / 写方案的强一致性,它也不能提供线性化保证,因为这些更新在副本中不是原子化发生的。为了拥有线性化,我们必须使用 Cassandra 中轻量化的事务支持。
顺序一致性
在顺序一致性中,一个进程对数据存储所做的操作也会在其它进程中以相同顺序发生。此外,所有操作的顺序都会与每个进程操作的相对顺序一致。基本上,它在操作的整体顺序中保留了进程级的顺序。
因果一致性
在因果一致性中,任何潜在的因果相关的操作都应该以相同的顺序对所有进程可见。简单的说,如果你基于一个先前观察到的单独操作执行了一个操作,那么对于其它进程,这些操作的顺序也应该相同。
为了满足因果一致性,应该支持以下行为:
这是一个非常有用的一致性模型,适用于许多实际的应用程序。例如,我们以一个由分布式数据存储支持的社交媒体网站为例。我们有并发用户与网站交互,更新他们的个人资料状态或者评论某个人的个人资料状态。用户 Anne,刚遇到了一个小事故,将状态设置为“遇到一个小事故,等待 X 光检查结果!”。她刚更新了这条状态,她就得到了她的检查结果,没有骨折。因此她将她的状态更新为“好消息:没有骨折!”。Bob 看到了 Anne 的最后一条消息,回复她“太好了!:)”。
在上述场景中,如果我们的数据存储提供至少因果一致性,所有其它用户会以正确的顺序看到来自 Anne 和 Bob 的消息。但是如果数据存储没有提供因果一致性,有可能其它人会看到 Anne 的第一条消息和 Bob 的消息,而没有看到 Anne 的第二条更新。那种情况就变得有点儿奇怪,好像 Bob 在对 Anne 的不幸感到幸灾乐祸,而其实不是这样的。因此,我们需要在类似这样的场景中具有因果一致性。
所以假设我们有一个具有因果一致性的数据存储,在相同情况下,假设 Tom 将他的状态更新为“我刚刚得到了我的第一辆车!”,就在 Anne 更新她的第一条状态之前。网站的一些用户在 Anne 的第一条信息之后看到他的状态。这种情况没有问题,因为 Tom 的更新发生在 Anne 的更新之前或之后在现实生活中并不重要。它们之间没有联系,即 Tom 的行为不是由 Anne 的行为导致的。其它没有因果关系的操作以最终一致性的行为操作。
一个支持因果一致性的分布式数据存储就是 MongoDB。它是基于 Lamport 逻辑锁实现的。
最终一致性
在一般的最终一致性中,如果不再向数据存储中写入数据,则数据存储中的所有副本都要聚合并最终达成一致。它不会提供任何其它保证,比如在最终值稳定之前的因果一致性。
实际上,这种一致性模型也适用于这样的场景:只有并发值更新,但这些值更新之间没有联系;用户并不关心中间值,只关心最终得到的稳定值。例如,以一个为每个城市发布当前气温的网站为例。这些值会不时变化。在某个时间点,一些用户可能会查看最新的气温值,而其它用户的值还没有更新。然而,最终,这个网站的所有用户都会得到更新。因此,存储这些值的分布式数据库的可能的传播延迟并不是一个大问题,只要最终所有的用户都会看到相同的气温值。
有关事务一致性模型的更多深入信息,请查看文末的资源章节。
现在,我们对于事务处理和一致性模型相关的方面有了一些基本的理解。当你在任何分布式处理环境,例如 MSA,工作时,这种理解就很有用。现在,我们来看看如何在 MSA 中进行数据建模。
3微服务架构中的数据建模
微服务的一个基本需求是高内聚和低耦合。这是自然需要的,因为一个开发团队的组织结构也会围绕这个理念构建。将会有单独的团队负责微服务,他们需要独立于其它团队的灵活性和自由度。这意味着,他们可以避免在设计和实现的内部细节方面与其它团队进行任何不必要的同步。
有了这些需求,微服务就不应该共享数据库。如果每个微服务不能拥有其自己的数据库,那么表示这些微服务需要被合并。
下面展示了一个电商后端的可能的微服务设计。
这里,我们用系统每个部分自己的微服务进行管理。这看起来很不错,直到我们需要处理事务。一个典型的操作包括创建一个用户订单,包含一组产品。使用库存服务(inventory service)检查过这些产品的可用性,在订单完成之后,库存会更新,减少那些产品的可用库存。在一个典型的单体应用中,你可以在单个 ACID 事务中执行如下操作。
SQL
BEGIN TRANSACTION
CHECK INVENTORY OF PRODUCT 1 FOR 5 ITEMS
CHECK INVENTORY OF PRODUCT 2 FOR 10 ITEMS
CREATE ORDER
ADD 5 ITEMS OF PRODUCT 1 TO ORDER
ADD 10 ITEMS OF PRODUCT 2 TO ORDER
DECREMENT INVENTORY OF PRODUCT 1 BY 5 ITEMS
DECREMENT INVENTORY OF PRODUCT 2 BY 10 ITEMS
INSERT PAYMENT
END TRANSACTION
在这个方案中,我们确信数据存储在数据操作之后会保持一致的状态。但现在,我们如何使用我们的微服务对上述操作进行建模?可能想到的一种解决方案如下。
在这里,一个协调器服务“Admin”通过调用每个服务的操作创建了一个服务编排。如果所有操作都没有问题地执行,这是可行的。但很有可能,这个流程中的某一步可能失败,例如当用户没有足够的额度或者网络通信失败时发生应用程序错误。例如,如果这个流程因为用户管理服务对支付处理服务不可用而导致失败,步骤 4 就会失败。但此时,我们已经创建了一个订单并更新了库存。所以,现在我们的系统中出现了不一致的状态,我们的库存报告的商品数量少了,但是没有人购买它们!这里明显的问题是,我们不是在一个单独的事务中执行这些操作的。在单独的事务中,如果一步失败,所有的操作都会回滚,系统会保持一致性。
我们的问题有什么可能的解决方案吗?最简单的解决方案是回归单体化方案,将所有的操作放到单个服务和单个数据库中,所有的操作在一个本地事务中执行。但是在这种情况下,假设我们已经决定这个大型单体应用程序不能扩展,我们必须将它分解成单独的微服务。在那种情况下,对于我们的事务问题,就只剩下一个基于 2PC 的方案。我们可以使用诸如 WS-TX 或 Ballerina 的分布式事务功能来执行一个在网络服务之间基于 2PC 的全局事务。如果你想要在你的事务中拥有 ACID 保证,那么相似的方案是唯一的选择。然而,这种方案应该谨慎使用,因为典型的 2PC 缺点(例如在后端数据库中增加锁定时间)仍然存在。这些缺点在微服务环境会因为额外的网络通信波动而增加。
然而,大多数现实生活中的工作流并不需要 ACID 保证,因为错误的操作可以使用相反的操作来逆转。因此,在我们的订单处理工作流中,如果某件事出错了,可以对已经完成的操作执行补偿操作,并回滚整个事务。这些操作包括将付款额度退回到用户的信用卡,通过增加回订单中的产品数量来更新产品库存,然后将订单记录更新为已取消。
我们的电子商务后端场景实际上不能仅仅建模为单个数据库事务,因为处理支付的操作是使用一个外部支付网关完成的,这个网关不是一个本地的或全局的(2PC)数据库事务。然而,这种情况有一个例子,就是基于全局事务的 2PC。在这种 2PC 场景中,全局事务的最后一个参与者不需要同时实现准备 prepare 和提交 commit 两个阶段,而是单独的准备 prepare 操作就足够用来执行它的操作。这就是所谓的最后资源提交优化。而且,只有在这种特定的场景下,工作流中的任何其它地方的这种类型的参与者都不可能有全局事务。
因此,现在我们决定,我们不需要通过 2PC 得到的数据具有严格的一致性,我们可以稍后再解决任何出现的问题。让我们来看下这个工作流的一个可能的执行。
这里,工作流在步骤 4 失败。从那时起,admin 服务应该以先前调用的服务的相反的顺序执行一系列补偿操作。但是这里有一个潜在的问题。如果 admin 服务在恢复操作时遇到临时网络问题之类的异常该怎么办?我们又遇到了一个数据不一致的问题,总体回滚没有完成,而且我们无法知道我们所做的上一个操作是什么以及之后如何修复它。
处理这个问题的一种方法是记录 admin 服务所做的操作。类似如下:
因此,管理服务可以跟踪已执行的操作,以及尚未执行的操作。但是,我们也必须考虑当像这样处理事件日志时,可能发生的边缘情况。这个 admin 服务和它的日志独立于其它的远程服务操作,因此这些交互本身不是以事务的方式工作的。有如下变化:
如果上面的第一个操作执行,然后服务在第二个操作之前崩溃了该怎么办?当 admin 服务再次继续其操作时,它会认为它没有进行库存恢复操作,会再次执行第一个操作。这会导致库存数据错误,因为它增加了两次库存数目,这很糟糕!这是我们经常在分布式系统中看到的至少有一次交付的情况。一个常见的处理这个问题的方案是将我们的操作建模为幂等的。也就是说,即使相同的操作执行了很多次,它不会导致任何损害,目标系统的状态会是相同的。
但是我们的库存回滚操作不是幂等的,因为它不是设定一个特定的值,而是增加目标系统中已经存在的值。因此,你不能重复这些操作。我们可以通过直接设置我们下订单之前的库存数量,来使这成为一个幂等的操作。但是,由于我们的事务是在微服务架构中建模的,它不会提供任何你在 ACID 事务(例如,严格的序列化一致性级别)中能够发现的隔离属性。也就是说,当我们的事务执行时,另一个用户可能也在创建另一个订单,涉及相同的产品,会修改相同的库存记录。因此,这两个事务的操作可能重叠,导致出现不一致的情况。
因此,实际上,不仅是在事务回滚的情况下,甚至在两个并发成功的事务中,由于缺乏隔离性,一个幂等操作可能导致数据丢失更新的效果。让我们来查看下面两个事务操作的时间线。
这里有两个事务 TX1 和 TX2。它们都为产品“P1”创建订单,P1 的库存初始值是 100。TX1 将创建一个包含 10 个产品的订单,而 TX2 会创建一个包含 20 个产品的订单。正如我们从上面的序列图中所见,检查库存和更新库存在这两个事务中不是原子性发生的,而是交错的,最终 TX2 的库存更新掩盖了 TX1 的库存更新。因此,最终 P1 的产品数量是 80,而它本应该是 70,因为两个事务总共购买了 30 个产品。所以现在库存数据库的数据错误地显示了实际可用的库存。
因此,我们因为这两个并发进程没有被恰当地隔离而造成了一个竞争条件。修复这个情况的一个方案是,通过一个减法操作来修正库存的值,例如 decrementInventoryStockCount(product, offset), 相对于数据库中现存的值。因此,可以在目标服务中使用单个 SQL 操作或者单个本地执行的事务来使这个操作是原子性的。
因此,通过这次更新,上面的交互可以用如下方式重写。
正如我们现在所见,随着减少库存中的数量,我们最终的数量将是一致的和正确的。
注意:我们仍会有一个不同的异常情况,在检查了初始的库存数量后,在查询时,如果其它事务已经购买了所有的库存,则产品库存可能为空。这可以简单地作为一个业务流程来处理,我们回滚这个操作,数据仍会保持一致。
有时候,由于我们在微服务通信中的事务隔离问题,不能使数据操作是幂等的。这意味着,如果我们不能确保一个远程微服务是否执行了一个操作,我们不能盲目地重新在一个事务中执行这个操作。这通过给微服务调用的操作设置一个唯一的事务 ID 来解决,因此目标微服务将用这个 ID 来创建一个执行的事务的历史记录。在这种方式中,对于每个微服务操作调用,可以执行一个本地事务来检查历史记录,看看这个事务是否已经执行过。如果还没有执行过,就会在本地事务中执行数据库操作,更新事务历史表。下面的代码展示了 decrementInventoryStockCount 操作在库存服务中使用上述策略的一种可能实现。
C
function decrementInventoryStockCount(txid, pid, offset) {
transaction {
tx_executed = check transaction table record where id=txid
if not tx_executed {
prod_count = select count from inventory where product=pid
prod_count += offset
set count=prod_count in inventory where product=pid
insert to transaction table with id=txid
}
}
}
4微服务和消息
所以现在,我们找到了一种一致的方法来在一系列微服务中执行我们的事务,拥有最终全有或全无的保证。在这个流程中,我们仍然必须维护我们的事件日志,并在一个可靠的持久化存储中更新它。如果运行统筹工作的协调服务出现故障,另一个实体必须触发它来检查事件日志并完成任何恢复操作。如果我们已经有一些中间件可以用来提供服务之间的可靠通信,来帮助完成这项任务,那就太好了。
这就是事件驱动架构(EDA)有用的地方。这可以使用一个消息代理来创建微服务之间的通信。使用这种模式,我们可以确保,如果我们成功向指向某个服务的消息代理发送了一条消息,它会在某个时候成功地被送到预期的收件人。这个保证使得我们的其它进程能够更容易建模。另外,消息代理的异步通信模型,允许同时读写,由于比较低的开销和往返调用的等待时间,因此提供了更好的性能。异常处理也更简单,因为即使目标服务挂了,消息代理也会保留消息并在目标节点可用时传递它们。此外,它可以对多个服务实例进行失败重试和负载均衡请求等其它操作。这种模式也鼓励了服务之间的松耦合。通信通过队列 / 主体进行,而且生产者和消费者并不需要明确地互相了解。Saga 模式 在实现微服务中的事务时遵循了这些一般准则。
实现这种模式有两种协调策略:编排(choreography)和统筹(orchestration)。
编排
在这种方案中,这些服务本身是知道操作流的。在将初始消息被发送到一个服务操作之后,它会生成下一条要发送到下一个服务操作的消息。服务需要对事务流有明确的了解,会导致服务之间更多的耦合。下图展示了将事务工作流实现为一个编排时,服务和队列之间的典型交互。
这里,我们可以看到,流程从客户端开始,通过其输入消息队列发送初始消息到第一个服务。在其业务逻辑中,它可以在一个用服务定义的本地事务中执行与数据有关的操作。在操作完成后,整个工作流会向编排中下一个服务的请求队列中增加一条消息。通过这种方式,整个事务上下文将通过这些消息传播到每个服务,直到事务完成。
如果工作流中的某个服务出现故障,我们需要回滚整个事务。为此,从发生故障的服务开始,它会清空它的资源,并且通过一个补偿队列向之前刚刚执行的服务发送一条消息。这将移动上一步的执行,做一些补偿操作来回滚其本地事务所做的变更,并且重复联系先前服务的操作来执行补偿操作。在这种情况下,这个异常处理链会到达第一个服务,这个服务最终会发送一条消息到响应队列。这是连接到客户端,通知发生了异常,并且已经使用补偿操作成功回滚了整个事务。
正如同步服务调用方案所示,当在各自的服务中执行本地事务时,我们应该维护一个事务历史表来确保我们不会在服务收到重复消息时重复执行本地操作。另外,为了不丧失工作流的连续性,服务应该在数据库事务完成并且下一条消息被添加到下一个服务的请求队列中之后,才确认来自其请求队列中的消息。这个流程确保我们不会丢失任何消息,并且整个事务将通过继续执行或回滚所有操作来完成执行。
统筹
在这种协调方案中,我们有一个专门的协调器服务,它会按顺序调用其它服务。协调器服务和其它服务之间的通信会通过请求和响应队列完成。这种模式和我们电商场景中的“Admin”服务类似。唯一的变化是使用消息进行通信。
协调器服务和其它服务之间的异步通信允许它将事务过程建模为一个状态机,其中使用服务完成的每一步都可以更新状态机。这个状态机应该在一个数据库中持久化,以便从协调服务的任何故障中恢复。下图展示了使用消息驱动策略的统筹协调方案是如何设计的。
与编排方案相比,统筹方案的服务之间的耦合更少。这是因为工作流是被协调服务驱动的,特定时刻的完整状态是在那个服务本身持有的。但是,这里的服务也不是完全独立的,因为它们的请求和响应是绑定到特定的队列的,固定的生产者和固定的消费者使用这些队列。因此,现在更难使用这些服务作为通用服务。
当我们的操作数量比较少时,基于编排的协调是可行的。对于复杂的操作,基于统筹的方案在对操作进行建模时更灵活。
在实现这种策略时,在开发框架中抽象出通信、状态机的持久化等细节是很重要的。否则,开发者将写更多代码来实现事务处理,而不是核心业务逻辑。另外,如果一个典型的开发人员总是从头实现这种模式,会更容易出错。
5为微服务选择一种事务模型
在我们实现事务时使用的任何技术中,我们需要明确每种方案给出的数据一致性保证。然后我们必须与我们的业务需求交叉检查,看看什么是最适合我们的。下面可以用作一般指南。
6总结
在本文中,我们研究了事务处理的基础知识,从 ACID 保证到使用 BASE 放松数据一致性保证,以及 CAP 定理如何在一个分布式系统中定义数据存储的权衡。然后,我们分析了在分布式数据存储和一般的分布式进程中的不同级别的数据一致性。这些数据一致性问题直接适用于 MSA 中的数据建模,在 MSA 中我们需要将各个独立的服务组合起来执行一个全局的事务。
在根据业务要求选择一种选项时,我们查看了每种方案的优势和权衡,并检查了一般准则。