数据库内核月报 - 2020 / 05

MySQL · 源码分析 · 8.0 · DDL的那些事

引言

MySQL 5.6/5.7 的用户可能会发现,create一张表过程中发生crash,重启后创建一张同名新表时,会发现创建失败。这是因为过去MySQL 5.6/5.7 的DDL操作不是原子的,一张表创建失败后会遗留下ibd文件。MySQL 8.0 对DDL的实现重新进行了设计,最大的改进是DDL操作支持原子特性。由于MySQL是一个多引擎数据库,在engine层(SE)和server层(SL)都维护了自己的数据字典对象,刚接触MySQL 8.0 DDL相关源码,可能会比较困难,特别是SL中用到较多的模板方法。本文对MySQL 8.0 中DDL的进行导读性介绍,并对一些理解上比较困难的地方进行详细阐述。

为了实现DDL原子性,MySQL 8.0 使用Innodb表存储相关的数据字典信息,这些数据字典表默认不可见,查看方法参照https://dev.mysql.com/doc/refman/8.0/en/data-dictionary-schema.html

mysql> SELECT name, schema_id, hidden, type FROM mysql.tables where schema_id=1 AND hidden='System';
+------------------------------+-----------+--------+------------+
| name                         | schema_id | hidden | type       |
+------------------------------+-----------+--------+------------+
| catalogs                     |         1 | System | BASE TABLE |
| character_sets               |         1 | System | BASE TABLE |
| check_constraints            |         1 | System | BASE TABLE |
| collations                   |         1 | System | BASE TABLE |
| column_statistics            |         1 | System | BASE TABLE |
| column_type_elements         |         1 | System | BASE TABLE |
| columns                      |         1 | System | BASE TABLE |
| dd_properties                |         1 | System | BASE TABLE |
| events                       |         1 | System | BASE TABLE |
| foreign_key_column_usage     |         1 | System | BASE TABLE |
| foreign_keys                 |         1 | System | BASE TABLE |
| index_column_usage           |         1 | System | BASE TABLE |
| index_partitions             |         1 | System | BASE TABLE |
| index_stats                  |         1 | System | BASE TABLE |
| indexes                      |         1 | System | BASE TABLE |
| innodb_ddl_log               |         1 | System | BASE TABLE |
| innodb_dynamic_metadata      |         1 | System | BASE TABLE |
| parameter_type_elements      |         1 | System | BASE TABLE |
| parameters                   |         1 | System | BASE TABLE |
| resource_groups              |         1 | System | BASE TABLE |
| routines                     |         1 | System | BASE TABLE |
| schemata                     |         1 | System | BASE TABLE |
| st_spatial_reference_systems |         1 | System | BASE TABLE |
| table_partition_values       |         1 | System | BASE TABLE |
| table_partitions             |         1 | System | BASE TABLE |
| table_stats                  |         1 | System | BASE TABLE |
| tables                       |         1 | System | BASE TABLE |
| tablespace_files             |         1 | System | BASE TABLE |
| tablespaces                  |         1 | System | BASE TABLE |
| triggers                     |         1 | System | BASE TABLE |
| view_routine_usage           |         1 | System | BASE TABLE |
| view_table_usage             |         1 | System | BASE TABLE |
+------------------------------+-----------+--------+------------+

源码导读

SL的相关源码都存放在sql/dd中,目录下dd_xxx.cc / dd_xxx.h,为SL数据字典操作的入口。以创建表以及表数据字典对象为例,dd_table.ccdd::create_table创建一个server层的,表的,数据字典对象。其类型为dd::Table,定义在sql/dd/types/table.h中。而dd::Table真正的实现放在sql/dd/impl/table_impl.h / sql/dd/impl/table_impl.cc中。

sql/dd/impl/cache中主要是操作数据字典缓存,大都是模板类或模板方法,模板成员为SL中的各种数据字典对象(如dd::Table)。sql/dd/impl/cache/dictionary_client.cc中实现了缓存对象的操作,包括从缓存获取、存取、丢弃等。

SE的相关源码主要存放在storage/innobase/dict/中,主要的innodb层的数据字典内存对象是dict_index_t, dict_table_t

数据字典持久化

由于篇幅有限,不能将所有的细节都阐述清楚,下面以创建一张基础表为例,简单介绍存数据字典的调用过程:

rea_create_base_table
  --> dd::create_tabl  // 创建数据字典对象dd::Table
     --> dd::create_dd_system_table / dd::create_dd_user_table
  --> dd::cache::Dictionary_client::store<dd::Table> // 存入数据字典信息
     --> dd::cache::Storage_adapter::store<dd::Table>
       --> dd::Table_impl::store_attributes
	   --> dd::Raw_record::update
       --> dd::Table_impl::store_children
  --> ha_create_table // 实际创建表

这里比较难以理解的是store_attributesstore_childrenstore_attributes会存本数据字典对象的属性值; dd::Raw_record::update调用Innodb接口,将数据字典修改持久化。同时dd::cache::Storage_adapter::store<dd::Table>会递归调用store_children,将与建表相关的数据字典表的也存起来:

bool Table_impl::store_children(Open_dictionary_tables_ctx *otx) {
  // ...
  return Abstract_table_impl::store_children(otx) ||
         m_indexes.store_items(otx) || m_foreign_keys.store_items(otx) ||
         m_partitions.store_items(otx) || store_triggers(otx) ||
         (!skip_check_constraints && m_check_constraints.store_items(otx));
}

即调用dd::Table_impl::store_children会同时将indexesforeign_keys等数据字典表更新

原子DDL

介绍MySQL 8.0 原子DDL的资料有很多,这里以创建表为例,简单阐述创建表过程与原子DDL相关的关键流程。

为了实现原子DDL,MySQL 8.0借助mysql.innodb_ddl_log,将DDL过程划分成以下四个步骤:

  1. Prpare: 写DDL logs到mysql.innodb_ddl_log,如对应一个per-file-table建表操作,会写入write_delete_space_log, write_remove_cache_log, write_free_tree_log三条记录。
  2. Perform: 执行DDL操作
  3. Commit: 更新数据字典并且提交数据字典事务
  4. Post-DDL: 重放并且移除mysql.innodb_ddl_log对应的DDL logs,重命名和移除大数据文件都放在这个过程中完成。

MySQL 8.0 借助Innodb的事务特性完成DDL操作。所有的DDL操作都会起一个innodb层的事务,对数据字典进行增删查改的操作,如果DDL事务执行失败,则进行回滚,这部分与正常事务是一致的。但由于DDL操作涉及文件操作,MySQL 8.0 通过DDL logs来辅助实现原子性,相关源码主要在storage/innobase/log/log0ddl.cc。当创建一张数据表的时候,需要写一条删除索引树的记录。假设DDL操作的事务为TRX_A,则在write_free_tree_log中另起一个事务TRX_B插入一条记录free tree log并马上提交,随后以TRX_A的身份将这条记录删除。当事务commit的时候会有两种情况:

  1. DDL事务TRX_A正常提交,mysql.innodb_ddl_log中没有记录,不需要进行重放
  2. DDL事务TRX_A回滚,则mysql.innodb_ddl_log中存在一条free tree log,重放删除对应的数据文件,并移除这条记录(Log_DDL::replay)

对于drop操作,其处理逻辑也是类似。但write_free_tree_log中会以DDL事务TRX_A的身份写入一条free tree log,则在Post-DDL中会真正地将表删掉并移除对应的记录。

所以,mysql.innodb_ddl_log只会在DDL过程中才会有记录。

Online DDL

Online DDL指的是在DDL期间,允许用户进行DML操作。并非所有DDL操作都支持Online DDL,官方文档 https://dev.mysql.com/doc/refman/8.0/en/innodb-online-ddl-operations.html 详细展示了所有DDL在执行期间是否允许进行DML操作。

这里阐述rebuild表时,Online DDL的关键流程():

  1. 持有MDL_SHARED_UPGRADABLE锁,检测表时是否存在
  2. 升级到MDL_EXCLUSIVE锁,禁止读写
  3. 更新数据字典对象
  4. 分配row_log对象记录增量
  5. 生成新表
  6. 降级为MDL_SHARED_UPGRADABLE,允许对原表进行读写(wait_while_table_is_used
  7. 用DDL事务的上下文,扫描老表的中,对该事务可见的数据,并用merge排序,最后插入到新表,详细见row_merge_build_indexes。PS:由于使用到merge外排序,所以会受到innodb_sort_buffer_size的限制。
  8. 在执行期间,原表的读写不阻塞,增量应用到原表中,并且会记录到row_log中
  9. 进入commit阶段,升级到MDL_EXCLUSIVE锁,禁止读写
  10. 在新表中apply row_log里的增量(row_log_apply)
  11. 更新innodb的数据字典表
  12. 提交DDL事务
  13. 重命名新表的ibd文件

值得注意的是:

  1. 这里row_log与mysql.innodb_ddl_log不一样,前者的源码主要在storage/innobase/row/row0log.cc,后者的相关代码在storage/innobase/log/log0ddl.cc。row_log是一个append形式的增量日志,里面有三种类型的记录,insert/update类型的如下所示:
    type: insert/update, 1 byte
    old pk extra size, 1 byte
    old pk, old pk size
    extra size, 1~2 byte
    extra data, extra size
    old record data, data size
    virtual column
    
  2. 上述第10步之后,即将锁升级为互斥锁,则可以认为已经没有事务在操作原表,否则无法升锁。所以在后续apply logs的时候,当发现delete类型的记录,则直接对新表进行purge,而不是标记为deleted mark。
  3. 了解上述inpalce rebuild类型的流程后,可以对官方DDL操作是否能支持online ddl有直观的理解。如drop primary key不支持online ddl,而drop primary的同时add primary却支持online ddl,两种方式都需要rebuild。但是对于前者,采用的是copy的方式而不是inplace,主要原因是新表没有主键,这种情况下innodb会默认为其申请一个隐藏的rowid作为主键,当apply logs的时候,由于log中的增量没有rowid信息,所以没法进行下去。