谈谈 DDL 的前世今生

作者 | 龙雪刚

1、前言

对于 MySQL DDL 来说,在 ONLINE DDL 之前,在线上直接执行 DDL 会是一件非常恐怖的事情。幸好 PT-OSC 和 这两年比较流行的 GH-OST 解决了这一难题。

对于 TiDB DDL 来说,DDL 相关原理的文章,比较容易搜索到的是官方写的《TiDB 源码阅读系列文章(十七)DDL 源码解析》,里面也详细介绍了整个过程。

这里不再对 DDL 的整体原理进行讲述,想从使用者的角度谈谈 DDL 这类重量级操作的前世和今生。

2、回顾

先回顾下 MySQL 的 DDL 的过程:(以 ADD INDEX 举例)

5.5 版本前:

1)创建临时表(空表),对临时表 执行 ADD INDEX 。

2)对原表添加 METADATA LOCK 。(加锁过程中,不可读写。加锁完成后,可读但不可写)

3)按主键索引扫描全表并插入到临时表,同时读取对应列,构建二级索引树。

4)原表和临时表互换表名,删除原表。

5.5 版本及以后:

1)ADD INDEX 做了一些优化:不再创建临时表。直接在原表修改表结构,扫描原表对应列构建二级索引树。

2)ADD / DROP COLUMN 仍然要创建临时表,与5.5之前版本处理方式一样。

可以看到,改表过程大概分如下几步:

1)创建新的表结构 or 直接在原表修改表结构

2)拷贝历史数据到新表

3)为了防止同步数据过程中有增量数据,则对原表加锁。(可以读,但不可写)

问题:

虽然允许读,但不可写,对于在线业务影响非常大。业务希望改表时,不能阻读读写。因此现在常用在线改表工具 PT-OSC 或者 GH-OST。

在线 DDL 需要解决两个问题:

1)如何将原表历史数据拷贝到新表,在拷贝数据过程中不阻塞原表读写;

2)拷贝原表历史数据过程中,原表新增数据如何同步到新表。

以 PT-OSC 为例:(以 ADD INDEX 举例)

1)创建新表(空表),对新表执行 ADD INDEX 。

2)对原表分别针对 INSERT,UPDATE,DELETE 语句创建三个 TRIGGER。INSERT / UPDATE TRIGGER 对应 REPLACE IGNORE 新表;DELETE TRIGGER 对应 DELETE IGNORE 新表。

3)对原表切分成各干个小的 CHUNK,分 CHUNK 扫描原表数据并插入到新表。

4)原表全表扫描完成之后,原表和新表互换表名,并删除 TRIGGER。

我们分析一下里面的设计逻辑:

1)第一个问题:PT-OSC 是通过将原表分成一个个很小的 CHUNK,每次只拷贝一个 CHUNK。在拷贝当前 CHUNK 时,对 CHUNK 加读锁。因此,在拷贝数据过程中,仍然加锁,只是由于每次加锁 CHUNK 很小并且加锁时间很短,业务上没有什么感知而已。

2)第二个问题:PT-OSC 是通过对原表 DML 语句创建三种不同的 TRIGGER。在创建 TRIGGER 完成之后,所有对原表的 DML 操作都能实时同步到新表。(此时还没有开始拷贝原表数据到新表)。

这里有一个设计非常巧妙的地方:当拷贝的原表数据已经由 TRIGGER 实时同步到新表时,该怎么处理呢?PT-OSC 通过巧妙设计三种 TRIGGER 对应的语句来实现。简单来说以原表数据为准。但是 PT-OSC 在高并发场景下容易出现 DEAD LOCK 导致整个服务被 HANG 住。其中一个非常重要的原因是创建的 TRIGGER 把原先的一条 SQL 变成了一个事务中的两条。在高并发场景下,容易生产 DEADLOCK。

以 GH-OST 为例:

GH-OST 借鉴了 PT-OSC 的优点,同时对同步增量数据做了一些优化:通过模拟从库,从主库上拉取 BINLOG,然后再解析 BINLOG 将增量实时同步到新表。

3、对比

与 MySQL 对比,TiDB 有两个明显的不同点:

表结构和数据解耦:

1)在 MySQL 上,表结构和数据无法解耦,修改表结构(ADD / DROP COLUMN)就必须要动数据。

2)在 TiDB 上 表结构和数据是解耦的,在读取数据的时候,会同时和表结构进行对比,如果发现表结构中存在多余列或者不存在列,则可以直接将多余列的默认值带上,或者过滤掉不存在列的数据。因此,对于 ADD / DROP COLUMN 操作,只需要修改表结构数据,整个过程会很快。

单机和分布式:

1)在 MySQL 上,修改表结构完成后,因为是单机,所以不存在修改过程中间状态。

2)在分布式,TiDB Server 有多个节点,每个节点会缓存表结构信息,每次表结构变动,会由 PD Server 主动通知各个 TiDB Server 更新表结构缓存信息。因此在修改完表结构之后,无法让多个 TiDB 在同一个时间点上看到最新的表结构。

为什么 TiDB Server 要缓存表结构信息?

其中一个原因:因为表结构是一条数据,会存在 TiKV Server上,如果 TiDB Server 不缓存起来,则每次都需要从 TiKV Server 上查找,不仅影响效率,同时由于 TiKV Server 和 TiDB Server 都是多机部署,两者有可能相隔万里,那每次去 TiKV Server 上查找就更加耗时。

TiDB 面对的问题:

存在一种场景:在同一时刻,不同的 TiDB 看到的表结构是不一样的,有的 TiDB 仍然看到旧表结构。从这些 TiDB 节点执行的 SQL 就有可能会报错。

解决思路:

在分布式架构中,既然上面的场景是无法避免的,也就是说一定会存在不同的 TiDB 节点在同一时刻看到的表结构不一样。

那么解决思路就聚焦在:即使看到了不一样的表结构,但要保证业务 SQL 执行不会出错。

4、方案

核心逻辑:

将表结构中间状态拉长,增加5种状态。让这 5 种状态通过连贯性不断迭代达到最终一致性(PUBLIC 或者 ABSENT)。并且要确保在过程中业务 SQL 不受影响。

1)ADD INDEX 引入 ABSENT、DELETE ONLY,WRITE ONLY,WRITE REORG,PUBLIC 这5种表结构状态

2)DROP INDEX 引入 PUBLIC,WRITE ONLY,DELETE ONLY,DELETE REORG,ABSENT 这 5 种表结构状态。

5 种状态:

1)ABSNET 和 PUBLIC :表示 DDL 操作前和操作完成之后。ADD 和 DROP 操作 正好相反。可以理解成新加的字段从无到有 OR 要删除的字段从有到无。

2)DELETE ONLY:看到这个状态的 TiDB Server ,DDL 动作只对 DELETE 语句有效。

3)WRITE ONLY:看到这个状态的 TiDB Server ,DDL 动作只对 DML 语句有效。即只对写操作有效,不读操作不可见。(UPDATE 语句 可以看成是 DELETE + INSERT )

4)WRITE REORG:表示开始拷贝历史表数据。

5)DELETE REORG:表示开始删除数据。(在 TiDB 上做了优化,历史数据删除 统一交由 GC 来做。因此在 TiDB 上 可以省略这一步)

5、论证

论证 ADD INDEX :

1)ABSENT 和 PUBLIC 是 DDL 操作的 开始和结束,因此这两个状态肯定要存在,而且顺序必须是 ABSENT → PUBLIC

2)WRITE REORG 拷贝历史数据用来构建索引数据,这一步也是必须步步骤。在构建完索引数据后,就可以认为 DDL 操作真正完成。因此顺序是:ABSENT → WRITE REORG → PUBLIC

3)回顾 MySQL DDL 过程可以知道,在拷贝历史数据之前,需要开始同步增量数据。也就是对于 WRITE 操作要能实时应用到新增的索引列上,而且这一步要在开始拷贝历史数据之前。如果拷贝历史数据这一步骤先开始,那么在开始拷贝数据 和 开始同步增量数据之间的 DDL 操作 就会遗漏,不会应用到新增索引列上。因此整个 DDL 顺序为:ABSENT → WRITE ONLY → WRITE REORG → PUBLIC

4)当有 TiDB Server 处于 ABSENT 状态 和 WRITE ONLY 状态时,会产生数据泄露。例如:

  1. 背景:表 A 有字段 ID,里面有三条记录,ID 列值为(1,3,5),为字段 ID 执行 ADD INDEX。
  2. 处于WRITE ONLY 状态 的 TiDB Server,执行 INSERT INOT A (ID)VALUES(6),则 ID 字段索引数据会新增一条 值为6的索引数据。
  3. 处于 ABSENT 状态的 TiDB Server 执行 DELETE FROM A WHERE ID = 6,也就是将上面步骤插入的6给删除掉,由于这个 TiDB Server 是 ABSENT 状态,则不会连带删除相应的索引 6 这条记录。这样一来,索引 6 这条记录就会发生泄露。

5)因此,在 WRITE ONLY 状态前,需要保证所有 TiDB SERVER 能对 DELETE 操作可见,因此整个 DDL 顺序为ABSENT → DELETE ONLY → WRITE ONLY → WRITE REORG → PUBLIC

论证 DROP INDEX:

1)PUBLIC 和 ABSENT 是 DDL 操作的 开始和结束,因此这两个状态肯定要存在,而且顺序必须是 PUBLIC → ABSENT

2)DELETE REORG 删除历史索引数据,这一步也是必须步骤。在删除完历史索引数据后,就可以认为 DDL 操作真正完成。因此顺序为 PUBLIC → DELETE REORG → ABSENT

3)在开始删除历史索引数据时,如果还有 TiDB Server 能够看到这个索引能通过这个索引进行查询,则可能会发生由于索引数据已经被删除掉,导致查询索引操作失败。因此,在 DELETE REORG 之前,需要对读操作不可见,需要所有 TiDB Server 至少处于读不可见,也即是 WRITE ONLY 状态。所以整个顺序为: PUBLIC → WRITE ONLY → DELETE REORG → ABSENT

4)在开始删除历史索引数据时,如果 TiDB SERVER 还能对索引数据进行 INSERT 操作,就会存在一边删除索引数据,一边又新增索引数据,导致新增的索引数据有可能无法被删除,从而导致数据泄露。因此在 WRITE ONLY 和 DELETE REORG 之间,TiDB Server 需要对 INSERT 操作不可见,由需要处于 DELETE ONLY 状态。因此整个顺序为 PUBLIC → WRITE ONLY → DELETE ONLY → DELETE REORG → ABSENT

5)TiDB 对删除历史数据做了优化,把删除动作交给 GC 来做,在 DDL 操作中不进行历史数据删除。因此整个顺序调整为:PUBLIC → WRITE ONLY → DELETE ONLY → ABSENT

ADD / DROP COLUMN

因为 TiDB 表结构 和 数据解耦,在 ADD / DROP COLUMN 时,不需要腾挪数据,因此过程比较简单。这里就不再赘述。