微服务数据一致性的演进:SAGA,CQRS,Event Sourcing的由来和局限-InfoQ
白小白:
讲微服务数据一致性的文章,网上比较多。此前 EAWorld 与发过几篇,包括《 微服务架构下的数据一致性保证(一)》、《 微服务架构下的数据一致性保证(二)》、《 微服务架构下的数据一致性保证(三):补偿模式》,以及《 使用消息系统进行微服务间通讯时,如何保证数据一致性》。本篇文章在我看来,是从一个纵向的维度把相关的一致性概念的演进过程,讲的比较清晰,简单的逻辑是这样的:
1、分布式带来一致性挑战;
2、2PC 效率太低,选择 SAGA 保证最终一致;
3、SAGA 的补偿环节可能失败,需要进行对账;
4、对账需要日志,需要记录日志;
5、用更改优先的方式记日志,更适合跨域操作,域内推荐事件优先;
6、用事件优先的方式记日志,即属于 CQRS 的一种模式;
7、CQRS 或者事件优先,也有并发和乱序的挑战,很难实现;
8、退而求其次,可以采用“设计一致性”;
9、实在无法一致,那就接受不一致。
(图示)分布式进程故障
在微服务中,逻辑上的原子操作经常跨越多个微服务。即使是单体应用架构下,也可能使用多个数据库或消息队列解决方案。使用几种独立的数据存储解决方案,如果分布式流程参与者之一失败,我们将面临数据不一致的风险,例如,向客户收费而不下订单,或者不通知客户订单成功。在本文中,我想分享一些在微服务体系架构下确保数据最终一致( http://t.cn/EzBWZSN)的技术。
为什么要做到这一点如此具有挑战性? 只要我们有多个存储数据的地方(而不是在一个数据库中),一致性就不会自动解决,工程师在设计系统时就需要考虑一致性。就目前而言,在我看来,业界还没有一个广为人知的解决方案来 原子化地更新多个不同数据源 中的数据,而且在可预见的将来,也不会有一个很快就可以使用的解决方案。
白小白:
数据一致性的挑战首先就源于 CAP 理论以及妥协方案 BASE 理论 (扩展阅读 http://t.cn/Re3i4xt)的限定,所谓 原子化地更新多个不同数据源 ,可以用订机票的例子来说明:
1、原子操作:你付了钱,航空公司也出了票,这两件事必须同时完成,这就是一个原子操作。
2、不一致:付了钱但没出票,或者没花钱却出了票,就是数据不一致。
3、最终一致:付了钱,票没出,但两天后钱退到了你的账户,这就是最终一致。退钱的过程,就叫做业务补偿。
简单理解分布式环境的 CAP,即一致性(C)和可用性(A)之间的权衡,给定的时间窗口和客户体验的前提下,想要原子化操作的各个数据源都一致(C)是不可能的,总会有某个时刻,表示钱的数据和表示票的数据,可能对不上,如果强调强一致性(C),就要在用户点击付款后等待很长的时间,来验证账户情况和票务情况,这就影响了可用性(A),而在分布式环境,重要的是用户体验,即使中间存在数据不一致,也可以通过补偿等手段来达成最终一致。这了形成了 BASE 理论的基本状况,即 BA,基本可用(允许适当的响应时间或者功能体验来尽量保证一致性),S,软状态(允许付了钱没出票的中间状态),E,最终一致。符合 BASE 的例子,有点像是我们在微信里买火车票或者机票时,会有个抢票过程,这实际上就是响应时间和功能上的妥协,毕竟没有在点击按钮后很快的反馈出票的结果,而先付款,后抢票,实际上就是软状态,即允许数据不一致的中间状态。
以自动化和省力的方式解决这个问题的一个尝试是以 XA 协议( http://t.cn/EzBmvFS)实现两阶段提交(2PC)模式( http://t.cn/RckficO)但是在现代大规模应用程序中(特别是在云环境中),2PC 的性能似乎不太好。为了消除 2PC 的缺点,我们必须牺牲 ACID 来遵循 BASE 原则,并根据需要采用不同的方式来满足数据一致性的要求。
白小白:
这里的 ACID 是指数据库事务正确执行的四个基本要素,结合上下文可以简化理解为 CAP,以避免引入更多的概念。XA 协议就是实现两阶段提交的一种规范。而所谓两阶段提交,就是把分布式数据源的操作分为两个步骤,即准备和提交两个阶段来确保数据一致性。准备阶段,询问所有参与方,是否可以进行操作。如果所有参与方都回复 OK,就进行操作,否则有某一方回复不 OK,就进行回滚。这有点像是我们我们玩的杀人游戏:
准备阶段:“天黑请闭眼,杀手请睁眼,杀手请杀人”。
提交阶段:“天亮请睁眼,孙悟空死了,没有遗言”
1、在准备阶段,法官将向所有杀手询问状态,即杀手得睁眼,有一个没睁眼的就没法玩。所以我们经常会经历法官说“杀手请睁眼,杀手请睁眼”的循环。
2、在准备阶段,但也有可能三个杀手两个睁了眼,然后睁眼的两个杀了一个人。有一个杀手死活不睁眼,这时候法官只能宣布游戏重来。这就是参与方有不 OK 的过程,重来就是回滚,即使杀了人也不算。
3、在准备阶段,只有三个人都睁眼了,并且成功的指出了杀谁,法官才会最终宣布死亡者,这就是提交。
可以看出,在准备阶段,参与者的协调会耗费大量的时间,这也是作者说,2PC 的性能似乎不太好的原因。关于 2 阶段、3 阶段及 SAGA 以外的数据一致性方案,感兴趣的,可以参考这篇文章( http://t.cn/EzBus4y)。
一、SAGA 模式
在多个微服务中处理一致性问题的最著名方法是 SAGA 模式( http://t.cn/EzB3uQA)可以将 SAGA 视为多个事务的应用程序级分布式协调机制。根据用例和需求,可以优化自己的 SAGA 实现,XA 协议正相反,试图以通用方案涵盖所有的场景。
SAGA 模式也并非什么新概念。它过去在 ESB 和 SOA 体系结构中就得到认知和使用,并最终成功地向微服务世界过渡。跨多个服务的每个原子业务操作可能由一个技术级别上的多个事务组成。Saga 模式的关键思想是能够回滚单个事务。正如我们所知道的,对于已经提交的单个事务来说,回滚是不可能的。但通过调用补偿行动,即通过引入“Cancel”操作可以变相的实现这一点。
(图)补偿操作
除了取消操作之外,还需要考虑使服务的幂等性,以便在发生故障时可以重新尝试或重新启动某些操作。应该对失败进行监测,对失败的反应应该积极主动。
白小白:
SAGA 对分布式事务的实现,依赖于应用程序为失败的情况,写好修正的逻辑代码,以便回滚时调用。这也就是“ 应用程序级 ”的含义。但在 SAGA 模式中,一个复杂的事务被拆分成若干个事务,并通过流程管理器串接,从而降低了整体事务管理的复杂性。
对账
如果在进程中间,负责调用补偿操作的系统崩溃或重新启动怎么办?在这种情况下,用户可能会收到错误消息,触发补偿逻辑,在处理异步用户请求时,重试执行逻辑。
(图)主要过程失效
要查找崩溃的事务并恢复操作或应用补偿,我们需要协调来自多个服务的数据。对从事金融领域工作的工程师来说,对账是一种熟悉的技术。你有没有想过,银行如何确保你的汇款不会丢失,或者在两家不同的银行之间是如何发生转账的?快速的答案是对账。
在会计领域,对账是确保两套记录(通常是两个账户的余额)一致的过程。对账手段确保离开帐户的钱与实际花费的钱相符。这是通过确保在特定会计期间结束时的余额匹配来实现的。(来源,Jean Scheid, “Understanding Balance Sheet Account Reconciliation”, Bright Hub, 8 April 2011)
回到微服务方面,使用相同的理念,我们可以在某些操作触发器上协调来自多个服务的数据。可以按计划执行对账操作,也可以在检测到出状况时,由监视系统触发相关操作。最简单的方法是按记录逐条进行比较,当然,也可以通过比较汇总值来优化此过程。在这种情况下,某个系统的数据将成为基准数据来对每条数据进行比对。
白小白:
此处的基准数据,原文是 single source of truth,在维基百科中的解释是,在分布式系统中,即使数据被分散复制,但应该只被存储一次,其他副本仅以引用形态来使用该数据。我想,用“基准数据”来翻译,可能更便于理解。
事件日志
再来讨论多步事务的情况。如何确定在对账过程中哪些事务在哪些环节上失败了?一种解决方案是检查每个事务的状态。在某些情况下,这个方法并不适用(比如无状态的邮件服务发送电子邮件或生成其他类型的消息)。在其他一些情况下,我们可能希望获得事务状态的即时可见性(也就是实时知晓其当前的状态),特别是在具有多个步骤的复杂场景中。例如,一个多步骤的订单,包括预订航班、酒店和转乘。
(图)复杂分布式过程
在这种情况下,事件日志可能会有所帮助。日志记录是一种简单但功能强大的技术。许多分布式系统依赖日志。“预写日志“就是在数据库内部实现事务行为或保持副本之间的一致性的方法。同样的技术也可以应用于微服务设计。在进行实际的数据更改之前,服务会编写一个日志条目,对即将实施的更改操作进行说明。实现方式上,事件日志是从属于协调服务的数据库中的表或集合。
(图)事件日志示例
事件日志不仅可用于恢复事务处理,还可用于向系统用户、客户或支持团队提供可见性。但是,在简单的场景中,服务日志可能是多余的,状态端点或状态字段就足够了。
编曲(Orchestration)与编舞(Choreography)
至此,您可能会认为 SAGA 只适用于编曲场景的一部分。但是 SAGA 也可以用于编舞场景,每个微服务只知道其中的一部分。SAGA 内置了处理分布式事务的正向流和负向流的相关机制。在编舞场景中,每个分布式事务参与者都有这样的知识。
白小白:
此处我没有翻译成大家常见的但完全无法理解的编制和编排,而是译为编曲和编舞,因为我觉得这样更便于理解这两种服务的组织方式(受这篇文章的启发 http://t.cn/EZVefsQ,尽管对于编舞我与作者有不同的理解)。如果举办一个软件开发词汇翻译比赛,Orchestration 与 Choreography,一定是难度榜的前列。Orchestration 的字面含义是管弦乐编曲,而 Choreography 的字面含义是舞蹈编排。深入理解,管弦乐尽管存在曲谱作为表演的规范,但最关键的环节在于指挥,让整个音乐的展现波澜壮阔;而舞蹈编排,一旦相关的动作和环节编制完成,在台上的表演完全依赖参与的舞者,并没有一个指挥能够在舞蹈表演的过程中起到关键作用。这也就是服务 Orchestration 和服务 Choreography 的运行模式的区别。
Orchestration 模式:使用一个中心控制机制来管理所有服务的协同和交互,中心了解所有参与方的情况和状态。
Choreography 模式:服务根据既定的规则协同工作,每个服务只了解与自己有关的内容。
所以,此段最后的“每个分布式事务参与者都有这样的知识”,指的是在编舞模式下,每个参与者独立的按照规则处理自己的事务。但规则是依托一些类似正向流和负向流的知识。此处的正向流,即按照一定的规则重试业务逻辑,执行顺序是沿既定的工作流,正向进行。而负向流,即执行补偿逻辑,执行的顺序是按工作流反向进行。举例:一个出行计划,流程是,订火车->订机票->订酒店
正向流:订火车->订机票->机票失败->机票重试…->订酒店
负向流:订火车->订机票->订酒店->酒店失败->取消酒店->取消机票->取消火车
二、单一写入事件
到目前为止,上述的一致性解决方案并不容易。它们确实很复杂。但有一种更简单的方法:每次只修改一个数据源。我们可以将更改服务的状态并发出事件这两个步骤分开,而不是在一个进程中处理。
“变更优先”原则
在主要业务操作中,我们修改自己的服务状态,而单独的流程则可靠地捕获相关变更并生成事件。这种技术被称为变更数据捕获(CDC)。实现此方法的一些技术包括 Kafka Connect( http://t.cn/EzrZOpE)或 Debezium ( https://debezium.io/)。
(图)用 Debezium 和 Kafka Connect 捕获数据修改状态
然而,有时不需要特定的框架来进行处理。一些数据库提供了一种跟踪操作日志的友好方法,如 MongoDB Oplog( http://t.cn/Ezrw6xj)。如果数据库中没有这样的功能,则可以使用时间戳轮询更改,或者使用最后处理的 ID 查询不可变记录。避免不一致的关键是使数据更改通知成为一个单独的进程。数据库记录在这种情况下为基准数据。一旦发生数据变更,相关数据即被捕获和记录。
(图)在没有特定工具的情况下更改数据捕获
变更数据捕获的最大缺点是业务逻辑的分离。更改捕获过程很可能存在于您的代码库中,与更改逻辑本身分离,这是不方便的。最广为人知的更改数据捕获应用场景是领域无关的更改复制,例如通过数据仓库进行数据共享。对于域内的事件,最好使用不同的机制,比如显式地发送事件。
白小白:
所谓的不方便,我的理解,不是指更改捕获过程与业务逻辑的分离,而是指用户需要为每个业务逻辑单独的实现更改捕获逻辑。
由于数据仓库的数据来自不同的数据源,比如 SQL Server 或者 Oracle 或者 MySQL,为确保数据的实时更新,需要通过 ETL 或者 CDC 的方法来进行数据的加载。其中,在采用 CDC 方法时,需要在数据变更的源和目标都安装第三方的 CDC 应用来进行数据的抽取。CDC 捕获变更的方式是在数据变更发生之后,通过读取数据库日志来进行的,这也是最佳的不影响数据的方式。(《Managing Data in Motion》,April Reeve)。事实上,在中文搜索引擎查找数据变更捕获时可知,Oracle 和 SQL Server 自身都提供了 CDC 的工具。而不需要依赖第三方应用。
“事件优先”原则
让我们对“基准数据”做一个逆向思考。如果我们不是首先写入数据库,而是先触发一个事件并与我们自己和其他服务共享这个事件呢?在这种情况下,事件成为基准数据。这将是一种 event-sourcing 的形态,在这种情况下,服务状态实际上变成了一个读模型,而每个事件都是一个写模型。
(图)事件优先方法
所以,这也是一种命令查询责任分离(CQRS)模式,将读写模型分离开来,但是 CQRS 本身并没有关注解决方案中最重要的部分,即如何由多个服务来对事件进行处理。
相反,事件驱动体系结构关注多个系统对事件的处理,但不突出强调事件是数据更新的基准数据。所以我想引入 “事件优先”原则作为此方法的名称:通过发出单个事件来更新微服务的内部状态-包括对我们自己的服务和任何其他感兴趣的微服务。
白小白:
CQRS,简单理解就是读取操作和写入操作分别处理。
事件溯源,是指将对数据进行增删改的命令按顺序记录为日志,这一日志本身是不可删改的,只能顺序增加。因此,通过对这一系列事件的回放,可以从一个空白的数据库重构造到最新的状态。
事件驱动,是一种发布订阅模型。某节点发生某个操作后,将产生一个消息,另一节点订阅这一消息并进行后续操作。节点之间并不知晓对方的存在。
事件驱动本身并不关心数据一致性,而是由 MQ 来进行保证。这就是文中“不突出强调事件是数据更新的基准数据”这句话的含义。而事件溯源又不关心事件的后续处理,即消息触发后续操作的过程,也就是文中“如何由多个服务来对事件进行处理”的含义。而“事件优先”原则显然希望综合两个理念的优点,从而也需要承受两种理念的固有缺陷。
采用“事件优先”方法的挑战也是 CQRS 本身的挑战。想象一下,在下订单之前,我们要检查商品的可用性。如果两个实例同时接收同一项的订单怎么办?两个实例将以读取模型同时检查库存,并触发一个订单事件。如果不解决这个问题,我们可能会遇到麻烦。
处理这些情况的通常方法是乐观并发:在事件中放置一个读取模型版本,如果已在使用者端更新读取模型,则忽略这个读取操作。另一种解决方案是使用悲观的并发控制,例如在查询项目可用性时为其创建锁。
“事件优先”方法的另一个挑战是对任何事件驱动的体系结构的挑战,即事件的顺序。多个并发消费者以错误的顺序处理事件可能会给我们带来另一种一致性问题,例如,处理尚未创建的客户的订单。
数据流解决方案(如 Kafka 或 AWS Kinesis)可以保证与单个实体相关的事件将按顺序处理(例如,只在创建用户之后才为客户创建订单)。例如,在 Kafka 中,您可以通过用户 ID 对主题进行分区,这样与单个用户相关的所有事件都将由分配给该分区的单个使用者处理,从而允许按顺序处理这些事件。相反,在使用消息代理机制时,消息队列虽然有其固有的执行顺序,但多个并发使用者使得按给定顺序进行消息处理非常困难,甚至不可能。这样就可能会遇到并发问题。
实际上,当 线性一致性 是必需的,或者在有许多数据约束(如唯一性检查)的情况下,“事件优先”方法很难实现。但在其他场景中但它确实可以大放异彩。然而,由于它的异步性质,并发和竞争条件的挑战仍然需要解决。
白小白:
所谓线性一致性(Linearizability)的场景,在分布式环境下,基本上是不需要考虑的,因为不可实现。(来源,Implementing Linearizability at Large Scale and Low Latency,Stanford University)
三、设计一致性
有许多方法可以将系统分成多个服务。我们努力将不同的微服务与不同的域相匹配。但是这些域有多细呢?有时很难将域与子域或聚合根区分开来。没有简单的规则来定义您的微服务拆分。
白小白:
域、子域、聚合,是领域驱动设计的概念,简单理解就是从业务角度对系统进行的不同颗粒度的划分,举例来说,一个电商系统:
域:电商
电商的子域:用户,订单,产品等
用户里的的聚合概念:地址,银行账号等
其中,地址其实是一系列概念的聚合,比如邮编、城市、省份等, “地址”就是聚合根对象,所有对邮编、城市、省份的操作不能直接进行,而要通过“地址”这一聚合根进行转发。
与其只关注领域驱动的设计,我建议采取务实的态度,并考虑所有设计选项的含义。其中一个含义是微服务隔离与事务边界的匹配程度。事务只驻留在微服务中的系统不需要上述任何解决方案。在设计系统时,一定要考虑事务边界。在实践中,可能很难以这种方式设计整个系统,但我认为我们的目标应该是尽量减少数据一致性的挑战。
接受不一致
虽然与帐户余额匹配是至关重要的,但在许多用例中,一致性的重要性要小得多。比如,为分析或统计目的收集数据。即使我们随机丢失了 10%的系统数据,从分析中获得的业务价值也很可能不会受到影响。
(图)与事件共享数据
白小白:
SendGrid 是一个电子邮件服务平台,可以帮助市场营销人员跟踪他们的电子邮件统计数据。如果需要实时获取发送邮件的状态(如:发送成功与否,对方有没有收到,收到之后的处理-打开,删除,判定为垃圾邮件等),就需要用到 SendGrid 的 WebHook 功能来进行实时的数据通知。
四、选择哪种解决方案
数据的原子更新需要两个不同系统之间的协商一致,形成对某值为 0 或者为 1 的共识。当涉及到微服务时,它归结为两个参与者之间的一致性问题,所有实际的解决方案都遵循一个经验法则:
在给定的时刻,对于每个数据记录,需要找到可信的基准数据。
基准数据可以是事件、数据库或某个服务。在微服务系统中实现一致性是开发人员的责任。我的做法如下:
-
尝试设计一个不需要分布式一致性的系统。不幸的是,对于复杂的系统来说,这几乎是不可能的。
-
尝试通过一次修改一个数据源来减少不一致的数量。
-
考虑一下事件驱动的体系结构。除了松散耦合之外,事件驱动体系结构的一大优势是天然的支持基于事件的数据一致性,可以将事件作为基准数据,也可以由变更数据捕获(CDC)生成事件。
-
更复杂的场景可能仍然需要服务、故障处理和补偿之间的同步调用。要知道,有时你可能不得不在事后对账。
-
将您的服务功能设计为可逆的,决定如何处理故障场景,并在设计阶段早期实现一致性。
关于作者: Grygoriy Gonchar,eBay @ebaytechberlin Motors Vertical 软件架构师。前 @Kreditech 主任架构师。专注于 Java,Scala,微服务,安全领域的技术话题。言论仅代表个人观点。