解读 MySQL8.0 数据字典重构源码
本文分享自华为云社区《【华为云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 流程:
8.0 流程:
在 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 版本的变化如表一。
3.原子性 DDL 与 DDL log 表
DDL 原子性由 InnoDB 在 8.0 的新能力保证,这部分能力与 DD 重构相关。一方面,元数据存储在 InnoDB 表中,本身就保证了事务性;另一方面,在 server 层存储元数据到基于 InnoDB 的 DD 表完成后,后续 DDL 流程中相关数据文件处理的原子性。例如,创表过程中索引的创建、IBD 文件的生成,则由另一张 DD 表 DDLlog 保证。
为了保证 DDL 的原子性,在 DDL 过程中,每一个对文件修改或对相关内存对象修改的动作,都会记录在基于 InnoDB 引擎的 DD 表 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 等,实现如下:
类似的 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 枚举值包括:
这些 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/
评论