GitHub 的 MySQL 高可用性实践分享
GitHub 使用 MySQL 作为所有非 git 仓库数据的主要存储, 它的可用性对 GitHub 的访问操作至关重要。GitHub 站点本身、GitHub 的 API、身份验证等等都需要进行数据库访问。我们运行着多个 MySQL 集群来为不同的服务和任务提供支持。我们的集群使用经典的主从配置, 主集群中的某个节点能够接受写入。其余的从集群节点异步同步来自主服务器的更改, 并提供数据的读取服务。
主节点的可用性尤为重要。没有主服务器, 集群无法接受写入:任何需要保留的写入数据都不能持久化保存,任何传入的更改(如提交、问题、用户创建、审阅、新存储库等)都将失败。
为了支持写操作,我们显然需要有一个可用的数据写入节点,一个主集群。但同样重要的是,我们需要能够识别或找到该节点。
在一个写入失败,提示说主节点崩溃的场景中,我们必须确保能启用一个新的主节点,并快速表明其身份。检测故障所需的时间、进行故障转移并公布新的主节点所花费的时间,构成了总的停机时间。
本文将介绍 GitHub 的 MySQL 高可用性和主服务发现解决方案,它使我们能够可靠地运行跨数据中心操作,容忍数据中心隔离,并使得出现故障时耗费的停机时间变得更短。
高可用目标
本文描述的解决方案,迭代并改进了之前在 GitHub 实现的高可用(HA)解决方案。随着规模的扩大,MySQL 的高可用策略必须适应变化。我们希望为 GitHub 中的 MySQL 和其他服务,提供类似的高可用策略。
在考虑高可用和服务发现时,有些问题可以引导你找到合适的解决方案。包含但不限于:
- 你能容忍多长的中断时间?
- 崩溃检测的可靠性如何?你能容忍错误报告(过早的故障转移)吗?
- 故障转移的可靠性如何?什么情况下可以失败?
- 解决方案在跨数据中心的场景下效果如何?在低延迟和高延迟的网络情况下如何?
- 解决方案是否允许一个完整的数据中心故障或者出现网络隔离?
- 有没有防止或缓解脑裂(两台服务器都宣称是某个集群的主节点,不知情对方的存在,并且都能接受写操作)的机制。
- 你能允许数据丢失吗?在多大程度上?
为了说明上面的一些情况,首先让我们讨论一下之前的高可用方案,并说说我们为什么要修改它。
移除基于 VIP 和 DNS 的服务发现
在之前的迭代版本中,我们:
- 使用 orchestrator 来做检测和故障转移
- 使用 VIP 和 DNS 做主节点的发现
在这个迭代版本中,客户端使用名字服务(比如 mysql-writer-1.github.net)来发现写节点。名字可以解析为一个虚拟 IP(VIP),这个 VIP 指向主节点。
因此,在正常情况下,客户端只需要解析名称,连接到解析后的 IP上,然后发现主节点也正在另一边监听链接(也就是客户端连上了主节点)。
考虑这个跨越三个不同数据中心的复制拓扑:
当主节点发生故障时,必须在副本集中选出一个服务器,提升为新的主节点。
orchestrator 将会检测到故障,选举出一个新的主节点,然后重新分配 name(名称)和 VIP(虚拟 IP)。客户端实际上并不知道主节点的真实身份:它们只知道 name(名字),而这个名字现在必须解析给新的主节点。不过,需要考虑:
VIP 是需要协作的:它们由数据库服务器自己声明和拥有。为了获得或释放 VIP,服务器必须发送 ARP 请求。拥有 VIP 的服务器必须在新提升的主节点获得 VIP 之前先释放掉。这还有一些额外的影响:
- 有秩序的故障转移操作会首先通知故障主节点并要求它释放 VIP,然后再通知新提升的主节点并要求它获取 VIP。如果无法通知到原主节点或者拒绝释放 VIP 怎么办?首先要考虑到,该服务器上存在故障场景,它不可能会不及时响应,或根本不响应。
- 我们最终可能会出现脑裂情况:两个注解同时声称拥有同一个 VIP。根据最短的网络路径,不同的客户端可能会连接到不同的服务器。
- 事实源于两个独立服务器间的协作,并且这个设置是不可靠的。
- 即使原主节点确实配合,工作流程也浪费了宝贵的时间:当我们通知原主节点时,切换到新主节点的操作一直在等待。
- 即使 VIP 发生变化,现有的客户端连接也不能保证与原服务器断开连接,而且我们可能仍然会经历脑裂。
VIP 受限于物理位置。它们属于交换机或者路由器。所以,我们只能将 VIP 重新分配到位于同一位置的服务器上。特别是,当新提升的服务器位于不同的数据中心时,我们无法分配 VIP,只能修改 DNS。
- 修改 DNS 需要较长的传播时间。根据配置,客户端会缓存 DNS 一段时间。跨数据中心(cross-DC)故障转移则意味着更多的中断时间:为了让所有客户端知晓新主节点的身份,需要花费更长的时间。
仅这些限制,就足以促使我们寻求新的解决方案,但考虑更多的是:
- 主节点通过 pt-heartbeat 心跳服务进行自行注入,目的是测量延迟和节流。这项服务必须从新提升的主节点开始。如果有可能的话,原主节点的服务将被关闭。
- 同样的,Pseudo-GTID 注入也是主节点自己管理的。它将从新的主节点开始,并在原主节点结束。
- 新的主节点被设为可写。如果可能的话,原主节点被设为只读。
这些额外的步骤是导致中断总时间的一个因素,并且引入了它们自己的故障和摩擦。
该解决方案生效了,GitHub 已经成功完成 MySQL 的故障迁移,但我们希望我们的 HA 在以下方面有所改进:
- 数据中心不可知
- 允许数据中心出现故障
- 删除不可靠的协作工作流
- 减少总的中断时间
- 尽可能地进行无损故障转移
GitHub 的高可用解决方案:orchestrator, Consul, GLB
我们的新策略,除了附带的改进外,还解决或减轻了上面的许多问题。在今天的高可用设置中,我们有:
- 使用 orchestrator 来做监测和故障转移。我们使用跨数据中心的 orchestrator/raft 方案,如下图。
- 使用 Hashicorp 的 Consul 来做服务发现。
- 使用 GLB/HAProxy 作为客户端和写节点的代理层。
- 使用选播(anycast)做网络路由。
新的设置将完全删除 VIP 和 DNS 的修改。在我们引入更多组件的同时,我们能够将组件解耦并简化任务,并且能够使用可靠、稳定的解决方案。下面逐一分析。
正常流程
正常情况下,应用程序通过 GLB/HAProxy 连接到写节点。
应用程序永远不知道主节点的身份。和之前一样,它们使用名字。例如,cluster1 的主节点命名为 mysql-writer-1.github.net。在我们当前的设置中,名字被解析为一个选播(anycast) IP。
使用选播时,名字在任何地方都被解析为相同的 IP,但流量会根据客户端位置的不同进行路由。需要指出的是,在我们的每个数据中心,都有 GLB(我们的高可用负载均衡)被部署在不同的容器中。指向 mysql-writer-1.github.net 的流量总是路由到本地数据中心的 GLB 集群。因此,所有客户端都由本地代理提供服务。
我们在 HAProxy 上运行 GLB。我们的 HAProxy 维护了一个写连接池:每个 MySQL 集群一个连接池,其中每个连接池只有一个后端服务器:集群的主节点。DC 中的所有 GLB/HAProxy 容器都具有相同的连接池,并且它们都指向相同的后端服务器。这样,如果一个应用程序想要写入 mysql-writer-1.github.net,它连接到哪个 GLB 服务器并不重要。它总会被路由到实际的 cluster1 主节点上。
对于应用程序而言,服务发现结束于 GLB,并且不再需要重新发现。就这样,通过 GLB 将流量路由到正确地址。
GLB 如何知道哪些服务器可以作为后端服务器,以及如何将更改传播到 GBL 呢?
Consul 的服务发现
Consul 是著名的服务发现解决方案,它也提供 DNS 服务。然而,在我们的解决方案中,我们用它作为高效的键值存储系统。
在 Consul 的键值存储中,我们写入了集群主控的标识。对于每一个集群,都有一个键值对记录标识集群的主 FQDN,端口,IPV4,IPV6。
每一个 GLB/HAProxy 节点都运行 consul 模板:每一个服务都在监听 consul 数据的变更(这里主要是对集群主控的数据变更)。consul 模板会生成一个有效的配置文件并且当配置变更时,能够自动重载 HAProxy。
因此,Consul 中主控标识的变更会被每一个 GLB/HAProxy 观察到,然后它们立即重新配置它们自己,在集群后端池中设置新的主控作为单一对象,并且进行重载以反映这些变更。
在 GitHub 中,每一个数据中心都有一个 Consul 设置,并且每一个设置都具有高可用性。然而,这些设置又互相独立,它们之间不进行互相复制或数据共享。
那么 Consul 是如何获得变更通知,在交叉数据中心中,信息又是如何分布的呢?
orchestrator/raft
运行一个 orchestrator/raft 设置:orchestrator 节点之间通过 raft 一致性算法进行通信。每一个数据中心有 1~2 个 orchestrator 节点。
orchestrator 负责失败检测,MySQL 故障转移,以及 Consul 主控的变更通知。故障转移通过单个 orchestrator/raft 主导节点进行操作,但是对于主控变更,产生新主控的消息会通过 raft 机制被传播到所有 orchestrator 节点。
一旦 orchestrator 节点接收到主控变更的消息,它们会与自己对应的本地 Consul 设置通信:它们都执行 KV 写操作。具有多个 orchestrator 节点的数据中心会有多个完全相同的 Consul 写操作。
整体流程
在主节点故障的场景中:
- orchestrator 节点检测到故障。
- orchestrator/raft 主导节点开始恢复。一个新的主节点被设置为 promoted 状态。
- orchestrator/raft 向所有 raft 集群节点通知主节点变更。
- 所有 orchestrator/raft 成员接收到主节点变更的通知。每个成员都向本地 Consul 写入包含新主节点身份的 KV 记录。
- 每个 GLB/HAProxy 都运行一个 consul 模版,用于监视 Consul KV 存储的变化,并重新配置和重新加载 HAProxy。
- 客户端流量被重定向到新的主节点上。
每个组件都有明确的责任归属,而且整个设计简单并且解耦。orchestrator 不需要知道负载均衡。Consul 不需要知道这些信息是从哪里来的。代理只关心 Consul,客户端只关心代理。
而且:
- 没有 DNS 的变更需要传播。
- 没有 TTL。
- 整个流程不需要原故障主节点的配合,它在很大程度上已被忽略。
其他细节
为了进一步确保流程的安全,我们还提供了以下内容:
- 将 HAProxy 的配置项 hard-stop-after 设置为一个非常短的时间。当在写连接池中使用新的后端服务器重新加载时,它会自动终止所有到原主节点的连接。
- 通过使用 hard-stop-after 配置项,我们甚至不需要客户端的配合,这也就缓解了脑裂的情况。值得注意的是,这并不是绝对的,我们还是需要一些时间来消灭旧连接。但是,在某个时间点之后,我们就会感到舒服,因为不会出现令人厌恶的意外。
- 我们并不严格要求 Consul 随时可用。实际上,我们只需要它在故障转移期间可用。如果 Consul 恰好这时不可用,GLB 将继续使用已知的信息运作,不采用任何极端的行动。
- GLB 被用于验证新提升的主节点的身份。类似于我们的 context-aware MySQL pools(上下文感知的 MySQL 线程池),在后端服务器上进行检查,以确保它确实是一个可写的节点。如果我们恰好在 Consul 中删除了主节点的身份,没有问题;空的条目会被忽略。如果我们在 Consul 中错误的写入了一个非主节点的名称,没有问题;GLB 将拒绝更新它并以最后已知的状态继续运行。
我们会在以下章节进一步完成备受关注和期望的高可用目标。
orchestrator/raft 失败检测
orchestrator使用全面方法来检测失败,因此这种方法非常可靠。我们不会观察到误报 —— 因为我们没有进行过早的故障转移,所以也不会产生不必要的中断时间。
通过完全的 DC 网络隔离(又称 DC 栅栏),orchestrator/raft 进一步处理这个问题。一个 DC 网络隔离会引起一些混淆:这个 DC 中的服务器是可以互相通信的。他们是与其他 DC 网络隔离,还是其他 DC 被网络隔离?
在一个 orchestrator/raft 设置中,raft 的 leader 节点就是运行故障转移的那个节点。leader 是取得了大多数节点支持的节点(特定数量)。我们的 orchestrator 节点部署就是这样,没有单一数据中心可以占大多数,任何 n-1 的 DC 也是如此。
在一个完全 DC 网络隔离的事件中,这个 DC 的 orchestrator 节点与其它 DC 中的对应节点失去连接。最终,隔离 DC 中的 orchestrator 节点不能作为 raft 集群的 leader 节点。如果任何这种节点碰巧成为了 leader 节点,它就会退出。一个新的 leader 节点可以从任何一个其他 DC 分配。leader 节点会得到其他所有 DC 的支持,这些 DC 彼此之间可以进行通信。
因此,调用 shots 的 orchestrator 节点将位于网络隔离数据中心之外。一个隔离 DC 应该有一个主服务器,orchestrator 会使用可用 DC 中的其中一个服务器将它替换来初始化故障转移。我们委托非隔离 DC 中的那些节点来做这个决定,以此来缓解 DC 隔离。
更快的公告
通过发出公告说主分支即将修改,可以进一步减少运行停机的总时间。如何实现这个想法?
当 orchestrator 开始进行故障迁移的时候,它会观察可用于升级的服务器队列。在了解自我复制的规则,以及接受提示和限制的情况下,在最好的行动方针中,它能做出基于一定训练的决策。
它可能意识到一个可以升级的服务器也是一个理想的候选策略,例如:
- 没有什么可以阻止服务器的升级(潜在用户已经暗示服务器优先升级),而且
- 认为服务器可以将它所有的版本视为复本。
在这个例子中 orchestrator 首先将服务器设置为可写,然后立即公告服务器的升级(我们的例子中是写到了 Consul KV),即使异步开始修复复制树,这种操作通畅会花费更多几秒的运算。
有可能当我们的 GLB 服务器完全重载时,复制树已经完好无损,但是这不是严格要求的。服务器可以接收到写操作!
半同步复制
在 MySQL 的半同步复制中,在获知更改已发送到一个或多个副本之前,主服务器不会确认事务已提交。它提供了一种实现无损故障转移的方法:应用于主服务器的任何更改都将应用于或等待应用于其中一个副本。
一致性带来的成本是:可用性风险。如果没有副本确认收到更改,主服务器将被阻塞并且写入操作将停止。幸运的是,这里有一个超时设置,在这之后主服务器可以恢复到异步复制模式,使写入操作再次可用。
我们已经把我们的超时设置在一个合理的低值:500ms。将更改从主服务器发送到本地 DC 副本,通常也发送到远程 DC,这个阈值是绰绰有余的。设置这个超时时间之后,我们可以观察到完美的半同步行为(无需回退到异步复制),并且在确认失败的情况下,可以在非常短的阻塞周期内获得让人满意的表现。
我们在本地 DC 副本上启用半同步,并且在主服务器宕机的情况下,我们期望(尽管不严格地执行)无损故障转移。对完整的 DC 故障进行无损故障转移的代价很高昂,我们并不期待这么做。
在试验半同步超时的同时,我们还观察到一种对我们有利的行为:主服务器在发生故障时,我们能够影响最佳候选人的标识。通过在指定的服务器上启用半同步,并将它们标记为候选服务器,我们可以通过影响故障的结果来减少总的停机时间。在我们的试验中,我们观察到,我们通常最终会得到最佳候选服务器,并因此发布快速公告。
心跳注入
我们没有在已提升/已降级的主设备上管理 pt-heartbeat 服务的启动/关闭,作为替代,我们选择随时随地运行它。这需要进行打一些补丁,以便使 pt-heartbeat 可以支持服务器端来回更改它们的 read_only 状态或其完全崩溃。
在我们目前的设置中,在主服务器及其副本上运行 pt-heartbeat 服务。在主服务器上,他们产生心跳事件。在副本服务器上,他们识别到服务器是只读的,并定期重新检查其状态。只要服务器升级为主服务器,该服务器上的 pt-heartbeat 会将服务器标识为可写,并开始注入心跳事件。
orchestrator 所有权委托
我们进一步委托到 orchestrator:
- 伪-GTID注入
- 设置被提升的主控作为可写的,清除它的复写状态
- 如果可能,设置老的主控为只读状态
对于新主控,以上所有这些操作减少了冲突的可能性。一个刚刚被提升的主控应该是在线的并且可接入,否则我们就不应该提升它。然后让 orchestrator 直接应用变更到被晋升的主控上也应该是合理的。
限制和缺点
代理层使得应用程序不知道主服务器的身份,但是对于主服务器它也掩盖了应用程序的身份。所有主服务器看到的连接都来自代理层,我们丢失了关于连接实际来源的信息。
随着分布式系统的发展,我们仍然遗留了未处理的场景。
值得注意的是,在数据中心隔离场景中,假设主服务器位于 DC 中,DC 中的应用程序仍然能写入主服务器。一旦网络恢复正常,可能会导致状态不一致。我们正努力在非常独立的 DC 中,通过实现一个可靠的 STONITH 来缓解这种脑裂。和以前一样,在将主服务器之前需要花费一些时间,可能出现短暂的脑裂。而避免脑裂的操作成本非常高。
更多的场景存在:故障转移时 Consul 的终端;部分 DC 隔离;其他的。我们知道,使用这种性质的分布式系统不可能消除所有的漏洞,因此,我们将焦点放在最重要的案例上。
结果
orchestrator/GLB/Consul 设置给我们提供以下功能:
- 可靠的故障检测
- 数据中心不可知的故障迁移
- 典型的无损故障迁移
- 对数据中心网络隔离的支持
- 缓解脑裂的问题(仍在实现中)
- 无合作相关的依赖
- 多数场景下大约 10~13 秒的断电恢复能力。(我们观察到一些场景下最长 20 秒的断电恢复和极端场景下最长 25 秒的情况)
结语
编排/代理/服务发现范例在解耦架构中使用众所周知的可信组件,这使得部署、运维和观察变得更加容易,并且每个组件可以独立扩展或缩减。我们将不断测试我们的设置,以继续寻求改进。