GitHub关系型数据库垂直分库实践

作者 GitHub 译者 屠灵

十多年前,与当时的大多数Web应用程序一样,GitHub也是一个使用Ruby on Rails开发的网站,它的大部分数据都保存在MySQL数据库中。

多年来,这个架构经历了多次迭代,以满足GitHub的增长和不断变化的弹性需求。例如,我们单独将某些功能的数据保存在独立的MySQL数据库中;我们增加了读副本数量,将读负载分摊到多台机器上;我们还使用了ProxySQL,减少主MySQL实例打开的连接数。

但不管怎样,GitHub仍然只有一个主数据库集群(我们称之为mysql1),这个集群保存着GitHub核心功能所需的大部分数据,比如用户信息、代码仓库、Issues和拉取请求。

随着GitHub的增长,这种架构难免会面临巨大的挑战。我们努力让数据库系统保持合理的大小,并使用更新、更强大的机器。任何一个影响mysql1的故障都会影响所有在这个集群保存数据的功能。

2019年,为了满足增长和可用性方面的需求,我们启动了一个计划,目标是改进我们对关系型数据库进行分库的工具和能力。正如你所想的那样,这是一项复杂而艰巨的任务,需要引入和创建各种各样的工具。

这样做的结果是,在2021年,数据库主机的负载降低了50%。这极大减少了与数据库相关的故障,并提升了GitHub网站的可靠性。

虚拟分库

我们引入的第一个概念叫作数据库模式虚拟分库。在进行真正的数据库分表之前,我们要先确保在应用层面能够将表分开,并且不影响团队开发新功能或修改已有的功能。

为此,我们将数据库表按照领域进行分组,并使用SQL Linter来分清领域之间的边界。这样我们才能安全地进行数据分库,避免执行跨分库的查询和事务。

模式领域(Schema Domain)

模式领域是我们用来实现虚拟分库的一个工具。模式领域就是指那些经常一起被用在查询(例如表连接和子查询)和事务中的数据库表的集合。例如,模式领域gists包含了与gists、gist_comments和starred_gists这些功能相关的表。因为它们具有相关性,所以应该被分在一起,它们合在一起被称为一个模式领域。

模式领域之间有清晰的边界,并暴露出各个功能之间模糊的依赖关系。在Rails应用程序中,这些信息保存在db/schema-domains.yml配置文件中,如下所示:

gists:

- gist_comments

- gists

- starred_gists

repositories:

- issues

- pull_requests

- repositories

users:

- avatars

- gpg_keys

- public_keys

- users

SQL Linter

我们基于模式领域构建了两个Linter,用于确保领域之间具有清晰的虚拟边界。我们在查询语句上添加注解,就可以识别出那些跨越多个模式领域的查询和事务,并可以允许一些例外情况。如果一个领域没有违反这个规则,就可以进行虚拟分库,它们的物理表就可以被迁移到另一个数据库集群中。

Query Linter

Query Linter用于检查只有属于同一个模式领域的表才能被针对同一个数据库的查询引用。如果它检测到查询中包含来自不同领域的表,就会抛出异常。异常中带有有用的信息,可以帮助开发人员解决问题。

因为Linter只在开发和测试环境中启用,开发人员可以在开发过程中发现不合规的查询。另外,在CI运行期间,Linter可以确保不会有新的不合规查询被引入。

Linter还提供了特殊的/* cross-schema-domain-query-exempted */注释,用它来注解SQL查询语句可以允许一些例外情况,将上述的异常忽略掉。

我们还给ActiveRecord增加了新方法,这样添加注释就更容易了:

Repository.joins(:owner).annotate("cross-schema-domain-query-exempted")

# => SELECT * FROM `repositories` INNER JOIN `users` ON `users`.`id` = `repositories.owner_id` /* cross-schema-domain-query-exempted */

将所有查询加上注解,就可以得到需要修改的查询语句的清单。以下是我们用来解决例外情况的常用方法。

有时候,我们只需要把表连接查询拆成单独的查询。例如,用ActiveRecord的preload方法取代includes方法。

另一种比较有挑战性的情况是has_many :through关系导致需要连接来自不同模式领域的表。对于这种情况,我们提供了通用解决方案:has_many新增了disable_joins选项,告诉ActiveRecord不要执行底层表连接操作,改为执行多次查询,并在查询之间传递主键值。

在应用层进行数据连接,而不是在数据库层,这也是一种常见的解决方案。例如,使用两个单独的查询替代INNER JOIN,然后在Ruby中执行“union”操作(例如,A.pluck(:b_id) & B.where(id:...))。

有时候,这样做会带来性能上的极大提升。根据数据结构和数据集势的不同,MySQL的查询计划器有时会生成性能较差的查询执行计划,而应用层的数据连接可以获得较稳定的性能。

与大多数与稳定性和性能相关的变更一样,这些都用Scientist库做过实验。我们对新旧两种实现进行了实验对比,可以客观地评估每一个变更的性能。

Transaction Linter

除了查询语句之外,事务也是我们的一个关注点。现有的应用程序代码都是基于一定的数据库模式。MySQL事务可以保证同一数据库不同表之间的一致性。如果事务中的查询所涉及的表被移到其他数据库中,那就无法保证一致性。

为了弄清楚需要检查哪些事务,我们引入了Transaction Linter。与Query Linter类似,它可以确保一个事务所涉及的表都属于同一个模式领域。

这个Linter运行在生产环境中,进行大量的采样,并将对性能的影响降到最低。结果被收集起来,用于分析哪些地方存在跨领域事务,这样我们就可以决定是否要更新某些代码或修改我们的数据模型。

对于那些对事务一致性要求很高的地方,我们将数据抽取到同属一个模式领域的新表中。这样可以确保它们位于同一个数据库集群中,继续享有事务一致性保证。这种情况多发生在“多态性”表上,这些表的数据来自不同的模式领域(例如,reactions表保存了来自多个不同功能的数据,如Issues、拉取请求、讨论等)。

不停机迁移数据

模式领域在经过虚拟分拆之后,就可以进行物理表迁移。为了进行数据迁移,我们采用了两种不同的方法:Vitess和写切换(Write-Cutover)。

Vitess

Vitess是一个建立在MySQL之上的伸缩层,用于满足数据分片需求。我们用了它的垂直分片特性,在不停机的情况下将一些表迁移到一起。

我们在Kubernetes集群上部署了Vitess的VTGate。应用程序连接到这些VTGate端点上,而不是直接连接到MySQL。VTGate实现了同样的MySQL协议,对于应用程序来说与MySQL没有什么两样。

VTGate进程通过Vitess的另一个组件VTTablet与MySQL实例发生交互。Vitess的数据表迁移特性是通过VReplication来实现的,这个组件负责在数据库集群之间复制数据。

写切换

在2020年初,Vitess的采用还处在早期阶段。除此之外,我们还采用了另一种迁移大规模数据表的方法。这样可以降低依赖单一解决方案所带来的风险,确保GitHub网站的持续可用性。

我们利用MySQL的常规复制特性将数据迁移到另一个集群。在一开始,新集群被加到旧集群的复制树中,然后再用一个脚本快速执行一些变更来实现切换。

在进行写切换之前的MySQL集群

在运行脚本之前,我们先调整应用程序和数据库复制结构,将目标集群cluster_b作为现有集群cluster_a的子集群。我们用ProxySQL实现MySQL主实例之间的多路客户端连接。cluster_b上的ProxySQL将流量路由到cluster_a的主实例上。有了ProxySQL,我们可以快速改变数据库的流量路由,将对客户端(也就是我们的Rails应用程序)的影响降到最低。

基于这样的结构,我们可以很自然地将数据库连接迁移到cluster_b。所有的读流量都流向复制了cluster_a主实例数据的主机,所有的写流量仍然流向cluster_a主实例。

随后,我们开始执行切换脚本:

  • 开启cluster_a主实例的只读模式。这个时候,所有向cluster_a和cluster_b的写入操作都是不允许的。所有尝试向数据库执行写入操作的Web请求都会失败,并返回500错误。
  • 从cluster_a主实例读取最后执行的MySQL GTID。
  • 轮询cluster_b主实例,确认最后执行的GTID已达到。
  • 停止从cluster_a到cluster_b的复制。
  • 更新cluster_b的ProxySQL配置,将流量重定向到cluster_b主实例。
  • 关闭cluster_a和cluster_b主实例的只读模式。
  • 大功告成!

经过精心的准备和调整,我们发现,即使是我们最繁忙的数据库表,执行完以上6个步骤也只需要几十毫秒。由于我们是在一天内流量最不繁忙的时间进行切换,因写入失败而导致的用户可感知错误非常少。这样的结果已经超出了我们的预期。

发现

我们通过写切换来拆分mysql1——我们最初的数据库主集群。我们一次性迁移了130张最繁忙的数据库表,它们为GitHub的核心功能提供支撑:代码仓库、Issues和拉取请求。写切换是我们用来降低迁移风险的一种策略,让我们可以使用多种独立的工具。另外,因为部署拓扑问题和需要提供读己之所写(Read-Your-Write)支持,我们并没有在所有地方都使用Vitess作为迁移数据库表的工具,但我们预计在未来会将它作为数据迁移的主要工具。

结果

在文章简介里所提到的mysql1,也就是我们的数据库主集群,它保存着GitHub核心功能的大部分数据,比如用户、代码仓库、Issues和拉取请求。从2019年开始,我们逐渐具备了对这个关系型数据库进行伸缩的能力,并获得了如下结果:

  • 在2019年,mysql1平均每秒处理95万个查询,其中90万个查询发生在副本上,5万个发生在主实例上。
  • 现在,也就是在2021年,同样是这些表,它们分布在不同的集群中。在两年之内,它们见证了持续的增长,而且一年比一年快。所有这些集群的服务器加在一起,平均每秒处理120万个查询,其中112万5千个查询发生在副本上,7万5千个发生在主实例上。与此同时,每台主机的平均负载减少了一半。

这极大减少了与数据库相关的故障,并提升了GitHub网站的可靠性。

更多的分库策略

除了垂直分库,我们也进行水平分库(也就是分片)。我们可以将数据库表拆分到多个集群中,为可持续的增长提供支持。我们将在后续文章中分享更多与之相关的工具、Linter和Rails改进的细节内容。

结论

在过去的十多年,GitHub学会了如何通过伸缩数据库来满足不断增长的需求。我们通常选择的是“普通”的技术,这些技术被证明很适合我们的规模,因为对于我们来说,可靠性是最为重要的。与此同时,我们也使用一些被业界证明可行的工具,有了这些工具,我们只需要对代码做简单的修改,它们为我们的数据库在未来增长铺平了道路。

原文链接https://github.blog/2021-09-27-partitioning-githubs-relational-databases-scale/