Skip to content

昆仑数据库分布式事务处理机制和原理

kunlundb edited this page Oct 9, 2021 · 1 revision

昆仑分布式数据库的分布式事务处理机制基于经典的两阶段提交算法,并在此基础上增强了其容灾能力和错误处理能力,以便做到任意时刻昆仑数据库集群的任意节点宕机或者网络故障、超时等都不会导致集群管理的数据发生不一致或者丢失等错误。另外,全局一致的MVCC也将在0.9版本完成,不过这部分内容我们过段时间再详述。

之所以要把分布式事务提交分成两阶段,就是为了避免部分节点在执行事务提交期间发生故障导致如下的错误,这些错误都会导致用户数据丢失或者出错:

  1. 一个分布式事务的一部分事务分支被提交,另一部分事务分支被回滚

  2. 应答给客户端事务提交成功,但是分布式事务所有分支全部被回滚

  3. 应答给客户端事务被回滚,但是分布式事务部分或者全部分支被提交

  4. 存储节点故障恢复时,某个存储节点的事务分支不能被正确地恢复。

在上面这些错误源中,第#4类错误处理由存储节点自身负责,分布式事务处理机制负责处理前3类错误,也是本文讨论的内容。对于第#4类错误,笔者曾经在FOSDEM 2021做过一次技术分享,https://fosdem.org/2021/schedule/event/mysql_xa/,国内视频连接在:昆仑分布式数据库 MySQL XA事务处理的容灾技术 (https://b23.tv/h7zzmR)以后也会陆续撰文详述。

经典的两阶段提交算法及其缺陷

两阶段提交(two phase commit,2PC)算法把分布式事务的提交分为preapre和commit两个阶段,第一阶段事务管理器GTM发送prepare命令给所有的resource manager(RM),每个RM就prepare分布式事务的本地分支,对于数据库系统来说也就是把它们的WAL日志刷盘以便即使RM宕机,在其恢复之后仍然可以提交(或者回滚)这些prepared状态的事务分支。prepare一个事务之后,这个事务进入prepared状态,之后既可以commit它,也可以rollback它。如果GTM收到所有的RM返回的都是成功,那么GTM就执行两阶段提交的第二阶段,即发送commit给每个参与的RM,于是RM就提交其prepared状态的事务分支,这样就完成了分布式事务的两阶段提交;如果第一阶段执行prepare命令时有RM返回了错误,那么GTM就发送rollback给所有参与者RM,让它们回滚GT的事务分支。

问题是,如果两阶段提交流程中发生GTM或者RM宕机等故障,那么这个两阶段提交流程就可能中断并且无法正确地继续的问题,导致上述#1,#2,#3描述的错误,这就会导致用户数据不一致或者出错。我们昆仑分布式数据库研发团队对这个经典的两阶段提交流程做了优化以便解决这些问题,达到坚不可摧的容灾能力。

昆仑数据库分布式事务处理模块和组件

一个昆仑分布式数据库集群包含若干个彼此独立且功能相同的计算节点做分布式事务处理(也就是本文的主要内容)和分布式查询处理;还包含若干个存储集群存储用户数据分片,每个存储集群使用高可用机制确保节点宕机数据不丢失; 还有一个结构与存储集群完全相同的元数据集群,它存储着这个集群的关键元数据信息,包括本文所说的commit log。最后还有一个cluster_mgr模块, 负责维护集群运行状态,并且处理因为节点故障而残留的prepared 状态的事务分支,后面这部分会在本文详述。

昆仑数据库分布式事务处理功能涉及的模块分布在计算节点,存储集群和元数据集群和cluster_mgr模块中(如 图1)。计算节点包含全局事务管理器(Global Transaction Manager,GTM),它掌握着一个计算节点中正在运行的每一个客户端连接(即Session,会话)中正在执行的分布式事务GT的内部状态,关键信息包括事务GT读写了哪些存储集群(storage shard),以及全局事务ID等。下图中的GT1,GT2内部状态为:GT1在存储集群1上执行的事务分支T11做了读写操作,在存储集群2上执行的事务分支T12做了写入操作;GT2在存储集群1上执行的事务分支T21做了只读操作,在存储集群2上执行的事务分支T22做了写入操作。

计算节点的GTSS后台进程负责成组批量写入全局事务的commit log日志到元数据集群中。 昆仑数据库会确保每一个记录了Commit log的全局事务GT,都一定会完成提交。具体的两阶段提交流程见下文,本节先把相关模块介绍完。 元数据集群也是一个高可用的MySQL 集群,它的commit log记录着每一个两阶段提交的事务的提交决定。这些提交决定是给cluster_mgr做错误处理使用的,实际生产系统场景下极少会真的用到,但是其信息非常关键。只有当计算节点或者存储节点发生宕机、断电等故障和问题时,才会被cluster_mgr用来处理残留的prepared状态的事务分支。

存储集群是一个MySQL在存储集群中,mysql的会话(THD)对象内部,包含分布式事务分支(简称 XA事务)的状态,在下图中存储节点1包含分布式事务GT1的事务分支GT1.T11和GT2的事务分支GT2.T21的本地执行状态;存储节点2包含分布式事务GT1的事务分支GT1.T12和GT2的事务分支GT2.T22的本地执行状态。

最后是cluster_mgr, 它是一个独立进程,借助元数据集群中的元数据,与存储集群和计算节点交互,辅助它们工作。在分布式事务处理这个场景下,它负责处理因为计算节点和/或存储节点宕机而残留的prepared状态的事务分支,根据每个事务分支所属的全局事务的commit log来决定提交或者回滚其事务分支。具体会在下文详述。

图1. 昆仑数据库分布式事务处理涉及的功能模块和组件

两阶段提交流程

在用户发送begin transaction给计算节点时,计算节点会在其内部开启一个新的分布式事务GT对象--- GTM会为这个分布式事务GT建立内部状态。然后在GT事务运行期间首次读写一个存储集群时,GTM会发送包含XA START在内的若干条SQL语句启动GT在这个存储集群中的事务分支,并初始化事务状态。然后发送DML语句来读写数据。计算节点会对收到的SQL语句做解析、优化、执行并计算应该向哪些目标分片发送什么样的SQL语句完成局部数据读写工作,只读写确实含有目标数据的存储集群。计算节点与一组存储节点的通信总是做异步通信,确保存储节点并发执行SQL语句。----不过这部分内容不是本文讨论的重点。

在一个分布式事务GT执行commit之前如果发生了昆仑数据库集群中的节点、网络故障或者存储节点的部分SQL语句执行出错,那么计算节点的GTM会回滚事务GT及其在存储节点上的所有事务分支,GT相当于没有被执行过,它不会对用户数据造成任何影响。

下面详述计算节点执行客户端发送commit的语句的分布式事务提交流程。事务提交的正常情况流程(时序图)见图2。

图2. 两阶段提交正常运行的时序图

第一阶段

当客户端/应用发送commit语句时,GTM根据分布式事务GT的内部状态选择提交策略 --- 当GT写入的存储集群少于2个时,对GT访问过的所有存储集群执行一阶段提交。在MySQL中这个SQL语句是XA COMMIT ... ONE PHASE;在分布式事务做一阶段提交过程中如果发生任意节点宕机,那么这些节点本地完成恢复即可正常工作,用户数据不会错乱、不一致。具体来说,如果宕机的节点包含那个唯一做过写入的节点WN,那么WN完成本地恢复后,如果GT在WN的事务分支TX被恢复了,那么GT的全部改动(全部在TX中)就是生效的,否则GT的全部改动(全部在TX中)就没有生效 --- 无论如何GT的原子性都是保持的。

如果宕机的节点全部都是GT的只读节点那么GT的任何改动都没有丢失,也不会造成GT的状态出错或者数据不一致。执行只读事务分支的存储节点重启并完成恢复后,那些之前运行中的为只读事务保留的undo log都会被InnoDB自动purge,其他之前运行时的内部状态全部在内存中,随着重启已经都消失了,因此完全可以忽略只读事务一阶段提交的任何错误。所以这种情况下,对其唯一的写入节点的commit语句可以正常继续执行。

当GT写入的存储集群不少于2个时,GTM对GT写入过的所有存储集群执行两阶段提交,并且对GT只读访问过的每个存储集群执行一阶段提交。执行两阶段提交时,第一阶段全部返回成功后才会执行第二阶段的提交(XA COMMIT) 命令,否则第二阶段会执行XA ROLLBACK命令回滚所有两阶段提交的事务分支。

批量写Commit log

在开始第二阶段提交之前,GTM会请求GTSS进程为每个GT写入commit log并且等待其成功返回。只有成功为GT写入commit log后GTM才会对GT开始第二阶段提交,否则直接回滚这些prepared状态的事务分支。GTM在每个后端进程(backend process是PostgreSQL术语,也就是执行一个用户连接中的SQL语句的进程,每个用户连接绑定一个后端进程) 中会把每个要开始第二阶段提交的分布式事务的ID等关键信息放入GTSS的请求队列然后等待GTSS通知请求完成。GTSS会把请求队列中所有的commit log写入请求转换为一条SQL insert语句发给元数据集群,该集群执行insert语句完成commit logging并向GTSS确认成功,然后GTSS即可通知每一个等待着的后端进程开始第二阶段提交。

如果commit log写入失败那么计算节点会发送回滚命令(XA ROLLBACK) 让存储集群回滚GT的事务分支。如果commit log 写入超时那么计算节点会断开与存储集群的连接以便让cluster_mgr事后处理。所有确认写入commit log的分布式事务一定会完成提交,如果发生计算节点或者存储节点故障或者网络断连等,那么cluster_mgr 模块会按照commit log 的指令来处理这些prepared 状态的事务分支。

元数据节点会不会成为性能瓶颈?

一定会有读者担心,把所有计算节点发起的分布式事务的commit log写到同一个元数据集群中,那么元数据集群会不会成为性能瓶颈?会不会出现单点依赖?

经过验证并与我们的预期相符的是:在100万TPS的极高吞吐率情况下,元数据集群也完全不会成为性能瓶颈。

具体来说,在1000个连接的sysbench测试满负荷运行时,GTSS批量写入commit log的这个组的规模通常在200左右,其他的工作负载以及相关参数配置(默认cluster_commitlog_group_size = 8 和cluster_commitlog_delay_ms=10)下,这个规模可能更大或者更小。考虑到每行commit log数据量不到20个字节(是与工作负载无关的固定长度),也就是200个存储集群的分布式事务会导致元数据集群执行一个写入约4KB WAL日志的事务,那么即使集群整体TPS达到100万每秒,元数据集群也只有5千TPS,每秒写入20MB WAL日志,对于现在的SSD存储设备来说是九牛一毛,完全可以负担的。所以即使存储集群满负荷运行,元数据集群的写入负载仍然极低 --- 元数据集群不会成为昆仑数据库集群的性能瓶颈。GTSS会最多等待cluster_commitlog_delay_ms毫秒以便收集至少cluster_commitlog_group_size个事务批量发送给元数据集群。通过调整这两个参数可以在commit log组规模和事务提交延时之间取得平衡。

第二阶段

当commit log写入成功后,GTSS进程会通知所有等待其commit log写入结果的用户会话(连接)进程,这些进程就可以开始第二阶段提交了。

第二阶段中,GTM向当前分布式事务GT写入过的每个存储节点并行异步发送提交(XA COMMIT)命令,然后等待这些节点返回结果。无论结果如何(断连,存储节点故障),GTM都将返回成功给用户。因为第二阶段开始执行就意味着这个事务一定会完成提交。甚至如果第二阶段进行过程中计算节点宕机或者断网了那么这个事务仍将提交,此时应用系统后端(也就是数据库的客户端)会发现自己的commit语句没有返回直到数据库连接超时(通常应用层也会让终端用户连接超时)或者返回了断连错误。

错误处理

在生产环境的分布式数据库集群的工作场景中,通常只有不到0.01%的分布式事务提交会发生错误,但是我们仍然需要处理所有可能发生的错误。因为哪怕执行了100亿笔事务,只要有1笔发生了提交错误,都会导致用户数据出错。数据库系统就是要确保事务永远正确地提交,ACID保障始终成立,没有例外。这对于分布式数据库系统来说,会比单机数据库更加复杂,因为可能的错误来源更多(多个计算节点和多个存储节点,及其之间的网络连接)。这也是为什么数据库系统的设计和实现会如此复杂,而分布式数据库系统的涉及和实现更加复杂。

下面,我们就看一下昆仑分布式数据库集群如何处理分布式事务提交过程中发生的错误。我们分别讲述两阶段提交的每个阶段的错误处理,以及批量写入commit log的错误处理。

第一阶段

图3. 第一阶段提交失败的处理

如果prepare阶段发生语句错误,网络断连或者超时,那么GTM会提交rollback记录请求给GTSS,并且不等待其返回结果就立刻发送rollback命令给出错的节点并且断连超时的连接,然后返回错误给客户端,告知客户端该事务GT被回滚。GTSS会在commit log中记录GT的提交指令为ROLLBACK, 这样cluster_mgr 随后处理GT的prepared事务分支时会回滚它们。

Commit logging 错误处理

图4. commit log写入失败的处理

如果GTSS写入commit log 出错或者超时,那么GTM会回滚GT的所有preapred事务分支,也就是发送XA ROLLBACK给 GT写入的所有存储集群,然后不论其结果如何都返回‘Aborted’给客户端标明GT被回滚了。即使XA ROLLBACK发送失败了那么这个事务分支仍然会按预期被cluster_mgr回滚。

第二阶段错误处理

图5. 第二阶段提交失败的处理

如果第二阶段发生网络错误或者超时,那么仍然返回提交成功给客户端。这是因为只要记录了commit log提交的任何分布式事务,都必须完成提交。如果执行第二阶段期间任何计算、存储节点发生宕机或者网络故障,那么cluster_mgr进程会根据commit log的指令,来处理这些事务分支 --- 如果指令是提交那么就提交GT的所有事务分支;如果指令是回滚或者无法找到GT的commit log,那么就回滚GT的所有事务分支。如果第二阶段进行过程中计算节点宕机或者断网了那么这个事务仍将提交,此时应用系统后端(也就是数据库的客户端)会发现自己的commit语句没有返回直到数据库连接超时(通常应用层也会让终端用户连接超时)或者返回了断连错误

延时损耗

由于两阶段提交的prepare和commit阶段都需要等待存储引擎flush WAL日志,并且在两个阶段之间还需要等待commit log写入元数据集群,所以两阶段提交的时耗一定比执行相同的SQL DML语句但做一阶段条会增加一些。 根据这个性能报告,昆仑数据库的两阶段提交在普通的服务器硬件配置和千兆网络情况下会增加约30毫秒的延时。这个30毫秒包括了commit log的写入,多执行一个阶段的等待时间以及所有这些额外步骤增加的网络通信时间开销等。这样的表现是非常好的,而且在商用服务器硬件和万兆网络环境下,或者在公有云平台等更好的网络和硬件上,可以确定这个延时增加会少于30毫秒。

总结

昆仑分布式数据库的分布式事务处理机制,确保了分布式事务执行和提交的一致性和容灾能力,在事务提交期间任何节点、网络故障都不会导致事务的ACID保障失效,从而确保了用户数据正确。在0.9版本中,昆仑数据库将支持全局MVCC一致性,那时将另外撰文介绍昆仑分布式数据库的全局MVCC的工作机制。