前言
一般,数据库事务的隔离级别会被设置成 读已提交,已满足业务需求,这样对应在 Fescar 中的分支(本地)事务的隔离级别就是 读已提交,那么 Fescar 中对于全局事务的隔离级别又是什么呢?如果认真阅读了 分布式事务中间件 Txc/Fescar-RM 模块源码解读 的同学应该能推断出来:Fescar 将全局事务的默认隔离定义成读未提交。对于读未提交隔离级别对业务的影响,想必大家都比较清楚,会读到脏数据,经典的就是银行转账例子,出现数据不一致的问题。而对于 Fescar,如果没有采取任何其它技术手段,那会出现很严重的问题,比如:
如上图所示,问最终全局事务 A 对资源 R1 应该回滚到哪种状态?很明显,如果再根据 UndoLog 去做回滚,就会发生严重问题:覆盖了全局事务 B 对资源 R1 的变更。那 Fescar 是如何解决这个问题呢?答案就是 Fescar 的全局写排它锁解决方案,在全局事务 A 执行过程中全局事务 B 会因为获取不到全局锁而处于等待状态。对于 Fescar 的隔离级别,引用官方的一段话来作说明:
全局事务的隔离性是建立在分支事务的本地隔离级别基础之上的。在数据库本地隔离级别 读已提交 或以上的前提下,Fescar 设计了由事务协调器维护的 全局写排他锁,来保证事务间的 写隔离,将全局事务默认定义在 读未提交 的隔离级别上。我们对隔离级别的共识是:绝大部分应用在 读已提交 的隔离级别下工作是没有问题的。而实际上,这当中又有绝大多数的应用场景,实际上工作在 读未提交 的隔离级别下同样没有问题。在极端场景下,应用如果需要达到全局的 读已提交,Fescar 也提供了相应的机制来达到目的。默认,Fescar 是工作在 读未提交 的隔离级别下,保证绝大多数场景的高效性。
下面,本文将深入到源码层面对 Fescar 全局写排它锁实现方案进行解读。Fescar 全局写排它锁实现方案在 TC(Transaction Coordinator) 模块维护,RM(Resource Manager) 模块会在需要锁获取全局锁的地方请求 TC 模块以保证事务间的写隔离,下面就分成两个部分介绍:TC- 全局写排它锁实现方案、RM- 全局写排它锁使用
一、TC—全局写排它锁实现方案
首先看一下 TC 模块与外部交互的入口,下图是 TC 模块的 main 函数:
上图中看出 RpcServer 处理通信协议相关逻辑,而对于 TC 模块真实处理器是 DefaultCoordiantor,里面包含了所有 TC 对外暴露的功能,比如 doGlobalBegin(全局事务创建)、doGlobalCommit(全局事务提交)、doGlobalRollback(全局事务回滚)、doBranchReport(分支事务状态上报)、doBranchRegister(分支事务注册)、doLockCheck(全局写排它锁校验)等,其中 doBranchRegister、doLockCheck、doGlobalCommit 就是全局写排它锁实现方案的入口。
/**
* 分支事务注册,在注册过程中会获取分支事务的全局锁资源
*/
@Override
protected void doBranchRegister(BranchRegisterRequest request, BranchRegisterResponse response,
RpcContext rpcContext) throws TransactionException {
response.setTransactionId(request.getTransactionId());
response.setBranchId(core.branchRegister(request.getBranchType(), request.getResourceId(), rpcContext.getClientId(),
XID.generateXID(request.getTransactionId()), request.getLockKey()));
}
/**
* 校验全局锁能否被获取到
*/
@Override
protected void doLockCheck(GlobalLockQueryRequest request, GlobalLockQueryResponse response, RpcContext rpcContext)
throws TransactionException {
response.setLockable(core.lockQuery(request.getBranchType(), request.getResourceId(),
XID.generateXID(request.getTransactionId()), request.getLockKey()));
}
/**
* 全局事务提交,会将全局事务下的所有分支事务的锁占用记录释放
*/
@Override
protected void doGlobalCommit(GlobalCommitRequest request, GlobalCommitResponse response, RpcContext rpcContext)
throws TransactionException {
response.setGlobalStatus(core.commit(XID.generateXID(request.getTransactionId())));
}
上述代码逻辑最后会被代理到 DefualtCore 去做执行
如上图,不管是获取锁还是校验锁状态逻辑,最终都会被 LockManger 所接管,而 LockManager 的逻辑由 DefaultLockManagerImpl 实现,所有与全局写排它锁的设计都在 DefaultLockManagerImpl 中维护。首先,就先来看一下全局写排它锁的结构:
private static final ConcurrentHashMap<String, ConcurrentHashMap<String, ConcurrentHashMap<Integer, Map<String, Long>>>> LOCK_MAP = new ConcurrentHashMap<~>();
整体上,锁结构采用 Map 进行设计,前半段采用 ConcurrentHashMap,后半段采用 HashMap,最终其实就是做一个锁占用标记:在某个 ResourceId(数据库源 ID) 上某个 Tabel 中的某个主键对应的行记录的全局写排它锁被哪个全局事务占用。下面,我们来看一下具体获取锁的源码:
如上图注释,整个 acquireLock 逻辑还是很清晰的,对于分支事务需要的锁资源,要么是一次性全部成功获取,要么全部失败,不存在部分成功部分失败的情况。通过上面的解释,可能会有两个疑问:
1. 为什么锁结构前半部分采用 ConcurrentHashMap, 后半部分采用 HashMap?
前半部分采用 ConcurrentHashMap 好理解:为了支持更好的并发处理;疑问的是后半部分为什么不直接采用 ConcurrentHashMap,而采用 HashMap 呢?可能原因是因为后半部分需要去判断当前全局事务有没有占用 PK 对应的锁资源,是一个复合操作,即使采用 ConcurrentHashMap 还是避免不了要使用 Synchronized 加锁进行判断,还不如直接使用更轻量级的 HashMap。
2. 为什么 BranchSession 要存储持有的锁资源
这个比较简单,在整个锁的结构中未体现分支事务占用了哪些锁记录,这样如果全局事务提交时,分支事务怎么去释放所占用的锁资源呢?所以在 BranchSession 保存了分支事务占用的锁资源。
下图展示校验全局锁资源能否被获取逻辑:
下图展示分支事务释放全局锁资源逻辑
以上就是 TC 模块中全局写排它锁的实现原理:在分支事务注册时,RM 会将当前分支事务所需要的锁资源一并传递过来,TC 获取负责全局锁资源的获取(要么一次性全部成功,要么全部失败,不存在部分成功部分失败);在全局事务提交时,TC 模块自动将全局事务下的所有分支事务持有的锁资源进行释放;同时,为减少全局写排它锁获取失败概率,TC 模块对外暴露了校验锁资源能否被获取接口,RM 模块可以在在适当位置加以校验,以减少分支事务注册时失败概率。
二、RM- 全局写排它锁使用
在 RM 模块中,主要使用了 TC 模块全局锁的两个功能,一个是校验全局锁能否被获取,一个是分支事务注册去占用全局锁,全局锁释放跟 RM 无关,由 TC 模块在全局事务提交时自动释放。分支事务注册前,都会去做全局锁状态校验逻辑,以保证分支注册不会发生锁冲突。在执行 Update、Insert、Delete 语句时,都会在 sql 执行前后生成数据快照以组织成 UndoLog,而生成快照的方式基本上都是采用 Select…For Update 形式,RM 尝试校验全局锁能否被获取的逻辑就在执行该语句的执行器中:SelectForUpdateExecutor,具体如下图:
基本逻辑如下:
执行 Select … For update 语句,这样本地事务就占用了数据库对应行锁,其它本地事务由于无法抢占本地数据库行锁,进而也不会去抢占全局锁。
循环掌握校验全局锁能否被获取,由于全局锁可能会被先于当前的全局事务获取,因此需要等之前的全局事务释放全局锁资源;如果这里校验能获取到全局锁,那么由于步骤 1 的原因,在当前本地事务结束前,其它本地事务是不会去获取全局锁的,进而保证了在当前本地事务提交前的分支事务注册不会因为全局锁冲突而失败。
注:细心的同学可能会发现,对于 Update、Delete 语句对应的 UpdateExecutor、DeleteExecutor 中会因获取 beforeImage 而执行 Select..For Update 语句,进而会去校验全局锁资源状态,而对于 Insert 语句对应的 InsertExecutor 却没有相关全局锁校验逻辑,原因可能是:因为是 Insert,那么对应插入行 PK 是新增的,全局锁资源必定未被占用,进而在本地事务提交前的分支事务注册时对应的全局锁资源肯定是能够获取得到的。
接下来我们再来看看分支事务如何提交,对于分支事务中需要占用的全局锁资源如何生成和保存的。首先,在执行 SQL 完业务 SQL 后,会根据 beforeImage 和 afterImage 生成 UndoLog,与此同时,当前本地事务所需要占用的全局锁资源标识也会一同生成,保存在 ContentoionProxy 的 ConnectionContext 中,如下图所示。
在 ContentoionProxy.commit 中,分支事务注册时会将 ConnectionProxy 中的 context 内保存的需要占用的全局锁标识一同传递给 TC 进行全局锁的获取。
以上,就是 RM 模块中对全局写排它锁的使用逻辑,因在真正执行获取全局锁资源前会去循环校验全局锁资源状态,保证在实际获取锁资源时不会因为锁冲突而失败,但这样其实坏处也很明显:在锁冲突比较严重时,会增加本地事务数据库锁占用时长,进而给业务接口带来一定的性能损耗。
三、总结
本文详细介绍了 Fescar 为在 读未提交 隔离级别下做到 写隔离 而实现的全局写排它锁,包括 TC 模块内的全局写排它锁的实现原理以及 RM 模块内如何对全局写排它锁的使用逻辑。在了解源码过程中,笔者也遗留了两个问题:
1. 全局写排它锁数据结构保存在内存中,如果服务器重启 / 宕机了怎么办,即 TC 模块的高可用方案是什么呢?
2. 一个 Fescar 管理的全局事务和一个非 Fescar 管理的本地事务之间发生锁冲突怎么办?具体问题如下图,问题是:全局事务 A 如何回滚?
对于问题 1 有待继续研究;对于问题 2 目前已有答案,但 Fescar 目前暂未实现,具体就是全局事务 A 回滚时会报错,全局事务 A 内的分支事务 A1 回滚时会校验 afterImage 与当前表中对应行数据是否一致,如果一致才允许回滚,不一致则回滚失败并报警通知对应业务方,由业务方自行处理。
参考
Fescar 官方介绍
fescar 锁设计和隔离级别的理解
姊妹篇:分布式事务中间件 TXC/Fescar—RM 模块源码解读
本文作者:中间件小哥阅读原文
本文为云栖社区原创内容,未经允许不得转载。