【编者的话】本文讲述了 FaceBook 内部如何从 MySQL 5.6 迁移到 8.0。

MySQL,一款由 Oracle 公司开发的开源数据库, Facebook 一些最关键的工作负载均有赖于它来提供动力。为了支持不断发展的业务需求,我们积极地开发了一些 MySQL 的新特性。这些功能改变了 MySQL 许多不同的领域,包括客户端连接器、存储引擎、优化器以及同步复制 ( replication )。每次升级到 MySQL 新的大版本,我们都需要花费大量的时间和精力来迁移我们的工作负载。这些挑战包括:

  • 将我们定制的一些功能移植到新版本;

  • 确保同步复制功能在大版本之间的兼容性;

  • 尽量让现有应用程序的查询需要做的改动最小化;

  • 修复一些妨碍服务器支持工作负载的性能问题。

我们上一次大版本升级,还是升级到 MySQL 5.6,那一次用了一年多的时间才搞定。5.7 刚发布的时候,我们还处在基于 5.6 版本开发我们的 LSM-Tree 存储引擎 MyRocks 的中期阶段。由于升级到 5.7,然后同时又同步开发一个新的存储引擎的话将会显著放缓 MyRocks 的开发进度,我们选择留在 5.6 版本,直到 MyRocks 开发完成。MySQL 8.0 版本发布时,我们刚刚把 MyRocks 发布到用户数据库( UDB )服务这一层。

该版本有着众多引人注目的特性,比如基于写集合的并行复制功能,以及一个提供原子性的 DDL 支持的事务数据字典。对我们来说,迁移到 8.0 也会为我们带来之前错过的一些 5.7 的特性,包括文档存储。5.6 版本已经接近尾声,我们希望在 MySQL 社区保持活跃,尤其是在我们搞的 MyRocks 存储引擎这块。8.0 的一些改进,比如即时 DDL ,可以加快 MyRocks 的数据库模式变更速度,但是,为了使用这些功能,我们需要是 8.0 版本的代码。考虑到更新代码的好处,我们最终决定迁移到 8.0 版本。我们将会分享我们是怎么搞定 8.0 迁移项目的 ———— 以及在此过程中我们发现的一些"惊喜"。在我们最初确定项目的边界时,很显然,迁移到 8.0 将会比迁移到 5.6 或者 MyRocks 更加困难:

  • 彼时,在我们内部定制的 5.6 分支上有超过 1700 个代码补丁要移植到 8.0。在我们移植这些补丁的同时,新的 Facebook MySQL 特性和补丁被源源不断地添加到 5.6 的代码库,让这个过程变得更加漫长;

  • 我们的生产环境里运行了大量的 MySQL 服务器,它们为众多形式各异的应用程序提供服务。我们也有不少用来管理 MySQL 实例的软件基础设施。这些应用程序执行的操作有:收集一些统计数据以及管理服务器备份等;

  • 从 5.6 升级到 8.0 完全跳过了 5.7 版本。5.6 里活跃的一些 API 在 5.7 版本可能已经被弃用了,到 8.0 可能就已经移除了,这就要求我们将那些用到了已经移除的 API 的应用程序更新到新版本;

  • Facebook 用到的许多功能尽管和 8.0 版本的类似,但是并不是向前兼容的,我们需要一个弃用和升级迁移的方案;

  • 运行 8.0 版本需要用到一些 MyRocks 的增强特性,包括本机分区和崩溃恢复。

代码补丁

我们首先设置了 8.0 分支,这样方便在开发环境里构建和测试。然后,我们开启了一段漫长的旅程:在新分支上移植 5.6 的补丁。刚开始的时候我们有超过 1700 多个补丁,但是我们能够把它们分成几个大类。我们大多数定制的代码都有良好的注释和描述,因此我们可以很轻松地确认,在后续的版本里应用程序是否仍然需要它,或者是否可以直接删掉。由特殊关键字或唯一变量名开启的特性也很容易确定其相关性,我们可以在应用程序的代码库里搜索引用到它们的地方。有时候难免会遇到一些搞不清楚具体用途的补丁,这时候就需要做一些侦探工作:挖掘之前的设计文档、发过的帖子和/或代码审查注释来了解它们的历史。

我们把各个补丁分别归类到四个 bucket 里:

1、删除:在 8.0 版本里不再使用或者已经存在相同功能的特性,不需要移植;

2、构建/客户端:一些非服务端特性,比如一些用于支持我们的构建环境,做过定制的 MySQL 工具,像 mysqlbinlog,又或是添加了异步客户端 API 这样的功能集,这些都会被移植到 8.0 版本;

3、非 MyRocks 服务器:我们给 mysqld 服务器移植了一些和 MyRocks 存储引擎无关的功能;

4、MyRocks 服务器:一些支持 MyRocks 存储引擎的功能将会被移植到 8.0 版本。

我们使用电子表格来跟踪每个补丁的状态和相关历史信息,然后在删除某个补丁时会记录相关的推理过程。用于更新同一功能的多个补丁将会被打包到一起进行移植。移植并提交到 8.0 分支的这些补丁,它们会注有 5.6 的提交信息。由于我们需要在大量的补丁里做筛选,移植状态方面的差异将不可避免,这些注释帮助我们克服了这些困难。

每一组客户端和服务端自然而然地成为一次软件发布的里程碑。在移植所有和客户端有关的改动后,我们得以将客户端工具和连接器代码更新到 8.0。一旦完成所有非 MyRocks 服务器特性的移植工作,那些 InnoDB 实例就可以部署 8.0 版本的 mysqld 了。在完成了 MyRocks 相关服务器功能的移植工作后,我们就可以更新 MyRocks 的部署版本了。

某些最为复杂的功能特性可能要求我们对 8.0 做一些大改,而且会有一些区域存在严重的兼容性问题。举个例子,上游 8.0 的 binlog 事件格式和我们一些定制的 5.6 改动有不兼容的地方。Facebook 内部的 5.6 版本里用到的一些错误代码定义和上游 8.0 一些新功能分配的部分有冲突。我们最终需要给我们的 5.6 服务器打上一些补丁,使其能够和 8.0 向前兼容。

完成所有这些功能的移植需要几年时间。到最后,我们评估了超过 2300 多个补丁,然后将其中的 1500 个移植到了 8.0 版本。

迁移之路

我们将多个相关的 mysqld 实例算作一个 MySQL 副本集。副本集里的每个实例包含相同的数据,但是在地理上会分布到不同的数据中心,这样可以保证数据的可用性和故障容灾。每个副本集会有一个主实例。其余的实例都是从实例。主实例处理所有的写请求,然后将数据异步地同步到所有的从实例。

注:观看视频

我们的起点是包含了 5.6 版本主从的副本集,最终目标是将整个副本集的主从实例迁移到 8.0 版本。我们采取了和 UDB MyRocks 迁移计划类似的方案

  1. 针对每个副本集,我们会使用 mysqldump 导出一份逻辑拷贝,然后用它创建和添加一组 8.0 的从实例。这些从实例不响应任何应用程序的读请求;

  2. 开放 8.0 从实例的读请求;

  3. 把 8.0 实例提升为主实例;

  4. 禁用 5.6 实例的读请求;

  5. 移除所有 5.6 实例。

每个副本集可以各自独立地执行上述步骤,然后根据需要停留在某个阶段。我们把这些副本集划分成一些更小的组,然后引导它们完成每次切换。一旦发现有问题,我们可以立马回滚到上一步。在某些情况下,一些副本集能够在其他副本开始前就已经执行到最后一步。

为了让这些数量庞大的副本集能够自动切换,我们需要构建一套新的软件基础设施。我们将副本集进行分组,然后通过简单地改动配置文件里的一行配置来切换步骤。一旦遇到问题,任何副本集都可以单独执行回滚。

基于行的同步复制

作为 8.0 迁移工作的一部分,我们决定使用基于行的同步复制(RBR)来标准化迁移过程。8.0 的某些特性会需要用到 RBR,它简化了 MyRocks 的移植工作。尽管我们绝大部分的 MySQL 副本集已经使用了 RBR,但是那些仍然跑着基于语句的同步复制(SBR)的实例转换到 RBR 却没那么容易。这些副本集通常会有一些没有任何高基数键的表。完全切换到 RBR 曾经是我们的目标之一,但是需要添加主键的长尾工作,其优先级却往往低于其他项目。

因此,我们要求迁移到 8.0 版本的实例必须得是 RBR 的。在评估以及为每张表添加主键之后,我们终于在今年完成了最后一个 SBR 副本集的转换。使用 RBR 还为我们提供了一个解决某个应用程序问题的替代解决方案,我们在把一些副本集迁移到 8.0 主库的时候遇到了这个问题,稍后会详细介绍到。

自动校验

大部分实例在迁移到 8.0 的过程中均涉及到利用我们的自动化基础设施和业务应用查询来测试和验证 mysqld 服务器。

随着 MySQL 实例数量的不断增长,我们用于管理服务器的自动化基础设施也在成长。为了确保我们实现的所有 MySQL 自动化操作在 8.0 版本都是兼容的,我们投资建设了一个测试环境,它会利用跑在虚拟机上的测试副本集来验证一些行为。我们编写了相关的集成测试,对每一块跑在 5.6 和 8.0 版本下的自动化任务进行灰度,然后验证它们的正确性。在这个执行过程中,我们发现了一些 bug 和不一致的行为。

等到我们的 MySQL 基础设施在 8.0 服务器上里里外外都验证了一番后,我们发现并修复(或 workaround )了不少有趣的问题:

  1. 用来解析错误日志、mysqldump 输出或是服务端 show 命令的软件很容易崩溃。服务端输出的某些细微变化常常会导致工具的解析逻辑出 bug ;

  2. 8.0 默认采用的是 utf8mb4 字符集,这一设定导致我们的 5.6 实例和 8.0 实例的字符集不匹配。8.0 的表可能使用新的 utf8mb4_0900 字符集,甚至对于之前在 5.6 生成的 show create table 也是这样,因为 5.6 里面用到的 utf8mb4_general_ci 的数据库模式没有显式地指定字符集。这些表的差异常常会导致同步复制和数据库模式校验工具出问题;

  3. 某些同步复制失败时抛出的错误代码发生了变化,我们必须修复我们的自动化程序以正确处理它们;

  4. 8.0 版本的数据字典淘汰了 table.frm 文件,但是我们的一些自动化程序仍然使用它们来检测数据库模式的更改;

  5. 我们不得不更新我们的自动化程序以支持在 8.0 中引入的动态权限功能。

应用层校验

我们希望对于业务应用来说,迁移过程尽可能是透明的,但是某些业务应用的查询在 8.0 上会出现性能下降甚至失败。

针对 MyRocks 实例的迁移工作,我们构建了一套 MySQL 的 shadow 测试框架,它会捕获生产的流量然后在测试实例上重放。对于每个应用程序的工作负载来说,我们会在 8.0 上构建测试实例,然后在它们上面重放捕获的线上流量。我们收集并记录了从 8.0 服务端返回的报错,然后发现了一些有意思的问题。不幸的是,并非所有这些问题都是在测试过程中发现的。举个例子,在迁移期间有些业务应用出现了交易死锁。在研究各种解决方案的同时,我们暂且先将这些业务应用回滚到使用 5.6 版本的实例:

  • 8.0 里引入的一些新的保留关键字,其中一小部分(如 group 和 rank )和应用查询中用到的热门的表、列名称或别名有冲突。这些查询语句里的表或者字段名称没有通过带上反引号来规避冲突,导致解析报错。使用了自动给查询语句里的列名称打上反引号的软件库的那些应用程序则没有触发这个问题,但是并非所有业务应用都是这样。解决问题的办法很简单,但是需要花时间来追查应用 owner 和生成这些查询的代码库;

  • 在 5.6 和 8.0 版本之间也发现了一些 REGEXP 方面的不兼容问题;

  • 5.6 版本的 InnoDB 有个 bug,小部分跑在上面然后又带有 insert … on duplicate key 查询的业务应用触发了所谓的重复读事务的死锁问题,该问题在 8.0 得到了修复,但是这一修复增加了事务死锁的可能性。在分析了我们的查询后,我们选择的是通过降低隔离级别来解决这些问题。自从我们切换到基于行的同步复制以后,我们便可以用上这个方案了;

  • 我们为 5.6 版本定制的文档存储和一些 JSON 函数在 8.0 上面不兼容。使用了文档存储的业务应用被要求将文档类型转换成文本然后再迁移。至于那些 JSON 函数,我们将一些 5.6 兼容的版本添加到了 8.0 服务端,业务应用后续可以自行找时间再迁移到 8.0 的 API。

我们在对 8.0 服务器的查询和性能测试的过程中发现了一些需要立即解决的问题:

  • 围绕 ACL 缓存这块,冒出来一些新的互斥锁的竞争热点。当同时打开大量连接时,它们有可能都阻塞在检查 ACL 这一步;

  • 当有很多 binlog 文件然后存在高频率的 binlog 写操作导致文件频繁轮换时,我们在 binlog 的索引访问里也发现了类似的争抢;

  • 一些有用到临时表的查询操作炸了。查询将会返回未知错误,或者运行时间过长最后超时。

和 5.6 相比,迁移到 8.0 以后实例的内存使用量增加了,尤其是 MyRocks 实例,因为在 8.0 版本里 InnoDB 必须被加载。默认的 performance_schema 设置启用了大量的监控指标并消耗了大量的内存。我们限制了内存的使用,只保留了少量的指标,然后通过修改代码来禁用那些无法手动关闭的表。然而,并非所有多出来的内存都是用在 performance_schema 上。我们需要检查和修改各种 InnoDB 的内部数据结构,从而进一步减少内存的占用。这一努力将 8.0 的内存使用量降低到了可接受的水平。

接下来

到目前为止,8.0 迁移已经花了好几年的时间。我们已经将许多 InnoDB 的复制集完全切换到 8.0 上运行。剩下的绝大部分实例还处在迁移路上的不同阶段。如今,我们大部分的定制功能都已经完成了 8.0 的移植工作,再去更新 Oracle 发布的小版本相对简单多了,我们在计划跟上最新版本的步伐。

跳过像 5.7 这样的大版本会引入一些问题,我们在迁移时也自然需要解决这些问题:

首先,我们不会原地升级线上的实例,而是要求使用逻辑转储和还原的方式来构建新的服务器。然而,对于非常大的 mysqld 实例来说,这可能需要实时地在生产服务器上跑很多天,而且这个过程中非常脆弱,在完成前很容易被打断。对于这些大实例,我们就得修改备份还原的系统,支持重新构建;

其次,检测 API 是否有更改要比想象的困难得多,因为 5.7 可以给我们业务应用的客户端抛出弃用警告,要求我们修复潜在的问题。对此,取而代之的是,我们需要在迁移生产的工作负载前执行额外的 shadow 测试来定位故障。如果用了自动给 schema 对象的名称打上反引号的 mysql 客户端软件的话会有助于减少兼容性问题的数量;

在一个副本集里支持两个大版本是一件很困难的事情。一旦一个副本集把它的主实例升级到了 8.0,那么最好是尽快禁用和删掉之前的 5.6 实例。有些业务客户倾向于发掘一些 8.0 独有的新功能,比如 utf8mb4_0900 字符集,使用这些功能的话可能会导致 8.0 到 5.6 版本的实例之间同步复制中断。

尽管我们的迁移之路上存在着种种坎坷,但是我们已经看到了运行 8.0 带来的诸多好处。一些业务应用已经选择提前切换到 8.0 版本以便可以用上文档存储和增强的 datetime 支持等功能。我们一直在考虑如何针对一些存储引擎功能提供支持,比如 MyRocks 上的 DDL。总体而言,升级到新版本在很大程度上扩展了我们在 Facebook 的使用场景。