当前位置: 首页 > 科技观察

再来说说MySQL事务的两阶段提交

时间:2023-03-14 23:24:40 科技观察

回想那些年,高并发还没有那么普遍,分布式也没有那么流行。第一次接触两阶段提交源于希望以事务的方式修改MongoDB中的多个集合数据,而MongoDB本身并不支持事务。官方推荐的方案是使用两阶段提交。MySQL和事务长期以来一直是工作中不可或缺的一部分。随着分布式的流行,两阶段提交的出现越来越频繁。但是,MySQL、事务、两阶段提交这三个名词组合起来形成一个整体,距离第一次接触也不过一年时间。刚开始接触MySQL事务两阶段提交这个概念的时候,还是有点激动的。因为我在研究MongoDB的两阶段提交的时候,其实并没有看懂。没想到多年后,我在MySQL中发现了二阶段提交的身影,心里似乎有那种感觉:在人群中找TA千百遍,回头一看,那个人是在光线昏暗的地方。这篇文章,我们就来看看MySQL事务是如何实现两阶段提交的。本文内容基于MySQL8.0.29源码。文本1.什么是两阶段提交?两阶段提交是一种保证分布式事务原子性的协议。两阶段提交的执行过程涉及两个角色:ResourceManager,资源管理器,负责管理一些资源。对于数据库,这里的资源指的是数据。如果把分布式事务看成一个整体,每个资源管理器都会负责其中的一部分,即分布式事务的一个本地事务。资源管理器在分布式事务中的作用就是工作,所以我们可以称之为执行器。事务管理器,TransactionManager,负责管理分布式事务,协调事务的提交、回滚和崩溃恢复。在分布式事务中,事务管理器是顾全大局,指挥资源管理器工作的角色,所以我们可以称之为协调器。两阶段提交,顾名思义,会包括两个阶段:准备阶段,协调者会询问所有执行者是否可以提交事务。至此,每一个本地事务的执行其实都已经执行完毕,数据写入也已经成功,commit的最后一丝颤抖也短暂了。如果任何执行器由于其执行的本地事务出现问题而无法提交,则分布式事务无法提交,协调器将通知所有执行器执行回滚操作。如果每个执行者都回复协调者可以提交,分布式事务就会进入下一阶段,也就是提交阶段。在commit阶段,coordinator会通知executor进行commit。执行者收到提交通知后,提交自己的本地事务。所有executor都提交后,两阶段提交结束,执行分布式事务。以上只介绍了二阶段提交的正常流程。实际上,两阶段提交的复杂性就在于异常的流程处理。对二阶段提交的完整流程感兴趣的朋友可以自行查找相关资料。2、MySQL两阶段提交场景在MySQL中,两阶段提交有四种使用场景:场景一,外部XA事务,数据库中间件,应用作为协调者,MySQL数据库实例作为执行者。XA事务也是分布式事务。其他支持分布式事务的数据库实例,如Oracle和SQLServer,也可以和MySQL一起作为执行器。在该场景下,MySQL使用如下XA系列命令实现两阶段提交:XASTARTxid启动分布式事务。XAENDxid,表示分布式事务中的SQL已经执行完毕。XAPREPARExid,执行分布式事务提交的准备阶段。XACOMMITxid,执行分布式事务提交的提交阶段。XAROLLBACKxid,回滚分布式事务。以上命令的使用方法,可以参考官方文档的XATransactions部分,链接:https://dev.mysql.com/doc/refman/8.0/en/xa.html场景2、内部XA事务单个MySQL实例,不启用binlog日志时,SQL语句涉及多个支持事务的存储引擎。TC_LOG_MMAP类对象充当协调者,多个支持事务的存储引擎充当执行者。TC_LOG_MMAP会打开一个名为tc.log的磁盘文件,通过MMAP映射到内存中,记录分布式事务的xid。场景三,单个MySQL实例的内部XA事务,没有启用binlog日志,SQL语句只涉及一个支持事务的存储引擎。在这种场景下,不需要两阶段提交,但是为了统一起见,还是会按照两阶段提交结构进行提交操作。TC_LOG_DUMMY类对象作为协调者,不记录xid,存储引擎作为执行者。从DUMMY可以看出,TC_LOG_DUMMY是一个变相的协调器。场景四,单个MySQL实例的内部XA事务,启用binlog日志,SQL语句涉及一个或多个支持事务的存储引擎。MYSQL_BIN_LOG类对象作为协调器,将分布式事务的xid记录在binlog日志文件中。binlog日志和存储引擎作为执行者。binlog日志和存储引擎都是独立的单元。为了保证多个存储引擎之间以及存储引擎与binlog日志之间的数据一致性,在事务提交时,这些操作要么提交,要么回滚。XA是required交易实现的。InnoDB是MySQL最常用的存储引擎。为了支持主从架构,还必须开启binlog日志。这是MySQL最常用的场景。下面以InnoDB存储引擎+binlog日志为例,介绍MySQL内部XA事务的两阶段提交过程3.Prepare阶段在prepare阶段之前,InnoDB已经完成了表中数据的写操作,这只是承诺或回滚这最后一次颤抖的问题。在prepare阶段,binlog日志和InnoDB主要做了这些事情:在prepare阶段,binlog日志中没有什么可做的,InnoDB主要做的是修改事务和undo段的状态,并记录xid(分布式事务的ID)。InnoDB会将事务对象在内存中的状态变为TRX_STATE_PREPARED,并将内存中事务的undo段对应的对象状态变为TRX_UNDO_PREPARED。修改完内存中各个对象的状态后,还没有完成,还要将事务undo段对应的段头页中UndoSegmentHeader的TRX_UNDO_STATE字段值改为TRX_UNDO_PREPARED。然后,将xid信息写入当前事务对应的日志组UndoLogHeader中的xid区域。修改TRX_UNDO_STATE字段值,写入xid。这两个操作必须修改撤销页面。在修改undopage之前,会先记录Redolog。4.commit阶段(1)commit阶段整体介绍。当到达提交阶段时,事务即将结束。写操作(包括增删改)已经完成,内存中的事务状态被修改,undo段的状态也被修改,xid信息也被写入UndoLogHeader,prepare阶段产生的Redolog已经写入Redologfiles。由于log_flusher线程每秒都会刷新一次磁盘,此时事务产生的重做日志可能已经刷新到磁盘中,也可能还停留在重做日志文件的操作系统缓冲区中。剩下的收尾工作包括:Redologflushing。事务的binlog日志从暂存点复制到binlog日志文件中。binlog日志文件刷新。InnoDB事务提交。为了保证主从数据的一致性,在同一个事务中,上面列出的收尾工作必须串行执行。redo和binloglogflushing都涉及磁盘IO。如果每提交一个事务,就刷新事务中的redolog和binloglog,这样会涉及到大量的IO操作,数据量很小。频繁的小量IO操作非常消耗磁盘读写性能。为了提高磁盘IO效率和事务提交效率,MySQL从5.6开始引入了binlog日志组提交功能。在5.7中,原本在prepare阶段进行的redologflushing操作被迁移到了commit阶段。binlog日志组提交有什么神奇之处,又是如何提高磁盘IO效率的呢?引入binlog日志组提交功能后,将提交阶段细分为3个子阶段。对于每个子阶段,这个子阶段可以有多个事务,写日志&刷盘操作可以结合起来:在flush子阶段,Redo日志可以一起刷,binlog日志可以写到binlog日志文件放在一起而不加锁。在sync子阶段,binlog日志可以一起flush。在提交子阶段,重做日志可以一起刷新。通过合并redologflushing操作、binloglog写入日志文件、binloglogflushing操作,可以将小数据量的多IO改为大数据量的少IO,提高磁盘IO效率。对比一下生活中的场景:这相当于大家从自己开车上下班到坐公交或者地铁上下班。道路上的汽车数量减少,通行效率大大提高,不会出现堵车的情况。既然要结合Redo和binlog日志的写入和刷写操作,就必须有一个manager负责协调这些操作。如果引入单独的协调线程,则会增加额外的开销。MySQL的解决方案是将同一个子阶段的事务线程分为两个角色:领导线程,第一个进入某个子阶段的事务线程是该子阶段当前组的领导线程。Follower线程,即第二个及以后进入某个子阶段的事务线程,都是该子阶段当前组的follower线程。事务线程是指事务所在的线程。我们可以把事务线程看作是事务的容器,一个线程执行一个事务。Leader线程管理事物的方式不是指挥Follower线程工作,而是帮助Follower线程完成所有工作。commit被细分为3个子阶段后,每个子阶段都会有一个队列记录该子阶段有哪些事务线程。为了保证先进入flush子阶段的事务线程必须先进入sync子阶段,先进入sync子阶段的事务线程必须先进入commit子阶段,每个子阶段都会持有一个mutex。接下来我们就来看看这三个子阶段是干什么的。(2)Flush子阶段在flush子阶段,第一个进入flush队列的事务线程将成为leader线程。第二个及后续进入flush队列的事务线程将成为follower线程。follower线程会进入等待状态,直到从commit子阶段收到leader线程的通知,才会苏醒,继续执行后续操作。leaderthread会获取一个mutex,保证flush子阶段在同一时间只有一个leaderthread。互斥量存储在MYSQL_BIN_LOG类的Lock_log属性中,我们将其称为Lock_log互斥量。flush子阶段leader线程的主要工作流程如下:第一步,清空flush队列。在清空之前,flushqueue会先被锁住,所有之前进入flushqueue的事务线程成为一个group。加锁后,flush队列会被清空。清除后,进入flush队列的事务线程属于下一组,之后第一个进入flush队列的事务线程将成为下一组的leader线程。需要注意的一点是,当前组的leader线程持有的Lock_log锁要到sync阶段才会释放。如果下一组的leader线程在当前组的leader线程释放Lock_log锁之前进入flush队列,则下一组的leader线程会阻塞,直到当前组的leader线程释放Lock_log锁。第二步:执行redologflushing操作,将InnoDB产生的redolog全部刷到磁盘。第三步,遍历leader线程牵头的一组follower线程,将follower线程中事务产生的binlog日志写入binlog日志文件。每个事务执行过程中产生的binlog日志,首先会被写入事务线程中专门用来存放事务binlog日志的临时磁盘文件,这是binlog日志在事务线程中的临时存储点。binlog日志磁盘临时文件有一个IO_CACHE(内存缓冲区),默认大小为32K。binlog日志会先写入到IO_CACHE中,IO_CACHE中的日志写满后会写入到一个临时磁盘文件中,然后清空IO_CACHE,以便后续写入binlog日志。在第二阶段提交的flush子阶段之后,每个事务产生的binlog日志会按照事务提交的先后顺序从暂存点复制到binlog日志文件中。binlog日志文件还有一个IO_CACHE,大小为8K。binlog日志从暂存点复制到binlog日志文件中,也是先写入IO_CACHE,IO_CACHE中的日志写满后写入binlog日志文件,然后清空IO_CACHE以便后续复用binlog日志。第一步,flushqueue已经提前清空了,如何遍历leader线程带领的一组follower线程呢?不用担心,既然领导者线程负责,它自然需要知道哪些追随者线程在它的组中。在每一个线程对象(thd)中,都会有一个next_to_commit属性,指向紧接着加入flush队列的线程。只要知道leader线程,根据每个线程的next_to_commit属性,就可以找到leader线程带领的一组follower线程。第四步,将binlog日志文件IO_CACHE中最后剩余的日志复制到binlog日志文件中。在将binlog日志从暂存点复制到binlog日志文件的过程中,必须先写入IO_CACHE,IO_CACHE中的日志写满后才会复制到binlog日志文件中。执行完步骤3后,binlog日志文件的IO_CACHE可能没有满,里面的日志不会复制到binlog日志文件中。所以第4步的存在就是将binlog日志文件IO_CACHE中最后剩余的小于8K的日志复制到binlog日志文件中。flush子阶段将binlog日志从暂存点复制到binlog日志文件,并没有flush到磁盘,而是写入binlog日志文件的操作系统缓冲区。(3)同步子阶段同步子阶段也有一个队列,就是同步队列。第一个进入同步队列的事务线程是同步子阶段的领导线程。进入同步队列的第二个和后续事务线程是同步子阶段的跟随者线程。flush子阶段完成后,其领导线程进入sync子阶段。flush子阶段的leader线程来到sync子阶段后,会先加入sync队列,然后它的follower线程也会一个个加入sync队列。flush子阶段的leader线程和它的follower线程加入sync队列后,leader线程会释放自己持有的Lock_log互斥锁。如果flush子阶段的leader线程在加入sync队列之前sync队列为空,则它会重新成为sync子阶段的leader线程,否则,它和它的所有follower线程都会成为sync子阶段的follower线程。在同步子阶段,领导线程仍然完成所有工作。follower线程一直处于等待状态,直到从commit子阶段收到leader线程的通知,才会退出等待状态,进行后续操作。leader线程开始工作前,会获取Lock_sync互斥锁,保证同一时刻只有一个leader线程处于sync子阶段。同步子阶段领导线程的主要工作流程如下:第一步,等待更多事务线程进入同步子阶段。只有满足一定的条件,领导线程才会进入等待过程。在介绍必须满足什么条件之前,我们先来看看leader线程为什么会有这个等待过程?前面提到binlog日志组提交的目的是将多个事务线程聚集在一起,然后将这些事务产生的redologs和binloglogs一起flush,从而提高磁盘的IO效率。leader线程的等待过程还是把更多的事务线程聚集在一起,从而积累更多的binlog日志,一起刷盘。如果没有这个等待过程,当第一个事务线程进入sync队列成为leader线程后,不管其他事务线程是否加入sync队列,都会不停的执行后续的流程。当数据库繁忙时,在leader线程开始执行follow-upprocess之前,可能会有很多其他事务线程加入sync队列成为它的follower线程。这种情况下leader线程有很多follower线程,它把这些follower线程的binlog日志一起flush,可以提高磁盘IO效率。当数据库不是那么忙的时候,在leader线程开始执行后续流程之前,可能没有或者只有很少的事务线程加入sync队列成为它的follower线程。在这种情况下,leader线程仍然只能将少量的binlog日志一起flush,binlog日志组提交功能对磁盘IO效率的提升不是那么明显。为了在数据库不太忙的情况下尽可能提高binlog日志组提交的效率,引入了leader线程的条件等待流程。此条件由系统变量sync_binlog控制。sync_binlog表示binlog日志刷新的频率。sync_binlog=0,MySQL不会主动发起binlog日志刷新操作。只需要将binlog日志写入binlog日志文件的操作系统缓冲区,操作系统决定何时执行刷写操作。sync_binlog=1,sync子阶段各组的leader线程都会触发flush操作。这意味着只要每个事务提交成功,binlog日志也必须刷到磁盘。sync_binlog=1是著名的双1设置之一。sync_binlog=N,sync子阶段每N组只会触发一次flush操作。也就是说,在执行一次磁盘刷盘操作后,group1到N-1的leaderthreads不会执行binlog日志刷盘操作。当到达第N组时,其leader线程会将第1组到第N组的所有事务线程产生的binlog日志一起flush。源码中有一个变量sync_counter,用来记录自sync子阶段最后一次磁盘操作后,有多少组leader线程没有进行过磁盘操作。每当leader线程没有进行磁盘操作时,sync_counter变量的值就会加1。只要leader线程执行了磁盘操作,sync_counter变量的值就会被清零,重新开始计数。重要的一点来了。如果某个组的leader线程判断sync_counter+1>=sync_binlog条件为真,那么leader线程就会执行刷盘操作。在刷盘之前,会触发等待更多事务线程进入同步子阶段的等待过程。也就是说,sync子阶段的leader线程在flushbinlog日志前会进入等待过程。目的是聚集更多的follower线程,一起flush更多的binlog日志。我们现在知道leader线程为什么要等待,什么情况下需要等待,需要等多久?等待过程持续多长时间由2个系统变量控制。binlog_group_commit_sync_delay,单位微秒,表示sync子阶段的leader线程在执行binlog日志文件flushing操作之前需要等待多少微秒,默认值为0。如果其值为0,则表示跳过等待过程。如果它的值大于0,leader线程会等待binlog_group_commit_sync_delay毫秒。但是在等待过程中,leader线程会定时检查sync队列中的事务线程数是否大于等于系统变量binlog_group_commit_sync_no_delay_count的值。只要binlog_group_commit_sync_no_delay_countsync的值大于0,并且队列中的事务线程数大于等于这个系统变量的值,就立即停止等待,开始执行第二步及后续操作。检查同步队列事务线程数的时间间隔为:除以binlog_group_commit_sync_delay用10得到结果,单位为微秒,如果结果大于1微秒,则时间间隔为1微秒。第2步,清除同步队列。在它被清除之前,同步队列会被锁定,在此之前进入同步队列的所有事务线程成为一个组。锁定后,同步队列将被清除。清除后,进入同步队列的事务线程属于下一组,之后第一个进入同步队列的事务将成为下一组的领导线程。第3步,binlog日志文件到磁盘。刷盘操作完成后,这组事务线程的binlog日志被刷到磁盘,实现了持久化,再也不用担心数据库崩溃了。这一步是否执行刷机操作也是由系统变量sync_binlog控制的,在第1步中已经详细介绍,这里不再赘述。(4)commitsub-phasecommit子阶段还有一个queue,就是commitqueue。第一个进入提交队列的事务线程是提交子阶段的领导线程。进入提交队列的第二个和后续事务线程是提交子阶段的跟随者线程。sync子阶段完成后,其leader线程将进入commit子阶段并加入commit队列,随后其follower线程也将一一加入commit队列。sync子阶段的leader线程和follower线程都加入commit队列后,leader线程会释放自己持有的Lock_sync互斥量。如果在同步子阶段的领导线程加入提交队列之前提交队列为空,它将再次成为提交子阶段的领导线程。否则,它和它的追随者线程都将成为提交子阶段的追随者线程。在commit子阶段,leader线程最重要的工作就是提交事务,然后在commit子阶段向所有follower线程发送通知。leader线程是否提交事务,是只提交自己的事务,还是一起提交所有follower线程的事务,由系统变量binlog_order_commits变量控制。binlog_order_commits默认值为ON,即leader线程不仅会提交自己的事务,还会提交所有follower线程的事务。如果binlog_order_commits的值为OFF,则表示领导线程只会提交自己的事务。leader线程提交事务后,会通知所有follower线程。follower线程收到通知后,会退出等待状态,继续接下来的工作,也就是收尾工作。每个事务线程都有一个commit_low属性。如果leader线程已经一起提交了follower线程的事务,则会将follower线程的这个属性的值设置为false。follower线程在进行收尾工作时,不需要Commit自己的业务。如果leader线程只提交了自己的事务,没有提交follower线程的事务,commit_low属性的值为true,follower线程在执行收尾工作时需要提交自己的事务。5.总结两阶段提交的核心逻辑就是将多个事务的redo日志合并到磁盘,将多个事务的binlog日志合并到磁盘,从而将小数据量的多次IO变成更大的数据和更少的IO,最终达到提高事务提交效率的目的。最后,以二期投稿的整体流程结束本文:本文转载自微信公众号“一树一溪”,可通过以下二维码关注。转载本文请联系艺书艺熙公众号。