09 异地多活架构

高可用计算架构和高可用存储架构的设计目的都是为了解决部分服务器故障时,如何保证系统能够继续提供服务。

在一些极端场景下,可能所有服务器都故障(例如,机房断电、机房火灾、地震、水灾等)导致业务整体瘫痪,即使有其他地区的备份,把备份业务系统全部恢复到能够正常提供业务,花费的时间也比较长。因为备份系统平时不对外提供服务,可能会存在很多隐藏的问题没有发现。如果期望达到即使在此类灾难性故障时,业务也不受影响,或者能够很快恢复,就需要设计异地多活架构。

0.1. 应用场景

判断一个系统是否符合异地多活,需要满足两个标准:

  • 正常情况下,用户无论访问哪一个地点的业务系统,都能够得到正确的业务服务。
  • 某个地方业务异常的时候,用户访问其他地方正常的业务系统,能够得到正确的业务服务。

实现异地多活架构代价很高,具体表现为:

  • 系统复杂度会发生质的变化,需要设计复杂的异地多活架构。
  • 成本会上升,毕竟要多在一个或者多个机房搭建独立的一套业务系统。

常见的新闻网站、企业内部的 IT 系统、游戏、博客站点等,可以不做异地多活的;因为这类业务系统即使中断,对用户的影响并不会很大。

共享单车、滴滴出行、支付宝、微信这类业务,就需要做异地多活;这类业务系统中断后,对用户的影响很大。

如果业务规模很大,尽量做异地多活:

  • 首先,能够在异常的场景下给用户提供更好的体验;
  • 其次,业务规模很大肯定会伴随衍生的收入(例如,广告收入),异地多活能够减少异常场景带来的收入损失。

0.2. 架构模式

根据地理位置上的距离来划分,异地多活架构可以分为同城异区、跨城异地、跨国异地。

0.2.1. 同城异地

将业务部署在同一个城市不同区的多个机房,然后将两个机房用专用的高速网络连接在一起。如果考虑一些极端场景(例如,停电、水灾),同城异地似乎没什么作用。

但同城的两个机房,距离上一般大约是几十千米,通过搭建高速的网络,两个机房能够实现和同一个机房内几乎一样的网络传输速度。这意味着虽然是两个不同地理位置上的机房,但逻辑上可以看作同一个机房,这样的设计大大降低了复杂度,减少了异地多活的设计和实现复杂度及成本。

极端灾难发生概率是比较低的,但机房火灾、机房停电、机房空调故障这类问题发生的概率更高,而且破坏力一样很大。而这些故障场景,同城异地架构都可以很好地解决。

因此,结合复杂度、成本、故障发生概率来综合考虑,同城异地是应对机房级别故障的最优架构。

0.2.2. 跨城异地

将业务部署在不同城市的多个机房,而且距离最好要远一些,这样才能有效应对某一地区的极端灾难事件。但“距离较远”这点并不只是一个距离数字上的变化,而是量变引起了质变,导致了跨城异地的架构复杂度大大上升。

距离增加带来的最主要问题是两个机房的网络传输速度会降低,这是物理定律的限制:

  1. 光速真空传播大约是每秒 30 万千米,
  2. 光纤中传输的速度大约是每秒 20 万千米,
  3. 传输中的各种网络设备的处理,实际还远远达不到理论上的速度。

除了距离上的限制,中间传输各种不可控的因素也非常多。

  1. 例如,挖掘机把光纤挖断、中美海底电缆被拖船扯断、骨干网故障等,这些线路很多是第三方维护,针对故障我们根本无能为力也无法预知。
  2. 例如,广州机房到北京机房,正常情况下 RTT 大约是 50 毫秒左右,遇到网络波动之类的情况,RTT 可能飙升到 500 毫秒甚至 1 秒,更不用说经常发生的线路丢包问题,那延迟可能就是几秒几十秒了。

以上描述的问题,虽然同城异地理论上也会遇到,但由于距离较短,中间经过的线路和设备较少,问题发生的概率会低很多。

而且同城异地距离短,即使是搭建多条互联通道,成本也不会太高,而跨城异地距离太远,搭建或者使用多通道的成本会高不少。

跨城异地距离较远带来的网络传输延迟问题,给异地多活架构设计带来了复杂性,如果要做到真正意义上的多活,业务系统需要考虑部署在不同地点的两个机房,在数据短时间不一致的情况下,还能够正常提供业务。

这就引入了一个看似矛盾的地方:数据不一致业务肯定不会正常,但跨城异地肯定会导致数据不一致。

如何解决这个问题呢?重点还是在“数据”上,即根据数据的特性来做不同的架构。

  • 如果数据要求强一致性,例如银行存款余额、支付宝余额等,这类数据是无法做到跨城异地多活,只能采用同城异地架构。
  • 如果数据一致性要求不高,或者数据不怎么改变,或者即使数据丢失影响也不大的业务,跨城异地多活就能够派上用场了。例如:
    • 用户登录(数据不一致时用户重新登录即可)、
    • 新闻类网站(一天内的新闻数据变化较少)、
    • 微博类网站(丢失用户发布的微博或者评论影响不大),这些业务采用跨城异地多活,能够很好地应对极端灾难的场景。

0.2.3. 跨国异地

将业务部署在不同国家的多个机房。相比跨城异地,跨国异地的距离更远,数据同步延迟更长,正常情况下可能就有几秒钟。这种程度的延迟已经无法满足异地多活标准的第一条:“正常情况下,用户无论访问哪一个地点的业务系统,都能够得到正确的业务服务”。

虽然跨城异地也会有此类同步延迟问题,但几十毫秒的延迟对用户来说基本无感知的;而延迟达到几秒钟就感觉比较明显了。

跨国异地多活的主要应用场景一般有这几种情况:

  1. 为不同地区用户提供服务:例如,亚马逊中国是为中国用户服务的,而亚马逊美国是为美国用户服务的,亚马逊中国的用户如果访问美国亚马逊,是无法用亚马逊中国的账号登录美国亚马逊的。
  2. 只读类业务做多活:例如,谷歌的搜索业务,由于用户搜索资料时,这些资料都已经存在于谷歌的搜索引擎上面,无论是访问英国谷歌,还是访问美国谷歌,搜索结果基本相同,并且对用户来说,也不需要搜索到最新的实时资料,跨国异地的几秒钟网络延迟,对搜索结果是没有什么影响的。

0.3. 跨城异地设计技巧

  • 同城异地:关键在于搭建高速网络将两个机房连接起来,达到近似一个本地机房的效果。架构设计上可以将两个机房当作本地机房来设计,无须额外考虑。
  • 跨城异地:关键在于数据不一致的情况下,业务不受影响或者影响很小,从逻辑的角度上来说其实是矛盾的,架构设计的主要目的就是为了解决这个矛盾。
  • 跨国异地:主要是面向不同地区用户提供业务,或者提供只读业务,对架构设计要求不高。

因此,跨城异地多活是架构设计复杂度最高的一种,下面是跨城异地多活架构设计的一些技巧和步骤。

0.3.1. 技巧 1:保证核心业务的异地多活

“异地多活”是为了保证业务的高可用,但并不是要保证所有业务都能“异地多活”!

为了架构设计去改变业务规则(特别是核心的业务规则)是得不偿失的。优先实现核心业务的异地多活架构!

0.3.2. 技巧 2:保证核心数据最终一致性

异地多活本质上是通过异地的数据冗余,来保证在极端异常的情况下业务也能够正常提供给用户,因此数据同步是异地多活架构设计的核心。但并不是要所有数据都实时同步!

异地多活架构面临一个无法彻底解决的矛盾:业务上要求数据快速同步,物理上正好做不到数据快速同步,因此所有数据都实时同步,实际上是一个无法达到的目标。

既然是无法彻底解决的矛盾,那就只能想办法尽量减少影响。有几种方法可以参考:

  1. 尽量减少异地多活机房的距离,搭建高速网络
  2. 尽量减少数据同步,只同步核心业务相关的数据(不重要的数据不同步,同步后没用的数据不同步,只同步核心业务相关的数据)
  3. 保证最终一致性,不保证实时一致性;最终一致性在具体实现时,根据不同的数据特征,进行差异化的处理,以满足业务需要。

0.3.3. 技巧 3:采用多种手段同步数据

数据同步是异地多活架构设计的核心,幸运的是基本上存储系统本身都会有同步的功能。虽然绝大部分场景下,存储系统本身的同步功能基本上也够用了,但在某些比较极端的情况下,存储系统本身的同步功能可能难以满足业务需求。

尤其是异地多机房这种部署,各种各样的异常情况都可能出现,只考虑存储系统本身的同步功能时,就会发现无法做到真正的异地多活。

解决的方案是将多种手段配合存储系统的同步来使用,甚至可以不采用存储系统的同步方案,改用自己的同步方案:

  1. 消息队列方式
  2. 二次读取方式:所谓的二次读取,第一次读取本地,本地失败后第二次读取对端,这样就能够解决异常情况下同步延迟的问题
  3. 回源读取方式:对于登录的 session 数据,由于数据量很大,可以不同步数据;但当用户在 A 中心登录后又在 B 中心登录,B 中心拿到用户上传的 session id 后,根据路由判断 session 属于 A 中心,直接去 A 中心请求 session 数据即可,反之亦然。
  4. 重新生成数据方式:对于“回源读取”场景,如果异常情况下,A 中心宕机了,B 中心请求 session 数据失败,此时就只能登录失败,让用户重新在 B 中心登录,生成新的 session 数据。

0.3.4. 技巧 4:只保证绝大部分用户的异地多活

某些场景下无法保证 100% 的业务可用性,总是会有一定的损失。因此,异地多活也无法保证 100% 的业务可用,例如:

  1. 密码不同步导致无法登录
  2. 用户信息不同步导致用户看到旧的信息等

这是由物理规律决定的,光速和网络的传播速度、硬盘的读写速度、极端异常情况的不可控等,都是无法 100% 解决的。

所以要忍受这一小部分用户或者业务上的损失,否则本来想为了保证最后的 0.01% 的用户的可用性,做一个完美方案,结果却发现 99.99% 的用户都保证不了了。

对于某些实时强一致性的业务,实际上受影响的用户会更多,甚至可能达到 1/3 的用户。

以银行转账这个业务为例,假设小明在北京 XX 银行开了账号,如果小明要转账,一定要北京的银行业务中心才可用,否则就不允许小明自己转账。

针对银行转账这个业务,虽然无法做到“实时转账”的异地多活,但可以通过特殊的业务手段让转账业务也能实现异地多活。例如,转账业务除了“实时转账”外,还提供“转账申请”业务,即小明在上海业务中心提交转账请求,但上海的业务中心并不立即转账,而是记录这个转账请求,然后后台异步发起真正的转账操作,如果此时北京业务中心不可用,转账请求就可以继续等待重试;假设等待 2 个小时后北京业务中心恢复了,此时上海业务中心去请求转账,发现余额不够,这个转账请求就失败了。

“转账申请”的这种方式虽然有助于实现异地多活,但其实还是牺牲了用户体验的,本来一次操作的事情,分为两次:一次提交转账申请,另外一次是要确认是否转账成功。

虽然无法做到 100% 可用性,为了让用户心里更好受一些,可以采取一些措施进行安抚或者补偿,例如:

  • 挂公告:说明现在有问题和基本的问题原因,如果不明确原因或者不方便说出原因,可以发布“技术哥哥正在紧急处理”这类比较轻松和有趣的公告。
  • 事后对用户进行补偿:送一些业务上可用的代金券、小礼包等,减少用户的抱怨。
  • 补充体验:对于为了做异地多活而带来的体验损失,可以想一些方法减少或者规避。以“转账申请”为例,为了让用户不用确认转账申请是否成功,可以在转账成功或者失败后直接给用户发个短信,告诉他转账结果,这样用户就不用时不时地登录系统来确认转账是否成功了。

0.3.5. 核心思想

采用多种手段,保证绝大部分用户的核心业务异地多活

0.4. 跨城异地设计步骤

0.4.1. 业务分级

按照一定的标准将业务进行分级,挑选出核心的业务,只为核心业务设计异地多活,降低方案整体复杂度和实现成本。

常见的分级标准有下面几种:

  1. 访问量大的业务:以用户管理系统为例,业务包括登录、注册、用户信息管理,其中登录的访问量肯定是最大的。
  2. 核心业务:以 QQ 为例,QQ 的主场景是聊天,QQ 空间虽然也是重要业务,但和聊天相比,重要性就会低一些。
  3. 产生大量收入的业务:以 QQ 为例,聊天可能很难为腾讯带来收益,因为聊天没法插入广告;而 QQ 空间可以插入很多广告,如果从收入的角度,QQ 空间做异地多活的优先级反而高于聊天。

以用户管理系统为例,“登录”业务符合“访问量大的业务”和“核心业务”这两条标准,因此登录是核心业务。

0.4.2. 数据分类

挑选出核心业务后,需要对核心业务相关的数据进一步分析,目的在于识别所有的数据及数据特征,这些数据特征会影响后面的方案设计。

常见的数据特征分析维度有:

  1. 数据量:包括总的数据量和新增、修改、删除的量。对异地多活架构来说,新增、修改、删除的数据就是可能要同步的数据,数据量越大,同步延迟的几率越高,同步方案需要考虑相应的解决方案。
  2. 唯一性:是否要求多个异地机房产生的同类数据(例如,用户 ID)必须保证唯一。
    1. 如果数据不需要唯一,那就说明两个地方都产生同类数据是可能的;
    2. 如果数据要求必须唯一,那只能一个中心产生数据或设计一个数据唯一生成的算法。
  3. 实时性:在一个中心修改了数据,要求多长时间必须同步到另一个中心,实时性要求越高,对同步的要求越高,方案越复杂。
  4. 可丢失性:数据是否可以丢失,部分丢失的数据是否会对业务产生重大影响。
  5. 可恢复性:数据丢失后,是否可以通过某种手段进行恢复,如果数据可以恢复,至少说明对业务的影响不会那么大,这样可以相应地降低异地多活架构设计的复杂度。

0.4.3. 数据同步

确定数据的特点后,可以根据不同的数据设计不同的同步方案。常见的数据同步方案有:

  • 存储系统同步:最常用最简单的同步方式(如,MySQL主从/主主数据同步),主流存储系统都支持,但是不能针对业务数据特点做定制化的控制。
  • 消息队列同步:采用独立消息队列进行数据同步(如,Kafka、ActiveMQ、RocketMQ 等),适合无事务性或者无时序性要求的数据。
  • 重复生成:数据不同步到异地机房,每个机房都可以生成数据,适合于可以重复生成的数据。例如,登录产生的 cookie、session 数据、缓存数据等。

0.4.4. 异常处理

无论数据同步方案如何设计,一旦出现极端异常的情况,总是会有部分数据出现异常的。例如,同步延迟、数据丢失、数据不一致等。

异常处理就是假设在出现这些问题时,系统采取的应对措施。异常处理主要有以下几个目的:

  • 问题发生时,避免少量数据异常导致整体业务不可用。
  • 问题恢复后,将异常的数据进行修正。
  • 对用户进行安抚,弥补用户损失。

常见的异常处理措施有这几类:

  1. 多通道同步:采取多种方式来进行数据同步,可以应对同步通道故障的情况。从成本与风险的角度考虑一般采取两个通道,数据库同步通道和消息队列同步通道不能采用相同的网络连接(例如,一公一内),需要数据是可以重复覆盖的(如,新建的帐号数据),无论哪个通道的数据先到,最终结果是一样的。
  2. 同步和访问结合:访问指异地机房通过系统的接口来进行数据访问,接口访问通道和数据库同步通道不能采用相同的网络连接(例如,一公一内),数据有路由规则,可以根据数据来推断应该访问哪个机房的接口来读取数据,由于有同步通道,优先读取本地数据,本地数据无法读取到再通过接口去访问,这样可以降低跨机房的异地接口访问数量,适合于实时性要求非常高的数据。
  3. 日志记录:在每个关键操作前后都记录一条相关日志并独立保持,故障恢复后将数据与日志比对进行数据修复。应对不同级别的故障,日志保存要求不同,应对的故障程度不同,复杂度、成本和收益也不同,常见的日志保存方式有:
    1. 服务器上保存日志,数据库中保存数据,可应对单台数据库服务器故障或者宕机的情况。
    2. 本地独立系统保存日志,可应对某业务服务器和数据库同时宕机的情况。
    3. 日志异地保存,可应对机房宕机的情况。
  4. 用户补偿:无论何种异常处理措施,都只能最大限度地降低受到影响的范围和程度,无法完全做到没有任何影响。采用人工的方式对用户进行补偿,弥补用户损失,培养用户的忠诚度。

0.5. 应对接口级故障

异地多活方案主要应对系统级的故障,例如,机器宕机、机房故障、网络故障等问题,虽然影响很大,但发生概率较小,而接口级别的故障虽然影响没有那么大,但是发生的概率比较高。

接口级故障的典型表现就是系统并没有宕机,网络也没有中断,但业务却出现问题了:

  • 例如,业务响应缓慢、大量访问超时、大量访问出现异常(给用户弹出提示“无法连接数据库”),这类问题的主要原因在于系统压力太大、负载太高,导致无法快速处理业务请求,由此引发更多的后续问题。
  • 例如,最常见的数据库慢查询将数据库的服务器资源耗尽,导致读写超时,业务读写数据库时要么无法连接数据库、要么超时,最终用户看到的现象就是访问很慢,一会访问抛出异常,一会访问又是正常结果。

导致接口级故障的原因一般有下面几种:

  1. 内部原因:程序 bug 导致死循环,某个接口导致数据库慢查询,程序逻辑不完善导致耗尽内存等。
  2. 外部原因:黑客攻击、促销或者抢购引入了超出平时几倍甚至几十倍的用户,第三方系统大量请求,第三方系统响应缓慢等。

解决接口级故障的核心思想和异地多活基本类似:优先保证核心业务优先保证绝大部分用户

0.5.1. 降级

系统将某些业务或者接口的功能降低,只提供部分功能或完全停止所有功能。降级的核心思想就是丢车保帅,优先保证核心业务

常见的实现降级的方式有:

  1. 系统后门降级:系统预留后门用于降级操作,实现成本低,但需要每台服务器操作效率低。例如,系统提供一个降级 URL,访问 URL 传入参数实现降级,但是存在安全隐患需要加入密码之类的安全措施。
  2. 独立降级系统:为了解决系统后门降级方式的缺点,将降级操作独立到一个单独的系统中,可以实现复杂的权限管理、批量操作等功能。

0.5.2. 熔断

  • 降级的目的是应对系统自身的故障,降级是服务端操作
  • 熔断的目的是应对依赖的外部系统故障的情况,熔断是客户端操作。

熔断机制实现的关键是需要有一个统一的 API 调用层,由 API 调用层来进行采样或者统计,如果接口调用散落在代码各处就没法进行统一处理了。

熔断机制实现的另外一个关键是阈值的设计,例如 1 分钟内 30% 的请求响应时间超过 1 秒就熔断,这个策略中的“1 分钟”“30%”“1 秒”都对最终的熔断效果有影响。实践中一般都是先根据分析确定阈值,然后上线观察效果,再进行调优。

0.5.3. 限流

  • 降级是从系统功能优先级的角度考虑如何应对故障,
  • 限流是从用户访问压力的角度来考虑如何应对故障。

限流只允许系统能够承受的访问量进来,超出系统访问能力的请求将被丢弃,保证一部分请求能够正常响应。限流一般都是系统内实现的,常见的限流方式可以分为两类:基于请求限流和基于资源限流。

0.5.3.1. 基于请求限流

从外部访问的请求角度考虑限流,常见的方式有:限制总量、限制时间量。

  • 限制总量的方式是限制某个指标的累积上限,常见的是限制当前系统服务的用户总量:
    • 某个直播间限制总用户数上限为 100 万,超过 100 万后新的用户无法进入;
    • 某个抢购活动商品数量只有 100 个,限制参与抢购的用户上限为 1 万个,1 万以后的用户直接拒绝。
  • 限制时间量指限制一段时间内某个指标的上限
    • 1 分钟内只允许 10000 个用户访问,
    • 每秒请求峰值最高为 10 万。

无论是限制总量还是限制时间量,共同的特点都是实现简单,但在实践中面临的主要问题是比较难以找到合适的阈值

例如系统设定了 1 分钟 10000 个用户,但实际上 6000 个用户的时候系统就扛不住了;也可能达到 1 分钟 10000 用户后,其实系统压力还不大,但此时已经开始丢弃用户访问了。

即使找到了合适的阈值,基于请求限流还面临硬件相关的问题。

例如一台 32 核的机器和 64 核的机器处理能力差别很大,阈值是不同的,可能有的技术人员以为简单根据硬件指标进行数学运算就可以得出来,实际上这样是不可行的,64 核的机器比 32 核的机器,业务处理性能并不是 2 倍的关系,可能是 1.5 倍,甚至可能是 1.1 倍。

为了找到合理的阈值,通常情况下可以采用性能压测来确定阈值,但性能压测也存在覆盖场景有限的问题,可能出现某个性能压测没有覆盖的功能导致系统压力很大;另外一种方式是逐步优化,即:先设定一个阈值然后上线观察运行情况,发现不合理就调整阈值。

基于上述的分析,根据阈值来限制访问量的方式更多的适应于业务功能比较简单的系统,例如负载均衡系统、网关系统、抢购系统等。

0.5.3.2. 基于资源限流

  • 基于请求限流是从系统外部考虑的,
  • 基于资源限流是从系统内部考虑的,即:找到系统内部影响性能的关键资源,对其使用上限进行限制。

常见的内部资源有:连接数、文件句柄、线程数、请求队列等。

例如,采用 Netty 来实现服务器,每个请求都先放入一个队列,业务线程再从队列读取请求进行处理,队列长度最大值为 10000,队列满了就拒绝后面的请求;也可以根据 CPU 的负载或者占用率进行限流,当 CPU 的占用率超过 80% 的时候就开始拒绝新的请求。

基于资源限流相比基于请求限流能够更加有效地反映当前系统的压力,但实践中设计也面临两个主要的难点:

  1. 如何确定关键资源,
  2. 如何确定关键资源的阈值。

通常情况下,这也是一个逐步调优的过程,即:设计的时候先根据推断选择某个关键资源和阈值,然后测试验证,再上线观察,如果发现不合理,再进行优化。

0.5.4. 排队

排队实际上是限流的一个变种,限流是直接拒绝用户,排队是让用户等待一段时间。排队虽然没有直接拒绝用户,但用户等了很长时间后进入系统,体验并不一定比限流好。

由于排队需要临时缓存大量的业务请求,单个系统内部无法缓存这么多数据,一般情况下,排队需要用独立的系统去实现,例如使用 Kafka 这类消息队列来缓存用户请求。

上次修改: 9 June 2020