一次 TiDB 迁移引发的“血案”

1. 背景

我需要一个能力更强的关系型数据库!

本人之前设计和实现了公司目前使用的离线作业调度系统,选用了 MySQL 作为主要存储。这个系统虽然不算什么黑科技产品,但是也承担了每天 16000~20000 个作业实例(instance)的创建和运行。

显然的,这张记录着运行实例(instance)的数据库表的空间复杂度为 O(m * t)(其中 m 代表每天运行作业实例的个数,而 t 则表示系统运行的天数)。可以想见,随着时间的推移,这张表的数据量基本是线性的增长。事实是,两年多的时间,系统运行的实例数已破千万 。

带来的副作用自然是查询性能的降低、用户响应时长的增长。而这个性能影响主要体现在列表页相关的查询上。考虑下面这个查询场景:通过业务线、实例名匹配、实例类型、实例的状态、负责人信息过滤实例,并且按照创建时间的倒序进行分页返回。

这个查询非常耗费性能,主要体现在如下几个方面:

  1. 业务线是任务(job)的属性,因此通过业务线进行过滤时需要进行多表的 join 操作。
  2. 实例名和负责人的模糊匹配:索引失效,没得洗。
  3. 大量查询结果的全局排序并分页。

在单机 MySQL 的支持下,我的经常性的运维操作就是把三个月之前运行的实例进行删除,确保 instance 表的行数维持在百万行的规模。

由于这个系统的开发和运维基本就是我的个人 Solo,所以当公司的 TiDB 向我递出橄榄枝时,我立刻回以拥抱。

TiDB 带给我欣喜的同时,也引发了新的问题。

2. 用户反馈出问题了

为什么我之前能正常运行的任务现在报错了?

迁移的过程此处按下不表,完全委托给 DBA。但是在接下来的两三天内我陆续接到了有同事的反馈:在没有变更表 schema 和计算逻辑的情况下,相邻两天的任务一个成功,一个失败。

依照用户的描述,我查了一下运行日志,HiveServer 抛出的异常表明计算脚本最后结果的 Schema 与 Hive 表不一致。因此我初步的判断还是计算脚本的问题。

我首先要排查成功和失败的实例是否使用了相同的计算脚本。我从两个实例的启动命令入手,发现两者所使用的文件果然不一样,失败的实例使用的文件 id 为 219021,而成功实例使用的文件 id 为 308972。

从数据库里查处这两个文件的具体信息,如下图所示:

截屏2020-08-28 上午12.19.35

上图出现了一个没有预料到的情况:主键大的那一条记录(id 308972)的创建时间竟然早于于主键小的那一条记录(id 219021),并且早了三天之久!

两条记录有点背离经验啊!

3. 两条不合常理的数据

为什么自增主键大的反而创建时间早呢?而且主键值的 gap 这么大!

从单机 MySQL 的最佳实践来看:

  1. MySQL 主键应该使用一个业务无关的自增的无符号整型来存储比较妥当。
  2. 创建时间和更新时间分别通过默认值 CURRENT_TIMESTAMPON UPDATE CURRENT_TIMESTAMP 来确保时间戳的自动更新。

基于上述理由,我们下意识都认为创建时间的递增顺序和主键的递增顺序是一致的,说人话就是:创建时间早的记录一般自增主键比较小。

当然也有反例,假如两条 SQL 分别通过两个事务并发插入批量数据,MySQL 会为两条 SQL 各自预留一个段主键值。如果主键值段比较大的 SQL 率先写入完成,那么就会造成类似的效果:1)先写入的数据主键值反而大;2)主键值之间留出了比较大的 gap。但是即使是这种场景下,写入数据的创建时间差也不会有 3 天这么大。

开发者的直觉告诉我,这个情况一定与 TiDB 迁移有关。向 DBA 咨询过后,我的猜想果然得到了验证。

TiDB 的自增列仅保证唯一,也能保证在单个 TiDB server 中自增,但不保证多个 TiDB server 中自增,不保证自动分配的值的连续性。

—— 《自增 ID》

TiDB 实现 AUTO_INCREMENT 隐式分配的原理是,对于每一个自增列,都使用一个全局可见的键值对用于记录当前已分配的最大 ID。由于分布式环境下的节点通信存在一定开销,为了避免写请求放大的问题,每个 TiDB 节点在分配 ID 时,都申请一段 ID 作为缓存,用完之后再去取下一段,而不是每次分配都向存储节点申请。

—— 《实现原理》

4. 是改 SQL 呢?还是清理数据呢?

光明白没用,问题还等着解决呢……

明白了技术层面出问题的原因,下面聊聊出问题的这个场景以及解决方案。

自然,从实体关系模型说起。数据库里记录了每个任务多版本所使用的构件(artifact)与文件(custom_file)之间的映射关系,其表结构如下所示:

CREATE TABLE `artifact` (
  `id` bigint(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id',
  `name` varchar(100) NOT NULL DEFAULT '' COMMENT '构件名称',
  `desc` varchar(2048) NOT NULL DEFAULT '' COMMENT '说明',
  `job_id` bigint(11) unsigned NOT NULL DEFAULT 0 COMMENT '任务',
  `owner` varchar(100) NOT NULL COMMENT 'owner',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin

CREATE TABLE `artifact_file` (
  `id` bigint(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id',
  `artifact_id` bigint(11) unsigned NOT NULL DEFAULT 0 COMMENT '构件id',
  `file_id` bigint(11) unsigned NOT NULL DEFAULT 0 COMMENT '文件id',
  ......
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

CREATE TABLE `custom_file` (
  `id` bigint(11) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id',
  `name` varchar(100) NOT NULL DEFAULT '' COMMENT '文件名',
  ......
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

在服务启动的时候,首先要从数据库中把每个任务最新的构件包含的文件列表读取出来进行初始化。获取每个任务最新构件的 SQL 写作:

SELECT    MAX(id) as id
FROM      artifact
GROUP BY  job_id;

这是一个分组取最大值的操作,简单明了。取出最新的 artifact.id (亦即 Max(id))之后进行 join 操作即可。

那么既然 TiDB 之上无法保证主键全局的自增,最新 这一语义便只能通过创建时间来表现了:

SELECT    job_id, MAX(create_time) as create_time
FROM      artifact
GROUP BY  job_id;

选取每个任务最新的时间戳,通过 job_idcreate_time 两个条件来 join 来筛选数据。看起来可行,但现实给予了无情的打脸。通过这个方式,每个任务返回了多条记录!

问题出在哪里呢?

  1. MAX(id) 同时满足了 最新 以及 selectOne 两大语义。
  2. job_idcreate_time 仅能满足 最新 这一语义,却无法保证 selectOne 的结果!换句话说,在时间戳所能描述的时间精度内,写入了两条甚至以上的数据(比如用户的多次点击导致数据重复写入)。

最直接的解决方案是:我通过 job_idcreate_time 两个列建立联合唯一索引,代价是我首先要清理已有的冗余数据,否则索引建不起来。

It will work, but it’s ugly.

另一个解决方案是:在 job_idcreate_time 查询结果的基础上再加一个 selectOne 的上层查询。类似:

SELECT  MAX(b.id)
FROM
(
    SELECT    job_id, MAX(create_time) as create_time
    FROM      artifact
    GROUP BY  job_id;
) a
INNER JOIN artifact b
ON a.job_id = b.job_id AND a.create_time = b.create_time
GROUP BY  a.job_id

应该可以运行,但是没有自然的解决方法了吗?

答案是有的!

在数据仓库的场景下,分组排序取第一这个语义一般可以通过窗口函数 ROW_NUMBER() OVER() 来实现。

庆幸的是,TiDB 也提供了窗口函数的支持!因此 SQL 可以写作:

SELECT id
FROM
(
  SELECT  id,
          ROW_NUMBER() OVER (PARTITION BY job_id ORDER BY create_time DESC) as rn
  FROM    artifact
) t
WHERE rn = 1

看着似乎没那么违和。

提交代码、测试、上线!

5. 复盘与思考

延伸思考一下……

问题虽然解决了,但是复盘整个过程也给我带来了两点启发。

首先,技术还是没有银弹,工程师还是要保持对技术的敬畏之心。

技术方案的调研和选择要慎重。如果当初迁移之前起码读过文档,应当不至于犯这种低级错误。经验在帮助我提高效率的同时,也给我制造了盲点和轻慢之心。

其次,像我这种规模的内部系统用起分布式数据库也是大姑娘坐花轿——头一遭。

分布式数据库的最佳实践在社区、在互联网上谈论的都还不算多。

SQL 查询的兼容性?分布式数据库如何设计索引?分布式查询计划如何做查询优化……

这一系列命题也有待更多的学习和研究。

有时候我也设想,如果社区也能够推出一个 SQL Advisor ,可以针对业务已有 MySQL 的查询进行分析、诊断和建议,那么能造福的应该不止我一个人。

举个例子:

对于我上文的 MAX(id) 的 SQL,通过语法分析完全可以发现出这个查询对自增主键的全局自增的依赖。从而发出警告,提醒我在迁移之前提前做好 SQL 的改造。

一般来说,数据库的迁移会由技术团队主动发起。怎么降低迁移的成本,减少业务团队的投入是一件非常有价值的事情。

如果我们在向分布式数据库迁移之前先对用户所有的查询进行解析,就可以对 SQL 的兼容性、改造方向以及优化方案提出定向、定量的精准建议。也许能够更好地促进 TiDB 的落地和发展吧。

以上~