


















我们经常会被问到这样一个问题:在一个下单流程中,如何保证数据的一致性。
如果我们在单服务单库中运行,那么很简单,使用数据库的事务就可以了。
但是正常来说,现在的所有服务都会采用微服务的架构,也就是说一个下单流程中,「订单服务」到「库存锁定」到「生成账单」到「支付交易」到「回调变更状态」,这几步将会有多个服务来共同完成。
此时我们必然不能让用户的任何一步失败,又或者必须保证失败后回滚一定成功,否则用户钱扣了,交易却没成功;或者造成了超卖,这些都会造成严重客诉。
为此才会引入分布式事务这个概念,也就是保障多个事务之间的一致性,要么全部成功,要么全部失败。
在开始前,还是来复习一些基本概念,以便后续方案中来检查是否满足这一概念。
数据库的事务中我们会经常提到 ACID,也就是数据库事务的基本原则。
而实际上,AID 都是为了保障 C 的一种手段。
而分布式系统中我们会经常听人提到 CAP 这个概念:
CAP 中的三点是不可能三角,也就是说永远不可能同时满足这三项,我们必须要有所取舍。
以下单场景为例:
这三个点不可能同时达成,意味着我们必然要放弃其中一个:
而很显然,我们不可能假设「全部可用」和「完全可靠」,因此在大多数场景下,我们只能通过牺牲一致性来构建我们的系统。
当然,前面我们提到的无论是 ACID 的一致性,还是 CAP 的一致性,更多的是强一致性。而在业务中,我们往往更多的是保证最终一致性,这也就是为什么我们的交易过程中可能会有延迟,但很少会真的出现重大问题。
Base 是对 CAP 中 AP 的补充,牺牲了强一致性来保证高可用。
我们把实现了 ACID 的事务叫做刚性事务(强一致性),而 Base(最终一致性)叫做柔性事务。
在开始事务之前,我们先来分析一下数据库事务的做法。
我们都知道数据库设计了 Undo / Redo 两种日志,Undo 日志拿来记录修改行、原值和新值,而 Repo 日志同样也会记录这些值,只是他们的用法并不相同,具体可以异常恢复的执行步骤:
因此,Redo 和 Undo 中的操作都需要是幂等操作。
如果不同库但是同服务,那么久不能简单的使用数据库的事务操作了,因为几个数据库之间是分开提交的,此时越来越接近我们想要讨论的分布式事务了。
当然,由于是在同一个服务中,所以我们直接在代码中进行操作就可以了,在这种情况下,我们会提到两个方案:2PC 和 3PC。
两段式提交中引入一个协调者来解决多库间的操作,假设我们需要同时操作 order、goods和user三张表,2PC 中一共有两个阶段:
commit,而是在确定执行完之后,给协调者回复是或否,如果是否,则回滚。commit,而如果收到了其中一个否,则通知所有参与者回滚。但是 2PC 看似美好的背后我们一眼就能看出的问题是:
commit是百分百送达的,如果部分参与者没收到 commit,那么他们的操作可能是 pending、提交或者回滚中的一种,无法保证数据一致性。三段式提交修改了两段式提交,将准备阶段拆细,先询问是否有把握执行成功,再发送给参与者需要写入 redo(不执行 commit),最后再执行 commit。
但是其实 2PC 遇到的问题仍没有得到很好的解决,它发送的指令更多了,也依旧不能解决网络问题,唯一改良的是准备阶段这个低性能操作的提前确定一定程度上对性能有所改善。
分布式事务基本都是为了实现最终一致性,也就是说,我们允许在中间过程中有一段时间的不一致,只要数据最终是一致的就可以了。
TCC(Try-Confirm-Cancel)又被称为补偿事务。它一共分为三步:
Try 中我们冻结了所需要的资源,这样就可以保证不会因为不一致而导致诸如超售之类的问题。
Confirm中我们消费冻结了的资源;而 Cancel则是一种回滚操作。
《凤凰架构》中有图来表示这一过程(其实主要就是懒得画图)。
在 Try 中,账号服务、仓库服务、商家服务会对资源进行预留,并通知成功与否。
如果全部成功,则执行 Confirm流程完成操作。
如果存在失败则执行 Cancel流程取消交易并且解除 Try对资源的冻结。
可以看出,TCC 整体的设计是非常安全而高效的,但是问题也仍然存在:
Try、Confirm、Cancel的实现。Try 阶段有一个失败了,那么会去调用 Cancel方法,这时部分业务可能实际并没有执行 Try,可能会造成空回滚。解决方案是:
Cancel时没有对应的 Try记录,则不执行
同样的,如果是响应慢,那么事务发起节点以为超时,准备 Cancel 的时候可能下游刚刚收到 Try命令,那么可以在同样的表中查到对应是否有 Cancel记录,如果有 Cancel,那么不执行 Try但无论如何,这是一种业务看起来改的很辛苦的方式,如果其中有一个服务是不可控的,可能就玩不下去(比如银行负责收钱),除此以外,可以用:https://seata.apache.org/zh-cn/这样的框架来简化你的实现成本。
在 SAGA 事务中,我们不需要进行冻结资源与解冻资源,因此他更适合大多数的业务场景。
SAGA 由一堆本地事务来组成分布式事务。每一个本地事务在更新完数据库之后,会发布一条消息或者一个事件来触发 SAGA 中的下一个本地事务的执行。如果一个本地事务因为某些业务规则无法满足而失败,SAGA 会执行在这个失败的事务之前成功提交的所有事务的补偿操作。
SAGA 通常会有两种实现:
根据我们刚刚的思路,假设我们有「账号」、「仓库」、「商家」三个服务。
在基于事件的过程中账号服务执行成功后会发送一个事件给仓库服务,仓库服务监听并且收到这个事件后进行减库存操作,如果扣除成功,再发送事件给商家,商家在根据事件执行,最后发送事件给账号服务告诉它变更用户的交易状态。
如果商家执行失败,会发送消息给仓库和账号,并进行回滚操作。
这个模式看上去很简单,但实际想想就会发现:
因此刚刚说的基于事件显得并不是特别靠谱,「基于命令」的实现也就是在此基础上诞生的。
在基于命令的模式中,我们考虑引入一个中央节点,用来记录执行了什么,这一设计原则比较像前面我们数据库事务中提到的 undo/redo 日志,也就是说,我们的事务系统来承担记录undo、redo和发送命令(调用)的责任。
如果期间有失败,那么执行 undo日志进行回滚即可。
当然,这里也会存在问题,那就是如果执行 undo期间,业务数据表又被修改了,那么执行的 undo可能会存在问题,这个时候可能就会造成脏写。
再这种情况下,为了避免造成脏写,还需要引入一个全局锁来锁住对应的变更(类似于行锁),避免同一行在回滚时有新的操作修改了该行数据。
虽然说相比 2PC,锁更为精细化,但行锁仍要等待事务完成后释放,因此性能仍有一定的牺牲。
当然,同样的,如果不引入中间调度器,也可以在业务本地建表来存储对应的执行状态。
也就是说,本来是由中间调度器来记录 undo、redo,现在由业务方本地来记录执行步骤:
由本地数据表来记录是否执行了指定的动作,方便重试和回滚,由消息中间件来保证送达。
但是这种实现意味着每个业务本地都会有一张事务表,看上去和 TCC 一样,就仍然依赖业务的实现。
基于 MQ 实现分布式事务本质上是将所有动作存储在 MQ 内,由 MQ 来完成送达和回滚。
比如 RocketMQ 就提供了事务消息:https://rocketmq.apache.org/zh/docs/featureBehavior/04transactionmessage/
详情可见单页文档连接,简单的来说,和 2PC 类似,会先发送半消息 MQ Server 是否能接收。能,则向生产者返回 ACK。
生产者收到 ACK 后开始执行,并向 MQ Server 提交 Commit or Rollback,决定是回滚还是推给下游服务。
如果 MQ Server 未收到二次确认,那么在一定时间后 MQ 将对生产者发送消息回查。
生产者针对消息会检查事务执行结果来决定二次提交 Commit or Rollback。
优点在于和中间调度者一样,和业务本身解耦了。但问题是需要两次网络请求,以及业务需要根据其标准实现回查接口。
最大努力交付这种模式中,如果下游业务没有接收到上游投递的消息,那么可以调用上游提供的补偿查询接口进行事务的补偿。
此时由下游消费者来保证事务的一致性。中间同样通过 MQ 来保证消息投递的可达。
当然,也可以是借由 MQ 来进行的不断重试,但无论如何,这种方式意味着不停地轮询。

在实际学习中,我发现不同的文章对这些分布式事务解决方案有不同的归类和细节上的出入,但从套路上来说,解决方案就是这几种,因为他们各有优劣,所以还是需要根据业务进行结合或者改造。
在实际操作的过程中,也可以使用成熟的分布式事务框架来简化开发流程,而不必重复造轮子,文章更多的是介绍范式和怎么选轮子的问题。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。