数据库内核月报 - 2019 / 12

MySQL · 引擎特性 · 动态元信息持久化

背景

MySQL 在 8.0 中引入了动态元信息持久化功能,目的是能持久化表上快速变化的元信息(fast-changing metadata),重启后元信息可以恢复到重启前的状态,比如 autoinc、update_time、index corrupt 信息等。目前实现了 2 种元信息的持久化,index corrupt 信息 和 autoinc。

关于这个功能,Upstream 有 2 个 worklog,WL#7816WL #6204WL#7816 引入了整个设计框架,并且实现了 index corrupt 的持久化;WL #6204 是在这个框架之上,实现了 autoinc 持久化功能。

关于 index corrupt 信息持久化,因为实际使用中遇到也比较少,所以大家可能会比较陌生。简单来说,当 InnodB 在运行过程中,发现索引坏掉,不管是物理数据的损坏(比如 index root page 无效、数据页损坏),还是逻辑数据的损坏(如 dup key),都会将 index 设置为 corrupted,后面对 index 的访问就会被屏蔽掉,或者报错。在 8.0 之前,这个 corrupt 信息是持久化在 SYS_INDEXES 内部系统表中的 (TYPE 字段)(参考 dict_set_corrupted())。但是因为对系统表的更新,是比较上层的,而发现 corrupt 时,是在 InnoDB 比较底层的逻辑,从底层去更新系统表,要持有上层的锁,这就很可能导致死锁,因此很多情况下,只是更新索引的内存结构,而不做持久化到系统表里(参考 dict_set_corrupted_index_cache_only())。8.0 引入 DD 表后,同样存在这样软件架构上下层的问题。因为可能没有持久化,corrupted 信息重启后就丢失,坏的索引就可能在重启后被访问,这会导致潜在的数据问题。

关于 autoinc 的持久化问题,相信 MySQL DBA 或者内核研发同学应该都很熟悉。著名的 bug #199 就是 autoinc 持久化问题。长期以来,InnoDB 都没有对 autoinc 做持久化,只在内存表对象 cache 中维护 autoinc 信息,重启后表的 autoinc 值是通过类似 SELECT MAX() 来初始化的,所以 InnoDB 表一定要对 autoinc 字段建索引,如果是组合索引,autoinc 字段必须是索引中第一个字段,这样 SELECT MAX() 逻辑才会比较快。

关于 autoinc 持久化的问题,AliSQL 和 PolarDB 很早就有了自己的解决方案,我们在早期的月报中介绍过,大家可以参考 InnoDB自增列重复值问题。简单来说,这个方案是将 autoinc 写入 PK root page,保存在一个原来不用的位置(PAGE_MAX_TRX_ID)。这个方案实现也发布在 AliSQL 开源版本AliSQL Persistent AUTO_INCREMENT ,同时我们也将这个方案贡献到 MariaDB MDEV-6076

下面笔者将会基于自己的理解,给大家介绍 8.0 动态元信息持久化功能,其中代码分析基于目前最新的 8.0.18 版本。

持久化框架原理

如前面所说,整个持久化设计方案是在 work log WL#7816 引入的,work log 也相当详细,大家也可以直接看 work log.

整体方案的核心是复用 (piggy-back) InnoDB redo log,,通过新增加一种逻辑 redo 类型 MLOG_TABLE_DYNAMIC_META,将元信息更新写入到 redo,一方面 redo 可以提供持久化保证,另一方面 redo 的层次比较底,基本可以在所有元信息变化的地方写入,不存在架构层次问题。

除了利用 redo 之外,还引入了一张 DD Buffer Table 来做辅助持久化。因为 checkpoint 之后,checkpoint lsn 之前的 redo 理论上就是丢弃的,所以之前的所有元信息更新,就需要重新写入 redo,这就会导致频繁重复写入元信息到 redo(copy across checkpoint)。DD Buffer Table 的目的就是为避免这种重复写入,相当于这种元信息的 checkpoint。DD buffer table 是一张 InnoDB 内部字典表,其本身的数据写入是受 redo 保护的。

整体的流程是这样的:

  1. 一旦元信息发生变化,就将新的元信息写入 redo log(MLOG_TABLE_DYNAMIC_META)。
  2. 在做 checkpoint 时,将上一次 checkpoint 后变化的所有元信息,写入到 DD Buffer Table。
  3. 在 slow shutdown 或者 export tablespace 时,将最新的元信息持久到 DD 表中,这时可以清空 DD Buffer Table。需要注意的是,这个只是规划中 (in plan)的逻辑,目前没有实现。
  4. 下次重启时,将 DD Buffer Table 中的元信息,和 redo log 中的元信息,apply 到表内存对象上。

具体实现

下面我们看下具体的代码实现

1. 全局 dict_persist_t

这是管理元信息持久化的一个全局数据结构,类似于 dict_sys_t,管理运行时元信息变化。

struct dict_persist_t {
  // 保护当前结构数据
  ib_mutex_t mutex; 

  // 所有元信息变化的表,都挂在这个list 上
  UT_LIST_BASE_NODE_T(dict_table_t)
  dirty_dict_tables;

  // 被标记为 METADATA_DIRTY 的表数量
  std::atomic<uint32_t> num_dirty_tables;

  // 负责对 DD Buffer Table (mysql.innodb_dynamic_metadata)的操作,
  DDTableBuffer *table_buffer;

  // 元信息持久化实现集合,目前有 2 种,autoinc 和 index corrupt
  Persisters *persisters;
}

参考函数

dict_persist_init()
dict_persist_close()

2. DDTableBuffer

操作 mysql.innodb_dynamic_metadata buffer table 表的实现。

mysql.innodb_dynamic_metadata 表结构如下:

CREATE TABLE `innodb_dynamic_metadata` (
  `table_id` bigint(20) unsigned NOT NULL,
  `version` bigint(20) unsigned NOT NULL,
  `metadata` blob NOT NULL,
  PRIMARY KEY (`table_id`)
) /*!50100 TABLESPACE `mysql` */ ENGINE=InnoDB

每个元信息表化的表,都会在 innodb_dynamic_metadata 中有一条记录,目前所有类型的元信息都拼在一个 blob 里的。

参考函数

DDTableBuffer::init()
DDTableBuffer::open()
DDTableBuffer::get()
DDTableBuffer::remove()
DDTableBuffer::replace()

3. 持久化 Persister 和 PersistentTableMetadata

PersistentTableMetadata 是动态元信息的内存表示,对应每个 dict_table_t 的所动态元信息。

Persister 负责: a) 在写入 Buffer Table 前将 PersistentTableMetadata 序列化成 btye stream,最终写入 blob 字段 b) 将从 Buffer Table 读取出的 blob 字段,反序列到 PersistentTableMetadata 中。

Persister 是一个基类,每种元信息要基于这个基类,实现自己的具体逻辑。 目前有 2 中元信息corrupted index 和 autoinc,对应 2 种 Persister, CorruptedIndexPersisterAutoIncPersister。序列化时会先序列化 corrupted index,再序列化 autoinc。

同时 Persister 还负责写入 redo log 时,redo record body 的构造。

目前所有动态元信息,都是用一种逻辑 redo record 类型 MLOG_TABLE_DYNAMIC_META 来记录的,那么怎么区分这个 record 是 corrupted index 还是 autoinc 呢?

序列化 stream 的头部第一个 byte 用来标识类型,每一种元信息对应一种类型

enum persistent_type_t {
  /** The smallest type, which should be 1 less than the first
  true type */
  PM_SMALLEST_TYPE = 0,

  /** Persistent Metadata type for corrupted indexes */
  PM_INDEX_CORRUPTED = 1,

  /** Persistent Metadata type for autoinc counter */
  PM_TABLE_AUTO_INC = 2,

  /* TODO: Will add following types
  PM_TABLE_UPDATE_TIME = 3,
  Maybe something tablespace related
  PM_TABLESPACE_SIZE = 4,
  PM_TABLESPACE_MAX_TRX_ID = 5, */

  /** The biggest type, which should be 1 bigger than the last
  true type */
  PM_BIGGEST_TYPE = 3
};

对 corrupted index,redo record body 是这样的

     1B (类型)      |     1B (index 个数) |     12B index id              | index id    |
 PM_INDEX_CORRUPTED |  corrupted index num  | table_id (4B) + index_id (8B) |  .....      |

对 corrupted index,redo record body 是这样的

     1B (类型)     |  1 ~ 11B (auto inc 值) |
 PM_TABLE_AUTO_INC |   auto inc compressed    |

所以通过对 body 第 1 个 byte 的复用,同一个 MLOG_TABLE_DYNAMIC_META redo record 类型,就可以表示多种元信息了。

innodb_dynamic_metadata.metadata 字段中的数据,和 redo body 是一样,不同的地方的,redo 只会有一种类型,而 innodb_dynamic_metadata.metadata 中可能是多种类型信息拼到一起的(目前最多2种)。

参考函数

Persister::write_log()
Persisters::write()
AutoIncPersister::write()
CorruptedIndexPersister::write()
dict_init_dynamic_metadata()
dict_table_read_dynamic_metadata()
CorruptedIndexPersister::read()
AutoIncPersister::read()

4. dirty_status 和 write back

dict_table_t 结构中,新增 dirty_status 状态标识,和 dirty_dict_tables 链表节点。

dirty_status 有三种状态:

enum table_dirty_status {
  METADATA_DIRTY = 0,

  METADATA_BUFFERED,

  METADATA_CLEAN
};

dirty_dict_tables 链表节点,用来将 METADATA_DIRTYMETADATA_BUFFERED 状态的 dict_table_t 挂到 dict_persist_t::dirty_dict_tables 链表上。

METADATA_DIRTY 的表,需要在 checkpoint 时,写入到 DD Buffer Table 中。写入 Buffer Table 后,状态变成 METADATA_BUFFERED,但是依然在 dict_persist_t::dirty_dict_tables 链表上。目前只在将 dict_table_t 对象从 cache 淘汰时,才会将其从 dirty_dict_tables 链表上移除。但是当被淘汰的表被打开时,依然会把加入到 dirty_dict_tables。

目前并没有将 dirty_status 从 METADATA_DIRTY 或者 METADATA_BUFFERED 变化为 METADATA_CLEAN 的逻辑。因为还没有将动态元信息写回到 DD 表中的逻辑。

参考函数

dict_persist_to_dd_table_buffer()
dict_table_persist_to_dd_table_buffer()
dict_table_persist_to_dd_table_buffer_low()
dict_table_remove_from_cache_low()
dict_table_load_dynamic_metadata()

5. 关闭和启动初始化

对于正常关闭,关闭前会做一次 checkpoint,将所有 METADATA_DIRTY 状态的元信息,写回到 DD Buffer Table 表。启动时,只需要初始化好 dict_persist->table_buffer 就可以,后续打开表创建 dict_table_t 对象时,会自动从 DD Buffer Table load 数据来 apply。

参考函数

dict_table_load_dynamic_metadata()

对于异常关闭,可能从最新的 checkpoint 后,有新的元信息变动还没写回 DD Buffer Table,这时就需要通过扫描 redo log 把这些元信息找出来。

这里新增了一个数据结构 MetadataRecover,挂在 recv_sys->metadata_recover。在 crash recover 扫描解析 redo log 过程中,如果遇到 MLOG_TABLE_DYNAMIC_META 类型的 redo 日志,就解析出元信息并缓存到 metadata_recover->m_tables map 中。在 crash recover 后,字典系统初始化时,会将之前缓存的元信息全部 apply 掉。

参考函数

MetadataRecover::parseMetadataLog()
MetadataRecover::apply()
srv_dict_recover_on_restart()

autoinc 持久化

前面是通用的分析和介绍,下面我们专门看下 autoinc 的持久化。

1. 持久化时机

autoinc 的持久化,并不是在产生时就做持久化,而是在 InnoDB 插入或者更新 PK 记录时,才持久化的,(参考row_ins_clust_index_entry_low()row_upd_clust_rec())。并且这个时候是不能访问 table->autoinc member 的,因为锁优先级问题,不能加 autoinc_mutex 锁,所以 autoinc 的值,是从 tuble 中解析出来的。

参考函数

row_ins_clust_index_entry_low()
row_upd_clust_rec()
row_get_autoinc_counter()
dict_table_autoinc_log()

2. 持久化粒度

因为 autoinc 是一个频繁更新的元信息,如果每次更新都用将 redo 落盘,会对性能有比较大的影响,同时引入新 mtr 的代价也比较大,所以 autoinc 写 redo 时,是当前上下文的 mtr,不做 mtr_commit,也不用 log_write_up_to() 来等 redo 真的 flush 下去。

这点和 index corrupt 是不同的,因为发生 index corrupt 相对来说是很小概率的,所以 index corrupt 是用一个自己独立 mtr,并且等 redo flush。

所以 autoinc 持久化并不是 100% 持久化的,不能保证 crash 场景的持久化: a) 一方面由于实现的原因,并不是 autoinc 递增后,就立马写 redo b) 另一方面出于性能的考虑,autoinc redo 并不马上落盘,redo 落盘依赖于事务提交

所以事务过程中,autoinc 发生变化后,InnoDB crash 是会导致 autoinc 回退的。因为通常我们的业务逻辑是依赖事务提交的,所以这个问题也是可以接受的。

参考函数

dict_set_corrupted()
dict_table_autoinc_log()

3. 用户侧行为变化

a) ALTER TABLE AUTO_INCREMENT = N 并不能将 autoinc 改成一个比实际数据小的值。 b) 做完 ALTER TABLE AUTO_INCREMENT = N 后,就立马重启,并不会取消这个 alter 效果,因为已经持久化了

有一种情况是可以 alter 回去的,比如因为数据插入,自增现在是 20,做 ALTER TABLE AUTO_INCREMENT = 100 后,自增变成100,在新的数据插入进来前,就立马做再做一次 ALTER TABLE AUTO_INCREMENT = 20 是可以改回 20 的。但是 ALTER TABLE AUTO_INCREMENT = 10 是改不回去的,因为表数据中已经有 20 这条记录了。

官方文档对行为变化也有说明,可以参考 InnoDB AUTO_INCREMENT Counter Initialization

4. 自增列强制索引限制是否可以去掉

因为自增值已经持久化了,我们在初始化时,就不需要 SELECT MAX(),是不是可以去掉自增上一定要加索引的限制呢?

a) 对于老版本数据(比如 5.7),升级到 8.0,因为老版本没有持久化,所以 SELECT MAX() 还是要的。但是老版本的索引限制是有的,所以表结构里自增肯定有是索引的。 b) 对于在新版本上新建的表,持久化机制会保证持久化。

所以理论上是可以的,我们也向 Upstream report 这个 feature request,感兴趣的可以关注下 bug #98093,期待 Upstream 后续版本可以移除这个限制。

5. 查看 DD Buffer Table

熟悉 8.0 的同学可能知道,默认情况下 DD 表是不让访问的,但是 Debug 版本可以去除这个访问限制

SET SESSION debug='+d,skip_dd_table_access_check';

但是貌似 DD Buffer Table 一直是空的,查不出数据:

mysql> select * from mysql.innodb_dynamic_metadata;
Empty set (0.00 sec)

这是为什么呢?我们知道 InnoDB 是支持 MVCC 的,在 PK 每条记录上有 DB_TRX_ID 表示最后更新的事务 id,其它人访问到这条记录后,用自己的 read view 和这个事务 id 比较,来判断是否可见。对于 DB Buffer Table 每条记录的事务 id 都被强制记为 0XFFFFFFFFFFFF,所以是看不到的(参考 DDTableBuffer::create_tuples() )。绕过方法也很简单,把隔离级别改成 RU。

mysql> set transaction_isolation = "read-uncommitted";
Query OK, 0 rows affected (0.00 sec)
mysql> select table_id, version, hex(metadata) from mysql.innodb_dynamic_metadata;
+----------+---------+---------------+
| table_id | version | hex(metadata) |
+----------+---------+---------------+
|        6 |       0 | 0201          |
|        7 |       0 | 0280FF        |
|        9 |       0 | 028135        |
|       12 |       0 | 028F4E        |
|       15 |       0 | 0233          |
|       19 |       0 | 02810C        |
|       21 |       0 | 0255          |

祝玩得开心!