高可用系统建设的一些思考

在参与公司几个多数据中心项目的容灾架构设计和落地后,积累了一些高可用和多数据中心容灾的实践经验和一些思考,希望总结和分享出来和大家一起学习。

可用性衡量指标

我们做软件系统核心是服务于业务,构建高可用系统本质也是为了让业务的服务质量提供,因为在构建高可用系统之前,我们需要根据业务特性确认我们系统需要怎么样的高可用级别,也就是需要一个指标度量我们系统的可用性。

可用性度量的指标有以下几个:

  • MTBF(Mean Time Between Failure),平均故障间隔,代表两次故障的间隔时间,也就是系统正常运转的平均时间。这个时间越长,系统稳定性越高。
  • MTTR(Mean Time To Repair),故障的平均恢复时间,也叫平均故障时间。这个值越小,故障对于用户的影响越小。

但 MTBF 和 MTTR 这两个指标中的故障不仅仅是IT系统宕机故障,也包括了性能问题和人为的错误。甚至USITS一项关于大型互联网服务的研究发现,运维配置错误是导致服务中断的首要原因,而硬件故障(服务器或网络)仅导致了 10-25% 的服务中断,但如何避免人为错误并不在今天的讨论当中。

所以,在针对数据中心的容灾,我们可能用到更多地是RTO和RPO这两个指标:

  • RTO(Recovery Time Object),恢复时间目标,RTO是反映数据中心业务恢复的及时性指标。
  • RPO(Recovery Point Objective),复原点目标,指数据中心能容忍的最大数据丢失量,是指当业务恢复后,恢复得来的数据所对应时间点,RPO取决于数据中心数据恢复到怎样的更新程度,反映数据中心恢复数据完整性的指标。

一般我们对现有系统做可用性改造时,可以先看看现有系统的基准值是多少,然后根据业务目标,确定要提升到多少来改善。

高可用改造层级

在和业务确定好可用指标后,接下来就需要对系统做高可用改造,从容灾级别可以分为三个层面:

  • 基于Region(地理区域)的高可用架构
  • 同城多Zone的高可用架构
  • 单Zone多实例的的高可用架构
    注:这里Region表示地区,一般是指行政单元比如地级市,Zone表示可用区,指电力和网络相互独立的物理区域

从软件架构的角度来看,针对可用性改造可以分为四个部分:

  • DNS服务
  • 负载均衡(LB)
  • 应用服务
  • 数据库/中间件

其中,DNS服务和负载均衡都是无状态的,数据库和中间件则是有状态的,而应用服务根据业务逻辑不同可能是无状态也可能是有状态的。

单机房多实例的的高可用架构

这种架构是最简单的,一般这种架构只有一个LB网关,通过LB转发到下游的应用服务,应用服务可以通过服务发现的方式做成多副本从而实现多活,比如可以用k8s部署应用服务,配合存活探针检查和k8s的service服务发现,可以轻松实现无状态应用的多活。如果你的服务不基于k8s部署,也可以基于微服务框架部署在多台主机上实现应用多活,只要实现服务发现、存活探针检查和自动的流量切换就可以了。

但对于有状态的,比如数据库和中间件,仅仅依赖服务发现和流量切换并不能解决问题,因为数据在多副本之间需要做同步。因此针对数据库或中间件的高可用方案基本都是需要专门设计,因为除了流量切换还要解决数据同步问题。这部分在后面数据库和中间件高可用解决方案在单独展开。

同城多机房的高可用架构

同城多机房(多AZ)容灾一般通过 BGP 实现单IP多线网络,然后机房之间通过专线相互打通物理网络,当出口流量出现故障时,可以通过 BGP 在路由层切换报文转发表来实现线路切换。架构图如下:

在数据链路正常的情况下,机房1,2会分别向路由宣告自己的路由表:
机房1:地域AS -> 运营商AS -> 机房1 AS
机房2:地域AS -> 运营商AS -> 机房2 AS

在机房1出现故障和运营商边缘(接入)路由器断开的时候,机房2会向运营商AS宣告连接机房1的最短路径从而让流量转发给自己:
地域AS -> 运营商AS -> 机房2 AS -> 机房1 AS

如果对路由器BGP协议不是很了解的可以参考BGP的wiki:
https://zh.m.wikipedia.org/zh-sg/%E8%BE%B9%E7%95%8C%E7%BD%91%E5%85%B3%E5%8D%8F%E8%AE%AE

基于地理区域的高可用架构

如果我们要做基于地理区域级别的高可用,那么我们需要DNS智能路由和跨地域的云连接。

DNS智能路由

DNS服务结合存活探测可以实现跨地域流量切换。当存活探测发现后端 LB 不可用的时候,可以直接修改DNS解析,使其失效。但需要注意 DNS 协议切换一些延迟,生效时间在10分钟~30分钟,因此 DNS 一般只用于跨地域的 LB 的高可用,只有当整个地区的 LB 不可用的时候才会被启用。

我们知道跨地域的故障可以根据智能DNS协议来切换流量到不同的LB中,那么DNS服务本身是如何做高可用的呢?
DNS 服务做高可用一般是基于 Anycast 和路由协议来实现,比如 BGP 或 OSPF。和同城多机房的高可用架构一样,通过路由协议实现单IP多线的网络架构来实现容灾切换。
Anycast 网络允许网络上的多台服务器使用相同的 IP 地址或一组 IP 地址, 通过 BGP 的路径选择算法改变路由的选择,从而使失效的DNS服务器节点下线,或者实现智能路由。
不过 DNS 服务做了高可用并不一定就万无一失了,2021年10月4日的 facebook 全球死机事件就是 BGP 配置错误导致了 facebooK 的 DNS 全部失效了。所以,像开头说的,高可用系统架构只是解决了系统硬件故障,但人为配置错误并不能避免。

云连接

跨地域的云连接可以是基于骨干网的专线,也可以是VPN。通过云连接将处在不同地域的子网连接起来构造一个互联互通的企业网。专线相比VPN网络传输会更稳定些,安全性更高,但价格也会更贵,成本更高。

数据库及中间件的高可用方案

数据库和中间件的容灾是一种典型的有状态服务应用的场景,其核心是数据复制和同步。前面说到的两个指标 RTO 和 RPO 就是围绕数据容灾来描述的,如果我们是双活架构那么RTO就是0,如果是主备那么RTO就是主备切换所需的时间;如何我们的数据复制采用完全同步的方式,RPO就是0,如果采用异步复制,那么RPO就是数据复制之间的时间差,如果是快照,那么RPO就是快照产生备份数据和。

数据复制

数据复制按照leader可以分为三种:单领导者(single leader,单主),多领导者(multi leader,多主) 和 无领导者(leaderless,无主)。
单主和多主都属于主从架构,从节点通过复制主节点的日志或变更流(change stream)来同步数据,但在使用场景上两者存在比较大的不同,多主架构一般被用于地理位置上的多数据中心的容灾和就近服务,而单主架构一般用于单数据中心,因为多主架构给整个系统带来的复杂度是很高的,我们需要处理数据冲突,这会给整个系统带来复杂性并降低性能。架构设计核心是要解决问题,因此本质上是一种取舍和balance,在架构设计的时候要视业务和场景而定。

单主架构

单主架构就是我们常说的主从架构,是分布式架构中最简单的架构,只在 leader 节点做读写操作,follower 节点提供读操作。
从库复制主库通常通过变更日志实现,这种变更日志既可以是预写日志(WAL),也可以是复制逻辑日志。
常用的数据库的数据复制:

  • Kafka的kafaka MirrorMarker
  • MySQL的 binlog 主从复制
  • PostgreSQL的 WAL 主从复制

多主架构

多主架构在主从数据同步逻辑上和单主架构是一样的,区别核心在多个主节点写入数据的时候如何进行数据同步。一般引入多主架构其中一个原因是解决跨地域数据同步问题,比如在单主架构下,一个在广州的用户在写入数据需要需要传输到北京的主节点上,那么性能就会比较差了。另一个原因则是多主的故障容忍要大于主从,比如在两个主节点的情况下,其中一个节点出故障的时候另一个节点并不会受到影响,只会影响一半的用户,而主从架构在从节点切换完成之前是全用户故障的。
我们可以先从最简单的,两个主节点来讨论。在双主的结构下,核心要解决的就是写入的时候的数据冲突问题,如下图所示:

在解决数据冲突的时候通常会采用以下的一些办法:

  • 最简单的方式就是采用同步的方式写入数据,即数据写入成功需要等待其他主库解决冲突之后,这样就将多个主库写入变成串行执行了,也就失去了多主库的核心。
  • 通过分库的方式来避免冲突,比如请求通过统一的路由让a用户数据都读写在A库中,而b类数据读写在B库,其实这样本质类似于将数据库以多分区的形式存放在多个地区,只是a库会通过数据复制在同步到b库。
  • 只保证所有副本最终一致性,通过全局唯一ID全局时钟确定最后更新的数据才写入。但是这种方法在异步的情况下会导致一部分中间数据丢失。

除了要解决数据冲突,多主在数据复制的时候还需要解决节点复制传播的顺序,也就是复制拓扑(replication topology)。
对于多主复制,常见的复制拓扑主要有三种:

  • 环型拓扑架构(circular topology),即一个数据节点将数据复制给相邻的节点,依次循环一周。
  • 星型拓扑架构(star topology),所有节点围绕着一个中心节点进行数据复制和交互。
  • 全拓扑架构(all-to-all topology),即所有节点相互之间都会进行数据的复制和交互,常见的比如 gossip 协议。

在多个主节点进行数据复制和传播的时候,由于会经过多个节点,节点之间需要识别携带其他节点的变更信息,比如每个节点添加有一个唯一ID标识其已经过的节点,这样才不会造成无休止的死循环无休止的传播。

无主架构

无主架构中每个节点都可以对外提供服务,从设计理念上可以看出无主架构天生就是为可用性而生,不过知道CAP理论的都知道,可用性和一致性不能兼得,无主架构是个典型的AP模型,其牺牲了强一致性用最终一致性代替。

无主架构中最出名的是 AWS 的 Dynamo,像 Cassandra 这种采用和他类似的无主架构的都被成为类Dynamo。Dynamo 采用Gossip协议来做复制数据,任何一个节点收到数据后会向其他节点异步地复制数据。那么 Dynamo 是怎么保证数据最终一致性的呢?Dynamo 使用 W + R > N 这个公式保证,R代表最少读取的节点个数,W代表最少写入的节点个数,N为数据副本数,这里的副本数并不是实际的物理节点,因为 Dynamo 使用的一致性 hash。

比如N有3个节点,R是2,W也是2,那么客户端向集群写入数据的时候只有在2个节点写入成功后才会返回给客户端,这个过程是同步的,剩下的两个节点则是异步的,在读数据的时候,必须读到2个节点,并取2个中最新的数据,可以看出这样肯定可以读到最新的数据。

当出现数据冲突的时候 Dynamo 通过引入向量时钟解决数据冲突:

向量时钟通过带上其他节点的向量时钟来确定偏序关系,按图上例子三个节点P0,P1,P2,初始三个节点都是(0,0,0)

  • 首先,P0写入在 a 写入数据,P0 的向量时钟为(1,0,0)
  • 当P0把时钟信息传播到 P1 的时候,对应的是时间点 b,P1 的向量时钟是(1,1,0),这时候 P1 节点如果有个c写入,那么他的向量时钟是(1,2,0),c的向量时钟里面的元素均大于等于 a,所以我们可以知道 c 时刻大于 a。
  • 这时候 P1 把时钟信息传播 d,向量时钟是(1,2,1),写入数据 e 的数据时钟是(1,2,2),这个信息传回 P0 后变成 g 时刻,向量时钟是(3,2,2)

在这里我们可以看到,a -> b -> c -> d -> e -> g 这个逻辑顺序是成立的,而在向量时钟上表现就是后一个所有元素都大于或等于前一个时间点。
但 f 和 c, e 的先后顺序关系是不确定的,在没有全局时钟的情况下你并不能知道谁先谁后,而在向量时钟上表现就是 f 不是所有元素都大于等于 c 或 e。因此,其实向量时钟表示为:

if V(a) > V(b)
then
a -> b

V(a) > V(b), 表示a向量的所有坐标元素大于b向量的所有对应坐标的元素,a->b 表示 a 到 b 存在事件顺序。

向量时钟只能解决最终一致性(收敛)问题,如果数据在达成最终一致性之前产生版本冲突,Dynamo 会将冲突版本返回给客户端,由业务自行判断。除了交由客户端判断,我们也可以采用“最后写入胜利(LWW, last write wins)”的策略,在数据生成的时候通过带上时间戳,最后比较两个版本的时间戳谁新以谁为准。

Dynamo 检测数据不一致用的 MerkleTree,MerkleTree 是通过一个 hash 树来计算每部分的数据,父节点是子节点数据的 hash,只要有一块数据变动了,最上层的根节点的hash就会改变,然后可以通过逐层递归的方式找到目标节点,查询时间复杂度是o(log(n)),如下图:

更多详细的 Dynamo 可以参考原论文,这是国外老哥的一篇论文笔记:

数据复制的几种方式

前面说了数据复制的几种架构,那么具体数据复制的形式有哪些呢?这里根据数据库复制数据的主体不同分为四类:

  • 基于语句(statement)的数据复制
  • 基于逻辑日志(行)的数据复制
  • 基于预写日志(WAL)的数据复制
  • 基于触发器的复制

基于语句复制
直接基于数据库的语句进行复制,MySQL 5.1 版本前都是基于语句进行复制,基于语句的主从复制下 MySQL 会将 SQL 变更语句写入 binlog 中,然后同步给从节点让其更新,基于语句的复制主要简单,而且传输数据量少,但其可能会存在不安全语句,而且每次更新都只能串行,特别是某些语句比如 INSERT ... SELECT 会因为锁比行复制慢更多。PostgreSQL 的 pgpool-II 也是一种基于语句复制工具,但其本身相当于数据库的 Proxy,而不是数据库自身提供的CDC。

复制逻辑日志(行)
逻辑日志是针对语句复制提出来的,因为基于statement的复制存在诸多问题,比如事务没办法并行复制,只有等待一个commit才能复制另一个,性能差。因此另一种,是以行为颗粒度基于逻辑日志的数据方式,经典代表就是 MySQL的binlog(row)格式。其对数据库表的写入记录:

  • 对于插入的行,日志包含所有列的新值。
  • 对于删除的行,日志包含足够的信息来唯一标识被删除的行,这通常是主键,但如果表上没有主键,则需要记录所有列的旧值。
  • 对于更新的行,日志包含足够的信息来唯一标识被更新的行,以及所有列的新值(或至少所有已更改的列的新值)。

复制预写日志(WAL)
很多数据库在写数据的时候为了磁盘顺序读写优化和事务性会引入预写日志(write ahead logs,WAL),因此一些数据同步方案会尝试利用 wal 特性来做数据复制和同步。比如 PostgreSQL 9.0之后的 PITR(Point in Time Recovery) 就是基于 WAL 做主从复制。 PostgreSQL 的预写日志复制传输提供两种:存档式(archive)和流式(streaming)。存档式就是等一个WAL文件写完后,再拷贝从节点;流式则是写完一条WAL记录就通过TCP直接传给从节点。存档式存在数据延迟较大,流式则再主节点崩溃时从节点存在丢失数据的可能。

PostgreSQL Archive Replication

PostgreSQL Streaming Replication

基于触发器的复制

上面讲的那些复制方式都是数据库系统提供的,比如基于语句和逻辑日志的复制是在数据库的 server 计算层来做,预写日志(WAL)则是在存储层做,而触发器是数据库系统系统的将自定义的程序注册进数据库让其在数据变更时自动触发。由于是由外部程序对变更进行捕捉,因此他的灵活性是最高的,像多主复制的冲突解决方案大部分都是基于触发器实现,比如 PostgreSQL 的 bucardo 就是基于 pg 的触发器来做多主的数据复制。

小结

整篇小作文洋洋洒洒写了5k多的字数,从系统架构、各层级组件的高可用到数据复制拓扑,希望可以给大家一个相对比较完整的视角去认识高可用系统的建设。

参考:

shikanon wechat
欢迎您扫一扫,订阅我滴↑↑↑的微信公众号!