如何解决服务中的事务问题
我们经常会被问到这样一个问题:在一个下单流程中,如何保证数据的一致性。
如果我们在单服务单库中运行,那么很简单,使用数据库的事务就可以了。
但是正常来说,现在的所有服务都会采用微服务的架构,也就是说一个下单流程中,「订单服务」到「库存锁定」到「生成账单」到「支付交易」到「回调变更状态」,这几步将会有多个服务来共同完成。
此时我们必然不能让用户的任何一步失败,又或者必须保证失败后回滚一定成功,否则用户钱扣了,交易却没成功;或者造成了超卖,这些都会造成严重客诉。
为此才会引入分布式事务这个概念,也就是保障多个事务之间的一致性,要么全部成功,要么全部失败。
事务概念
在开始前,还是来复习一些基本概念,以便后续方案中来检查是否满足这一概念。
ACID
数据库的事务中我们会经常提到 ACID,也就是数据库事务的基本原则。
- 原子性(Atomicity):一个事务的所有系列操作步骤被看成一个动作,所有的步骤要么全部完成,要么一个也不会完成。如果在事务过程中发生错误,则会回滚到事务开始前的状态,将要被改变的数据库记录不会被改变。
- 一致性(Consistency):一致性是指在事务开始之前和事务结束以后,数据库的完整性约束没有被破坏,即数据库事务不能破坏关系数据的完整性及业务逻辑上的一致性。
- 隔离性(Isolation):主要用于实现并发控制,隔离能够确保并发执行的事务按顺序一个接一个地执行。通过隔离,一个未完成事务不会影响另外一个未完成事务。
- 持久性(Durability):一旦一个事务被提交,它应该持久保存,不会因为与其他操作冲突而取消这个事务。
而实际上,AID 都是为了保障 C 的一种手段。
CAP
而分布式系统中我们会经常听人提到 CAP 这个概念:
- 一致性(Consistency):在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访问同一份最新的数据副本)
- 可用性(Availability):在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(对数据更新具备高可用性)
- 分区容错性(Partition Tolerance):以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择。
CAP 中的三点是不可能三角,也就是说永远不可能同时满足这三项,我们必须要有所取舍。
以下单场景为例:
- 一致性问题意味着,可能用户实际付钱了,但是订单回调失败,因此显示上用户仍未付款;又或者是用户下单后没有及时减少库存,造成了超卖。
- 可用性问题意味着,如果我有有三个节点可以负责减库存,如果其中一个节点挂了,其他两个节点能否完成减库存的重任。
- 分区容错性问题,意味着分区通信失败的情况下是否会造成影响,比如如果下单到锁库存失败了,是否会对整体业务造成影响。
这三个点不可能同时达成,意味着我们必然要放弃其中一个:
- 要放弃一致性,意味着假设流程中每个节点一定是基于正确的数据在处理值,不强求一致
- 要放弃可用性,意味着假设流程中每个节点我们得假设必须是全部可用的,不强求可用
- 要放弃分区容忍性,就意味着我们假设网络永远是可靠的,不强求网络可靠
而很显然,我们不可能假设「全部可用」和「完全可靠」,因此在大多数场景下,我们只能通过牺牲一致性来构建我们的系统。
当然,前面我们提到的无论是 ACID 的一致性,还是 CAP 的一致性,更多的是强一致性。而在业务中,我们往往更多的是保证最终一致性,这也就是为什么我们的交易过程中可能会有延迟,但很少会真的出现重大问题。
Base
- Basically Available(基本可用):分布式系统在出现不可预知故障的时候,允许损失部分可用性
- Soft state(软状态):软状态也称为弱状态,和硬状态相对,是指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
- Eventually consistent(最终一致性):最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。
Base 是对 CAP 中 AP 的补充,牺牲了强一致性来保证高可用。
我们把实现了 ACID 的事务叫做刚性事务(强一致性),而 Base(最终一致性)叫做柔性事务。
同库场景
在开始事务之前,我们先来分析一下数据库事务的做法。
我们都知道数据库设计了 Undo / Redo 两种日志,Undo 日志拿来记录修改行、原值和新值,而 Repo 日志同样也会记录这些值,只是他们的用法并不相同,具体可以异常恢复的执行步骤:
- 分析:扫描日志并找到所有没有 End Record 的事务,准备恢复
- Redo:重新回放需要执行的事务,执行完毕后增加 End Record 行表示事务结束
- Undo:事务 Redo 失败,剩下的就是需要回滚的,根据对应的 Undo 信息回滚
因此,Redo 和 Undo 中的操作都需要是幂等操作。
不同库同服务场景
如果不同库但是同服务,那么久不能简单的使用数据库的事务操作了,因为几个数据库之间是分开提交的,此时越来越接近我们想要讨论的分布式事务了。
当然,由于是在同一个服务中,所以我们直接在代码中进行操作就可以了,在这种情况下,我们会提到两个方案:2PC 和 3PC。
两段式提交:2PC
两段式提交中引入一个协调者来解决多库间的操作,假设我们需要同时操作 order
、goods
和user
三张表,2PC 中一共有两个阶段:
- 准备阶段:准备阶段需要准备好事务操作,也就是说,协调者先会给参与者(也就是各库)发请求,询问是否准备完毕。各库会先开始执行内部操作,但不进行
commit
,而是在确定执行完之后,给协调者回复是或否,如果是否,则回滚。 - 提交阶段:如果全部收到了是,那么协调者将会通知所有参与者进行
commit
,而如果收到了其中一个否,则通知所有参与者回滚。
但是 2PC 看似美好的背后我们一眼就能看出的问题是:
- 单点问题:协调者本身是个单点,如果协调者出现问题,那么大家就都不能正常运行了
- 同步阻塞:如果其中一个参与者出现了网络问题,那么所有参与者都会卡着不进行提交,在此期间数据库是上锁的,将造成严重的性能问题。
- 网络问题:我们无法保证
commit
是百分百送达的,如果部分参与者没收到commit
,那么他们的操作可能是 pending、提交或者回滚中的一种,无法保证数据一致性。
三段式提交:3PC
三段式提交修改了两段式提交,将准备阶段拆细,先询问是否有把握执行成功,再发送给参与者需要写入 redo(不执行 commit
),最后再执行 commit。
但是其实 2PC 遇到的问题仍没有得到很好的解决,它发送的指令更多了,也依旧不能解决网络问题,唯一改良的是准备阶段这个低性能操作的提前确定一定程度上对性能有所改善。
分布式事务
分布式事务基本都是为了实现最终一致性,也就是说,我们允许在中间过程中有一段时间的不一致,只要数据最终是一致的就可以了。
TCC
TCC(Try-Confirm-Cancel)又被称为补偿事务。它一共分为三步:
- Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需用到的业务资源(保障隔离性)。
- Confirm:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。Confirm 阶段可能会重复执行,因此本阶段所执行的操作需要具备幂等性。
- Cancel:取消执行阶段,释放 Try 阶段预留的业务资源。Cancel 阶段可能会重复执行,也需要满足幂等性。
Try
中我们冻结了所需要的资源,这样就可以保证不会因为不一致而导致诸如超售之类的问题。
Confirm
中我们消费冻结了的资源;而 Cancel
则是一种回滚操作。
《凤凰架构》中有图来表示这一过程(其实主要就是懒得画图)。
在 Try
中,账号服务、仓库服务、商家服务会对资源进行预留,并通知成功与否。
如果全部成功,则执行 Confirm
流程完成操作。
如果存在失败则执行 Cancel
流程取消交易并且解除 Try
对资源的冻结。
可以看出,TCC 整体的设计是非常安全而高效的,但是问题也仍然存在:
- 业务侵入与开发成本:要实现这样一个事务,意味着整体链路中的每一环都需要有一个
Try
、Confirm
、Cancel
的实现。 链路超时的影响:如果
Try
阶段有一个失败了,那么会去调用Cancel
方法,这时部分业务可能实际并没有执行Try
,可能会造成空回滚。解决方案是:- 在发起事务同时生成事务 Unique ID
- 在每一步执行时写入事务 ID 和业务 ID 和执行步骤
- 如果执行
Cancel
时没有对应的Try
记录,则不执行
同样的,如果是响应慢,那么事务发起节点以为超时,准备 Cancel 的时候可能下游刚刚收到Try
命令,那么可以在同样的表中查到对应是否有Cancel
记录,如果有Cancel
,那么不执行Try
但无论如何,这是一种业务看起来改的很辛苦的方式,如果其中有一个服务是不可控的,可能就玩不下去(比如银行负责收钱),除此以外,可以用:https://seata.apache.org/zh-cn/这样的框架来简化你的实现成本。
SAGA 事务
在 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 来进行的不断重试,但无论如何,这种方式意味着不停地轮询。
总结
在实际学习中,我发现不同的文章对这些分布式事务解决方案有不同的归类和细节上的出入,但从套路上来说,解决方案就是这几种,因为他们各有优劣,所以还是需要根据业务进行结合或者改造。
在实际操作的过程中,也可以使用成熟的分布式事务框架来简化开发流程,而不必重复造轮子,文章更多的是介绍范式和怎么选轮子的问题。
参考资料
植入部分
如果您觉得文章不错,可以通过赞助支持我。
如果您不希望打赏,也可以通过关闭广告屏蔽插件的形式帮助网站运作。