熟悉Git线上仓库平台的同学都应该知道Githb和GitLab的基础机构都是一样,开始的时候都是以Ruby on Rails 为应用服务架构,以Mysql数据库为数据库架构。Gitlab经过版本迭代在Gitlab 9的时候数据库换成了PostgreSQL。Github由于是闭源只提供在线服务所以其架构演变我们对其知之甚少。最近GitHub随机数博客公布其数据库架构发展演变情况,请和虫虫一起来学习一下。
概述
GitHub于2007年开发,其初始公司叫Logical Awesome,三位创始人用Ruby on Rails共同开发。2008年2月beta版本上线,4月正式发布,7月发布代码片段收藏的Gists功能,12月发布网站托管的pages功能。2009年issues功能上线,基本功能完备。
其初始的数据架构为Mysql单实例,用来保存其底层元数据。多年来,其数据架构经历了多次迭代,用以支持不断增长的用户规模发展和功能需求。比如某些对某些功能数据进行横向分库,也通过主从副本以,用多个数据库分开来做负载均衡,并通过ProxySQL中间件做统一代理。
GitHub的核心数据架构为一个主要的数据库集群(称为 mysql1),其中包含GitHub核心功能服务的大部分数据,例如用户配置文件、存储库、问题和拉取请求等元数据。
2019年,为了应对面临的增长和可用性挑战,GitHub制定了一个数据架构优化计划,以改进其数据架构和关系数据库分区能力。
截止目前,该优化计划取得显著效果,其mysql1数据集群主机的负载减少了50%,极大地减少了与数据库相关的事件数量并提高了GitHub用户业务的可靠性。
虚拟分区
Github数据架构改进的第一个步是引了数据库模式的虚拟分区的概念。在物理移动数据库表之前,必须确保它们分离虚拟在应用程序层中 ,并且这必须在不影响开发新功能或现有功能的团队的情况下进行。为此,首先将统一在一起的数据库表分组到schema域中,并使用SQL linter强制执行域之间的边界。这样就可以在后续工作中可以安全地对数据进行分区,而不会以跨越分区的查询事务。
Schema域
schema域是用于实现虚拟分区的工具。Schema域描述了一组紧密耦合的数据库表,这些表在查询(例如,在使用表连接或子查询时)和事务中经常一起使用。例如,gists域包含所有支持GitHub Gist功能的表——比如gists, gist_comments和starred_gists表。这些表是同属的,就应该一直在一起。Schema域是对其进行编码的第一步。
schema域设置了明确的边界,并暴露了功能之间有时隐藏的依赖关系。在Rails应用程序中,信息存储在一个简单的YAML配置文件中
db/schema-domains.yml. 其一个示例如下:
gists:
- gist_comments
- gists
- starred_gists
repositories:
- issues
- pull_requests
- repositories
users:
- avatars
- gpg_keys
- public_keys
- users
linter确保此文件中的表列表与我们的数据库模式相匹配。反过来,同一个 linter强制将schema域分配给每个表。
SQL linter
建立在schema域之上,两个新的SQL linter强制域之间的虚拟边界。他们通过添加查询注释并将它们视为豁免来识别跨越schema域的任何违规查询和事务。如果一个域没有违规,它就被虚拟分区并准备好物理移动到另一个数据库集群。
查询 linter
查询linter 验证在同一数据库查询中只能引用属于同一schema域的表。如果它检测到来自不同域的表,它会抛出一个异常,并为开发人员提供一条有用的消息提示,以避免该问题。
由于linter仅在开发和测试环境中启用,因此开发人员在开发过程的早期就会遇到违规错误。此外,在CI运行期间,linter确保不会意外引入新的违规行为。
linter 有一种方法可以通过使用特殊注释SQL查询来抑制异常:
/* cross-schema-domain-query-exempted */
为了更加轻松翻边的添加注释,还构建了一个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导致的关系JOINs跨来自不同schema域的表。为此,开发了一个通用的解决方案,has_many有一个 disable_joins告诉 Active Record 不要做任何事情的选项JOIN跨基础表的查询。相反,它会运行多个传递主键值的查询。
在应用程序中而不是在数据库中加入数据是另一种常见的解决方案。例如,替换 INNER JOIN带有两个单独查询的语句,而是在Ruby中执行“联合”操作(例如, A.pluck(:b_id) & B.where(id: ...))。
在某些情况下,这会带来惊人的性能 提升。根据数据结构和规模,MySQL 的查询计划器有时会创建次优的查询执行计划,而应用程序端连接则具有更稳定的性能成本。
与几乎所有与可靠性和性能相关的更改一样,将它们发布在这些 Scientist 实验之后,为请求的子集执行旧的和新的实现,使能够评估每个更改对性能的影响。
事务linter
除了查询,事务也是一个问题。现有的应用程序代码在编写时考虑了特定的数据库模式。MySQL事务保证数据库内表之间的一致性。如果事务包括对将移动到单独数据库的表的查询,它将不再能够保证一致性。
为了了解需要审查的事务,还引入了事务linter。与查询linter类似,它验证在给定事务中一起使用的所有表都属于同一schem域。
该linter在生产中运行并进行大量采样,以将性能影响降至最低。收集和分析 linting结果以了解大多数跨域事务发生的位置,使得可以决定更新某些代码路径或调整我们的数据模型。
在事务一致性保证至关重要的情况下,将数据提取到属于同一schema域的新表中。这确保它们保持在同一个数据库集群上,因此继续具有事务一致性。这通常发生在包含多态表来自不同schema域的数据的中(例如,一个reactions表存储不同功能的记录,如问题、拉取请求、讨论等)
零停机数据迁移
虚拟隔离schema域已准备好物理移动到另一个数据库集群。为了动态移动表,方案中使用了两种不同的方法:Vitess和自定义write-cutover过程。
Vitess
Vitess是云原生的MySQL代理中间件,可以用于对MySql集群进行分片和负载均衡,通过其垂直分片功能可以将生产中的多组表迁移到一起,而无需停机。
VTGate可以实时获取Vitess设置的当前状态,并通过另一个Vitess 组件VTTablet与MySQL实例对话。Vitess的表移动功能则由VReplication提供支持,负责数据库集群之间复制数据。
通过在K8S集群中部署Vitess VTGate,然后应用程序的数据池连接到VTGate进程,而无需直连接MySQL。Vitess MySQ协议,对后端应用是来说等同Mysq实例。
write-cutover过程
由于Vitess的采用在2020年初仍处于探索阶段,因此先开发了一种替代方法来一次性移动大量表格。这降低了依赖单一解决方案来确保GitHub持续可用的风险。
通过使用MySQL的常规复制功能将数据提供给另一个集群。最初,新集群被添加到旧集群的复制树中。然后脚本会快速执行一系列更改以实现切换。
在运行脚本之前,先准备应用程序和数据库复制,同一个复制的集群cluster_b是做为现有集群cluster_a的子集群. 用ProxySQL将客户端连接多路复用连接到 MySQL主数据库。cluster_b的ProxySQL配置为将流量路由到主cluster_a集群。ProxySQL使得能够快速更改数据库流量路由,并且对数据库客户端的影响最小。
通过这种设置,可以将数据库连接移动到cluster_b而无需改动任何东西。所有读取流量仍然流向从cluster_a基本的。所有写入流量都保留在cluster_a。
然后运行一个执行以下内容的转换脚本:
启用只读模式 cluster_a基本的。此时,所有写入cluster_a和cluster_b被阻止。所有尝试写入这些数据库主数据库的Web请求都失败并导致500报错。
从主集群cluster_a查看上次执行的MySQL GTID。
查询luster_b验证最后执行的GTID是否同步。
停止从cluster_a到cluster_b的主从复制。
更新 ProxySQL 路由配置将流量引导至cluster_b。
解除cluster_a和 cluster_b的只读模式。
经过充分的准备和练习,对于最繁忙的数据库表,通过这六个步骤可以在几十毫秒内实现数据切换。由于在一天中流量最低的时间执行此类转换,因此只会由于写入失败而导致少数面向用户的错误。该方法的结果比预期的要好。
经验教训
数据迁移过程中主要使用了write-cutover过程用于拆分mysql1集群。一次迁移了130个最繁忙的表及其支持GitHub的核心功能:存储库、问题和拉取请求。这个过程是作为一种风险缓解策略而创建的。
由于部署拓扑和读写支持等因素,实际中优化方案中并没有选择Vitess作为在每种情况下移动数据库表的工具。预计将来有机会将其用于大多数数据迁移。
结果
通过使用虚拟分区和多个inter检查器,GitHub数据库被划分成不同的Schema域实现不同业务的数据库横向分库。通过Vitess和自定义write-cutover过程实现具体分库的数据迁移,同时实现了数据架构的升级,升级后数据查询QPS由之前的95w/s到现在的120w/s,集群主机的平均负载也减少一半,性能上获得极大的提高,用户的响应和体验都得到极大改善。其数据架构升级过程和所用的工具也都是开源工具(比如ProxySQL,Vitess等),可供大家直接拿来直接使用。