作者|Shlomi Noach
译者|张健欣
本文阐述了 GitHub 的 MySQL 高可用性和主服务发现解决方案,这个方案使得 GitHub 能够可靠地进行跨数据中心运维、克服数据中心隔离的影响并实现故障时的短宕机时间。
GitHub 使用 MySQL 作为所有非git
项目的主要数据存储,因此 MySQL 的可用性对于 GitHub 的运维来说至关重要。站点本身、GitHub 的 API、身份验证等都需要数据库访问。我们运行多个 MySQL 集群来服务我们的不同服务和任务。我们的集群使用经典的主 - 副设置,其中集群的单个节点(主节点)能够接受写操作。其它集群节点(副节点)异步更新主节点的变更并服务我们的读流量。
主节点的可用性特别地重要。主节点不可用时,集群就不能接受写操作:任何需要持久化的写操作都不能被持久化。任何传入的变更,例如提交代码、提问题、用户创建、代码审查、新建代码库等等,都会失败。
为了支持写操作,我们显然需要有一个可用的写节点,即集群的主节点。但同样重要的是,我们需要能够识别,或者发现,那个节点。
遇到一个故障时,比如主节点崩溃的场景,我们必须确保存在一个新的主节点,并且能够快速通告其身份。检测故障、运行故障恢复以及通告新主节点身份所花费的时间组成了总宕机时间。
本文阐述了 GitHub 的 MySQL 高可用性和主服务发现解决方案,这个方案使得我们能够可靠地进行跨数据中心运维、克服数据中心隔离的影响并实现故障时的短宕机时间。
高可用性目标
本文描述的解决方案是对 GitHub 先前实现的高可用性(HA)解决方案的迭代和改进。随着我们规模的扩大,我们的 MySQL HA 策略必须适应变化。我们希望对我们的 MySQL 和 GitHub 的其它服务运用相似的 HA 策略。
当考虑高可用性和服务发现时,一些问题可以指导你找到一个恰当的解决方案。这些问题包括但不限于:
你能容忍的宕机时间是多久?
崩溃检测的可靠性如何?你能容忍假阳性(过早进行故障恢复)吗?
故障恢复的可靠性如何?它在哪些情况下会失败?
解决方案跨数据中心能力如何?在低延迟和高延迟网络的能力如何?
解决方案能克服完整的数据中心故障或网络隔离的影响吗?
如果有的话,什么机制能够防止或减轻脑裂现象(两个服务器都宣称是指定集群的主节点,都独立地彼此无意识地接受写操作)?
你能够承受数据丢失吗?到什么程度?
为了说明上述一些问题,让我们先看一下我们之前的 HA 迭代以及为什么我们要改变它。
远离基于 VIP 和 DNS 的服务发现
在我们之前的迭代中,我们使用:
orchestrator (https://githubengineering.com/orchestrator-github/) 用于故障监听和故障恢复
VIP 和 DNS 用于发现主节点
在那个迭代中,客户端通过使用一个名称,例如mysql-writer-1.github.net
来发现写节点。这个名称解析为主节点获取的虚拟 IP 地址(Virtual IP address,VIP)。
因此,平常的时候,客户端会只解析这个名称,连接解析到的 IP 地址,然后找到正在另一端监听的主节点。
这个副本拓扑,跨越 3 个不同的数据中心:
当发生一个主节点故障事件时,一个新的服务器(副本之一),必须被提升为主节点。
orchestrator
将监测到一个故障,提升一个新的主节点,然后采取行动重新分配名称 /VIP。客户端并不知道主节点的身份:它们所知道的只是一个名称,而那个名称现在一定解析到了新的主节点。然而,注意:
VIP 是协作的:它们被数据库服务器本身声明和拥有。为了获取或释放一个 VIP,一个服务器必须发送一个 ARP 请求。在新提升的主节点获取这个 VIP 之前,拥有这个 VIP 的服务器必须先释放这个 VIP。这有一些不如人意的效果:
一个故障恢复操作按顺序首先会请求挂掉的主节点释放 VIP,然后请求新提升的主节点获取这个 VIP。但如果老的主节点无法访问或者拒绝释放 VIP 呢?假设在那台服务器上一开始发生了一个故障,那么它也很可能不会及时响应或者根本不响应。
我们会面临裂脑处境:两个主机声称拥有相同的 VIP。不同的客户端根据最短网络路径,可能会连接到其中任何一个服务器。
这个问题的根源是依赖于两个独立服务器的合作,而这种设置是不可靠的。
即使旧的主节点合作,这个工作流也浪费了宝贵的时间:切换到新的主节点时,需要等待与旧的主节点的联系。
而且当 VIP 改变时,不能保证现有的客户端与旧的服务器的连接断开,从而导致我们仍面临裂脑处境。
在我们的设置中,VIP 与物理地址绑定。它们属于一个交换机或路由器。因此,我们只能将 VIP 重新分配给相互定位的服务器。特别是,在某些情况下,我们不能将 VIP 分配给在不同数据中心提升的服务器,并且必须更改 DNS。
- DNS 的变化需要更长的时间来传播。客户端会为了预配置时间而缓存 DNS 名称。一个跨数据中心的故障意味着更长的宕机时间:让所有客户端意识到新主节点的身份需要花费更长时间。
仅仅这些限制就足以促使我们去寻找一种新的解决方案,但还有更多的顾虑:
主节点通过
pt-heartbeat
服务来自我注入心跳,从而达到延迟测量和节流控制的目的。这个服务必须在新提升的主节点上开启。如果可能的话,这个服务会在旧的主节点上会被关停。同样地,Pseudo-GTID 注入是由主节点自我管理的。它需要在新的主节点上开启,而且最好在旧的主节点上关停。
新的主节点被设置为可写的。如果可能的话,旧的主节点被设置为
read_only
。
这些额外的步骤的执行时间构成了总宕机时间的一部分,并且引入了它们自己的故障和冲突。
这个解决方案是有效的,而且 GitHub 已经有运行良好的非常成功的 MySQL 故障恢复措施,但是我们想要在以下方面提升我们的高可用性:
不依赖数据中心。
克服数据中心故障的影响。
移除不可靠的协作工作流。
减少总宕机时间。
尽可能实现无损故障恢复。
GitHub 的高可用性方案:orchestrator、Consul 和 GLB
我们的新策略以及附带的改进,解决或者减轻了上述的许多担忧。在目前的高可用性设置中我们使用:
orchestrator 用来运行故障监听和故障恢复。我们使用了如下图所示的一个跨数据中心的 orchestrator/raft。
Hashicorp 公司的用于服务发现的 Consul。
作为客户端和写操作节点之间的代理层的 GLB/HAProxy。
用于网络路由的
anycast
。
新设置完全删除了 VIP 和 DNS 更改。并且在引入更多组件的同时,我们使得组件解耦并简化了任务,还使用了稳健的解决方案。详解如下:
一个普通的工作流
平常,App 通过 GLB/HAProxy 连接到写操作节点。
App 不会意识到主节点的身份。和以前一样,它们使用一个名称。例如,cluster1
的主节点会是mysql-writer-1.github.net
。然而,在我们目前的设置中,这个名称会被解析到一个任播(anycast)IP。
通过anycast
方法,这个名称在任何地方都被解析为相同的 IP,但是流量会根据客户端位置分别进行路由。特别地,我们的每个数据中心都在多个区域部署了 GLB(我们的高可用负载均衡)。到mysql-writer-1.github.net
的流量通常路由到本地数据中心的 GLB 集群。因此,所有的客户端都是由本地代理服务的。
我们在 HAProxy 上运行 GLB。我们的 HAProxy 有写操作池:每个 MySQL 集群一个池,而每个池有一个后端服务器作为这个集群的主节点。所有的 GLB/HAProxy 区域在所有的数据中心都拥有相同的写操作池,而它们都指向这些池中完全相同的后端服务器。因此,如果一个 App 想要向mysql-writer-1.github.net
写入,这跟它与哪个 GLB 服务器连接无关。它将总是被路由到cluster1
主节点。
就 App 而言,服务发现在 GLB 终止,并且永远不需要重新发现。流量都是在 GLB 上路由到正确的目的地。
那么,GLB 如何知道将哪些服务器作为后端列表,以及我们如何将更改传播到 GLB?
Consul 的服务发现
Consul 作为一种服务发现解决方案而闻名,并且还提供 DNS 服务。然而在我们的解决方案中,我们用它作为一个高可用的键值对(KV)存储器。
我们使用 Consul 的 KV 存储器写入集群主节点的身份。对于每个集群,都有一套 KV 记录表明集群的主节点的 fqdn、port、ipv4 和 ipv6。
每个 GLB/HAProxy 节点都运行 consul-template:一个监听 Consul 数据变化的服务(在我们的案例中:是指集群主节点数据的变化)。console-template
会生成一个有效的配置文件,并且能够基于配置的变化重新加载 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 写操作。拥有 1 个以上orchestrator
的数据中心将会向 Consul 有多次(等同的)写操作。
整合工作流
在一个主节点宕机场景:
orchestrator
节点监测到故障。orchestrator/raft
领导开始一次恢复措施,提升一个新的主节点。orchestrator/raft
将主节点变更通告给所有 raft 集群节点。每个
orchestrator/raft
成员接收到一个领导变更通知。它们各自在本地 Consul 的 KV 存储器中更新新的主节点的身份。每个 GLB/HAProxy 都运行了
consul-template
,监控 Consul 的 KV 存储中的变更,然后重新配置和加载 HAProxy。客户端流量被重定向到新的主节点。
此外:
无需传播 DNS 变更。
没有 TTL。
这个流程不需要挂掉的主节点的合作。它很大程度上被忽略了。
更多细节
为了进一步保障这个流程,我们还做了如下工作:
HAProxy 配置了一个非常短的
hard-stop-after
。当它用写操作池中的一个新的后端服务器重新加载时,它会自动终止任何现存的与旧的主节点的连接。通过
hard-stop-after
,我们甚至不需要来自客户端的配合,而且这样减轻了裂脑场景。值的注意的是,这并不是严密的,在我们杀死旧连接之前会过去一段时间。但是在那之后,我们就可以放心不会出现令人讨厌的意外。我们并没有严格要求 Consul 在所有时间都是可用的。事实上,我们只需要它在故障恢复时可用。如果 Consul 碰巧挂掉了,GLB 会继续使用最近的已知值操作,不会采取剧烈的行动。
GLB 被设置来验证新提升的主节点的身份。类似于我们的上下文感知 MySQL 池,在后端服务器进行检查,来确认它确实是一个写操作节点。如果碰巧删除了 Consul 中的主节点信息,没有问题;空白的条目会被忽略。如果我们在 Consul 中误写入了一个非主节点服务器的名称,没有问题;GLB 会拒绝更新它并使用上次已知的状态运行。
我们会在下面章节中进一步解决担忧并追求高可用性目标。
orchestrator/raft 故障检测
orchestrator
使用一种整体方案来检测故障,因此是非常可靠的。我们不观测假阳性:我们不会过早启动故障恢复,因此不会遭受不必要的宕机时间。
orchestrator/raft
进一步解决了一个完整的数据中心网络隔离的情况(即数据中心围栏)。数据中心网络隔离会引起混淆:那个数据中心中的服务器能够彼此通信。是它们与其它数据中心网络隔离了?还是其它数据中心被网络隔离了?
在一个orchestrator/raft
设置中,raft
领导节点是运行故障恢复的节点。领导节点是指获得大多数群体支持的节点。我们的orchestrator
节点部署就是这样,没有单个数据中心占大多数支持,任何 n-1 个数据中心占大多数支持。
在一个完整的数据中心网络隔离事件中,那个数据中心中的orchestrator
节点与其他数据中心中的对等节点断开连接。因此,在隔离的数据中心中的orchestrator
节点不能成为raft
集群的领导节点。如果任何这种节点碰巧成为领导节点,它也会下台。一个新的领导节点会从其它数据中心分配。这个领导节点将获得所有其它数据中心的支持,而这些数据中心能够彼此通信。
因此,orchestrator
节点就是网络隔离的数据中心之外的一个节点。在一个隔离的数据中心应该有一个主节点,orchestrator
将启动故障恢复,用可用数据中心之一里的一个服务器取代它。我们通过将决策委托给非隔离数据中心中的群体来减轻数据中心隔离。
更快的通告
可以通过更快速地通告主节点变更来进一步减少总宕机时间。这如何实现呢?
当orchestrator
开始故障恢复时,它观测可被提升的服务器群。理解复制原则并遵从暗示和限制,能够基于最佳做法作出优化的决策。
需要意识到,可用于提升的服务器也是一个理想的候选者,例如:
没有什么可以阻止服务器的提升(而且用户已经潜在暗示这些服务器是首选提升对象);
这些服务器能够将其所有的兄弟节点作为复制品。
在这种情况,orchestrator
首先将服务器设置为可写的,然后迅速通告服务器的提升(写入 Consul KV),同时异步开始修复复制树(这个操作通常会花费更多时间)。
很可能当我们的 GLB 服务器完全重新加载时,复制树已经完好无损了,但这不是严格必需的。服务器可以接收写操作!
半同步复制
在 MySQL 的半同步复制中,在变更已经提交到一个或多个副本之前,主服务器不会承认这个事务提交。这提供了一种实现无损故障恢复的方法:任何提交到主节点的变更都已经应用或者等待被应用到某个副本。
一致性伴随着成本:可用性风险。如果没有副本确认收到变更,主节点会阻塞并且写操作会停顿。幸运的是,有一个超时配置,超过超时时间,主节点能够恢复到异步复制模式,使得写操作再次可用。
我们将我们的超时配置设置为一个合理的低值:500ms
。这足够将主节点的变更传递给本地数据中心副本以及远程的数据中心。有了这个超时,我们就可以观测完美的半同步行为(不回滚到异步复制),同时在确认失败的情况下会感受到一个可接受的非常短的阻塞时间。
我们在本地数据中心副本上启用半同步,而且在主节点挂掉事件中,我们期望(尽管并不严格强制)无损故障恢复。但是,我们不会期望一个完整的数据中心故障的无损故障恢复,因为它的代价非常大。
在进行半同步超时实验时,我们还观察到一种对我们有利的现象:我们能够在主节点故障中影响理想的候选者的身份。通过在指定服务器上启用半同步并将它们标记为候选者,我们能够通过影响故障结果来减少总宕机时间。我们在实验中观察到,我们通常能够提升理想的候选者并因此快速进行通告。
我们选择在任何地方任何时间管理pt-heartbeat
服务的开启 / 关闭,而不是只在提升 / 降级的主节点上管理pt-heartbeat
服务的开启 / 关闭。这需要一些补丁,改变它们的read_only
状态或者完全奔溃,以便使 pt-heartbeat 与服务器一致。
在我们当前设置中,pt-heartbeat
服务运行在主节点和副本上。在主节点上,它们生成心跳事件。在副本上,它们标识服务器是read_only
并周期性检查它们的状态。一旦一个服务器被提升为主节点,那个服务器上的pt-heartbeat
将其标识为可写的,并开始注入心跳事件。
orchestrator 所有权委托
我们进一步委托给orchestrator
:
Preudo-GTID 注入
将提升的主节点设置为可写的并清除它的复制状态
如果可能的话,将旧的主节点设置为
read_only
在新的主节点上,这减少了摩擦。被提升的主节点明显需要是活跃的和可访问的,否则我们不会提升它。那么,可以让orchestrator
直接将变更应用到提升的主节点上。
限制和缺陷
代理层使得 App 意识不到主节点的身份,同时它还对主节点屏蔽了 App 的身份。主节点看到的都是来自代理层的连接,而我们丢失了真正连接来源的信息。
随着分布式系统的发展,我们仍然面临未处理过的场景。
尤其是,在一个数据中心隔离场景中,假设主节点是在隔离的数据中心,那个数据中心的 App 仍然能够向主节点写入。一旦网络恢复,这可能导致状态不一致。我们通过从非常孤立的数据中心实现一个可靠的 STONITH 来减轻这种裂脑现象。像之前一样,主节点降级之前会经过一些时间,并且会存在一段时间的裂脑。避免裂脑现象的运维成本非常高。
存在更多场景:故障恢复时 Consul 宕机;部分数据中心隔离;其它场景等。我们明白,在这种性质的分布式系统中,不可能关闭所有的漏洞,因此我们关注最重要的场景。
结果
我们的 orchestrator/GLB/Consul 设置提供了:
可靠的故障检测
不依赖数据中心的故障恢复
典型的无损故障恢复
数据中心网络隔离支持
减轻裂脑现象(更多工作仍在进行中)
没有合作依赖
大部分场景下的
10-13 秒
的总宕机时间总宕机时间在非常少的场景下会长达
20 秒
,在极端情况下会长达25 秒
。
结论
orchestratoion/proxy/service-discovery 范式在解耦架构中使用了众所周知且令人信赖的组件,使得它更容易部署、运维和观测,并且每个组件都可以独立地扩大或缩小规模。我们将不断测试我们的设置,从而不断寻求改进。