取法其上,得乎其中

从传统的本地事务和全局事务到分布式事务的理论学习

概述

本地事务

所谓本地事务,实际上指的是单一服务访问单一数据源的场景,而单一服务访问多个数据源的场景一般是指全局事务(外部事务)。具体的名词实际上无需关注,只要知道存在几个数据源就行了,而具体两者的差异主要是依照本地事务的思路无法解决多个数据源存在的问题,而具体的问题由其后在全局事务中再讲述。

原子性和持久性的实现

原子性和持久性在事务中是密切相关的两个属性。如果我们想要实现原子性来保证一个事务要么全部成功要么全部失败而不存在中间状态,必须要将整个操作正确的持久化到某个地方,然后才能得知这个操作究竟有没有完成,如果完成了自然皆大欢喜,反之则要想办法把之前已经完成的操作去作以撤销。

而在业务操作中往往会遇到以下场景:

  • 未提交事务,写入后崩溃:假设出现了三个操作,当执行两个操作后崩溃,但事务并未提交,因此这个事务理应要被撤销。
  • 已提交事务,写入前崩溃:事务已经提交,但系统宕机导致数据丢失,那么这个事务对应的操作应要被恢复。

说白了,这样的场景下数据库需要能够实现两个功能,即操作恢复和操作回滚。这个时候就出现了一个名为 Commit Logging 的解决方案。即:当要执行一个修改操作的时候,数据库系统完整把对应数据的“所在的内存页、磁盘块,要从什么数据修改到什么数据”记录到日志文件里面,然后在日志中再加入“Commit Record”的标志以代表开始真正修改数据,当数据修改完毕之后再在日志中加入“End Record”以代表写入完毕。

如此这样确实能够解决原子性和一致性的要求了,但每次操作都要等待日志完全落盘才能进行操作,并没有完全的压榨出完整的性能,即使某个事务要修改的数据量非常庞大,即使系统资源足够空闲,但也不允许在日志前提前进行操作。

于是提出了一种提前写入日志(Write-Ahead Logging)的思路,允许在日志完全落盘之前提前进行操作,MySQL InnoDB引擎使用的思路就是这个。实际上这个问题无法解决的主要原因是在于:如果日志落盘失败,并没有方法把操作对应的数据进行撤销。既然这样...那么在操作的时候记录一下这个操作的逆操作,如果在此期间宕机,后续检查到事务没有完成的话直接通过这个记录了的逆操作回滚就可以了。

隔离性的实现

隔离性本质来讲是指不同的事务之间对数据的互相隔离,使一个事务去修改某个数据的时候,另一个事务不会受其影响。那么如何才能做到这样呢?似乎只有加锁...让他们按照顺序进行操作,而对操作实行的锁和对应粒度就衍生了数据库隔离级别这回事儿。

一般情况下现代数据库均提供了三种锁:

  • 写锁(排它锁),当数据被一个事务加上写锁的时候,其他事务必须等待这个事务释放写锁才能进行读写。
  • 读锁(共享锁),当数据被一个事务加上读锁的时候,其他事务亦可以获取到读锁,如果要获取写锁则需要等待该读锁被释放。
  • 范围锁,对某个范围整体加上写锁,指该范围内的数据不允许读也不允许写。

而数据库中在一个场景下使用哪一种加锁策略进行量化,也就产生了四种隔离级别:

  1. 可串行化,对所有的读写操作都加上对应的锁(写锁,读锁,范围锁)
  2. 可重复读,对所有的读写操作加写锁和读锁,但不加范围锁。
  3. 读已提交,写操作加写锁且伴随到事务提交,但读操作加读锁只持续到操作结束
  4. 读未提交,写操作加写锁且伴随到事务提交,不加读锁

从这个角度来看就很明显的体会到了,为什么各种不同的隔离级别可以防护各种隔离问题(幻读、不可重复读、脏读)。

全局事务

所为全局事务如前面所说一般是指单个服务在面临多个数据源的场景但实际上对“全局事务”从理论角度讲并没有“单个服务”的约束,因为它提出的本身是为了实现在分布式场景下的强一致性解决方案,但目前几乎所有的多节点互相调用彼此服务的场景下都不能接受强一致性带来的性能损耗,而主推弱一致性的解决方案,所以下文中的全局事务仅限于指代单个服务面临多数据源场景。

而全局事务本身需要做的事情是通过协调多个数据源的一致动作,实现全局事务的统一提交和统一回滚。而在此就提出了一套名称为XA的事务架构,主要是定义了一个用于协调全局事务的事务管理器和用于驱动本地事务的局部资源管理器之间的接口。比如Java就实现了这个接口,也就是JTA。

概念完毕,下面通过一个具体的样例来了解全局事务的场景下究竟会出现什么样的问题。

try{
    用户服务.扣钱();
    商家服务.扣除剩余();
    仓库服务.发货();
    用户服务.commit();
    商家服务.commit();
    仓库服务.commit();
}catch (Exception e){
    用户服务.rollback();
    商家服务.rollback();
    仓库服务.rollback();
}

上面的样例是通过编程式事务方式去实现“用户下单->商家扣除商品剩余->仓库发货”的一个流程。如果这些服务都处于不同的数据源,那么可预期的事情就是:假设用户服务已提交,而仓库服务异常导致进入catch代码段的话,这个时候会发现用户服务根本无法提交!因为它已经在前面执行了提交操作。

因此,XA架构为了解决这个问题而提出了两段式提交(2PC)和三段式提交(3PC)。

两段式提交

主要讲事务提交拆分为了“准备阶段”和“提交阶段”,在此期间协调者和参与者通过信息的交互了解各个部分能否成功的进行任务,进行统一的管理。

1.准备阶段
协调者询问事务的所有参与者是否准备提交,而参与者在这个阶段需要去做准备工作,所谓准备工作主要是将事务对应的操作全部完整的记录在重做日志中,而跟本地事务记录日志的主要区别在于不会进入Commit Logging阶段。如果这个时候参与者记录完毕,则向协调者响应协“Prepared”,反之返回“Non-Prepared”。

2.提交阶段
如果所有的事务参与者都向协调者响应了“Prepared”,那么协调者首先持久化自己本地的事务状态为“Commit”,其后向所有的参与者发送提交指令,其他参与者收到指令后立即执行提交操作。自此事务便可以认为已经成功了,就算某个参与者宕机,但其后在恢复阶段依然可以通过重做日志进行恢复。
而反之,如果有一个参与者响应“Non-Prepared”或者在规定时间内没有相应(超时),那么则认定本次事务是失效的,随即持久化本地事务状态为“Abort”发起回滚指令,要求各个参与者进行回滚

存在哪些问题?

首先从功能性的角度来看,这个思路确实能够解决对事务的统一协调,但如果真的以分布式场景的主要挑战———网络是不可靠的,这样一个角度来看,这个思路一定是会出现问题的。
而主要的问题就出现在“提交阶段”,从要进行的任务角度上来说这个阶段相较准备阶段要轻量级很多,但当在“协调者收到了所有参与者响应可以提交的时候,协调者成功的持久化了自己本地事务的状态准备向参与者发送提交指令”的这一刻由于网络中断而发送失败,那么将导致协调者本身可以正常的持久化数据,而其他的事务参与者丢失这些数据,如此产生了数据一致性问题。

还有,无论两个阶段的哪一个,协调者都至关重要,在这个过程中,由于协调者的存在是允许参与者宕机的,但如果是协调者宕机呢?毕竟协调者是一个单节点。

除此之外,还有所有参与者以及协调者在准备阶段都需要等待其他的参与者成功响应准备信号才能够进行接下来的操作,如此每次事务完成的时间都取决于最短板,这将是一种严重的性能影响。

三段式提交

相较于二段式提交而言,三段式主要是将“准备阶段”细分为了两个阶段,分别是“CanCommit”和“PreCommit”,而把提交阶段称之为“DoCommit”。其实主要是在二段式的准备阶段中写重做日志是一件很重的操作,那么如果能有某种方法去预先的评估自己数据库当前状态,然后判断是否能够完成事务,那么就可以减少事务还需要回滚的几率。而如果事务回滚几率减少的话,那么即使后面协调者宕机,仍然可以激进的去选择提交事务以避免数据丢失。

"CanCommit"
阶段的具体标准可以根据系统的设计和要求而有所不同,但一般来说,以下是可能用于判断事务能否提交的一些通用标准和考虑因素:

  • 本地事务状态:检查本地事务的执行状态,确保没有发生严重错误,例如数据库连接失败、死锁等。如果本地事务执行失败,可能会导致整个事务无法提交。
  • 数据库一致性:确保提交事务后,数据库仍然保持一致性。这可能涉及到检查外键约束、唯一性约束等数据库级别的约束条件,以及业务逻辑上的一致性要求。
    并发控制:确保事务提交时不会违反并发控制机制。例如,检查是否持有了必要的锁,或者在基于多版本并发控制(MVCC)的系统中,确保事务读取的数据版本是合适的。
  • 事务隔离级别:考虑事务的隔离级别,确保当前事务的执行不会违反所选择的隔离级别。不同的隔离级别规定了事务之间的可见性和影响程度,而"CanCommit"阶段需要保证这些规定得到遵守。
  • 全局事务状态:根据协调者的指示和全局事务状态,判断当前参与者是否满足全局事务的提交条件。协调者可能会提供一些全局条件,例如两阶段提交(2PC)中的预提交指示。
  • 事务日志:如果系统使用了事务日志,检查日志中的信息,确保日志中记录的操作与当前数据库状态一致。这有助于崩溃恢复和保证事务的持久性。
  • 业务逻辑一致性:根据具体的业务规则和逻辑,确保事务提交后系统仍然满足业务的一致性要求。这可能需要对业务逻辑进行定制的检查。

因此三段式主要做了:

  1. CanCommit,参与者综合数据库状态判断能否完成事务操作,如果有一个响应不行,就终止事务
  2. PreCommit,参与者写重做日志
  3. DoCommit,如果各个参与者都完成了重做日志,则再由协调者与参与者发起提交。

存在哪些问题?

虽然在CanCommit阶段预先评估了各个数据库的状态从而可以激进的在协调者宕机后提交状态,但并不能绝对的解决一致性问题,也就是说协调者宕机问题仍然存在,对于一致性的风险并没有改进。

最后,如果遇到事务需要回滚的场景,三段式肯定是要比二段式性能要更高的,但事务在正常提交的场景下两者依然存在严重的性能问题。说白了,它俩仍然有问题~

分布式事务

分布式事务相较于本地事务和全局事务,主要指的是多个服务同时访问多个数据源的场景,比如微服务下的场景就是这样。

CAP定理

CAP名称来源于三个关键词的缩写:

  • 一致性 Consistency,从各个服务节点读取到的数据是相同的

    • 强一致性(广义上的一致性),不管请求那个节点,必须得到最新值。(在一些关系型数据库中,必须要等待所有节点都同步完成,才会响应修改成功)
    • 最终一致性,不保证节点之间数据必须一致,而要求随着时间推移,数据是一致的
  • 可用性 Availability,每次请求都能正常的被响应
  • 分区容错性 Partition tolerance,即系统内各个节点由于网络故障原因导致不同节点互相隔离而产生不同的数据(分区),但系统要保证即使这样的情况下仍要向外提供服务。

而根据数学证明,这三个指标最多只能同时满足两个,即我们的系统只有三个选择:CA、CP、AP。但由于目前的网络是不可能绝对安全的,所以实际上并不能丢弃P,而目前的系统就只有要么损失高可用达到CP(也就是我们前面说的全局事务),要么损失一致性达到AP(即使数据不一致也要提供服务)。

而从业务的角度上,除了银行、证券这类设计到金钱交易的服务,宁可中断也不能容忍数据出现不一致的问题以外,大多数系统是不能容忍节点越多反而由于一致性问题导致可用性越低的。因此实际上大多数服务反而选择的是即使数据不一致也要提供服务的AP系统。

因此,当我们在讨论分布式事务的时候,一般研究的都不是如前面的本地事务、全局事务中的强一致性模型,只能退而求其次的去追求“最终一致性”。

而下面的“可靠消息队列”、“TCC事务”、“SAGA事务”都是基于非强一致而实现最终一致性的一些思路。而后续所有的业务场景均是:用户扣款 -> 仓库发货 -> 商家收款。

可靠消息队列

假设用户发起购买一个商品,已知要进行三个服务下的三个子事务,分别是:用户扣款 -> 仓库发货 -> 商家收款。那么我们第一步可以有一种优化性能的方式就是,根据已有的监控、日志去判断这三个子事务经常出现问题的是哪一个,比如当前系统经常遇到的问题是用户的余额不足,那么我们可以根据这些信息对这三个子事务进行排序,即让“用户扣款”这个子事务优先执行。

那么当账号服务扣款完成后,在自己的数据库中建立一张消息表,里面存放:

  • 事务ID:某UUID
  • 扣款:100元
  • 扣款状态:已完成
  • 仓库出库:商品ID
  • 仓库状态:进行中
  • 商家收款:100元
  • 商家状态:进行中

随后账号服务可以直接返回,提示前台“业务进行中”,然后账户服务在系统中建立一个消息服务,定期的去轮训消息表中的这一条,只要某个服务对应的状态还是“进行中”,那么就对其发送一条信息,一直等到对应的服务去请求账号服务告知完成结果。
那么无论账号服务向另外两个服务发送的信息丢失几次,最终一定会获得响应。当然,在其他服务处理账号服务的期间是需要确保幂等性的,保证对应操作只会去做一次。

而这种方法的主要思路就是:将最有可能出错的业务以本地事务的方法完成后,采用不断重试的方式来促使同一个分布式事务中的其他关联业务全部完成。

但这样的方式并非是没有问题的。如果高并发的去进行下单,而基于可靠消息队列下的各个请求之间并不具备隔离性,比如在短时间内两个用户同时在不同的服务下成功下单,但两个用户购买的额度之和则超出商品本身数量。

具象化一点就是:
用户A:账户服务A 扣款(购买50件) -> 仓库服务A(剩余90件)-> 商家服务打钱
用户B:账户服务A 扣款(购买50件) -> 仓库服务B(剩余90件)-> 商家服务打钱

在它们两个购买的瞬间,是无法对整个仓库服务中某个商品的数量进行互斥的!因此对于一个请求要和另外一个请求存在隔离性的需求,一般采用后续要讲的TCC事务。

TCC事务

TCC事务主要由:Try、Confirm、Cancel,三个单词组成。主要是为了解决不同请求之间的隔离性问题。它的思想主要是在代码层面把具体的业务拆分成三个步骤:

  1. Try 尝试执行阶段,调用所有服务的Try请求,将自己所需要的资源进行锁定
  2. Confirm 确定执行阶段,基于自己锁定的资源进行操作
  3. Cancel 取消执行阶段,释放自己锁定的资源

其中如果代入到我们的业务场景下就是,

  1. 第一步循环的去调用各个仓库服务,让各个仓库服务预扣除资源。假设目前仓库服务A的“遥遥领先手机”数量为1,冻结表中被冻结的“遥遥领先手机商品”记录为0,那么这样就满足当前的Try条件,然后在冻结表中加入记录,即声明当前冻结了“遥遥领先手机商品”1个数量。
  2. 如果Try步骤成功,那么执行Confirm阶段,向各个仓库服务循环发送幂等的扣除商品数量请求,并删除冻结的商品资源
  3. 如果Try步骤失败或网络超时以及其他原因,则执行Cancel阶段,对已预扣资源的服务进行补偿操作,即向各个仓库服务循环幂等的删除冻结的商品资源

只要Try阶段的冻结的资源是合法的,则Confirm阶段的扣除商品数量也一定是合法的。而恰好容易迷糊的点就是冻结操作是如何去做的,目前我查阅资料主要是通过在记录表中去记录冻结了多少个资源,但是如果两个用户恰好一人冻结了不同的一般仓库服务的资源,那么此次业务完全失败,则都需要执行一半数量的Cancel操作,那么只要仓库服务足够多,且商品竞争力度足够大,怕不是需要很久才能诞生一个用户恰好同时冻结全部库存服务的资源。所以如果客观的考虑解决超售问题,可能仅靠TCC事务的方式仍然不能解决。

此外,由于第一步的Try阶段需要循环在各个仓库服务中去执行冻结操作,我觉得完全可以将原有的资源数量分散给每个服务器,分而治之咯。

最后,实际上由于TCC带来的更高的开发成本和业务侵入性,通常并不会通过编码的方式去解决这样的问题,完全可以通过一些分布式事务组件来完成。

SAGA事务

简单的说,就是把一个很大的事务拆分成小事务,然后小事务分为正向操作和反向操作,即反向操作是进行补偿措施,可以理解为Redo Log和Undo Log的关系。
balabala~

最后

可能有部分理论存在理解偏差,还请告知,谢谢!

参考书籍:

《凤凰架构》 作者: 周志明

从传统的本地事务和全局事务到分布式事务的理论学习

https://ku-m.cn/index.php/archives/790/

作者

KuM

发布时间

2023-11-19

许可协议

CC BY 4.0

添加新评论