本文是我最初于2017年发表在我的个人微信公众号里面,现发布在这里。
1.1. 概述
TDSQL XA全局事务(global transaction)是指用户客户端连接到TDSQL XA分布式数据库系统后发起和执行的事务,也就是TDSQL XA处理的分布式事务。一个全局事务可能会写入数据到多个后端mysql 数据库实例,每个实例上面的本地事务都是这个全局事务的事务分支(transaction branch)。客户端发起全局事务提交时,运行在TDSQL XA的网关模块中的全局事务管理器会控制该事务访问的所有后端mysql数据库实例完成两阶段提交。
TDSQL XA的全局事务的隔离级别最高可以达到serializable级别,条件是网关与后端MySQL连接中设置隔离级别为serializable。在select 语句总是使用事务锁做并发控制的情况下(本文全部内容均假设mysql使用innodb存储引擎,后文不再赘述),网关与后端mysql的连接中设置的隔离级别就是全局事务的隔离级别。 根据用户在数据库会话中设定的隔离级别的不同,TDSQL XA的全局事务可以达到read committed, repeatable read, 或者serializable隔离级别。
一个TDSQL XA集群中可以有任意多个网关实例,并且连接到每个网关上的客户端连接都可以发起分布式事务。并且我们为了避免网关实例之间的通信开销和因此导致的脆弱性,网关被设计成不做任何实例间通信。但即便如此,只要所有连接都是用serializable隔离级别那么TDSQL XA执行的事务仍然可以达到可串行化隔离级别。
1.2. 全局可串行化
为什么本地事务以serializable隔离级别运行就可以确保全局事务的serializable隔离级别?在介绍如何做到这一点之前,先介绍一下TDSQL XA的网关的两点重要的内部设计:
- 独立的后端连接
对于连接到网关的每个客户端连接,网关会向这个连接当中的语句访问的每一个后端DB发起一个独立的连接。并且每一个变量设置会传播到后端的所有连接中。比如,如果你在客户端设置了set tx_isolation=”serializable”; 那么这个设置会被网关设置到你的客户端连接对应的每一个网关与后端DB的连接当中。
- 语句串行执行
网关解析一条SQL语句后如果决定发送处理后的语句到多个后端DB,那么这个发送操作是并行的,也就是不需要等待每个DB返回结果就会发到下一个DB。但是,只有收到全部本条语句的执行结果并且汇集后,才返回结果给客户端。然后,网关才会接收和处理下一条客户端发过来的语句。
上面两点看似简单平常,但是它们是网关能够保障每个后端DB的局部事务调度结果产生相同的全局事务调度结果的关键。
举例来说,假设有并发执行的全局事务GT1和GT2,它们的隔离级别都被设置为serializable,根据#1,网关与后端的连接上面隔离级别也都设置为serializable了。GT1和GT2在set1上面更新同一行,并且在set2上各自插入不同的插入一行,事务分支: GT1 {T11, T12},GT2{T21, T22}。
Set1Set2GT1T11:update t1 set a=100 where pk=1T12:insert into t2 values(100,200);GT2T21:update t1 set a=200 where pk=1T22:insert into t2 values(10,20);
由于T11与T21并发更新同一行,如果T11先更新,那么T11会拿到pk=1的那行(标注为R1)的事务锁直到GT1结束提交才释放,然后T21才能拿到R1的行事务锁开始执行,然后T22执行。所以执行顺序就是 GT1->GT2(T11->T12->T21->T22);类似地,假如是T21先拿到了R1的行锁,那么执行顺序就将是 GT2->GT1(T21->T22->T11->T12)。也就是说,运行在每个MySQL实例上的事务锁调度机制可以确保全局事务的串行执行。
我们可以得出这个推论:全局事务的本地事务分支的依赖关系也是全局事务的依赖关系。这里的依赖关系就是事务锁的等待关系。按照上例来说,T11先拿到R1的事务锁,那么T21就依赖于T11,于是也能够导致GT2依赖于GT1。也就是说这点是成立的:
T11-> T21 ==> GT1->GT2 (1)
网关内部设计“#2 语句串行执行”正是确保这点的关键。
根据数据库事务处理的基本理论,如果某个并发事务调度机制可以让具有依赖关系的事务构成一个有向无环图(DAG),那么这个调度就是可串行化的调度。由于每个后端DB都在使用serializable隔离级别,所以每个后端DB上面并发执行的事务分支构成的依赖关系图一定是DAG。使用上面的推论(1),每个后端DB上面并发执行的事务分支的依赖关系图通过图的合并操作就自然形成了TDSQL XA所处理的并发执行的全局事务的依赖关系图GTG;如果这个图GTG是一个有向无环图,那么这些全局事务一定是在可串行化隔离级别下运行的;如果GTG有环,那么在serializable隔离级别下一定则会发生死锁,并且很可能是全局死锁,那么innodb死锁处理机制和TDSQL XA的全局死锁处理机制就会解除这些死锁。我会另外撰文讲解TDSQL XA的全局死锁处理机制。
如果一个事务在read-committed隔离级别下运行,它的读锁在当前select语句结束后就释放;在repeatable-read隔离级别下,其读锁在事务结束时刻才释放。根据上面的推论(1),可以轻易得出:对TDSQL XA来说,其本地mysql事务在read-committed/repeatable-read隔离级别下运行时,TDSQL XA的全局事务也是在read-committed/repeatable-read隔离级别下运行。
1.2.1. 假想基于MVCC的全局可串行化机制
使用serializable隔离级别时innodb就不再使用MVCC做查询了,而是基于锁,即使你不在select语句中加上for updates/lock in share mode。如果要基于MVCC实现TDSQL XA 的可串行化隔离级别是有巨大代价的,这个代价主要是集群的性能损失以及可靠性损失。因此我们并没有这样做。不过出于QQ号买号平台技术探索的兴趣,我们可以想想假如要做到这一点应该怎么做,有什么问题。
为了实现可串行化隔离级别,我们就需要一个全局事务id分发机制或者队列产生全局顺序。比如PGXL的GTM就是全局事务ID生成器,而这个GTM实例就是一个单点故障源。即使再为它实现容灾,它也仍然是一个性能和可扩展性瓶颈。
同时,由于mysql innodb使用MVCC做select(除了serializable和for update/lock in share mode子句),还需要将这个全局事务id给予innodb做事务id,同时,还需要TDSQL XA集群的多个set的innodb 共享各自的本地事务状态给所有其他innodb(这也是PGXL 所做的),任何一个innodb的本地事务的启动,prepare,commit,abort都需要通知给所有其他innodb实例。只有这样做,集群中的每个innodb实例才能够建立全局完全有一致的、当前集群中正在处理的所有事务的状态,以便做多版本并发控制。这本身都会造成极大的性能开销,并且导致set之间的严重依赖,降低系统可靠性。这些都是我们要极力避免的。
1.3. Select语句的数据一致性
如果mysql 的连接上面隔离级别不是serializable并且select 语句不使用for update/lock ins hare mode子句的话,其实mysql(innodb)使用的是多版本并发控制(MVCC) 。但是在使用分布式事务的情况下,使用MVCC是有问题的。
这是因为innodb 的MVCC只针对同一个mysqld进程内的事务有效,innodb并不能知道一个本地事务分支所属的全局事务在其他innodb实例当中的事务分支的状态(active, committed, prepared, etc),因此有可能查询到一个未完全提交的全局事务的改动---只有本地事务分支完成了提交,其他mysql实例上面还没有完成提交。归根到底的原因是无法得到全局一致性快照,但是如上节所述,全局一致性快照的维护代价极其昂贵,并不适合OLTP系统。
举例来说,假设有并发执行的全局事务GT1和GT2,它们在set1和set2的分支分别是GT1 {T11, T12},GT2{T21, T22},并且在set1和set2上面GT1查询的行和GT2更新的行都有交集。GT1 做跨set 的select时,GT2正在提交。如果select使用mvcc的话,网关发送GT1的select语句到set1上面时,T11的快照包含T21的改动,因为T11在set1上面启动的时候T21此刻已经完成本地提交;网关发送GT1的select语句到set2上面时,T12的快照不包含T22的改动,因为T12在T22完成提交之前就已经启动了。这样的话,GT1的这个select语句就会查询到GT2的T21的更新,而GT2还没有提交完成因为GT2的T22还未完成提交,所以GT1的这个select语句不包含GT2的T22的更新。也就是说GT1读取到了GT2的一部分更新但是本应该读到的另外一部分更新结果却因为T22尚未提交而没有读取到。这就是一个一致性问题。在使用事务锁做select的情况下,这个问题就不会出现了。
本例中如果GT1的select语句本来也只会访问到set1上面的数据,那么尽管GT2.T22并未完成提交,那么对于TDSQL XA来说也不算是一致性问题,这是因为在TDSQL XA中,只要commit log中记录好了要提交的事务就一定会完成提交,所以尽管GT1读取到GT2.T21的改动时GT2还没有完成提交,但是GT2一定会提交,并且GT1也并不需要(不可能)读取到GT2当中未完成提交的事务分支的更新,也就是说GT1读取到的是完整的稳定的可靠的结果。在使用事务锁做select的情况下这个现象仍然会出现,无论使用的是哪个隔离级别,但是这是完全没问题的。以本例具体来说,如果GT1只在set1上面运行,并且GT1与GT2.T21有事务锁冲突,那么只要GT2.T21完成提交,那么GT1就可以继续执行,即使此刻GT2.T22还没有完成提交。由于GT2必然会完成提交并且GT1所使用的GT2的更新是稳定可靠的并且时完整的,所以GT1可以正确地执行。
在使用MVCC做select查询的情况下,做两阶段提交的全局事务只能做到最终一致性,MVCC有可能读取到没有完全提交(i.e.在所有参与的set上都完成提交)的GT的部分改动。一般情况下,这个不一致的时间窗口很小;需要agent提交时会时间窗口会比较长。对一致性要求高的话,就是用serializable隔离级别。
1.3.1. 解决方案
为了解决上述问题,XA事务做select就不能使用MVCC。这需要每一个select语句使用锁来做并发控制,具体由两种办法,最简单的就是在网关与后端的连接当中设置隔离级别是serializable,这样所有的select自动都是加共享锁的。或者客户端对每个select语句都显示使用加锁子句: select ... lock in share mode/for update也可以。这样就可以读取一致性的数据,并且这样的好处是你可以在read committed 或者repeatable read隔离级别下对select使用事务锁以便确保select的数据一致性,缺点是对于现有应用来说需要修改所有的select查询语句。对上面的例子来说,这么做以后,T12到set2上面做select查询时候会被T22阻塞,直到T22也完成提交,也就是GT2完成提交。这样GT1查询到的结果就是已经提交的事务的结果。如果GT1只会查询到set1上面的数据也就是说T12不存在,那么GT1尽管在GT2提交完成之前就有可能读取到它在set1上面的改动,但是如前所述这是没问题的。
1.4. 结论
在select 语句总是使用事务锁做并发控制的情况下,网关与后端mysql的连接中设置的隔离级别就是全局事务的隔离级别。TDSQL XA的全局事务可以达到read committed, repeatable read, 或者serializable隔离级别。
如果select使用MVCC的话那么可能会有查询结果的数据一致性问题,这些问题可以通过让select获取事务锁来避免;如果select使用事务锁会更多地增加死锁的发送几率,并且一定程度上降低事务并发性能。这些都是为了数据一致性必须付出的代价。