42讲电商系统的分布式事务调优
你好,我是刘超。
今天的分享也是从案例开始。我们团队曾经遇到过⼀个⾮常严重的线上事故,在⼀次DBA完成单台数据库线上补丁后,系统偶尔会出现异常报警,我们的开发⼯程师很快就定位到了数据库异常问题。
具体情况是这样的,当玩家购买道具之后,扣除通宝时出现了异常。这种异常在正常情况下发⽣之后,应该是整个购买操作都需要撤销,然⽽这次异常的严重性就是在于玩家购买道具成功后,没有扣除通宝。
究其原因是由于购买的道具更新的是游戏数据库,⽽通宝是在⽤户账户中⼼数据库,在⼀次购买道具时,存在同时操作两个数据库的情况,属于⼀种分布式事务。⽽我们的⼯程师在完成玩家获得道具和扣除余额的操作时,没有做到事务的⼀致性,即在扣除通宝失败时,应该回滚已经购买的游戏道具。
从这个案例中,我想你应该意识到了分布式事务的重要性。
如今,⼤部分公司的服务基本都实现了微服务化,⾸先是业务需求,为了解耦业务;其次是为了减少业务与业务之间的相互影响。
电商系统亦是如此,⼤部分公司的电商系统都是分为了不同服务模块,例如商品模块、订单模块、库存模块等等。事实上,分解服务是⼀把双刃剑,可以带来⼀些开发、性能以及运维上的优势,但同时也会增加业务开发的逻辑复杂度。其中最为突出的就是分布式事务了。
通常,存在分布式事务的服务架构部署有以下两种:同服务不同数据库,不同服务不同数据库。我们以商城为例,⽤图示说明下这两种部署:
通常,我们都是基于第⼆种架构部署实现的,那我们应该如何实现在这种服务架构下,有关订单提交业务的分布式事务呢?
分布式事务解决⽅案
我们讲过,在单个数据库的情况下,数据事务操作具有ACID四个特性,但如果在⼀个事务中操作多个数据库,则⽆法使⽤数据库事务来保证⼀致性。
也就是说,当两个数据库操作数据时,可能存在⼀个数据库操作成功,⽽另⼀个数据库操作失败的情况,我们⽆法通过单个数据库事务来回滚两个数据操作。
⽽分布式事务就是为了解决在同⼀个事务下,不同节点的数据库操作数据不⼀致的问题。在⼀个事务操作请求多个服务或多个数据库节点时,要么所有请求成功,要么所有请求都失败回滚回去。通常,分布式事务的实现有多种⽅式,例如XA协议实现 的⼆阶提交(2PC)、三阶提交(3PC),以及TCC补偿性事务。
在了解2PC和3PC之前,我们有必要先来了解下XA协议。XA协议是由X/Open组织提出的⼀个分布式事务处理规范,⽬前
MySQL中只有InnoDB存储引擎⽀持XA协议。
XA规范
在XA规范之前,存在着⼀个DTP模型,该模型规范了分布式事务的模型设计。
DTP规范中主要包含了AP、RM、TM三个部分,其中AP是应⽤程序,是事务发起和结束的地⽅;RM是资源管理器,主要负责管理每个数据库的连接数据源;TM是事务管理器,负责事务的全局管理,包括事务的⽣命周期管理和资源的分配协调等。
XA则规范了TM与RM之间的通信接⼝,在TM与多个RM之间形成⼀个双向通信桥梁,从⽽在多个数据库资源下保证ACID四个特性。
这⾥强调⼀下,JTA是基于XA规范实现的⼀套Java事务编程接⼝,是⼀种两阶段提交事务。我们可以通过源码简单了解下JTA 实现的多数据源事务提交。
⼆阶提交和三阶提交
XA规范实现的分布式事务属于⼆阶提交事务,顾名思义就是通过两个阶段来实现事务的提交。
在第⼀阶段,应⽤程序向事务管理器(TM)发起事务请求,⽽事务管理器则会分别向参与的各个资源管理器(RM)发送事务预处理请求(Prepare),此时这些资源管理器会打开本地数据库事务,然后开始执⾏数据库事务,但执⾏完成后并不会⽴刻 提交事务,⽽是向事务管理器返回已就绪(Ready)或未就绪(Not Ready)状态。如果各个参与节点都返回状态了,就会进
⼊第⼆阶段。
到了第⼆阶段,如果资源管理器返回的都是就绪状态,事务管理器则会向各个资源管理器发送提交(Commit)通知,资源管
理器则会完成本地数据库的事务提交,最终返回提交结果给事务管理器。
在第⼆阶段中,如果任意资源管理器返回了未就绪状态,此时事务管理器会向所有资源管理器发送事务回滚(Rollback)通知,此时各个资源管理器就会回滚本地数据库事务,释放资源,并返回结果通知。
但事实上,⼆阶事务提交也存在⼀些缺陷。
第⼀,在整个流程中,我们会发现各个资源管理器节点存在阻塞,只有当所有的节点都准备完成之后,事务管理器才会发出进
⾏全局事务提交的通知,这个过程如果很⻓,则会有很多节点⻓时间占⽤资源,从⽽影响整个节点的性能。
⼀旦资源管理器挂了,就会出现⼀直阻塞等待的情况。类似问题,我们可以通过设置事务超时时间来解决。
第⼆,仍然存在数据不⼀致的可能性,例如,在最后通知提交全局事务时,由于⽹络故障,部分节点有可能收不到通知,由于
这部分节点没有提交事务,就会导致数据不⼀致的情况出现。
⽽三阶事务(3PC)的出现就是为了减少此类问题的发⽣。
3PC把2PC的准备阶段分为了准备阶段和预处理阶段,在第⼀阶段只是询问各个资源节点是否可以执⾏事务,⽽在第⼆阶段, 所有的节点反馈可以执⾏事务,才开始执⾏事务操作,最后在第三阶段执⾏提交或回滚操作。并且在事务管理器和资源管理器中都引⼊了超时机制,如果在第三阶段,资源节点⼀直⽆法收到来⾃资源管理器的提交或回滚请求,它就会在超时之后,继续提交事务。
所以3PC可以通过超时机制,避免管理器挂掉所造成的⻓时间阻塞问题,但其实这样还是⽆法解决在最后提交全局事务时,由于⽹络故障⽆法通知到⼀些节点的问题,特别是回滚通知,这样会导致事务等待超时从⽽默认提交。
事务补偿机制(TCC)
以上这种基于XA规范实现的事务提交,由于阻塞等性能问题,有着⽐较明显的低性能、低吞吐的特性。所以在抢购活动中使
⽤该事务,很难满⾜系统的并发性能。
除了性能问题,JTA只能解决同⼀服务下操作多数据源的分布式事务问题,换到微服务架构下,可能存在同⼀个事务操作,分别在不同服务上连接数据源,提交数据库操作。
⽽TCC正是为了解决以上问题⽽出现的⼀种分布式事务解决⽅案。TCC采⽤最终⼀致性的⽅式实现了⼀种柔性分布式事务, 与XA规范实现的⼆阶事务不同的是,TCC的实现是基于服务层实现的⼀种⼆阶事务提交。
TCC分为三个阶段,即Try、Confirm、Cancel三个阶段。
Try阶段:主要尝试执⾏业务,执⾏各个服务中的Try⽅法,主要包括预留操作;
Confirm阶段:确认Try中的各个⽅法执⾏成功,然后通过TM调⽤各个服务的Confirm⽅法,这个阶段是提交阶段; Cancel阶段:当在Try阶段发现其中⼀个Try⽅法失败,例如预留资源失败、代码异常等,则会触发TM调⽤各个服务的
Cancel⽅法,对全局事务进⾏回滚,取消执⾏业务。
以上执⾏只是保证Try阶段执⾏时成功或失败的提交和回滚操作,你肯定会想到,如果在Confirm和Cancel阶段出现异常情况, 那TCC该如何处理呢?此时TCC会不停地重试调⽤失败的Confirm或Cancel⽅法,直到成功为⽌。
但TCC补偿性事务也有⽐较明显的缺点,那就是对业务的侵⼊性⾮常⼤。
⾸先,我们需要在业务设计的时候考虑预留资源;然后,我们需要编写⼤量业务性代码,例如Try、Confirm、Cancel⽅法;最后,我们还需要为每个⽅法考虑幂等性。这种事务的实现和维护成本⾮常⾼,但综合来看,这种实现是⽬前⼤家最常⽤的分布式事务解决⽅案。
业务⽆侵⼊⽅案——Seata(Fescar)
Seata是阿⾥去年开源的⼀套分布式事务解决⽅案,开源⼀年多已经有⼀万多star了,可⻅受欢迎程度⾮常之⾼。
Seata的基础建模和DTP模型类似,只不过前者是将事务管理器分得更细了,抽出⼀个事务协调器(Transaction Coordinator 简称TC),主要维护全局事务的运⾏状态,负责协调并驱动全局事务的提交或回滚。⽽TM则负责开启⼀个全局事务,并最终发起全局提交或全局回滚的决议。如下图所示:
按照Github中的说明介绍,整个事务流程为:
TM 向 TC 申请开启⼀个全局事务,全局事务创建成功并⽣成⼀个全局唯⼀的 XID;
XID 在微服务调⽤链路的上下⽂中传播;
RM 向 TC 注册分⽀事务,将其纳⼊ XID 对应全局事务的管辖;
TM 向 TC 发起针对 XID 的全局提交或回滚决议;
TC 调度 XID 下管辖的全部分⽀事务完成提交或回滚请求。
Seata与其它分布式最⼤的区别在于,它在第⼀提交阶段就已经将各个事务操作commit了。Seata认为在⼀个正常的业务下, 各个服务提交事务的⼤概率是成功的,这种事务提交操作可以节约两个阶段持有锁的时间,从⽽提⾼整体的执⾏效率。
那如果在第⼀阶段就已经提交了事务,那我们还谈何回滚呢?
Seata将RM提升到了服务层,通过JDBC数据源代理解析SQL,把业务数据在更新前后的数据镜像组织成回滚⽇志,利⽤本地事务的 ACID 特性,将业务数据的更新和回滚⽇志的写⼊在同⼀个本地事务中提交。
如果TC决议要全局回滚,会通知RM进⾏回滚操作,通过XID找到对应的回滚⽇志记录,通过回滚记录⽣成反向更新SQL,进
⾏更新回滚操作。
以上我们可以保证⼀个事务的原⼦性和⼀致性,但隔离性如何保证呢?
Seata设计通过事务协调器维护的全局写排它锁,来保证事务间的写隔离,⽽读写隔离级别则默认为未提交读的隔离级别。
总结
在同服务多数据源操作不同数据库的情况下,我们可以使⽤基于XA规范实现的分布式事务,在Spring中有成熟的JTA框架实现了XA规范的⼆阶事务提交。事实上,⼆阶事务除了性能⽅⾯存在严重的阻塞问题之外,还有可能导致数据不⼀致,我们应该 慎重考虑使⽤这种⼆阶事务提交。
在跨服务的分布式事务下,我们可以考虑基于TCC实现的分布式事务,常⽤的中间件有TCC-Transaction。TCC也是基于⼆阶事务提交原理实现的,但TCC的⼆阶事务提交是提到了服务层实现。TCC⽅式虽然提⾼了分布式事务的整体性能,但也给业 务层带来了⾮常⼤的⼯作量,对应⽤服务的侵⼊性⾮常强,但这是⼤多数公司⽬前所采⽤的分布式事务解决⽅案。
Seata是⼀种⾼效的分布式事务解决⽅案,设计初衷就是解决分布式带来的性能问题以及侵⼊性问题。但⽬前Seata的稳定性有待验证,例如,在TC通知RM开始提交事务后,TC与RM的连接断开了,或者RM与数据库的连接断开了,都不能保证事务的⼀致性。
思考题
Seata在第⼀阶段已经提交了事务,那如果在第⼆阶段发⽣了异常要回滚到Before快照前,别的线程若是更新了数据,且业务
⾛完了,那么恢复的这个快照不就是脏数据了吗?但事实上,Seata是不会出现这种情况的,你知道它是怎么做到的吗? 期待在留⾔区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他⼀起讨论。
精选留⾔ <br />![](https://cdn.nlark.com/yuque/0/2022/png/1852637/1646315648684-b0d9c5c7-e249-4b1d-9320-7d912119d105.png#)QQ怪<br />不太理解seata默认隔离级别为啥是未提交读,不怕脏读?还是为了保证性能才做的妥协?<br />2019-08-28 00:01<br />作者回复<br />默认情况下,seata认为⼤多数分布式业务涉及到脏读的可能性⽐较⼩,所以保证了⼤多数场景下的⾼效性。
如果需要达到全局的 读已提交,seata也提供了相应的机制来达到⽬的。
2019-08-28 09:37
-W.LI-
⽼师好!
Seata 设计通过事务协调器维护的全局写排它锁,来保证事务间的写隔离,⽽读写隔离级别则默认为未提交读的隔离级别。这个全局写排他锁⽀持那⼏种锁啊?
⾏锁,表锁,间隙锁,元数据锁别的记不起来了如果⽀持的锁粒度不够吞吐量也会降低很多吧。
2019-08-27 08:08
作者回复
赞,全局写排它锁是根据resourceId + table + pks实现。
2019-08-28 09:56
-W.LI-
课后习题:全局写锁,第⼀阶段没有准确的提交或者回滚前,后续业务⽆法持有锁。我本来还想问下⽼师这个是怎么做到的,不过⽼师最后写了嘿嘿省的问了。默认读为提交,不怕脏读么?
TCC协议具体每⼀步怎么做讲⼀下么⽼师?
已订单⽀付为例,
try:尝试预扣处理,怎么预扣呢。⽤redis锁库存还是直接怎么锁。(抢购装备,游戏币和装备都在try阶段锁定。冲突⼤的后try,提交的时候冲突⼤的先commit?)。
try阶段,如果同时有多个事务进⾏try操作都能try成功么?如果⽀持try成功感觉有可能出现课后问题的情况。try这⼀步很重要啊
,需要保证try以后,⼀定能提交成功,也⼀定能回滚。会不会有万⼀的?万⼀兜底解决是⼈⼯处理么?
2019-08-27 08:05
作者回复
对的,我们⾸先要使⽤重试机制,其次保证记录⽇志。
2019-08-28 10:03
再续啸傲
⽂中“如果 RM 决议要全局回滚,会通知 RM 进⾏回滚操作”,按照学习后的理解,应该是TC决定是否要进⾏全局回滚,不知道我理解的是否有偏差,忘⽼师指正
2019-09-04 08:44
作者回复
对的,已修正
2019-09-04 19:21
Jxin
1.第⼀次听到TCC,感觉也是两阶段提交的思想,只是把询问变成try操作。
2.⾄于补偿,感觉mq的⽅式更像是在补偿。(在这⾥,因为mq是最终⼀致所以个⼈觉得更像补偿)
3.分布式事务框架还有个lcn,事务的搬运⼯。
2019-09-04 00:31
任鹏斌
⽼师有个问题阿⾥的开源分布式⽅案是事务管理器是单点的,如果挂掉了会不会引起事务不⼀致?
2019-09-03 16:49
作者回复
会的,我在⽂中已经提到了
2019-09-04 19:30
N
⽼师您好,微服务间在事务中通过dubbo调⽤的⽅式是不是也可以实现分布式事务?
2019-09-01 23:00
作者回复
可以的,Seata中有个基于dubbo实现的分布式事务的例⼦,有兴趣可以⾃⼰参考⼿动实践⼀下
2019-09-02 19:45
灿烂明天
⽼师好,我看⽹上有些是⽤mq消息中间件来解决分布式事务的,其实这个⽅案能不能解决分布式事务问题的?他的思想是基于
tcc的吗?
2019-08-28 08:52
作者回复
可以的,⽬前很多团队⽤过MQ实现分布式事务,也是基于TCC的思想实现。
2019-08-28 09:41
许童童
分⻚式事务中常⽤的⽅法:
1.⼆阶段提交
2.三阶段提交
- TCC事务
- Seata(有待验证)
2019-08-27 14:53
晓杰
全局写锁,如果线程还没有提交或者回滚事务,其他线程⽆法获得锁
⽼师,默认的隔离级别是读未提交,不是会发⽣脏读吗,这⾥是不是有问题
2019-08-27 14:12
作者回复
默认情况下,seata认为⼤多数分布式业务涉及到脏读的可能性⽐较⼩,所以保证了⼤多数场景下的⾼效性。
2019-08-28 09:42
疯狂咸⻥
⽼师,会讲Paxos算法么,⾯试经常会问道
2019-08-27 13:42
作者回复
可以考虑加餐
2019-08-30 10:04
疯狂咸⻥
⽼师是全能!
2019-08-27 13:30
JackJin
seata⼀阶段提交拿 全局锁 尝试被限制在⼀定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。这句话不理解,麻烦⽼师解答⼀下!
2019-08-27 11:18
作者回复
这句话在哪看到的呢,没有太理解。seata是根据⼀个全局事务ID进⾏上下⽂传播的。
2019-08-28 09:47
JackJin
⼀阶段本地事务提交前,需要确保先拿到 全局锁 。拿不到 全局锁 ,不能提交本地事务。
拿 全局锁 的尝试被限制在⼀定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。以⼀个示例来说明:
两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进⾏更新操作,m 的初始值 1000。
tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的 全局锁 ,本地提交释放本地锁。 tx2 后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的 全局锁 ,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待 全局锁 。
2019-08-27 11:14
LW
思考题:全局事务ID,应该是参照数据库的事务ID来实现⼀致性的吧
2019-08-27 10:43
作者回复
对的,赞
2019-08-28 09:48
密码123456
通过查询未提交读的事务版本号?
2019-08-27 08:26
作者回复
差不多,由事务协调器维护的全局写排他锁,来保证事务间的写隔离。
2019-08-28 09:52