跨REST微服务的事务?

246

假设我们有一个用户、钱包REST微服务和一个API网关将它们粘合在一起。当Bob在我们的网站上注册时,我们的API网关需要通过用户微服务创建用户和通过钱包微服务创建钱包。

现在这里有几种情况可能会出错:

  • 创建用户Bob失败:没关系,我们只需向Bob返回错误信息。我们正在使用SQL事务,所以没有人曾经在系统中看到过Bob。一切都很好 :)

  • 在我们的钱包创建之前,用户Bob被创建了,但是API网关突然崩溃了。现在我们有一个没有钱包的用户(不一致的数据)。

  • 创建用户Bob并且在我们创建钱包时,HTTP连接断开了。钱包创建可能已成功,也可能未成功。

有哪些解决方案可以防止这种数据不一致性的发生?是否有模式允许事务跨越多个REST请求?我已经阅读了维基百科关于两阶段提交的页面,它似乎涉及到了这个问题,但我不确定如何在实践中应用它。这篇原子分布式事务:RESTful设计论文也很有趣,尽管我还没有读过它。
另外,我知道REST可能并不适合这种用例。也许处理这种情况的正确方法是完全放弃REST,并使用像消息队列系统这样的不同通信协议?或者应该在我的应用程序代码中强制执行一致性(例如,通过具有检测不一致性和修复它们的后台作业或通过在我的用户模型上具有“创建”、“已创建”等值的“状态”属性)?

4
有趣的链接:https://news.ycombinator.com/item?id=7995130 - Olivier Lalonde
4
如果一个用户没有钱包就不合理,为什么要为它创建一个单独的微服务?也许最初的架构存在问题?顺便问一下,为什么需要一个通用的API网关?有特别的原因吗? - Vladislav Rastrusny
6
@VladislavRastrusny 这只是一个虚构的例子,但你可以把钱包服务看作是由 Stripe 等机构处理。 - Olivier Lalonde
1
你可以使用进程管理器来跟踪事务(进程管理器模式),或让每个微服务知道如何触发回滚(saga管理器模式),或执行某种形式的两阶段提交。 (http://blog.aspiresys.com/software-product-engineering/producteering/distributed-transactions-in-microservices/) - andrew pate
“如果一个用户没有钱包就没有意义,为什么要为此创建一个单独的微服务”--例如,除了用户不能存在于没有钱包的情况下之外,它们没有任何共同的代码。因此,两个团队将独立开发和部署用户和钱包微服务。这不是首先进行微服务的整个重点吗? - Nik
1
@OlivierLalonde - 快进到2019年...你最终是如何处理这个问题的?最好的方法/解决方案是什么?如果您能回答这个很棒的问题,那将会很有帮助。 - C.P.
11个回答

181

不合理的事情:

  • 使用REST服务进行分布式事务。按照定义,REST服务是无状态的,因此它们不应该成为跨越多个服务的事务边界中的参与者。您的用户注册用例场景是有意义的,但是使用REST微服务创建用户和钱包数据的设计不好。

会让你头疼的事情:

  • 分布式事务中的EJBs。这是理论上可行但实际上不可行的事情之一。现在我正在尝试使跨JBoss EAP 6.3实例的远程EJB的分布式事务正常工作。我们已经与RedHat支持团队交谈了几周,但还没有解决。
  • 一般的两阶段提交解决方案。我认为2PC协议是一个很棒的算法(多年前我用RPC在C语言中实现过它)。它需要全面的故障恢复机制,包括重试、状态存储库等。所有的复杂性都隐藏在事务框架中(例如:JBoss Arjuna)。然而,2PC并非完全可靠。有些情况下事务根本无法完成。那么你就需要手动识别和修复数据库不一致性。如果你很幸运,这可能发生一百万次中的一次,但根据你的平台和场景,这可能每100个事务中就会发生一次。
  • Sagas(补偿事务)。创建补偿操作的实现开销和激活补偿的协调机制都存在。但是补偿也并非完全可靠。你仍然可能会遇到不一致性(=一些头痛)。

可能最好的替代方案是什么:

  • 最终一致性。ACID样式的分布式事务和补偿事务都不是完美的,两者都可能导致不一致性。最终一致性通常比“偶发不一致性”更好。有不同的设计解决方案,例如:
    • 您可以使用异步通信创建更强大的解决方案。在您的场景中,当Bob注册时,API网关可以向NewUser队列发送消息,并立即回复用户说“您将收到一封电子邮件确认帐户创建。”队列消费者服务可以处理消息,在单个事务中执行数据库更改,并向Bob发送电子邮件以通知帐户创建。
    • 用户微服务在同一数据库中创建用户记录钱包记录。在这种情况下,用户微服务中的钱包存储是主钱包存储的副本,仅对钱包微服务可见。有一个触发器或定期启动的数据同步机制,用于从副本向主服务器发送数据更改(例如,新钱包),反之亦然。

但如果您需要同步响应呢?

  • 重构微服务。如果使用队列的解决方案不起作用,因为服务消费者需要立即得到响应,那么我宁愿将用户和钱包功能重构为同一服务(或至少在同一虚拟机中,以避免分布式事务)。是的,这距离微服务更远,更接近单体应用,但可以为您节省一些麻烦。

7
最终一致性对我很有用。在这种情况下,“NewUser”队列应该具有高可用性和弹性。 - Ram Bavireddi
1
@RamBavireddi,Kafka或RabbitMQ是否支持弹性队列? - v.oddou
@v.oddou 是的,他们会。 - Ram Bavireddi
2
@PauloMerson,我不确定你如何将补偿事务与最终一致性区分开来。如果在您的最终一致性中创建钱包失败怎么办? - balsick
2
@balsick 最终一致性设置所面临的挑战之一是增加了设计复杂性。通常需要进行一致性检查和纠正事件。解决方案的设计因情况而异。在回答中,我建议在通过消息代理发送消息进行处理时创建钱包记录的情况下。在这种情况下,我们可以设置一个死信通道,即如果处理该消息时生成错误,则可以将消息发送到死信队列并通知负责“钱包”的团队。 - Paulo Merson
1
@PauloMerson说:“REST服务不应该参与跨越多个服务的事务边界”,您是指每个微服务都应该在不与其他服务交互的情况下完成事务吗?这似乎违反了SRP(和DRY),因为只有钱包服务应该知道如何实例化钱包,而封装用户实体的用户服务仅知道钱包ID(如果有)和从钱包服务公开的数据契约;但这主要取决于用户和钱包之间的关系...也许只是示例有误。 - Carmine Ingaldi

82

最近我在一次面试中被问到了一个经典问题:如何调用多个 Web 服务并仍然保留某种错误处理。现今,我们在高性能计算中避免使用两阶段提交。很多年前我读过一篇关于事务的“星巴克模型”的论文:想象一下在星巴克点咖啡的整个过程,包括下单、支付、制作和领取你所点的咖啡……虽然这里对问题进行了简化,但是采用两阶段提交模型会导致所有员工都需要等待并停止工作,直到你拿到咖啡。你明白这个情景吗?

相反,“星巴克模型”更加高效,采用了“尽力而为”模式并在过程中对错误进行补偿。首先,他们确保你已经付款!然后,有一个带有你订单信息的消息队列附着在杯子上。如果在过程中出现任何问题,比如你没有拿到咖啡,或者咖啡不是你点的等等,我们就进入补偿流程并确保你得到你想要的东西或者退款。这是提高生产率最有效的模型。

有时候星巴克浪费一杯咖啡,但整个过程是高效的。在构建 Web 服务时,还有其他技巧需要考虑,比如设计它们的方式让其可以被调用任意次数并仍然提供相同的最终结果。因此,我的建议是:

  • 不要对定义 Web 服务过于苛刻(我对当今流行的微服务兴起并不完全信服:存在太多扩展风险);

  • 异步操作可以提高性能,所以尽可能采用异步方式,并通过电子邮件发送通知;

  • 构建更智能的服务,使其可“可召回”地处理任意次数,使用 uid 或 taskid 处理订单底层至顶部,每一步都验证业务规则;

  • 使用消息队列(JMS 或其他)并转向错误处理器,通过执行相反操作来实现“回滚”操作。与此同时,处理具有异步订单将需要某种队列来验证进程的当前状态,因此需要考虑这一点;

  • 如果一切都失败了(因为这种情况可能不经常发生),将其放入手动处理错误的队列中。

让我们回到最初发布的问题。创建账户和钱包,确保一切都完成。

假设调用一个 Web 服务来编排整个操作。

Web 服务的伪代码如下:

  1. 调用账户创建微服务,传递一些信息和一个独特的任务 ID 1.1。账户创建微服务首先检查是否已经创建了该账户。任务 ID 关联着账户记录。微服务检测到该账户不存在,因此创建它并存储任务 ID。注意:可以调用此服务 2000 次,它总是执行相同的结果。该服务用“收据”回答,其中包含执行撤消操作所需的最少信息。

  2. 调用钱包创建,给出账户 ID 和任务 ID。假设某个条件无效,无法执行钱包创建。调用返回一个错误,但未创建任何内容。

  3. 编排程序被通知有错误发生。它知道需要终止账户创建,但不会自己执行。它将通过传递在步骤 1 结束时收到的“最小撤消收据”来请求钱包服务执行此操作。

  4. 账户服务读取撤消收据并知道如何撤消操作。撤消收据甚至可以包含有关它自己调用的另一个微服务执行工作的信息。在这种情况下,撤消收据可能包含账户 ID 和执行相反操作所需的其他一些额外信息。在我们的情况下,为简化事情,让我们仅仅是使用账户 ID 删除账户。

  5. 现在,假设Web服务没有接收到账户创建撤销已经执行成功或失败的通知。它将简单地再次调用账户的撤销服务。该服务通常不会失败,因为其目标是使账户不再存在。因此,它检查账户是否存在,并发现无法撤销任何操作。所以它返回操作成功。
    Web服务向用户返回帐户无法创建的消息。
    这是一个同步的示例。我们可以以不同的方式管理它,并将情况放入针对帮助台的消息队列中,如果我们不希望系统完全恢复错误的话。我曾经在一家公司看到过这样的情况,因为后端系统提供的钩子不够多,无法纠正各种情况。帮助台接收包含成功执行的信息的消息,并具有足够的信息来像完全自动化的撤销收据一样修复事情。
    我已经进行了搜索,微软的网站上有关于这种方法的模式描述。它被称为补偿性事务模式: Compensating transaction pattern

2
你觉得能否进一步扩展这个答案,为提问者提供更具体的建议?目前这个答案有些模糊难懂。虽然我知道星巴克如何提供咖啡,但我不清楚在 REST 服务中应该模仿这个系统的哪些方面。 - jwg
我添加了一个与原始帖子中提供的案例相关的示例。 - user8098437
2
刚刚添加了一个链接,指向微软描述的补偿事务模式。 - user8098437
2
请注意,在某些复杂的情况下(正如微软文档中所明确指出的那样),补偿事务可能根本不可能。在这个例子中,想象一下,在钱包创建失败之前,有人可以通过对账户服务进行GET调用来读取有关相关账户的详细信息,而这个服务理想情况下应该不存在,因为账户创建已经失败了。这可能会导致数据不一致。这种隔离问题在SAGAS模式中是众所周知的。 - Anmol Singh Jaggi
1
阅读你的回答,我想象“撤销”操作涉及对新添加记录的删除操作。但是如果这些“撤销”操作本身失败了呢?那么用户数据库中的数据将一直保持不一致状态,直到被删除。 - David Prifti
感谢“补偿事务”这个词。它描述了我在过去几天中想要解决的问题。有一些批评意见,比如在某些时候可能会出现一些不必要的读取操作。问题:使用补偿事务和两阶段提交(无事务)的混合是否存在任何缺点?想象一下,我想要在数据库中插入一个列为active=false的值。当我遇到错误时,请求者请求回滚,否则提交(更新active=true)。 - DubZ

40

所有分布式系统都会面临事务一致性的问题。最好的方法就像你说的那样,使用两阶段提交。将钱包和用户创建为待定状态。在创建后,再单独调用激活用户的功能。

这个最后的调用应该是可以安全重复的(以防连接中断)。

这将需要最后一个调用同时了解到两个数据表(以便可以在单个JDBC事务中完成)。

或者,您可能想考虑为什么如此担心没有钱包的用户。您认为这会导致问题吗?如果是这样,也许将它们作为单独的REST调用是一个不好的想法。如果用户没有钱包就不能存在,那么您应该在创建用户的原始POST调用中添加钱包。


谢谢您的建议。用户/钱包服务只是为了说明问题而虚构的。但我同意应该尽可能设计系统以避免需要交易。 - Olivier Lalonde
7
我同意第二个观点。看起来,你的微服务应该在创建用户时同时创建钱包,因为这个操作代表了一个原子工作单元。此外,你可以阅读这篇文章 http://www.eaipatterns.com/docs/IEEE_Software_Design_2PC.pdf。 - Sattar Imamov
2
这实际上是一个非常好的想法。撤销操作很麻烦。但是在挂起状态下创建某些内容要少得多。任何检查都已经完成,但尚未创建任何确定性的内容。现在我们只需要激活所创建的组件。我们甚至可以以非事务方式完成这个过程。 - Timo

12

我的看法是,微服务架构的一个关键方面是事务仅限于单个微服务(单一职责原则)。

在当前示例中,用户创建将成为自己的事务。用户创建将向事件队列推送一个USER_CREATED事件。钱包服务将订阅USER_CREATED事件并进行钱包创建。


1
假设我们想要避免任何2PC,并且假设用户服务写入数据库,那么我们不能使用户将消息推送到事件队列成为事务性的,这意味着它可能永远无法传递到钱包服务。 - Roman Kharkovski
@RomanKharkovski 确实是一个重要的问题。解决它的一种方法可能是启动事务,保存用户,发布事件(不是事务的一部分),然后提交事务。(最坏的情况是非常不可能的,提交失败,那些响应事件的人将无法找到用户。) - Timo
1
然后将事件存储到数据库以及实体中。有一个定时作业来处理存储的事件并将它们发送到消息代理。 - Yan Khonski
2
如果钱包创建失败,而且需要删除没有钱包的用户,那么您的处理方法是什么?钱包应该将WALLET_CREATE_FAILED事件发送到单独的队列中,用户服务将消费并删除用户吗? - harshit2811

7
如果我的钱包只是与用户在同一个SQL数据库中的另一组记录,那么我可能会将用户和钱包创建代码放在同一个服务中,并使用正常的数据库事务设施处理它。
听起来你在问当钱包创建代码需要触及另一个系统或系统时会发生什么?我认为这完全取决于创建过程的复杂性和/或风险。
如果只是涉及到接触另一个可靠数据存储(例如无法参与您的SQL事务的数据存储),那么根据整个系统参数,我可能愿意冒险第二次写入的微小几率。我可能什么也不做,但会引发异常并通过补偿事务或甚至一些特别的方法处理不一致的数据。正如我总是告诉我的开发人员:“如果应用中出现了这种情况,它不会被忽视”。
随着钱包创建的复杂性和风险的增加,您必须采取措施来减轻所涉及的风险。比如说有些步骤需要调用多个合作伙伴API。
此时,您可能会引入消息队列以及部分构建的用户和/或钱包的概念。
确保您的实体最终正确构建的一个简单而有效的策略是重试作业,直到它们成功,但很多取决于您的应用程序使用案例。
我还会仔细思考为什么在我的配置过程中有一个容易失败的步骤。

4

一个简单的解决方案是使用用户服务创建用户,并使用消息总线,其中用户服务会发出其事件,钱包服务在消息总线上注册,监听用户创建事件并为用户创建钱包。同时,如果用户进入钱包界面查看他的钱包,则检查用户是否刚刚创建,并显示您的钱包创建正在进行中,请稍后再查看。


3
有哪些解决方案可用于防止此类数据不一致性发生?
传统上,使用分布式事务管理器。几年前,在Java EE世界中,您可能会将这些服务创建为EJB,这些服务部署在不同的节点上,您的API网关将对这些EJB进行远程调用。应用服务器(如果正确配置)使用两阶段提交自动确保在每个节点上提交或回滚事务,以便保证一致性。但这要求所有服务都部署在相同类型的应用服务器上(以便它们兼容),实际上只能与单个公司部署的服务一起使用。
是否有模式允许跨多个REST请求处理事务?
对于SOAP(好吧,不是REST),有WS-AT规范,但我曾经需要集成的服务都没有支持它。对于REST,JBoss有一些正在进行中的工作。否则,“模式”就是要么找到一个可以插入到您的架构中的产品,要么构建自己的解决方案(不建议)。
我为Java EE发布了这样的产品:https://github.com/maxant/genericconnector 根据您引用的论文,还有尝试-取消/确认模式和Atomikos的相关产品。
BPEL引擎使用补偿处理在远程部署服务之间的一致性。

另外,我知道REST可能并不适用于此用例。也许处理这种情况的正确方法是完全放弃REST,并使用像消息队列系统这样的不同通信协议?

有许多将非事务资源“绑定”到事务中的方法:
  • 正如您建议的那样,您可以使用事务消息队列,但它将是异步的,因此如果您依赖于响应,它会变得混乱。
  • 您可以将需要调用后端服务的事实写入数据库,然后使用批处理调用后端服务。同样,异步,所以可能会变得混乱。
  • 您可以使用业务流程引擎作为API网关来编排后端微服务。
  • 您可以使用远程EJB,如开头所提到的,因为它支持分布式事务。

或者我应该在我的应用程序代码中强制执行一致性(例如,通过具有检测不一致性并修复它们的后台作业或通过在我的User模型上具有“creating”,“created”值等的“state”属性)?

反对者说:为什么要构建这样的东西,当有产品可以为您完成这项工作(请参见上文),而且很可能做得比您好,因为它们经过了尝试和测试?


3
在微服务世界中,服务之间的通信应该通过 REST 客户端或消息队列来完成。根据服务之间的通信方式,处理跨服务的事务有两种方法。个人而言,我更喜欢使用消息驱动架构,以便用户可以执行非阻塞操作。让我们用一个例子来说明:
  1. 创建一个名为BOB的用户,并将事件CREATE USER推送到消息总线。
  2. 订阅此事件的钱包服务可以创建与用户对应的钱包。
需要注意的一件事是选择一个强大可靠的消息骨干,以便在失败时可以保留状态。您可以使用 Kafka 或 RabbitMQ 作为消息骨干。由于最终一致性,执行会有延迟,但这可以通过套接字通知轻松更新。通知服务/任务管理器框架可以是通过异步机制(如套接字)更新事务状态并帮助 UI 显示正确进度的服务。

2
个人而言,我喜欢微服务的理念,即根据使用情况定义模块,但正如你的问题所提到的,它们在银行、保险、电信等传统企业中存在适应性问题。

分布式事务不是一个好选择,许多人现在更倾向于最终一致性系统,但我不确定这对银行、保险等行业是否可行。

我写了一篇关于我的解决方案的博客,也许这可以帮助你……

https://mehmetsalgar.wordpress.com/2016/11/05/micro-services-fan-out-transaction-problems-and-solutions-with-spring-bootjboss-and-netflix-eureka/


0

最终一致性是关键。

  • 选择其中一个服务成为事件的主要处理程序。
  • 该服务将使用单个提交处理原始事件。
  • 主要处理程序将负责异步地将次要影响通知其他服务。
  • 主要处理程序将进行其他服务调用的编排。

指挥官负责分布式事务并掌控全局。它知道要执行的指令并协调执行它们。在大多数情况下,只有两个指令,但它可以处理多个指令。

指挥官负责保证执行所有指令,这意味着重试。当指挥官尝试影响远程更新并且没有得到响应时,它没有重试。这样,系统可以配置为不太容易出现故障并自我修复。

由于我们有重试,因此我们具有幂等性。幂等性是能够以某种方式执行两次操作,使得最终结果与仅执行一次相同的属性。我们需要在远程服务或数据源处具有幂等性,以便在接收到指令超过一次的情况下,它仅处理一次。

最终一致性 这解决了大多数分布式事务的挑战,但我们需要考虑几个要点。 每次失败的事务都将跟随一个重试,尝试重试的次数取决于上下文。

一致性是最终的,即在重试期间系统处于不一致状态,例如如果客户订购了一本书并进行了付款,然后更新库存数量。如果库存更新操作失败,并且假设这是最后一本库存,则该书仍将可用,直到库存更新操作成功为止。重试成功后,您的系统将保持一致。


在「it has no retry」中有一个拼写错误,我相信应该是「it has to retry」。 - KrishPrabakar

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接