数据库事务--分布式事务(4)
谈及分布式事务,就必须涉及多个服务,多个数据源。既然还是属于事务,那么目的还是尽可能保证数据一致性,只不过手段上,不能像传统事务那么简单的在单数据源上解决问题。
例子引入
下面笔者尝试根据自己有限的理论介绍清楚分布式事务,到底解决什么问题,各个解决方案有什么问题。
首先引入,分布式事务中常用例子,一个电商场景中用户购买商品,涉及仓储服务,订单服务,账户服务。
用代码来表示就是这样的:
1 | |
上面的执行顺序还有待考量,在实践中尽可能将本地操作,和最容易出错的操作先执行。如果我们可以保证每个 sql 都可以执行成功,那么确实也不需要什么额外措施。然而,物理机器和网络是不可靠的,任意的 sql 执行失败,都会导致数据的不一致。
比如,我们很自然想到为方法加上本地事务(可以简单的为方法加上@Transaction 注解),当订单服务,库存服务的 sql 执行失败,都可以正确的回滚。而当账户服务的 sql 执行失败了,抛出了异常,订单服务可以正常回滚,然而库存服务无法感知到这一点,数据不一致了。
这里存在问题的根因是,不同服务之间的状态无法感知,那么,让服务之间互相感知一般有两种方式,一种是服务之间互相通信,另一种有一个中心管理者来管理所有服务,显然第一种方案的通信成本会随着服务数量增多而显著增加。所以,工程上,一般都是添加第三方管理者。
2PC
引入一个中心管理者,其核心思想其实就是二阶段提交。
第一阶段:各个微服务提交事务,并且将执行状态上报给中心管理者
第二阶段:中心管理者根据各个微服务的执行情况,给与反馈,只有所有微服务都成功了,才能提交分布式事务,否则就回滚
3PC
- 3pc 比 2pc 多了一个 can commit 阶段,减少了不必要的资源浪费。因为 2pc 在第一阶段会占用资源,而 3pc 在这个阶段不占用资源,只是校验一下 sql,如果不能执行,就直接返回,减少了资源占用。(这里资源占用是指对资源进行加锁了)
- 引入超时机制。同时在协调者和参与者中都引入超时机制。
2pc: 只有协调者有超时机制,超时后,发送回滚指令。
3pc: 协调者和参与者都有超时机制。
协调者超时: can commit,pre commit 中,如果收不到参与者的反馈,则协调者向参与者发送中断指令。
参与者超时: pre commit 阶段,参与者进行中断; do commit 阶段,参与者进行提交。
实践
基于上述的理论,工程上已经有多种模式可以使用了。
XA 模式
XA 其实可以理解为分布式事务处理(DTP,distribute transaction process)的一个处理规范协议,有很多数据库已经支持了。
XA 模式将组件分为三种角色
AP(application):开启全局事务的应用程序
TM(transaction manager):全局事务管理器,管理全局事务和各个子事务的状态
RM(resource manager):资源管理器,参与者需要实现 XA 协议,因此一般都是数据库来承担。可见这个模式对代码侵入性比较小
执行过程:
- 准备阶段:rm 本地提交本地子事务,但不写入最后的 commit record(即:不释放锁资源)。将执行结果通知给 tm
- 执行阶段:tm 根据各个子事务的结果,让 rm 们进行 commit/rollback
特点: - 强一致性
- 性能差劲:整个过程有三次持久化(准备阶段 rm 写 redo 日志,tm 做状态持久化,提交阶段 rm 写入提交日志),两次远程调用。而且具有木桶效应,最慢的 rm 不完成任务,其他 rm 不能释放锁资源
- 单点效应:rm 宕机了,可以在 rm 的提交结果过程引入超时机制,但 tm 的回复过程宕机了,无法处理。需要做好 tm 集群的高可用
AT 模式
在 AT 模式下,业务基本无感知的,都是通过 seate 做了一层代理来实现功能的。
事务执行过程:
- 准备阶段:Seata 会拦截“业务 SQL”,首先解析 SQL 语义,找到“业务 SQL”要更新的业务数据,在业务数据被更新前,将其保存成“before image”,然后执行“业务 SQL”更新业务数据,在业务数据更新之后,再将其保存成“after image”,最后生成框架行锁。以上操作全部在一个数据库事务内完成。
- 执行阶段:
- commit:将“before image”和“after image”删除,释放框架行锁
- rollback:将对应的数据和“after image”进行对比,如果数据一致,说明这个过程没有发生脏写(虽然有行锁,但是这是框架层面的,不能排除有其他数据库连接修改了数据,还是可能会脏写),直接把数据还原为“before image”。如果数据不一致,则必须人工介入
可以看出 AT 模式和 XA 模式特别像,都是 2PC 的典型应用,不过有两点不同:
- AT 模式的 RM 是业务应用,不是数据库处理的。
- AT 模式在准备阶段会直接提交本地事务,不会占用锁资源,避免了木桶效应。(这里值得一提的是,seata 虽然释放了本地锁资源,但是会保存一个框架级别的“全局锁”,这个锁是行锁,用于做写隔离效果的)
这个模式是一个使用比较广泛的分布式事务解决方案
TCC 模式
上面介绍的两种模式都是业务侵入性比较小的方案。也正是因为它的业务侵入性比较小,导致它的灵活性不够,只能基于 sql 维度解决问题。
TCC 主要有三个步骤,也是两个阶段:
- Try:预留资源
- Confirm:执行的业务操作提交
- Cancel:预留的资源释放
这三个步骤的划分都是基于业务上的,我们在使用这个模式的时候首先就得把业务模型拆为两阶段。
举一个扣 100 元的例子,try 阶段就得冻结用户 100 元,confirm 阶段就直接提交,cancel 阶段就撤销冻结操作。但是,“冻结”操作我们有很多实现方式,可以新建一张金钱冻结数据表,或者依据什么高可靠的消息队列,不需要像 XA 模式保留锁资源,或者像 AT 模式保留一个框架全局行锁。这种模式的灵活性高很多,带来的性能也会高很多。就是对业务侵入比较强。
在此基础上再提出几个只有再业务层面才能实现的技术点:
- 允许空回滚:try 操作失败了,rm 收到了 cancel 命令,此时 rm 没有发现事务 id 时也需要能回滚成功
- 防悬挂设计:悬挂的意思是 cancel 比 try 更先执行。比如,try 操作超时了,cancel 回滚成功,此时 try 命令又来了,rm 就不应该可以再执行 try 命令了,否则就会数据不一致
- 幂等性:其实,分布式系统的场景下,尽可能所有接口都设计成幂等的。因为网络是不稳定的,我们会通过重试操作来减少这种不稳定带来的影响,术语叫做“最大努力交付”。
这些技术点,基本都需要业务的参与。
SAGA 模式
这个模式的核心思想是将一个长的分布式事务,分解为一个个子事务,并且为子事务设计补偿操作。只要所有子事务可以正确提交,分布式事务就算正确提交了。如果有的子事务无法正确提交,那么有两种操作:
- 正向恢复:重复尝试失败的子事务,做到“最大努力交付”,这种适用于事务必须要成功的场景
- 反向恢复:执行目前已经执行了的子事务的补偿操作。如果补偿操作失败,也得做到“最大努力交付”。否则让人工介入
SAGA 必须保证所有子事务都得提交或者补偿,但 SAGA 系统本身也可能崩溃,所以系统本身也得做到高可用。
这个模式的业务侵入性也很强,因为补偿是在业务层面做的。这个和回滚不太一样,它需要业务的参与。比如扣 100 元钱,补偿是指加上 100 元,而回滚是撤销扣钱的操作。
总结
事务的目的是保证数据一致性,本地事务的执行过程保证了无论系统在什么场景下,数据都是一致的。
但是在分布式的场景下,可以看到,这四种模式有各自的优缺点,没有一种模式适合所有的场景,因地制宜才是最好的选择。
分布式系统的引入,带来了机遇,也带来了技术挑战,分布式系统在极端场景下需要人工的介入才能保证数据一致性。
当然,除了分布式事务这种“很重”的操作,在很多业务场景中,我们还是会选择使用“最终一致性”这种性能更高的方案,还是要因“场景”制宜的。
参考链接
seata 官网: Seata AT 模式
AT 模式的行锁:Seata AT模式原来是这样实现行锁的 - 掘金 (juejin.cn)
《凤凰架构》:凤凰架构:构建可靠的大型分布式系统 | 凤凰架构 (icyfenix.cn)