乐趣区

深入解读MySQL8.0 新特性 :Crash Safe DDL

前言
在 MySQL8.0 之前的版本中,由于架构的原因,mysql 在 server 层使用统一的 frm 文件来存储表元数据信息,这个信息能够被不同的存储引擎识别。而实际上 innodb 本身也存储有元数据信息。这给 ddl 带来了一定的挑战,因为这种架构无法做到 ddl 的原子化,我们在线上经常能够看到数据目录下遗留的临时文件,或者类似 server 层和 innodb 层列个数不一致之类的错误。甚至某些 ddl 可能还遗留元数据在 innodb 内,而丢失了 frm,导致无法重建表…..(我们为了解决这个问题,实现了一个叫 drop table force 的功能,去强制做清理….)
(以下所有的讨论都假定使用 InnoDB 存储引擎)
到了 8.0 版本,我们知道所有的元数据已经统一用 InnoDB 来进行管理,这就给实现原子 ddl 带来了可能,几乎所有的对 innodb 表,存储过程,触发器,视图或者 UDF 的操作,都能做到原子化:
– 元数据修改,binlog 以及 innodb 的操作都放在一个事务中
– 增加了一个内部隐藏的系统表 `mysql.innodb_ddl_log`,ddl 操作被记录到这个表中,注意对该表的操作产生的 redo 会 fsync 到磁盘上,而不会考虑 innodb_flush_log_at_trx_commit 的配置。当崩溃重启时,会根据事务是否提交来决定通过这张表的记录去回滚或者执行 ddl 操作
– 增加了一个 post-ddl 的阶段,这也是 ddl 的最后一个阶段,会去:1\. 真正的物理删除或重命名文件; 2\. 删除 innodb_ddl_log 中的记录项; 3. 对于一些 ddl 操作还会去更新其动态元数据信息 (存储在 `mysql.innodb_dynamic_metadata`,例如 corrupt flag, auto_inc 值等)
– 一个正常运行的 ddl 结束后,其 ddl log 也应该被清理,如果这中间崩溃了,重启时会去尝试重放:1. 如果已经走到最后一个 ddl 阶段的(commit 之后 ),就 replay ddl log,把 ddl 完成掉;2\. 如果处于某个中间态,则回滚 ddl

由于引入了 atomic ddl,有些 ddl 操作的行为也发生了变化:
– DROP TABLE: 在之前的版本中,一个 drop table 语句中如果要删多个表,比如 t1,t2, t2 不存在时,t1 会被删除。但在 8.0 中,t1 和 t2 都不会被删除,而是抛出错误。因此要注意 5.7->8.0 的复制问题 (DROP VIEW,CREATE USER 也有类似的问题)
– DROP DATABASE: 修改元数据和 ddl_log 先提交事务,而真正的物理删除数据文件放在最后,因此如果在删除文件时崩溃,重启时会根据 ddl_log 继续执行 drop database

测试:
MySQL 很贴心的加了一个选项 innodb_print_ddl_logs,打开后我们可以从错误日志看到对应的 ddl log,下面我们通过这个来看下一些典型 ddl 的过程
root@(none) 11:12:19>SET GLOBAL innodb_print_ddl_logs = 1;
Query OK, 0 rows affected (0.00 sec)

root@(none) 11:12:22>SET GLOBAL log_error_verbosity = 3;
Query OK, 0 rows affected (0.00 sec)
CREATE DATABASE
mysql> CREATE DATABASE test;
Query OK, 1 row affected (0.02 sec)

创建数据库语句没有写 log_ddl,可能觉得这不是高频操作,如果创建 database 的过程中失败了,重启后可能需要手动删除目录。
CREATE TABLE
mysql> USE test;
Database changed
mysql> CREATE TABLE t1 (a INT PRIMARY KEY, b INT);
Query OK, 0 rows affected (0.06 sec)

[InnoDB] DDL log insert : [DDL record: DELETE SPACE, id=428, thread_id=7, space_id=76, old_file_path=./test/t1.ibd]
[InnoDB] DDL log delete : by id 428
[InnoDB] DDL log insert : [DDL record: REMOVE CACHE, id=429, thread_id=7, table_id=1102, new_file_path=test/t1]
[InnoDB] DDL log delete : by id 429
[InnoDB] DDL log insert : [DDL record: FREE, id=430, thread_id=7, space_id=76, index_id=190, page_no=4]
[InnoDB] DDL log delete : by id 430
[InnoDB] DDL log post ddl : begin for thread id : 7
InnoDB] DDL log post ddl : end for thread id : 7
从日志来看有三类操作,实际上描述了如果操作失败需要进行的三项逆向操作:删除数据文件,释放内存中的数据词典信息,删除索引 btree。在创建表之前,这些数据被写入到 ddl_log 中,在创建完表并 commit 后,再从 ddl log 中删除这些记录。另外上述日志中还有 DDL log delete 日志,其实在每次写入 ddl log 时是单独事务提交的,但在提交之后,会使用当前事务执行一条 delete 操作,直到操作结束了才会提交。
加列 (instant)
mysql> ALTER TABLE t1 ADD COLUMN c INT;
Query OK, 0 rows affected (0.08 sec)
Records: 0 Duplicates: 0 Warnings: 0

[InnoDB] DDL log post ddl : begin for thread id : 7
[InnoDB] DDL log post ddl : end for thread id : 7

注意这里执行的是 Instant ddl, 这是 8.0.13 新支持的特性,加列操作可以只修改元数据,因此从 ddl log 中无需记录数据
删列
mysql> ALTER TABLE t1 DROP COLUMN c;
Query OK, 0 rows affected (2.77 sec)
Records: 0 Duplicates: 0 Warnings: 0

[InnoDB] DDL log insert : [DDL record: DELETE SPACE, id=487, thread_id=7, space_id=83, old_file_path=./test/#sql-ib1108-1917598001.ibd]
[InnoDB] DDL log delete : by id 487
[InnoDB] DDL log insert : [DDL record: REMOVE CACHE, id=488, thread_id=7, table_id=1109, new_file_path=test/#sql-ib1108-1917598001]
[InnoDB] DDL log delete : by id 488
[InnoDB] DDL log insert : [DDL record: FREE, id=489, thread_id=7, space_id=83, index_id=200, page_no=4]
[InnoDB] DDL log delete : by id 489

[InnoDB] DDL log insert : [DDL record: DROP, id=490, thread_id=7, table_id=1108]
[InnoDB] DDL log insert : [DDL record: RENAME SPACE, id=491, thread_id=7, space_id=82, old_file_path=./test/#sql-ib1109-1917598002.ibd, new_file_path=./test/t1.ibd]
[InnoDB] DDL log delete : by id 491
[InnoDB] DDL log insert : [DDL record: RENAME TABLE, id=492, thread_id=7, table_id=1108, old_file_path=test/#sql-ib1109-1917598002, new_file_path=test/t1]
[InnoDB] DDL log delete : by id 492
[InnoDB] DDL log insert : [DDL record: RENAME SPACE, id=493, thread_id=7, space_id=83, old_file_path=./test/t1.ibd, new_file_path=./test/#sql-ib1108-1917598001.ibd]
[InnoDB] DDL log delete : by id 493
[InnoDB] DDL log insert : [DDL record: RENAME TABLE, id=494, thread_id=7, table_id=1109, old_file_path=test/t1, new_file_path=test/#sql-ib1108-1917598001]
[InnoDB] DDL log delete : by id 494
[InnoDB] DDL log insert : [DDL record: DROP, id=495, thread_id=7, table_id=1108]
[InnoDB] DDL log insert : [DDL record: DELETE SPACE, id=496, thread_id=7, space_id=82, old_file_path=./test/#sql-ib1109-1917598002.ibd]

[InnoDB] DDL log post ddl : begin for thread id : 7
[InnoDB] DDL log replay : [DDL record: DELETE SPACE, id=496, thread_id=7, space_id=82, old_file_path=./test/#sql-ib1109-1917598002.ibd]
[InnoDB] DDL log replay : [DDL record: DROP, id=495, thread_id=7, table_id=1108]
[InnoDB] DDL log replay : [DDL record: DROP, id=490, thread_id=7, table_id=1108]
[InnoDB] DDL log post ddl : end for thread id : 7
这是个典型的三阶段 ddl 的过程:分为 prepare, perform 以及 commit 三个阶段:

Prepare: 这个阶段会修改元数据,创建临时 ibd 文件 #sql-ib1108-1917598001.ibd, 如果发生异常崩溃,我们需要能把这个临时文件删除掉,因此和 create table 类似,也为这个 idb 写了三条日志:delete space, remove cache, 以及 free btree
Perform: 执行操作,将数据拷贝到上述 ibd 文件中,(同时处理 online dmllog), 这部分不涉及 log ddl 操作

Commit: 更新数据词典信息并提交事务, 这里会写几条日志:

DROP : table_id=1108
RENAME SPACE: #sql-ib1109-1917598002.ibd 文件被 rename 成 t1.ibd
RENAME TABLE: #sql-ib1109-1917598002 被 rename 成 t1
RENAME SPACE: t1.ibd 被 rename 成 #sql-ib1108-1917598001.ibd
RENAME TABLE: t1 表被 rename 成 #sql-ib1108-1917598001
DROP TABLE: table_id=1108
DELETE SPACE: 删除 #sql-ib1109-1917598002.ibd

实际上这一步写的 ddl log 描述了 commit 阶段操作的逆向过程:将 t1.ibd rename 成 #sql-ib1109-1917598002, 并将 sql-ib1108-1917598001 rename 成 t1 表,最后删除旧表。其中删除旧表的操作这里不执行,而是到 post-ddl 阶段执行

Post-ddl: 在事务提交后,执行最后的操作:replay ddl log, 删除旧文件,清理 mysql.innodb_dynamic_metadata 中相关信息

DELETE SPACE: #sql-ib1109-1917598002.ibd
DROP: table_id=1108
DROP: table_id=1108

加索引
mysql> ALTER TABLE t1 ADD KEY(b);
Query OK, 0 rows affected (0.14 sec)
Records: 0 Duplicates: 0 Warnings: 0

[InnoDB] DDL log insert : [DDL record: FREE, id=431, thread_id=7, space_id=76, index_id=191, page_no=5]
[InnoDB] DDL log delete : by id 431

[InnoDB] DDL log post ddl : begin for thread id : 7
[InnoDB] DDL log post ddl : end for thread id : 7
创建索引采用 inplace 创建的方式,没有临时文件,但如果异常发生的话,依然需要在发生异常时清理临时索引, 因此增加了一条 FREE log,用于异常发生时能够删除临时索引.
TRUNCATE TABLE
mysql> TRUNCATE TABLE t1;
Query OK, 0 rows affected (0.13 sec)

[InnoDB] DDL log insert : [DDL record: RENAME SPACE, id=439, thread_id=7, space_id=77, old_file_path=./test/#sql-ib1103-1917597994.ibd, new_file_path=./test/t1.ibd]
[InnoDB] DDL log delete : by id 439
[InnoDB] DDL log insert : [DDL record: DROP, id=440, thread_id=7, table_id=1103]
[InnoDB] DDL log insert : [DDL record: DELETE SPACE, id=441, thread_id=7, space_id=77, old_file_path=./test/#sql-ib1103-1917597994.ibd]
[InnoDB] DDL log insert : [DDL record: DELETE SPACE, id=442, thread_id=7, space_id=78, old_file_path=./test/t1.ibd]
[InnoDB] DDL log delete : by id 442
[InnoDB] DDL log insert : [DDL record: REMOVE CACHE, id=443, thread_id=7, table_id=1104, new_file_path=test/t1]
[InnoDB] DDL log delete : by id 443
[InnoDB] DDL log insert : [DDL record: FREE, id=444, thread_id=7, space_id=78, index_id=194, page_no=4]
[InnoDB] DDL log delete : by id 444
[InnoDB] DDL log insert : [DDL record: FREE, id=445, thread_id=7, space_id=78, index_id=195, page_no=5]
[InnoDB] DDL log delete : by id 445

[InnoDB] DDL log post ddl : begin for thread id : 7
[InnoDB] DDL log replay : [DDL record: DELETE SPACE, id=441, thread_id=7, space_id=77, old_file_path=./test/#sql-ib1103-1917597994.ibd]
[InnoDB] DDL log replay : [DDL record: DROP, id=440, thread_id=7, table_id=1103]
[InnoDB] DDL log post ddl : end for thread id : 7
Truncate table 是个比较有意思的话题,在早期 5.6 及之前的版本中, 是通过删除旧表创建新表的方式来进行的,5.7 之后为了保证原子性,改成了原地 truncate 文件,同时增加了一个 truncate log 文件,如果在 truncate 过程中崩溃,可以通过这个文件在崩溃恢复时重新 truncate。到了 8.0 版本,又恢复成了删除旧表,创建新表的方式,与之前不同的是,8.0 版本在崩溃时可以回滚到旧数据,而不是再次执行。以上述为例,主要包括几个步骤:

将表 t1.ibd rename 成 #sql-ib1103-1917597994.ibd
创建新文件 t1.ibd
post-ddl: 将老文件 #sql-ib1103-1917597994.ibd 删除

RENAME TABLE
mysql> RENAME TABLE t1 TO t2;
Query OK, 0 rows affected (0.06 sec)

DDL LOG:
[InnoDB] DDL log insert : [DDL record: RENAME SPACE, id=450, thread_id=7, space_id=78, old_file_path=./test/t2.ibd, new_file_path=./test/t1.ibd]
[InnoDB] DDL log delete : by id 450
[InnoDB] DDL log insert : [DDL record: RENAME TABLE, id=451, thread_id=7, table_id=1104, old_file_path=test/t2, new_file_path=test/t1]
[InnoDB] DDL log delete : by id 451

[InnoDB] DDL log post ddl : begin for thread id : 7
[InnoDB] DDL log post ddl : end for thread id : 7
这个就比较简单了,只需要记录 rename space 和 rename table 的逆操作即可. post-ddl 不需要做实际的操作
DROP TABLE
DROP TABLE t2
[InnoDB] DDL log insert : [DDL record: DROP, id=595, thread_id=7, table_id=1119]
[InnoDB] DDL log insert : [DDL record: DELETE SPACE, id=596, thread_id=7, space_id=93, old_file_path=./test/t2.ibd]

[InnoDB] DDL log post ddl : begin for thread id : 7
[InnoDB] DDL log replay : [DDL record: DELETE SPACE, id=596, thread_id=7, space_id=93, old_file_path=./test/t2.ibd]
[InnoDB] DDL log replay : [DDL record: DROP, id=595, thread_id=7, table_id=1119]
[InnoDB] DDL log post ddl : end for thread id : 7
先在 ddl log 中记录下需要删除的数据,再提交后,再最后 post-ddl 阶段执行真正的删除表对象和文件操作
代码实现:
主要实现代码集中在文件 storage/innobase/log/log0ddl.cc 中,包含了向 log_ddl 表中插入记录以及 replay 的逻辑。
隐藏的 innodb_log_ddl 表结构如下
def->add_field(0, “id”, “id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT”);
def->add_field(1, “thread_id”, “thread_id BIGINT UNSIGNED NOT NULL”);
def->add_field(2, “type”, “type INT UNSIGNED NOT NULL”);
def->add_field(3, “space_id”, “space_id INT UNSIGNED”);
def->add_field(4, “page_no”, “page_no INT UNSIGNED”);
def->add_field(5, “index_id”, “index_id BIGINT UNSIGNED”);
def->add_field(6, “table_id”, “table_id BIGINT UNSIGNED”);
def->add_field(7, “old_file_path”,
“old_file_path VARCHAR(512) COLLATE UTF8_BIN”);
def->add_field(8, “new_file_path”,
“new_file_path VARCHAR(512) COLLATE UTF8_BIN”);
def->add_index(0, “index_pk”, “PRIMARY KEY(id)”);
def->add_index(1, “index_k_thread_id”, “KEY(thread_id)”);
记录类型
根据不同的操作类型,可以分为如下几类:
FREE_TREE_LOG 目的是释放索引 btree,入口函数 log_DDL::write_free_tree_log,在创建索引和删除表时会调用到
对于 drop table 中涉及的删索引操作,log ddl 的插入操作放到父事务中,一起要么提交要么回滚对于创建索引的 case, log ddl 就需要单独提交,父事务将记录标记删除,这样后面如果 ddl 回滚了,也能将残留的 index 删掉。
DELETE_SPACE_LOG
入口函数:Log_DDL::write_delete_space_log
用于记录删除 tablespace 操作,同样分为两种情况:

drop table/tablespace, 写入的记录随父事务一起提交,并在 post-ddl 阶段 replay
创建 tablespace, 写入的记录单独提交,并被父事务标记删除,如果父事务回滚,就通过 replay 删除参与的 tablespace
RENAME_SPACE_LOG

入口函数:Log_DDL::write_rename_space_log
用于记录 rename 操作,例如如果我们把表 t1 rename 成 t2, 在其中就记录了逆向操作 t2 rename to t1. 在函数 Fil_shard::space_rename() 中,总是先写 ddl log, 再做真正的 rename 操作. 写日志的过程同样是独立事务提交,父事务做未提交的删除操作
DROP_LOG
入口函数:Log_DDL::write_drop_log
用于记录删除表对象操作,这里不涉及文件层操作,写 ddl log 在父事务中执行
RENAME_TABLE_LOG
入口函数:Log_DDL::write_rename_table_log
用于记录 rename table 对象的逆操作,和 rename space 类似,也是独立事务提交 ddl log, 父事务标记删除
REMOVE_CACHE_LOG
入口函数:Log_DDL::write_remove_cache_log
用于处理内存表对象的清理,独立事务提交,父事务标记删除
ALTER_ENCRYPT_TABLESPACE_LOG
入口函数:Log_DDL::write_alter_encrypt_space_log
用于记录对 tablespace 加密属性的修改,独立事务提交. 在写完 ddl log 后修改 tablespace page0 中的加密标记
综上,在 ddl 的过程中可能会提交多次事务,大概分为三类:

独立事务写 ddl log 并提交,父事务标记删除, 如果父事务提交了,ddl log 也被顺便删除了,如果父事务回滚了,那就要根据 ddl log 做逆操作来回滚 ddl
独立事务写 ddl log 并提交, (目前只有 ALTER_ENCRYPT_TABLESPACE_LOG)
使用父事务写 ddl log,在 ddl 结束时提交。需要在 post-ddl 阶段处理

post_ddl
如上所述,有些 ddl log 是随着父事务一起提交的,有些则在 post-ddl 阶段再执行, post_ddl 发生在父事提交或回滚之后: 若事务回滚,根据 ddl log 做逆操作,若事务提交,在 post-ddl 阶段做最后真正不可逆操作(例如删除文件)
入口函数:Log_DDL::post_ddl –>Log_DDL::replay_by_thread_id
根据执行 ddl 的线程 thread id 通过 innodb_log_ddl 表上的二级索引,找到 log id, 再到聚集索引上找到其对应的记录项,然后再 replay 这些操作,完成 ddl 后,清理对应记录
崩溃恢复
在崩溃恢复结束后,会调用 ha_post_recover 接口函数,进而调用 innodb 内的函数 Log_DDL::recover(), 同样的 replay 其中的记录,并在结束后删除记录。但 ALTER_ENCRYPT_TABLESPACE_LOG 类型并不是在这一步删除,而是加入到一个数组 ts_encrypt_ddl_records 中, 在之后调用 resume_alter_encrypt_tablespace 来恢复操作,
参考文档
1. 官方文档 2. wl#9536: support crash safe ddl

本文作者:zhaiwx_yinfeng 阅读原文
本文为云栖社区原创内容,未经允许不得转载。

退出移动版