微服务架构中的数据一致性

   在微服务中,一个逻辑原子操作通常可以跨多个微服务。即使是单一系统也可能使用多个数据库或消息传递解决方案。有了几个独立的数据存储解决方案,如果某个分布式流程参与者失败,我们就会冒数据不一致的风险——例如没有下订单就向客户收费,或者没有通知客户订单成功。

 

    本文我将分享一些我学到的使微服务之间的数据最终保持一致的技术。为什么实现这个目标如此具有挑战性?一旦我们有多个存储数据的地方(不是在一个数据库中),一致性就不会自动解决,程序猿在设计系统时需要注意一致性。就目前而言,在我看来,业界还没有一个广为人知的解决方案来自动更新多个不同数据源中的数据——业界短期内应该不会这种问题的解决方案出来。

 

    两阶段提交(2PC)模式的XA协议是一种解决此问题的一种尝试之一。但是在现代的大规模应用程序中(特别是在云环境中),2PC的性能似乎不太好。为了弥补2PC的不足,根据不同的业务场景我们需要基于ACID为基础在业务层面做一些一致性问题的处理。

    

Saga模式

在微服务中处理一致性问题的最著名的方法是Saga模式。可以将Sagas视为多个事务的应用程序级分布式协调。根据用例和需求,可以优化自己基于Saga模式的实现。相反,XA协议试图覆盖所有场景。Saga模式在微服务之前就在ESB和SOA体系结构中被使用。现在,它已经成功地过渡到微服务。跨越多个服务的每个原子业务操作可能由技术级别上的多个事务组成。Saga模式的关键思想是回滚处理。对于已经提交的事物是不能做回滚操作的,这就需要通过调用补偿机制来实现——通过引入“取消”操作。

 

补偿操作

除了取消之外,还应该考虑使服务具有幂等性,以便在出现故障时重试或重新启动某些操作。故障应该被监控,对故障的反应应该是积极的。

 

一致性比较

如果在处理过程中,负责调用补偿操作的系统崩溃或重新启动,该怎么办?在这种情况下,用户可能会收到一条错误消息,补偿逻辑应该被触发,或者——当处理异步用户请求时,执行逻辑应该被恢复。要查找崩溃的事务并恢复操作或应用补偿,我们需要协调来自多个服务的数据。对账是在金融领域工作过的工程师所熟悉的一种技术。你有没有想过银行是如何确保你的转账没有丢失,或者在两家不同的银行之间是如何转账的?快速的答案是对账。

 

在会计上,核对是确保两套记录(通常是两个账户的余额)一致的过程。对账是用来确保离开账户的钱与实际花费的钱相匹配。这是通过确保在一个特定的会计期末余额匹配来实现的。- Jean Scheid,“理解资产负债表账户对账”,Bright Hub, 2011年4月8日

 

回到微服务,使用相同的原则,我们可以在某个操作触发器上协调来自多个服务的数据。当检测到故障时,可以按预定的基础或由监视系统触发操作。最简单的方法是逐记录比较。这个过程可以通过比较聚合值来优化。在这种情况下,其中一个系统将是每个记录的真实来源。

 

事件日志

多阶段事物。如何确定在协调过程中哪些事务可能已经失败,哪些步骤已经失败?一种解决方案是检查每个事务的状态。在某些情况下,此功能不可用(想象一下发送电子邮件或生成其他类型消息的无状态邮件服务)。在其他一些情况下,可能希望实时看到事务状态,特别是在具有多步骤的复杂场景中。例如,预订机票、酒店和转机的多步骤订单。

 

复杂的分布式过程

在这些情况下,事件日志可以提供帮助。日志记录是一种简单但功能强大的技术。许多分布式系统依赖于日志。“写前日志记录”是数据库如何实现事务行为或在内部维护副本之间的一致性。同样的技术也可以应用于微服务设计。在进行实际的数据更改之前,服务会写一个日志条目,说明其进行更改的意图。实际上,事件日志可以是协调服务拥有的数据库中的表或集合。

 

示例事件日志

事件日志不仅可以用于恢复事务处理,还可以用于向系统用户、客户或支持团队提供可见性。但是,在简单的场景中,服务日志可能是冗余的,状态端点或状态字段就足够了。

 

编制与编排

至此,你可能认为sagas只是编制场景的一部分。但是sagas也可以用于编排,其中每个微服务只知道流程的一部分。Sagas包括处理分布式事务的正流和负流的知识。在编排中,每个分布式事务参与者都有这种知识。

 

Single-Write与事件

到目前为止描述的一致性解决方案并不容易。它们确实很复杂。但是有一种更简单的方法:每次修改一个数据源。我们可以将这两个步骤分开,而不是更改服务的状态并在一个进程中发出事件。

 

最先改变

在主业务操作中,我们修改自己的服务状态,而独立的流程可靠地捕获更改并生成事件。这种技术称为更改数据捕获(Change Data Capture, CDC)。实现这种方法的一些技术是Kafka Connect或Debezium。

 

改变数据捕捉与Debezium和Kafka连接

然而,有时并不需要特定的框架。有些数据库提供了跟踪操作日志的友好方式,例如MongoDB Oplog。如果数据库中没有此类功能,则可以通过时间戳轮询更改,或者使用不可变记录的最后处理ID查询更改。避免不一致的关键是使数据更改通知成为一个单独的进程。在本例中,数据库记录是真实数据的唯一来源。只有首先发生变化时,才会捕捉到变化。

 

改变数据捕获没有特定的工具

更改数据捕获的最大缺点是业务逻辑的分离。更改捕获过程很可能存在于代码库中,与更改逻辑本身分离——这很不方便。更改数据捕获最著名的应用是与域无关的更改复制,例如与数据仓库共享数据。对于域事件,最好采用不同的机制,例如显式发送事件。

 

Event-First

如果我们不首先写入数据库,而是触发一个事件,并与我们自己和其他服务共享该事件,会发生什么情况呢?在这种情况下,事件成为唯一的来源。这将是事件源的一种形式,其中我们自己的服务的状态有效地成为一个读模型,而每个事件都是一个写模型。

 

其次一方面,它是一个命令查询责任隔离(CQRS)模式,在这种模式中我们分离了读和写模型,但是CQRS本身并不关注解决方案中最重要的部分——使用多个服务来处理事件。

 

相反,事件驱动的体系结构关注多个系统使用的事件,但不强调事件是数据更新的唯一原子部分这一事实。因此,我想将“event-first”作为这种方法的名称:通过向我们自己的服务和任何其他感兴趣的微服务发出单个事件来更新微服务的内部状态。

 

“事件优先”方法的挑战也是CQRS本身的挑战。想象一下,在下订单之前,我们想要检查商品的可用性。如果两个实例同时收到相同项目的订单,该怎么办?两者都将并发地检查读模型中的库存并发出订单事件。如果没有某种掩护方案,我们可能会遇到麻烦。

 

处理这些情况的通常方法是乐观并发性:将一个读模型版本放入事件中,如果消费者端上的读模型已经更新,则在消费者端忽略它。另一种解决方案是使用悲观并发控制,例如在检查项的可用性时为项创建一个锁。

 

“事件优先”方法的另一个挑战是任何事件驱动的体系结构的挑战——事件的顺序。由多个并发使用者以错误的顺序处理事件可能会给我们带来另一种一致性问题,例如处理尚未创建的客户的订单。

 

在实践中,“事件优先”方法很难在需要线性化的场景或具有许多数据约束(如唯一性检查)的场景中实现。但它在其他情况下确实很出色。然而,由于其异步性,并发性和竞态条件的挑战仍然需要解决。

 

设计的一致性

将系统拆分为多个微服务的方式有很多。我们努力将不同的微服务与不同的领域相匹配。但是领域的边界在哪里呢?很难区分界定。没有简单的规则来定义微服务的划分。

 

与其只关注领域驱动的设计,我建议更务实地考虑设计选项的所有含义。其中一个影响是微服务隔离与事务边界的一致性。事务只驻留在微服务中的系统不需要上述任何解决方案。在设计系统时,我们一定要考虑事务边界。在实践中,以这种方式设计整个系统可能很困难,但是我认为我们应该以最小化数据一致性挑战为目标。

 

接受不一致

虽然匹配帐户余额非常重要,但在许多用例中,一致性的重要性要低得多。想象一下为分析或统计目的收集数据。即使我们从系统中随机丢失10%的数据,分析的业务价值也很可能不会受到影响。

 

与事件共享数据

选择哪种解决方案

数据的原子更新需要在两个不同的系统之间达成一致,如果一个值是0或1,则需要达成一致。当谈到微服务时,它归结为两个参与者之间的一致性问题,所有实用的解决方案都遵循一个简单的规则:在给定的时刻,对于每个数据记录,需要找到系统信任的数据源。

 

真实的业务来源可能是事件、数据库或某个服务。在分布式微服务系统中实现数据的一致性是开发人员的职责所在。下面是我的一些个人总结:

 

  • 尝试设计一个不需要分布式一致性的系统。不幸的是,这对于复杂的系统来说几乎是不可能的。

 

  • 尝试通过每次修改一个数据源来减少不一致的数量。

 

  • 考虑事件驱动架构。除了松耦合之外,事件驱动体系结构的强大功能是实现数据一致性的一种自然方式,方法是将事件作为事实的单一来源,或者将事件作为变更数据捕获的结果生成事件。

 

  • 有一些复杂的业务场景仍然需要服务、故障处理和补偿之间的同步调用。有时候可能需要做对账操作。

 

  • 将服务功能设计为可逆的,决定如何处理故障场景,并在设计阶段的早期实现一致性。

欢迎关注本人公众号交流学习微服务架构中的数据一致性