假设我们有一个用户、钱包 REST 微服务和一个将事物粘合在一起的 API 网关。当 Bob 在我们的网站上注册时,我们的 API 网关需要通过 User 微服务创建一个用户,并通过 Wallet 微服务创建一个钱包。
现在这里有一些情况可能会出错:
用户 Bob 创建失败:没关系,我们只是向 Bob 返回一条错误消息。我们正在使用 SQL 事务,所以没有人在系统中看到 Bob。一切都很好:)
用户 Bob 已创建,但在我们的钱包创建之前,我们的 API 网关硬崩溃。我们现在有一个没有钱包的用户(数据不一致)。
用户 Bob 已创建,当我们创建钱包时,HTTP 连接断开。钱包创建可能已经成功,也可能没有。
有哪些解决方案可以防止这种数据不一致的发生?是否存在允许事务跨越多个 REST 请求的模式?我已阅读 Two-phase commit 上的 Wikipedia 页面,该页面似乎涉及到这个问题,但我不确定如何在实践中应用它。这篇 Atomic Distributed Transactions: a RESTful design 论文似乎也很有趣,虽然我还没有读过。
或者,我知道 REST 可能不适合这个用例。处理这种情况的正确方法可能是完全放弃 REST 并使用不同的通信协议(如消息队列系统)吗?或者我应该在我的应用程序代码中强制执行一致性(例如,通过有一个后台作业来检测不一致并修复它们,或者在我的用户模型上使用“创建”、“创建”值等的“状态”属性)?
什么没有意义:
带有 REST 服务的分布式事务。根据定义,REST 服务是无状态的,因此它们不应成为跨越多个服务的事务边界的参与者。您的用户注册用例场景是有意义的,但是使用 REST 微服务创建用户和钱包数据的设计并不好。
什么会让你头疼:
具有分布式事务的 EJB。这是在理论上有效但在实践中无效的事情之一。现在我正在尝试为跨 JBoss EAP 6.3 实例的远程 EJB 实现分布式事务。我们已经与 RedHat 支持讨论了数周,但仍未奏效。
一般的两阶段提交解决方案。我认为 2PC 协议是一个很棒的算法(很多年前我用 RPC 用 C 实现了它)。它需要全面的故障恢复机制,包括重试、状态存储库等。所有的复杂性都隐藏在事务框架中(例如:JBoss Arjuna)。但是,2PC 不是故障证明。在某些情况下,交易根本无法完成。然后,您需要手动识别和修复数据库不一致。如果幸运的话,它可能在百万次交易中发生一次,但根据您的平台和场景,它可能在每 100 次交易中发生一次。
Sagas(补偿交易)。有创建补偿操作的实现开销,以及最后激活补偿的协调机制。但补偿也不是失败的证明。您可能仍然会遇到不一致的情况(= 一些头痛)。
什么可能是最好的选择:
最终的一致性。类似 ACID 的分布式事务和补偿事务都不是故障证明,两者都可能导致不一致。最终的一致性通常比“偶尔的不一致”要好。有不同的设计解决方案,例如: 您可以使用异步通信创建更强大的解决方案。在您的场景中,当 Bob 注册时,API 网关可以向 NewUser 队列发送消息,并立即回复用户说“您将收到一封电子邮件以确认帐户创建”。队列消费者服务可以处理消息,在单个事务中执行数据库更改,并将电子邮件发送给 Bob 以通知帐户创建。用户微服务在同一个数据库中创建用户记录和钱包记录。在这种情况下,用户微服务中的钱包存储是主钱包存储的副本,仅对钱包微服务可见。有一种基于触发器的数据同步机制或定期启动以将数据更改(例如,新钱包)从副本发送到主节点,反之亦然。
您可以使用异步通信创建更强大的解决方案。在您的场景中,当 Bob 注册时,API 网关可以向 NewUser 队列发送消息,并立即回复用户说“您将收到一封电子邮件以确认帐户创建”。队列消费者服务可以处理消息,在单个事务中执行数据库更改,并将电子邮件发送给 Bob 以通知帐户创建。
用户微服务在同一个数据库中创建用户记录和钱包记录。在这种情况下,用户微服务中的钱包存储是主钱包存储的副本,仅对钱包微服务可见。有一种基于触发器的数据同步机制或定期启动以将数据更改(例如,新钱包)从副本发送到主节点,反之亦然。
但是,如果您需要同步响应怎么办?
重构微服务。如果由于服务使用者需要立即响应而导致队列解决方案不起作用,那么我宁愿将用户和钱包功能重新设计为并置在同一个服务中(或至少在同一个 VM 中以避免分布式事务)。是的,它离微服务更远,更接近单体,但会让你免于头疼。
这是我最近在一次采访中被问到的一个经典问题,如何调用多个 Web 服务并在任务中间仍然保留某种错误处理。今天,在高性能计算中,我们避免了两阶段提交。多年前我读过一篇关于交易的“星巴克模型”的论文:想想在星巴克订购、支付、准备和接收您订购的咖啡的过程......我把事情过分简化了,但两阶段提交模型会建议在您收到咖啡之前,整个过程将是所有涉及的步骤的单一包装事务。然而,在这种模式下,所有员工都会等到你拿到咖啡后才停止工作。你看到图片了吗?
相反,“星巴克模式”通过遵循“尽力而为”模式并补偿过程中的错误来提高生产力。首先,他们确保您付款!然后,有消息队列将您的订单附加到杯子上。如果过程中出现问题,例如您没有得到咖啡,不是您订购的咖啡等,我们会进入赔偿流程,确保您得到您想要的或退款,这是最有效的模式以提高生产力。
有时,星巴克会浪费一杯咖啡,但整个过程是高效的。当您构建 Web 服务时,还需要考虑其他技巧,例如以可以多次调用它们并且仍然提供相同最终结果的方式设计它们。所以,我的建议是:
定义 Web 服务时不要太精细(我不相信最近发生的微服务炒作:走得太远的风险太大);
异步提高了性能,所以更喜欢异步,尽可能通过电子邮件发送通知。
构建更智能的服务,使其可“调用”任意次数,使用 uid 或 taskid 进行处理,将遵循自下而上的顺序直到结束,在每个步骤中验证业务规则;
使用消息队列(JMS 或其他)并转移到错误处理处理器,这些处理器将通过应用相反的操作将操作应用于“回滚”,顺便说一下,使用异步顺序将需要某种队列来验证进程的当前状态,所以考虑一下;
万不得已,(因为它可能不会经常发生),将其放入队列中以手动处理错误。
让我们回到最初发布的问题。创建一个帐户并创建一个钱包并确保一切都已完成。
假设调用了一个 Web 服务来编排整个操作。
Web 服务的伪代码如下所示:
调用帐户创建微服务,传递一些信息和一些唯一的任务 id 1.1 帐户创建微服务将首先检查该帐户是否已创建。任务 ID 与帐户的记录相关联。微服务检测到该帐户不存在,因此创建它并存储任务 ID。注意:这个服务可以被调用 2000 次,它总是会执行相同的结果。该服务以“包含最少信息的收据来回答,以便在需要时执行撤消操作”。调用 Wallet 创建,为其提供帐户 ID 和任务 ID。假设条件无效并且无法执行钱包创建。调用返回错误但没有创建任何内容。协调器被告知错误。它知道它需要中止帐户创建,但它自己不会这样做。它将通过在步骤 1 结束时收到的“最小撤消收据”要求钱包服务执行此操作。帐户服务读取撤消收据并知道如何撤消操作;撤消收据甚至可能包含有关另一个微服务的信息,它本可以调用自己来完成部分工作。在这种情况下,撤消收据可能包含帐户 ID 以及执行相反操作所需的一些额外信息。在我们的例子中,为了简化事情,假设只是使用其帐户 ID 删除该帐户。现在,假设 Web 服务从未收到执行帐户创建撤消的成功或失败(在这种情况下)。它只会再次调用帐户的撤消服务。而且该服务通常应该永远不会失败,因为它的目标是让帐户不再存在。因此它会检查它是否存在,并且发现无法撤消它。所以它返回操作成功。 Web 服务向用户返回无法创建帐户。
这是一个同步示例。如果我们不希望系统完全恢复错误,我们可以以不同的方式管理它并将案例放入针对帮助台的消息队列中。”可以向后端系统提供挂钩以纠正情况。帮助台收到包含成功执行的消息并有足够的信息来修复问题,就像我们的撤消收据可以以全自动方式使用一样。
我已经进行了搜索,微软网站上有这种方法的模式描述。它被称为补偿事务模式:
Compensating transaction pattern
所有分布式系统都存在事务一致性问题。做到这一点的最好方法就像你说的,有一个两阶段提交。将钱包和用户创建为挂起状态。创建后,进行单独调用以激活用户。
最后一次调用应该可以安全地重复(以防您的连接中断)。
这将需要最后一次调用知道两个表(以便可以在单个 JDBC 事务中完成)。
或者,您可能想考虑一下为什么您如此担心没有钱包的用户。你相信这会导致问题吗?如果是这样,也许将它们作为单独的休息电话是一个坏主意。如果用户在没有钱包的情况下不应该存在,那么您可能应该将钱包添加到用户(在创建用户的原始 POST 调用中)。
恕我直言,微服务架构的关键方面之一是事务仅限于单个微服务(单一责任原则)。
在当前示例中,用户创建将是一个自己的事务。用户创建会将 USER_CREATED 事件推送到事件队列中。钱包服务将订阅 USER_CREATED 事件并创建钱包。
如果我的钱包只是与用户在同一个 sql 数据库中的另一组记录,那么我可能会将用户和钱包创建代码放在同一个服务中,并使用普通的数据库事务工具来处理。
听起来你在问当钱包创建代码需要你接触另一个或多个其他系统时会发生什么?我说这完全取决于创建过程的复杂程度和风险程度。
如果只是接触另一个可靠的数据存储(比如一个不能参与您的 sql 事务的数据存储),那么根据整个系统参数,我可能愿意冒第二次写入不会发生的极小机会的风险。我可能什么都不做,但会引发异常并通过补偿事务甚至一些临时方法来处理不一致的数据。正如我总是告诉我的开发人员:“如果这种事情发生在应用程序中,它不会被忽视”。
随着钱包创建的复杂性和风险增加,您必须采取措施降低所涉及的风险。假设某些步骤需要调用多个合作伙伴 API。
此时,您可能会引入消息队列以及部分构造的用户和/或钱包的概念。
确保您的实体最终正确构建的一个简单而有效的策略是让作业重试直到它们成功,但这在很大程度上取决于您的应用程序的用例。
我也会仔细思考为什么我的配置过程中有一个容易失败的步骤。
一个简单的解决方案是您使用用户服务创建用户并使用消息总线,其中用户服务发出其事件,并且钱包服务在消息总线上注册,监听用户创建的事件并为用户创建钱包。同时,如果用户在 Wallet UI 上查看他的 Wallet,请检查用户是否刚刚创建并显示您的钱包正在创建中,请过一段时间再检查
有哪些解决方案可以防止这种数据不一致的发生?
传统上,使用分布式事务管理器。几年前,在 Java EE 世界中,您可能将这些服务创建为部署到不同节点的 EJB,并且您的 API 网关将对这些 EJB 进行远程调用。应用程序服务器(如果配置正确)使用两阶段提交自动确保在每个节点上提交或回滚事务,从而保证一致性。但这要求所有服务都部署在同一类型的应用程序服务器上(以便它们兼容),并且实际上只能与单个公司部署的服务一起使用。
是否存在允许事务跨越多个 REST 请求的模式?
对于 SOAP(好的,不是 REST),有 WS-AT 规范,但我曾经集成的任何服务都没有支持它。对于 REST,JBoss 有 something in the pipeline。否则,“模式”是要么找到可以插入架构的产品,要么构建自己的解决方案(不推荐)。
我已经为 Java EE 发布了这样一个产品:https://github.com/maxant/genericconnector
根据您参考的论文,还有来自 Atomikos 的 Try-Cancel/Confirm 模式和相关产品。
BPEL 引擎使用补偿处理远程部署的服务之间的一致性。
或者,我知道 REST 可能不适合这个用例。处理这种情况的正确方法可能是完全放弃 REST 并使用不同的通信协议(如消息队列系统)吗?
有很多方法可以将非事务性资源“绑定”到事务中:
正如您所建议的,您可以使用事务性消息队列,但它将是异步的,因此如果您依赖响应,它会变得混乱。
您可以将需要调用后端服务的事实写入数据库,然后使用批处理调用后端服务。同样,异步,所以会变得混乱。
您可以使用业务流程引擎作为 API 网关来编排后端微服务。
如开头所述,您可以使用远程 EJB,因为它支持开箱即用的分布式事务。
或者我应该在我的应用程序代码中强制执行一致性(例如,通过有一个后台作业来检测不一致并修复它们,或者在我的用户模型上使用“创建”、“创建”值等的“状态”属性)?
玩魔鬼的倡导者:为什么要构建这样的东西,当有产品可以为您做到这一点时(见上文),并且可能比您做得更好,因为它们已经过试验和测试?
在微服务世界中,服务之间的通信应该通过休息客户端或消息队列。根据您在服务之间的通信方式,可以有两种方法来处理跨服务的事务。我个人更喜欢消息驱动的架构,因此长事务应该是用户的非阻塞操作。让我们举个例子来解释一下:
使用事件 CREATE USER 创建用户 BOB 并将消息推送到消息总线。订阅此事件的钱包服务可以创建用户对应的钱包。
您必须注意的一件事是选择一个健壮可靠的消息主干,它可以在发生故障时保持状态。您可以使用 kafka 或 rabbitmq 作为消息传递主干。由于最终的一致性,执行会有延迟,但可以通过套接字通知轻松更新。通知服务/任务管理器框架可以是通过像套接字这样的异步机制更新事务状态的服务,并且可以帮助 UI 更新显示正确的进度。
我个人喜欢微服务的想法,由用例定义的模块,但正如你的问题所提到的,它们对银行、保险、电信等经典业务有适应问题......
正如许多人提到的,分布式事务不是一个好的选择,人们现在更多地追求最终一致的系统,但我不确定这是否适用于银行、保险等......
我写了一篇关于我提出的解决方案的博客,也许这可以帮助你....
最终的一致性是这里的关键。
选择其中一项服务成为事件的主要处理程序。
该服务将通过单次提交处理原始事件。
主要处理程序将负责将次要效果异步传递给其他服务。
主要处理程序将执行其他服务调用的编排。
指挥官负责分布式事务并进行控制。它知道要执行的指令并将协调执行它们。在大多数情况下,只有两条指令,但它可以处理多条指令。
指挥官负责保证所有指令的执行,这意味着退休。当指挥官试图进行远程更新并且没有得到响应时,它没有重试。通过这种方式,系统可以配置为不易发生故障并自行修复。
因为我们有重试,所以我们有幂等性。幂等性是能够做某事两次的属性,这样最终结果就好像它只做了一次一样。我们需要远程服务或数据源的幂等性,以便在它多次收到指令的情况下,它只处理一次。
最终一致性这解决了大多数分布式事务的挑战,但是我们需要在这里考虑几点。每个失败的事务都会进行一次重试,尝试重试的次数取决于上下文。
一致性是最终的,即,当系统在重试期间脱离一致性状态时,例如,如果客户订购了一本书,并付款,然后更新库存数量。如果库存更新操作失败并假设这是最后可用的库存,则该书仍然可用,直到库存更新的重试操作成功为止。重试成功后,您的系统将保持一致。
为什么不使用支持脚本/编程的 API 管理 (APIM) 平台?因此,您将能够在 APIM 中构建复合服务,而不会干扰微服务。为此,我使用 APIGEE 进行了设计。
不定期副业成功案例分享