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

说说MySQL8.0的原子DDL原理

时间:2023-03-21 16:24:01 科技观察

背景MySQL5.7的字典信息存储在非事务表中,存储在不同的文件中(.FRM,.PAR,.OPT,.TRN,.TRG等。).所有的DDL操作都不是CrashSafe,对于组合DDL(ALTER多表)会有一些成功和一些失败,而不是整体失败。这样一来,主从复制就出现了问题,也使得基于复制的高可用系统不再安全。MySQL8.0引入了一个新特性——原子DDL,解决了上述问题。什么是原子DDL?DDL是指数据定义语言(DataDefinitionLanguage),它负责数据结构的定义和数据对象的定义。AtomicDDL是指一个DDL操作是不可分割的,要么全部成功,要么全部失败。有什么限制?MySQL8.0仅通过InnoDB存储引擎支持原子DDL。支持的语句:数据库、表空间、表和索引的CREATE、ALTER和DROP语句,以及TRUNCATETABLE语句。MySQL8.0系统表全部采用InnoDB存储引擎存储,涉及字典对象的支持原子DDL。支持的语句:CREATE和DROP,存储过程、触发器、视图和用户定义函数(UDF)的ALTER操作,用户和角色的CREATE、ALTER、DROP语句,适用的RENAME语句,以及GRANT和REVOKE语句。不支持语句:INSTALLPLUGIN,UNINSTALLPLUGININSTALLCOMPONENT,UNINSTALLCOMPONENTREATESERVER,ALTERSERVER,DROPSERVER实现原理是什么?首先,8.0将字典信息存储在事务引擎(InnoDB存储引擎)的系统表中。这样就把DDL操作转化为一组对系统表的DML操作,这样就可以根据事务引擎自己的事务失败后回滚来保证系统表的原子性。看起来这里可以实现DDL的原子性,但实际上并没有那么简单。首先,字典信息不仅仅是系统表,还有一组字典缓存,如:TableSharecacheDDcacheInnoDBdict另外,字典信息只是数据库对象的元数据,DDL操作不仅需要修改字典信息,也可以是真正的Operation对象,对象本身缓存在内存中。TablespaceDynamicmetaBtreeibd文件缓冲池中tablespace的pagepage另外binlog也要考虑DDL失败。因此,当原子DDL处理DDL失败时,不仅直接回滚系统表中的数据,还保证了内存缓存,数据库对象也可以回滚到一致状态。实现细节为了解决数据库对象在DDL失败情况下的回滚,8.0引入了系统表DDL_LOG。该表在mysql库中。它既不可见也不可操纵。如果你想知道这张表的结果,先编译一个调试版的MySQL:SETSESSIONdebug='+d,skip_dd_table_access_check';显示创建表mysql.innodb_ddl_log;可以看到如下表结构:intunsignedDEFAULTNULL,`index_id`bigintunsignedDEFAULT_NULL,`unsignedDEFAULTNULL,`old_file_path`varchar(512)CHARACTERSETutf8COLLATEutf8_binDEFAULTNULL,`new_file_path`varchar(512)CHARACTERSETutf8COLLATEutf8_binDEFAULTNULL(PRIMARYNULL`id`),KEY``thread_))/*!50100TABLESPACE`mysql`*/ENGINE=InnoDBAUTO_INCREMENT=48DEFAULTCHARSET=utf8COLLATE=utf8_binSTATS_PERSISTENT=0ROW_FORMAT=DYNAMIC在8.0中这个表需要满足两种场景以及两个任务:场景一:遇到需要回滚部分完成的DDL的DDL失败场景。场景二:DDL正在进行,出现故障(掉电,软硬件故障等),重启机器需要完成部分DDL。两个任务:任务一:失败后回滚,执行逆向操作。任务2:如果成功,执行清理。可能有人会问,为什么执行成功还需要做清理工作呢?清理完成是因为ibd文件和索引一旦删除就无法恢复。为了实现回滚,DDL在删除这些对象时,并没有真正删除它们,而是先将它们备份起来,以备回滚时使用。因此,只有在确认DDL已经成功执行,不再需要这些备份对象后,才能进行清理工作。比如为了把这个原理说清楚,我们先从比较简单的CREATETABLE开始,从里面可以看出来。假设8.0debug版本已经编译完成,innodb_file_per_table开启,首先执行如下命令:mysql>setgloballog_error_verbosity=3;QueryOK,0rowsaffected(0.00sec)mysql>setglobalinnodb_print_ddl_logs=on;QueryOK,0rowsaffected(0.00sec)打开ddllog日志,然后建表:mysql>createtablet2(aint);查询正常,0行受影响(25分26.42秒)可以看到如下日志:XXXXX8[Note][MY-012473][InnoDB]DDLloginsert:[DDLrecord:DELETESPACE,id=20,thread_id=8,space_id=6,old_file_path=./test/t2.ibd]XXXXX8[Note][MY-012478][InnoDB]DDLlogdelete:20XXXXX8[Note][MY-012477][InnoDB]DDLloginsert:[DDL记录:REMOVECACHE,id=21,thread_id=8,table_id=1067,new_file_path=test/t2]XXXXX8[Note][MY-012478][InnoDB]DDLlogdelete:21XXXXX8[Note][MY-012472]][InnoDB]DDLloginsert:[DDLrecord:FREE,id=22,thread_id=8,space_id=6,index_id=157,page_no=4]XXXXX8[Note][MY-012478][InnoDB]DDLlogdelete:22XXXXX8[Note][MY-012485][InnoDB]DDLlogpostddl:beginforthreadid:8XXXXX8[Note][MY-012486][InnoDB]DDLlogpostddl:endforthreadid:8createtableDDL只有反向操作日志记录,没有清理操作日志记录。细心的读者看到日志中插入一条DDL日志,然后又将其删除时,可能会一头雾水。但这就是MySQL的原子DDL的秘密。我们选择DELETESPACE,DDL日志写入函数Log_DDL::write_delete_space_log来揭示这个过程。dberr_tLog_DDL::write_delete_space_log(trx_t*trx,constdict_table_t*table,space_id_tspace_id,constchar*file_path,boolis_drop,booldict_locked){ut_ad(trx==thd_to_trx(current_thd));ut_ad(table==nullptr||dict_table_is_file_per_table(table));if(skip(table,trx->mysql_thd)){return(DB_SUCCESS);}uint64_tid=next_id();ulintthread_id=thd_get_thread_id(trx->mysql_thd);dberr_terr;trx->ddl_operation=true;DBUG_INJECT_CRASH("ddl_log_crash_before_delete_space_log",crash_before_delete_space_log_counter++);if(is_drop){//(1)err=insert_delete_space_log(trx,id,thread_id,space_id,file_path,dict_locked);if(err!=DB_SUCCESS){returnerr;}DBUG_INJECT_CRASH("ddl_log_crash_after_delete_space_log",crash_after_delete_space_log_counter++);}else{//(2)err=insert_delete_space_log(nullptr,id,thread_id,space_id,file_path,dict_locked);if(err!=DB_SUCCESS){返回错误;}DBUG_INJECT_decrashspace_CRASH("dd",crash_after_delete_space_log_counter++);DBUG_EXECUTE_IF("DDL_Log_remove_inject_error_2",srv_inject_too_many_concurrent_trxs=true;);err=delete_by_id(trx,id,dict_locked);//(3)ut_ad(err==DB_SUCCESS||err==DB_TOO_MANIF_ECCURRENT_SCONUTR);DDL_Log_remove_inject_error_2",srv_inject_too_many_concurrent_trxs=false;);DBUG_INJECT_CRASH("ddl_log_crash_after_delete_space_delete",crash_after_delete_space_delete_counter++);}return(err);}在createtable?这个过程中调用write_delete_space_log,is_drop?为false?,执行以上代码执行分支(2)?并且(3)注意insert_delete_space_log第一个参数为空,表示会创建一个后台事务(调用trx_allocate_for_background)将DELETE_SPACE插入到innodb_ddl_log表中,然后提交事务。注意(3)delete_by_id第一个参数是trx,其中trx是这个DDL的事务,(3)的动作是删除这个事务中(2)插入的记录,为什么会这样?logi呢C?下面讨论两种情况,如上图所示:如果插入DDL日志后,DDL的所有步骤都执行成功,最后事务trx提交成功,那么innodb_ddl_log中没有DDL的记录,所以在后续的post_ddl中Donothing(后面会介绍post_ddl)。插入DDL日志后,如果DDL的某个步骤失败,则回滚DDL所在的事务trx。这时,上图中的删除[DELETESPACE,id=20]动作也会回滚。最后innodb_ddl_log中会存在DELETESPACE记录,后面会执行post_ddl进行重放(replay),从而删除失败的createtable的DDL创建的表空间。可以发现createtable的DDL创建了一个表空间,它肯定会在innodb_ddl_log中插入一条相反动作DELETESPACE的日志记录到innodb_ddl_log中,所以也叫逆向操作日志。其他DDL日志记录的操作,如REMOVECACHE和FREE日志记录的写入也类似。复杂的DDL不仅会插入反向操作日志记录,还会插入清理操作日志。例如,TRUNCATE表操作会将原始表空间重命名为零时间表空间。DDL成功后,需要通过post_ddlReplayDDL日志记录删除临时表空间。如果失败,post_ddl需要重放DDL日志,进行反向操作,将临时表空间重命名为原表空间。总之,如果是逆向操作日志,使用后台trx插入提交,然后使用trx删除;如果是清理日志,使用trx插入。注意:innodb_ddl_log表与其他InnoDB表相同。InnoDB引擎上的所有操作都会产生Redo日志和Undo记录,所以不要把DDL日志表中的反向操作记录当成Undo日志。两者不在同一抽象层次上。而且,反向操作是在另一个事务中进行的,回滚时,Undolog是在同一个原始事务中进行的。需要讨论的几个问题:DDL是否需要刷新日志?我们知道MySQL有一个innodb_flush_log_at_trx_commit参数。当设置为0时,Redolog在commit时不会立即刷新到持久化存储中。虽然它可以提高性能,但是在断电或关机时有一定概率丢失已提交的事务。对于DML操作来说,这只是事务的丢失,但是对于DDL来说,DDL事务的丢失会导致数据库元数据与其他数据不一致,从而使数据库系统无法正常工作。所以trx_commit会根据事务是否是DDL操作做特殊处理:无论innodb_flush_log_at_trx_commit参数怎么设置,与DDL相关的事务在commit时一定要记录!写DDL日志的时机在了解了DDL日志的机制之后,笔者向大家提一个问题。createtable,是先执行write_delete_space_log还是先创建表空间?我们首先假设先创建表空间(actionA),然后写入反向操作日志(actionB)。如果A执行后有drop,此时B还没有执行,此时createtable动作还没有完成,innodb_ddl_log中没有DELETESPACE等DDL反向日志记录。数据库崩溃恢复后,数据库系统系统表数据会回滚,但不会删除A创建的表空间。由于处于中间状态,createtable此时不是原子DDL。因此,在DDL的每一步中,先将该步的逆向操作日志写入innodb_ddl_log,然后再执行该步。也就是说DDLLog的写入时间是在步骤执行之前。createtable已经写到DDL日志中了,但是还没有创建表空间就断电了怎么办?没关系,在post_ddl做Replay的时候会处理。DDL操作完成后,不管DDL事务是提交还是回滚,Replay的调用逻辑都会调用post_ddl函数,post_ddl会调用replay函数进行Replay。另外,在MySQL8.0数据库崩溃恢复的过程中,相对于MySQL5.7,还有ha_post_recover的过程,会调用log_ddl->recover来重放innodb_ddl_log的所有日志记录。replay_by_thread_id在post_ddl中被调用,replay_all在crashrecovery中被ha_post_recover调用,其逻辑描述如下:根据传入的thread_id作为索引(thread_id和trx可以一一对应),所有的记录得到,然后根据记录的内容,依次执行Replay动作,最后删除已经重放的记录。replay_all倒序获取innodb_ddl_log中的所有记录,依次执行Replay动作,最后删除已经重放的记录。可以看出,上面两个函数都有倒序获取记录的过程。为什么他们需要颠倒顺序?反函数(1)逆向操作如果我们把DDL中的每一步都看成一个函数,那么参数就是数据库系统。假设第i步函数为oi,则n步为n个函数的复合函数:即复合函数的反函数为所有步的反函数的逆复合。所以逆向操作需要对DDL日志进行逆序处理。(2)清理操作DDL清理操作往往没有顺序要求,逆向操作和正向操作的效果往往是一样的,所以统一逆序处理是没有问题的。幂等性类似于Redo和Undo,每一种日志重放都要考虑其幂等性。所谓幂等性,就是执行多次和执行一次的效果是一样的。尤其是死机恢复时,重放反向操作时,还没完成就断电了,再次进行死机恢复。此时可能会出现多次重放操作。因此,MySQL8.0必须考虑幂等性来实现这些重放操作。最典型的就是重放一些删除操作,首先要判断数据库对象是否存在。如果存在,则删除它,否则什么也不做。Tips:说到这里,作者推荐一本书《具体数学:计算机科学中的一块基石》这本书讲解了很多计算机科学中用到的数学知识和技巧,尤其侧重于算法分析。服务器层的动作开始更新DDL。无论失败与否,tableshare都必须执行一次缓存更新,tdc_remove_table;DDL成功后,执行事务提交,否则执行事务回滚;不管事务commit还是rollback,调用post_ddl,post_ddl的作用上面已经介绍过了,用来重放系统表innodb_ddl_log中记录的日志;在crashrecovery时,除了执行Redolog和回滚未提交的事务外,还需要执行ha_post_recover,InnoDB的ha_post_recover调用post_ddl进行DDL的逆向操作;binglog处理只有一个原则,就是DDL事务成功。并提交后调用write_bin_log写入binlog。注意MySQL8.0支持原子DDL,并不意味着可以通过SQL语句命令回滚DDL。事实上,除了SQLServer,几乎所有的数据库系统都不支持DDLSQL命令进行回滚,DDL回滚带来的问题远远多于它带来的好处。MySQL8.0只承诺单个DDL语句的原子性,不保证多个DDL组合也能保持原子性。某大厂为了实现Truncatetableflashback,只是在MySQLServer层将truncatetable动作转换为renametable动作。在闪回过程中,通过RENAMEDDL组合重新执行表、索引和约束,实现闪回。危险,不保证原子性。作者也完成了这个功能,并没有采取这样的招数,而是老老实实改造了server层、InnoDB存储引擎、binlog,完全保证其原子性。MySQL8.0采用这种方式实现原子DDL并不意味着其他数据库也采用这种方式实现原子DDL。