摘要:本文对社区MySQL5.7 到8.0 演进过程中数据字典DD的重构(缓存,持久化),Atomic DDL的关键实现进行了分析。
本文分享自华为云社区《【华为云MySQL技术专栏】MySQL 8 数据字典重构源码解读》,作者:GaussDB 数据库
1.背景介绍
在MySQL5.7 版本的使用实践过程中,我们很容易遇到DDL崩溃后导致数据不一致的问题,具体场景描述如下:
主备高可用架构部署下,备机回放执行DROP TABLE的中途,因触发其它社区bug导致备机mysqld进程crash。重新拉起备机后,因存储表结构的FRM文件与表空间IBD没有被同时清理,导致再次执行DROP TABLE失败,需手动清理备机物理文件,这给自动化运维带来了很大阻碍。
这个问题的实质是MySQL5.7 版本的DDL非原子性、数据字典的架构是有缺陷的。MySQL 社区从5.7到8.0版本的演进过程中,其中一大改动是对数据字典(Data dictionary, 下文缩写为DD)的重构及与之相关原子性Data Definition Language (Atomic DDL)的支持。重构的动机来源于5.7版本数据字典的以下问题[1]:
(1)Server层和存储引擎插件层的数据字典未统一。Server和存储引擎分别维护了各自的DD信息,导致部分DD信息冗余,进而带来DD信息不同步的隐患。
(2)不同类型的DD文件缺乏统一的访问API,不利于后续的维护和拓展。
(3)非原子DDL:数据字典被存放在非事务的表里。如果mysqld在DDL中途crash,会导致数据残留和复制问题。
(4) Information_schema的性能受到批评。5.7版本中,Information_schema表的定义是临时表,这些临时表的数据来源于FRM文件、存储引擎的统计信息等。最主要的缺点是与表结构文件FRM的交互会导致大量I/O开销,性能较差[2]。
下文将分析MySQL8.0 版本数据字典重构相关的代码,并解释重构后如何解决在5.7 版本中存在的相关问题。
2. 数据字典的变化
DD的重构是如何影响DDL语句流程和锁系统的呢?可以先从最常见的CREATE TABLE,即创非临时表DDL的场景入手观察。
2.1 5.7 vs 8.0 创表流程对比
对比5.7和8.0创表流程中的主要接口:
5.7流程:
(server层)
mysql_create_table
|-open_tables->lock_table_names // 对schema上IX锁
|-mysql_create_table_no_lock->create_table_impl
|-access检查是否有重名FRM
|-get_cached_table_share // server层校验表名是否存在
|-ha_table_exists_in_engine // 调存储引擎的HA接口,但大多数存储引擎没实现
|-rea_create_table
|-mysql_create_frm//持久化:server层表结构文件FRM
|-ha_create_table->ha_create // 进入存储引擎,各自实现
(引擎层)
ha_innobase::create
|-create_table_info_t::prepare_create_table //表名预处理等
|-row_mysql_lock_data_dictionary //加dict_sys的锁dict_sys_mutex_enter();
|-create_table_info_t::create_table
|-create_table_def
|-dict_mem_table_create // malloc dict_table_t类+填列
|-dict_table_add_to_cache // 加入dict_sys的hash
|-row_create_table_for_mysql
|-...fil_ibd_create//写IBD文件
|-create_index //创建二级索引/处理外键约束等
|-innobase_commit_low
|-create_table_update_dict;至此已经commit, 更新一些统计信息
|-row_mysql_unlock_data_dictionary //释放dict_sys的锁 dict_sys_mutex_exit();
8.0流程:
(server层)
mysql_create_table
|-mdl_locker.ensure_locked(db) // 同5.7 对schema上IX锁
|-各种初期检查,但不包括FRM
|- rea_create_base_table
|-Dictionary_client::store //将新表信息写人DD的InnoDB表
|-dd::acquire_for_modification // dd_client在线程内DD缓存加入新表元数据
|-ha_create_table->ha_create // 进入存储引擎,各自实现
(引擎层)
ha_innobase::create->innobase_basic_ddl::create_impl
|-create_table_info_t::prepare_create_table //表名预处理等
|-create_table_info_t::create_table// 不在此处操作dict_sys->mutex
|-create_table_def
|-dict_mem_table_create // malloc dict_table_t类+填列
|-row_create_table_for_mysql
|-...fil_ibd_create/btr_sdi_create_index //写IBD文件和其中的SDI
|-dict_sys_mutex_enter() // 8.0仅在dict_table_add_to_cache前后操作锁
|-dict_table_add_to_cache // 加入dict_sys的hash
|-dict_sys_mutex_exit();
|-create_index
(回到外层SQL)
trans_commit_implicit->dd::cache::Dictionary_client::commit_modified_objects
在server层进入InnoDB之前,5.7 和8.0 版本最主要的区别是元数据的持久化存储。5.7 版本写FRM文件,8.0 版本直接将元数据写入InnoDB表,详见下文2.2章节。除此之外,8.0 版本代码中,对server层数据字典缓存机制进行了重构,详见下文2.3章节。在进入InnoDB后,InnoDB表的元数据缓存结构dict_sys的持锁粒度,也在8.0 版本变得更精细,详见下文2.4章节。
2.2 元数据持久化策略的变化
对比上述流程,不难发现,在server层调用存储引擎接口进入InnoDB之前,MySQL 5.7 和8.0 版本分别在rea_create_table(5.7)和 rea_create_base_table(8.0) 实现了一部分持久化相关步骤。
5.7 中,server层首先写FRM文件持久化表结构,并通过检查同名FRM文件是否存在来保证同名表不会被重复创建。
8.0 中,不再使用FRM文件,通过Dictionary_client::store->Storage_adapter::store 的调用,直接将元数据的改动写入InnoDB格式的数据字典表(DD table)中,由InnoDB引擎的能力保证这条元数据改动的事务性。其真正持久化,是在DDL事务提交之后。取消独立FRM文件,也避免了上文背景描述中提到的问题:DDL过程中,mysqld进程崩溃的场合,无法保证IBD和FRM文件同时被创建或清理。相比5.7检查同名FRM文件冲突的做法,8.0 版本由元数据锁(Metadata Lock, MDL)规避并发创同名表的场景。
2.3 DD缓存机制的变化
MySQL 8.0 版本在server层元数据缓存的最主要改动是引入了二级缓存,新增了两种类型DD的缓存:会话私有的局部缓存Local Cache和所有会话可见的全局共享缓存Shared_dictionary_cache。
server层查询DD时,首先通过dd::cache::Dictionary_client类的接口,查询会话自身的局部缓存Local Cache。如果在自身的Local Cache不命中,再去查询全局缓存Shared_dictionary_cache,在全局缓存中命中的DD对象将同时被加入会话的局部缓存。
当这两种缓存皆不命中时,才会去调存储引擎InnoDB的接口查询。如果在存储引擎查询到相应的DD对象,返回的对象将同时更新到会话自身的局部缓存Local Cache和Server层的全局缓存Shared_dictionary_cache。
对比5.7 和8.0 在server层元数据缓存机制的实现:5.7 在 server层只有一层全局的table_def_cache,在创表之前,调用get_cached_table_share进行重复性校验。
get_cached_table_share通过HashMap中表名和元数据的映射关系,查找相应表元数据的内存结构。如果只有一层全局的元数据缓存,为了保证多线程环境下的安全,不可避免的会涉及线程间锁的竞争。8.0 版本引入的会话级局部缓存Local Cache,命中时不用再去访问全局的缓存,能够大幅减少锁冲突的频率,提升了性能。
2.4 InnoDB的dict_sys_t的变化
在InnoDB内部,单独维护了一套元数据信息缓存,也就是我们常说的dict_sys_t,里面维护了当前已经在InnoDB打开的表的元数据信息。该InnoDB的元数据信息缓存从5.7 延续到了8.0 。
创表时,InnoDB层读取Server层传递下来新表的元数据信息,在其内部创建一个对应的dict_table_t结构来维护,然后调用dict_table_add_to_cache将该dict_table_t加入到dict_sys的hash表中。dict_sys->mutex是InnoDB整个dict_sys的锁,8.0 在 dict_table_add_to_cache 调用的前后,才获取和释放dict_sys->mutex;而5.7 则在 ha_innobase::create的大部分流程都持有这把锁,从内存中DD表对象dict_table_t的堆内存申请、填写到commit后统计信息的更新。这个区别影响了并发创表的效率。
2.5 Information_schema的变化
在5.7 版本,information_schema是基于临时表实现,其依赖于独立的表结构FRM文件,产生大量I/O开销,导致性能较差;而在8.0 版本,DD相关的表基于InnoDB引擎持久化存储,information_schema的定义成为基于这些DD table的视图。相比5.7 版本,这种基于视图的做法,避免了读取FRM文件时与磁盘的交互,基于DD表的视图查询,也能充分利用优化器和DD表本身的索引提升性能。
2.6 DD变化总结
总结上文,DD从5.7版本到8.0版本的变化如表一。
表一
变化项 | 5.7 | 8.0 |
冗余文件 | 独立于IBD的FRM文件,DDL过程中crash时,两者不一致。字符集文件db.opt、TRG触发器文件等 | 取缔FRM等独立文件,统一用事务性的DD表存储。 |
元数据持久化 | 表结构依赖独立的FRM文件 | 基于InnoDB的DD表,有事务性。 |
Information Schema | 临时表,依赖于FRM,IO开销大 | 视图,基于InnoDB引擎的DD表,性能优化 |
Server层缓存机制 | 一层全局的Table_def_cache | 两层: |
InnoDB DD缓存 | 创表流程中,单个线程持锁贯穿整个创建流程,影响并发度 | 细粒度持锁/解锁 |
3.原子性DDL与DDL log表
DDL原子性由InnoDB在8.0的新能力保证,这部分能力与DD重构相关。一方面,元数据存储在InnoDB表中,本身就保证了事务性;另一方面,在server层存储元数据到基于InnoDB的DD表完成后,后续DDL流程中相关数据文件处理的原子性。例如,创表过程中索引的创建、IBD文件的生成,则由另一张DD表DDLlog保证。
为了保证DDL的原子性,在DDL过程中,每一个对文件修改或对相关内存对象修改的动作,都会记录在基于InnoDB引擎的DD表DDL log里。其类定义和内存中的实例为:
1. class Log_DDL
2. dict_table_t *ddl_log;
DDL每一个关键步骤执行完,这张DDL log表直接记录与该已执行步骤相对应的回滚操作。以创建一张不包含二级索引的表为例,InnoDB层会执行以下函数调用:
create_index->row_create_index_for_mysql->dict_create_index_tree_in_mem,创建完B+树索引后,会有Log_DDL::write_free_tree_log-> Log_DDL::insert_free_tree_log 的调用。
Log_DDL::write_free_tree_log会记录2条日志:一条是“创建索引”对应的回滚日志,即删除对应索引的操作;另一条是删除日志,对以上的回滚日志进行删除。
如果DDL事务最终是提交的,删除日志就会被提交,则创索引对应的回滚操作不会被执行;而如果DDL最终是被回滚的,那么删除日志本身也被回滚,而创索引对应的回滚操作就会被执行,最终该新建的索引会被回滚,以此来完成DDL真正的回滚。如果DDL涉及到其它文件或者内存操作,都是按照相同的逻辑进行回滚日志和删除日志的记录,以确保DDL的提交和回滚之后,对应的文件和内存得到正确的清理和复位。
Log_DDL::insert_free_tree_log中的回滚日志,具体内容即“创索引”的回滚操作:与创B+树对应的操作,即释放索引对应的B+树。在DDL log中新增的一条DDL_Record,记录了create table到一半时索引的信息:space、page、id等,实现如下:
1. DDL_Record record;
2. record.set_id(id);
3. record.set_thread_id(thread_id);
4. record.set_type(Log_Type::FREE_TREE_LOG);
5. record.set_space_id(index->space);
6. record.set_page_no(index->page);
7. record.set_index_id(index->id);
8.
9. {
10. DDL_Log_Table ddl_log(trx);
11. error = ddl_log.insert(record);
12. }
类似的DDL log记录还有:
1. ALTER TABLE RENAME时,有Log_DDL::write_rename_table_log,分别记录新老表名。
2. 创建表空间时的Log_DDL::write_delete_space_log。
3. 上文创表过程中dict_table_add_to_cache 将InnoDB的内存DD结构存入dict_sys后,Log_DDL::write_remove_cache_log。
4. DROP TABLE时Log_DDL::write_drop_log,记录将要被drop的table id。
在事务处理的最后或在重启后crash recovery的流程中,无论事务应该提交还是回滚,server层接口 handlerton结构体的post_ddl 接口都会调用相应存储引擎的实现,进入InnoDB后的函数接口为innobase_post_ddl->Log_DDL::post_ddl。
Post_ddl步骤中,如果事务最终被提交,那么如前文所述,DDL log中的回滚日志会被彻底删除,回滚不会被执行,无需对提交前已经执行的创索引、RENAME等步骤做额外的动作。一些场景下,文件操作日志将会被执行,例如删表操作的最终清理:对比上文DDL log记录的命名可以发现,只有drop table的接口名Log_DDL::write_drop_log的命名方式并非“已执行步骤的回滚操作”,而是drop自己。这是因为drop table只有在DDL事务提交时,才会真正执行删除操作,进行最终的清理;如果没有commit,删除没有真正发生时,并不需要真正地对删除进行回滚操作。
如果DDL事务最终被回滚,那么上文所述DDL log中的删除日志本身也被回滚,而DDL log中的回滚日志会被执行,根据不同的回滚类型,创建的索引会被删除,RENAME的表名会被退回老表名,存入InnoDB层元数据缓存dict_sys的内存结构将被清理。
4. MDL锁的部分变化
4.1 代码架构的重构
上文所说8.0 版本对DD的重构,对元数据锁(Metadata Lock,MDL)较为直观的一个改变是代码架构的重构。
8.0 在sql/dd/impl/dictionary_impl.cc 中,dd的namespace内,封装了常见的table和tablespace级别的排他、共享MDL接口,例如:server层刚进入CREATE TABLE流程时,在mysql_create_table_no_lock接口中, 对整个库加intention exclusive(IX)级别MDL锁的步骤,将其封装在类dd::Schema_MDL_locker中。
对于这些常见的表级、库级的MDL操作,5.7 版本通过MDL_REQUEST_INIT等宏管理MDL请求,这些宏的直接调用分散在各种接口的实现中,缺乏统一的函数封装,可维护性较差。而在8.0 版本中,即使这些dd namespace下的接口在最底层的调用仍然为MDL_REQUEST_INIT宏不变,这种设计模式也体现了8.0 DD重构后server层对DD统一管理的思路。
4.2 MDL锁类型的拓展
enum_mdl_namespace 枚举值记录了MDL锁的不同类型对象,在常规的库、表、触发器、函数等之外,8.0新增的MDL枚举值包括:
1. SRID,
2. ACL_CACHE,
3. COLUMN_STATISTICS,
4. RESOURCE_GROUPS,
5. FOREIGN_KEY,
6. CHECK_CONSTRAINT,
7. BACKUP_TABLES,
8. BACKUP_LOCK,
这些MDL的枚举值细化了MDL的粒度。例如FOREIGN_KEY枚举值,在ALTER TABLE RENAME 流程中,重命名Foreign Key时,会单独对外键的名字加MDL锁;ACL_CACHE枚举值是在用户鉴权发生变化的语句执行过程中持锁,此时其他新建立的连接如果拿不到ACL cache的MDL锁,则无法鉴权进行连接。
4.3 新增SDI的MDL
在8.0 版本,DD由于表结构不再依赖于server层的FRM文件。除了server层共用的DD表之外,InnoDB还将这份信息以(Serialized dictionary information (SDI)格式存在了tablespace 的物理文件(.IBD)中。
这份SDI元数据是为了应对DD出错的情况下,能够基于单个IBD文件使用ibd2sdi工具获取表结构、恢复数据。InnoDB将SDI信息写入同IBD文件的做法,相比5.7 版本基于独立FRM文件、缺乏原子性的实现方式更可靠;在DD表损坏时,单个IBD文件仍然可以通过自带的SDI信息,恢复出表结构,即表数据文件自我描述的,可以不依赖于DD解析自身(尽管MyISAM在8.0 版本仍把SDI作为独立文件)。
这个新增的SDI机制,在drop table/tablespace时需要MDL锁,在事务提交时自动释放,其接口为:dd_sdi_acquire_exclusive_mdl/dd_sdi_acquire_shared_mdl。但是,这把MDL锁不会与其它库表冲突,是因为其输入的表名和库名会被特殊处理,如其库名为dummy_sdi_db,而表名使用SDI_的前缀,实现和真正的space id进行字符串拼接。
MDL因DD的重构,还有其他很多方面的变化,在本篇中不再展开。
5. 总结
本文对社区MySQL5.7 到8.0 演进过程中数据字典DD的重构(缓存,持久化),Atomic DDL的关键实现进行了分析:在server层,通过InnoDB为引擎的数据字典表取代了FRM文件,保证了元数据存储的事务性,并通过Local Cache、Shared_dictionary_cache二级缓存,减少锁冲突,提升性能。Atomic DDL的关键实现基于InnoDB为引擎的数据字典表DDL log,将元数据和DDL的操作存入事务性存储引擎的数据字典表中,有效保证了元数据的一致性。
6. 参考
[1] https://dev.mysql.com/blog-archive/mysql-8-0-data-dictionary-background-and-motivation/
[2] https://dev.mysql.com/blog-archive/mysql-8-0-improvements-to-information_schema/