本人小白一个,不能保证博客中内容都准确,如果博客中有错误的地方,望各位多多指教,请指正。欢迎找我一起讨论
分布式事务知识总结
1、什么是分布式事务?
首先搞清楚本地事务(Local Transaction)
倘若将一个单一的服务(单体服务)操作作为一个事务,那么整个服务操作只能涉及一个单一的数据库资源,这类基于单个服务单一数据库资源访问的事务,被称为本地事务
分布式事务
一个请求到达服务1,然后必须要这三个服务共同完成对请求的这些事才算一个事务,也就是说这三个服务做的事要么同时成功,要么同时失败。
(官话解释:分布式事务指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上,且属于不同的应用,分布式事务需要保证这些操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。)
2、分布式事务中,为什么数据库不能保证AICD?
数据库只能保证对应服务事务的ACID,无法保证其他服务事务的ACID。
如果服务2宕机了,服务2的事务回滚,但是无法请服务1 和服务3的事务回滚,此时就出问题。
3、那怎么解决上面的分布式事务的问题呢?
首先搞清楚CAP定理
在分布式系统中,一致性、可用性、分区容错性,无法同时兼顾,必须有所取舍,而一般情况下,分区容错无法避免,因此可以认为 CAP 的 P 总是成立。所以在保证分区容错性的情况下,要么保证一致性,要么保证可用性。
一致性和可用性,为什么不可能同时成立?
答案很简单,因为可能通信失败(即出现分区容错)。
CP:如果保证服务器2 的一致性,那么 服务器1 必须在写操作时,锁定 服务器2 的读操作和写操作。只有数据同步后,才能重新开放读写。锁定期间,服务器2 不能读写,没有可用性。一个保证了CP而一个舍弃了A的分布式系统,一旦发生网络故障或者消息丢失等情况,就要牺牲用户的体验,等待所有数据全部一致了之后再让用户访问系统。设计成CP的系统其实也不少,其中最典型的就是很多分布式数据库,他们都是设计成CP的。在发生极端情况时,优先保证数据的强一致性,代价就是舍弃系统的可用性。分布式系统中常用的Zookeeper也是在CAP三者之中选择优先保证CP的。
AP:如果保证 服务器2 的可用性,那么势必不能锁定 服务器2,所以一致性不成立。要高可用并允许分区,则需放弃一致性。一旦网络问题发生,节点之间可能会失去联系。为了保证高可用,需要在用户访问时可以马上得到返回,则每个节点只能用本地数据提供服务,而这样会导致全局数据的不一致性。这种舍弃强一致性而保证系统的分区容错性和可用性的场景和案例非常多,12306买票等
C:一致性Consistency
写操作之后的读操作,必须返回该值
一致性分为:
强一致性:要求更新过的数据能被后续的访问都能看到
弱一致性,能容忍后续的部分或者全部访问不到
最终一致性,经过一段时间后要求能访问到更新后的数据
CAP中说的一致性指的是强一致性
比如:某条记录是 value1,用户向 服务器1 发起一个写操作,将其改为value2。
现在问题来了,如果此时 用户向 服务器2 发起一个读操作,哦吼,服务器2 的值没有改变 ,返回的是value1,此时读取 服务器 1 和服务器 2 的值不一样,出问题呀!!!
那怎么办?
为了保持一致,那就只能在服务器 1 在做写操作的时候,发一条消息给服务器2,让服务器2也改变呗。
A:可用性Availability
只要收到用户的请求,服务器就必须给出回应
P:分区容错性Partition tolerance
分布式系统中各个子系统(一个子系统为一个区)都是通过网络通信,既然是通过网络通信,就会存在丢包啊、断网啊什么乱七八糟的问题,系统设计的时候,必须考虑到这种情况。一般来说,分区容错无法避免,因此可以认为 CAP 的 P 总是成立
再搞清楚BASE理论
对于分布式的架构模式,即使做不到强一致性,我们可以根据当前系统的业务,在可用性和一致性中做一个权衡,从而达到最终一致性
Basically Available(基本可用)、Soft state(软状态)、和 Eventually consistent(最终一致性)
Basically Available(基本可用):
什么是基本可用呢?假设系统,出现了不可预知的故障,但还是能用,相比较正常的系统而言:
响应时间上的损失:正常情况下的搜索引擎 0.5 秒即返回给用户结果,而基本可用的搜索引擎可以在 1 秒作用返回结果。
功能上的损失:在一个电商网站上,正常情况下,用户可以顺利完成每一笔订单,但是到了大促期间,为了保护购物系统的稳定性,部分消费者可能会被引导到一个降级页面(给个系统繁忙提示)。
Soft state(软状态):
什么是软状态呢?
相对于原子性而言,要求多个节点的数据副本都是一致的,这是一种 “硬状态”。
允许系统中的数据存在中间状态,并认为该状态不影响系统的整体可用性,即允许系统在多个不同节点的数据副本存在数据延时。
Eventually consistent(最终一致性)
系统能够保证在没有其他新的更新操作的情况下,数据最终一定能够达到一致的状态,因此所有客户端对系统的数据访问最终都能够获取到最新的值。
4、解决办法
上面那个东西(CAP定理和BASE理论)搞清楚了,现在就开始说分布式事务解决(保证一致性)方案
① 2PC(两阶段提交):由事务协调者和事务参与者组成
第一阶段: 预提交(预先锁定资源(订单、库存)、预先校验数据库、中间件服务是否正常)
事务协调者给每个参与者发送Prepare消息,参与者接收到消息后执行自己的本地事务,并写入本地的undolog日志表中,然后将执行结果发送给事务协调者。此时事务还没有commit
第二阶段:commit/rollback
事务协调者根据参与者发来的执行成功或者执行失败消息,给参与者发送commit 或者 rollback消息,参与者根据事务管理器的指令执行commit或者rollback操作,并释放锁资源。
值得注意的是:
1)第一阶段和第二阶段执行时间相隔很短
2)如果第二阶段出问题了,怎么办?
第二阶段有重试机制,重试不行记录日志,用定时任务进行一些补偿操作,实在不行,就根据日志发出报警,短信通知让人工操作。
3)请求为什么不直接去事务协调器?
业务请求由服务提供,每个服务都会注册到事务协调器,事务协调器只负责协调事务,与业务无关,业务请求由业务服务提供。
4)atomikos框架底层就是2pc,可解决分布式事务中垮库问题
大概过程就是 :
(1)将数据源交给atomikos,atomikos将其封装成atomikos数据源
(2)atomikos开启atomikos全局事务
(3)拿atomikos数据源做sql操作
(4)atomikos事务做commit 或者 rollback ===》 此处进行了2pc的commit / rollback 封装
5)2PC 在第一阶段会占用数据库资源,直到二阶段正常commit 或者 rollback 之后才会释放资源。
=======》所以说2pc存在缺陷:资源占用大,效率低
②三阶段提交(对2PC的改进)
第一阶段 can commit : 协调者向参与者发送询问信息,看看参与者可不可以commit,参与者接收信息给出答复 (不占资源)
第二阶段 pre commit :预提交
所有参与者均都回复的是可以,协调者想参与者发出预提交指令
参与者接收到指令,会执行事务操作,并写入本地的undolog日志表中
如果可以commit成功,就进行ack确认
有一个回复的是不可以或者等待超时后协调者无法收到参与者的回复,就放弃本次事务
第三阶段 do commit :真正提交
协调者向参与者发出do commit 指令 参与者收到do commit请求后,会正式执行事务提交,并释放整个事务期间占用的资源。参与者进行ack应答。
值得注意的是:
1)协调者和参与者都有重试机制
2)减少阻塞,因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit。而不会一直持有事务资源并处于阻塞状态
3)在docommit阶段,如果参与者无法及时接收到来自协调者的docommit请求时,会在等待超时之后,会继续进行事务的提交。(其实这个应该是基于概率来决定的,当进入第三阶段时,说明参与者在第二阶段已经收到了pre commit请求,那么协调者产生pre commit请求的前提条件是他在第二阶段开始之前,收到所有参与者的can commit响应都是Yes。(一旦参与者收到了PreCommit,意味他知道大家其实都同意修改了)所以,一句话概括就是,当进入第三阶段时,由于网络超时等原因,虽然参与者没有收到commit,但是他有理由相信:成功提交的几率很大)
③TCC补偿机制
事务协调器首先发起所有的分支事务的try操作,任何一个分支事务的try操作执行失败,事务协调器将会发起所有分支事务的Cancel操作,若try操作全部成功,TM将会发起所有分支事务的Confirm操作,其中Confirm/Cancel操作若执行失败,TM会进行重试。
分为三个阶段:
Try 阶段主要是对业务系统做检测及资源预留
Confirm 阶段主要是对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段
Cancel 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放
值得注意的是:
TCC中的每个服务都需要开发对应的Try 、confirm、cancel API接口
TCC整体性能高,锁加在了服务内部,并不是一个全局锁,锁的粒度小,有锁马上就释放了,提高了吞吐,不像2PC那样,并发上来,吞吐很低
通常情况下,采用TCC则认为Confirm阶段是不会出错的。即 :只要Try成功,Confirm一定成功。若Confirm阶段真的出错了,需引入重试机制或人工处理。
通常情况下,采用TCC则认为Cancel阶段也是一定成功的。若Cancel阶段真的出错了,需引入重试机制或人工处理。
由于Confirm和Cancel失败需进行重试,因此需要实现为幂等性是指同一个操作无论请求多少次,其结果都相同。
TCC框架 : ByteTCC
④Seata:Seata用于解决分布式事务
具体的去seata官网看吧 : http://seata.io/zh-cn/docs/overview/what-is-seata.html
和钱有关的一定要保证强一致性,可以不保证可用性
Seata AT模式 实现了强一致性(保证ACID)
Seata Saga模式实现了弱一致性(不保证隔离性,基于BASE理论实现最终一致性)
Seata AT模式
首先搞清楚几个概念:TC、RM、TM
TC(Transaction Coordinator): 事务协调器(seata-server),维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚。
TM(Transaction Manager): 控制全局事务的边界,负责开启一个全局事务,并最终发起全局提交或全局回滚的决议。
事务管理者(事务发起者),也是一个微服务,是大哥,充当全局事务的发起者(决定了全局事务的开启、回滚、提交等),凡是被@GlobalTransactional标注的微服务就可以看做成一个TM。 该微服务既是一个TM,也是一个RM
RM(Resource Manager): 控制分支事务,负责分支注册、状态汇报,并接收事务协调器的指令,驱动分支(本地)事务的提交和回滚。
资源管理者,可以理解为一个一个的微服务,也可叫做事务参与者
seata AT模式第一阶段:
1、开启全局事务
1.1 TM向TC申请开启全局事务,生成全局事务id XID,为每个RM生成一个分支事务id BID ,将全局事务id存入globaltable中
2、RM注册分支事务
2.1生成seata代理数据源
2.2生成sql执行器识别器
2.3通过识别器识别sql语句,生成对应的sql执行器
2.4关闭自动提交
2.5拿到行锁
2.6查询前置镜像 beforeImage
2.7执行业务sql
2.8查询后置镜像 afterImage
2.9根据前置镜像和后置镜像生成undolog对象,并存入到undolog表中
2.10争抢全局事务锁(去看一下写操作的主键id操作记录是不是已经存在locktable表中)
2.10.1没争抢到(表中已经有记录),产生事务冲突 (重试10次,每次sleep 30 ms)
2.10.1.1 本地事务回滚,释放行锁
2.10.1.2 回到2.5步
2.10.2抢到了,注册分支事务成功,提交本地事务
2.10将提交结果上报TC,释放行锁
seata AT模式第二阶段:
提交:如果第一阶段本地事务都没问题
1、TM向TC发起全局提交的指令
2、TC向每个RM发起全局提交指令,并且释放全局锁
3、RM收到 TC 的分支提交请求,把请求放入一个异步任务的队列中,马上返回提交成功的结果给 TC,异步任务阶段的分支提交请求将异步和批量地删除相应 UNDO LOG 记录。
回滚:如果有一个存在问题
1、TM向TC发起全局回滚的指令
2、RM收到 TC 的分支回滚请求,开启一个本地事务,执行如下操作。
2.1通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录
2.2数据校验: 拿 UNDO LOG 中的后镜与当前数据进行比较
如果有不同,说明数据被当前全局事务之外的动作做了修改。这种情况,需要人工处理
如果有相同,根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句(反向sql)
2.3提交本地事务。并把本地事务的执行结果(即分支事务回滚的结果)上报给 TC
2.4等待所有RM回滚完再释放全局锁
值得注意的是:
1、seata的全局隔离级别是读未提交
2、怎么防止脏写(写隔离)
第一阶段本地事务提交前需要先拿到全局锁,拿不到全局锁就提交不了全局事务,只能本地事务回滚,释放本地锁
3、怎么防止脏读(读隔离)
在数据库本地事务隔离级别 读已提交或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交
如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是业务方法加 @globalTransactional 或者 加@Transactional 和 @globalLock 一起,通过查询语句加 SELECT FOR UPDATE 。
SELECT FOR UPDATE 语句的执行会申请 全局锁 ,如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到,即读取的相关数据是 已提交 的,才返回。
4、seata AT模式第一阶段直接提交 ====> 就不用占用资源了,解决了资源占用问题 ====>为什么能这么做?====>因为日志中记录了回滚信息(undolog对象)
⑤消息队列+本地事件表
⑥消息队列+可靠消息服务方案(对⑤的改进)
⑦最大努力通知方案 (短信通知) -------- 百度一下 你就知道了
柔性事务,适用于最终一致性时间敏感度低的业务 且 被动方处理结果不影响主动方的处理结果
应用场景:银行通知、商户通知
番外:
全局事务开启流程:
springboot 整合seata
只要加@GlobalTransactional注解就行了,这就说明了什么问题?
肯定用到了AOP增强 和 springboot的自动装配
spring启动:
①order微服务的service用@GlobalTransactional注解标识
②order服务发起全局事务
③springboot的自动装配 去META-INF/spring.factories下的GlobalTransactionAutoConfiguration
④通过@Bean 注入GlobalTransactionScanner(全局事务扫描器)
GlobalTransactionScanner实现了InitializingBean(接口),调用afterPropertiesSet()方法初始化 RM 和 TM
GlobalTransactionScanner继承了AbstractAutoProxyCreator(AOP和事务的顶级父类) 通过postProcessAfterInitialization() 进行加强把标注了@GlobalTransactional注解所在的类生成代理对象,并且织入注解拦截器GlobalTransactionalInterceoer
⑤此时请求来了,拦截器 拦截下来
先获取目标对象
再通过反射获取Method
然后解析Method加没加@GlobalTransactional注解,加没加@GlobalLock注解
然后根据不同注解调用不同的方法
⑥如果加了@GlobalTransactional注解,就执行 handleGlobalTransaction() 方法
在此方法中,调用了execute方法,在此方法中:
1、 获取或者创建一个全局事务
2、获取全局事务注解信息
3、调用beginTransaction() 方法,开启全局事务
beginTransaction()干了什么?
1)、通过transactionManager.()方法去通过TM对象与seata-server通信,然后去数据库中的globalTable表中插入一条记录,然后返回一个全局事务id(xid)
2)、通过GlobalStatus.Begin,标识全局事务开启状态
3)、通过RootContext.bind(xid) 绑定全局事务id
⑦TMClient 通过netty和Seata-server交互(通信)
⑧在seata-server中 调用DefaultCoordinator类中的doGlobalBegin(),最终调用DefualtCore类中的begin()方法
begin()方法干了什么?
1)、创建GlobalSession (全局session)
2)、为GlobalSession添加监听器,通过SPI机制去读取META-INF/service目录下 的DataBaseSessionManager
3)、调用GlobalSession的begin()方法,在此方法中执行监听器的 lifecycleListener.onBegin(this) 方法,监听 onBegin方法开启事务
⑨ onBegin方法 中 拿到 DatabaseTransactionStoreManager 对象调用writeSession
通过SPI机制去读取META-INF/service下的io.seata.server.store.TransactionStoreManager
⑩writeSession 方法中拿到 LogStoreDataBaseDAO对象 调用 insertGlobalTransacttion()方法 ,最终通过JDBC原生的语句将全局事务的ID存入global_table表中
通过SPI机制去读取META-INF/service下的io.seata.core.store.LogStore
分支事务提交
①代理数据源拦截sql
seata-server中有一个statementProxy类,有个executeUpdate/executeQuery方法,该方法中通过ExecuteTemplate.execute方法去拦截sql
②生成对应的sql执行器识别器
③根据识别器识别类型,生成对应的sql执行器
④然后跳到executeAutoCommitTrue方法,在此方法中关闭自动提交,然后跳到executeAutoCommitFalse方法执行下面操作
⑤获取本地行锁, 查询生成前置快照(beforeImage)
⑥执行业务sql
⑦查询生成后置快照 afterImage
⑧根据beforeImage和afterImage 生成Undolog(日志回滚对象),将undolog对象插入undolog表
⑨ 调用register()方法 注册分支事务 去拿全局锁
在此方法中会判断是否注册分支事务成功:
如果不成功(全局锁冲突),回滚本地事务,释放本地锁,默认重试十次,每次sleep 30 ms
全局锁表示的是我们操作的数据库表+操作的数据主键(product:1)的记录会保存到seata-server的数据库的lock_table表中。 如果表中有这条数据说明其他事务在操作这条数据,你就不能操作,抛出异常回滚等待重试
如果成功,提交本地事务
在此处会判断是否提交本地事务成功:
如果不成功,向seata-server异常一阶段提交上报,释放本地锁
如果成功,向seata-server正常一阶段提交上报,释放本地锁