异地多活是分布式系统架构设计的一座高峰,当业务系统走到需要考虑异地多活这一步,其体量和复杂度都会达到很高的水准。接入层、逻辑层、数据层的三层架构,基本上是每个业务都会拥有的基础架构形态,而三层架构的关键在于数据层,本文将从数据层切入探讨异地多活对于基础架构设计的影响。
信息技术的发展,渗透到人们各类活动的方方面面,应对的问题五花八门,纷繁错杂,催生了面向各种业务而非常复杂的软件系统。架构的核心目的就在于解决软件系统的复杂性问题,在互联网分布式系统下的大体量的业务,其复杂性尤其高,主要来自下面几个方面:- 高可用,分布式系统中节点众多,引发故障不可避免,如何减少故障的影响,尽快从故障中恢复,就是高可用设计的关键;
- 高性能,大体量业务的海量请求,需要软件系统能够应对大并发量能力,具备强大的吞吐量,而且要有更短的响应时延;
- 高扩展,功能迭代、请求模型、外部环境都是变化不定的,软件系统需要针对这种种变化,作出良好设计,以便灵活应对;
- 低成本,软件系统往往是一种商业行为,需要关注商业价值,其构建要衡量投入产出比,最小化成本实现最大化商业价值;
- 安全性,软件系统安全性要求,需防范数据泄露、保护用户隐私、防止非法访问及操作,确保系统稳定可靠,维护用户权益;
- 多功能,业务是多变的,架构设计的前瞻性归根到底是有局限性的,未知的未知对架构的破坏性巨大,是复杂性的根本所在。
架构是一个庞大的话题,设计原则、抽象方法、业务解耦、领域模型、模块划分等等,每一个方面都是大有文章。不过,一般来说,不管多么复杂的软件系统,都可以抽象为“基于数据的一系列处理逻辑组合,供目标用户接入使用的系统”,接入、逻辑、数据,这就是本文讨论的“基础架构”,如下图所示。基础架构着重关注高可用、高性能、高扩展的要求,本文将从后台的视角展开,看看这几个要素对于基础架构的设计影响。需要考虑异地多活的业务,大概率是要把高可用当作核心目标,高可用重点关注的是软件系统面对故障的应对方案。互联网分布式系统的任何组成部分,都不是百分百绝对可靠的,总是会有发生故障的可能,要保障系统的可用性,就需要针对故障做容灾设计。容灾的本质,在于提供冗余以避免单点故障问题,当系统的某个组成部分发生故障时,可以由冗余部分接管服务使服务整体不受(或少受)影响。在业务发展的不同阶段,业务体量和规模决定其对于灾难的接受程度是不同的,容灾要应对的单点故障类型也不一样:- 单机器,业务起步阶段,体量非常小,单机部署就能够支撑,这时候面临的单机故障可能会是磁盘损坏、操作系统异常、数据误删等,为了应对这种故障,避免数据丢失,需要做一些数据备份,搭建主从;
- 单机房,业务规模增长,体量比较大,需要用到相当多的机器,这时候有了更高的追求,在部署的时候,会将机器部署到不同的机房,以规避单个机房故障带来的影响;
- 单城市,业务持续增长,体量非常大,同城市内的多个机房已经不能满足业务的容灾需要,如果发生城市级别的灾害,例如台风、地震、洪水等灾害,会使城市成为整个服务的单点。
解决城市级别的单点故障,也就是本文题目中的“异地多活”了。城市单点问题,和单机器、单机房的单点问题,有着巨大的不同。城市在台风、地震、洪水等极端剧烈灾害时成为单点,而这些灾害往往影响大片区域,该区域内的多个城市会一起受到牵连。所以,要解决城市单点问题,就需要将冗余做到距离较远的另一个区域,例如同属于珠三角城市圈的广州深圳,同属于京津唐城市圈的北京天津,同属于长三角城市圈的上海杭州,聚集在一个城市圈内的城市,往往共享了很多基础设施,这样的距离不能满足容灾的需要。要达到异地多活的容灾目标,基本都需要将服务分别部署在千里之外,例如深圳上海分布,北京上海分布等。在基础架构中,一般来说,逻辑层负责计算,是无状态的,可以做到无缝切换接管。逻辑层服务的根本是对数据的读取、处理、写入,数据层的故障,涉及到数据的同步、搬迁、恢复,要保证其完整性和一致性才可以切换投入使用,所以,基础架构的容灾关键在于数据层。数据层的操作涉及读和写,因为读操作不涉及到数据状态的变更,可以通过副本的方式方便扩展,而写操作为了保证写入数据在多份冗余之间的完整性和一致性,需要做数据复制,所以,数据层的关键,在于写请求的处理上。跨城的写时延发生质变是因为,在做跨城级别之前的容灾时,基本上所有业务对于时延都是能接受的,数据的复制直接采用同步的方式即可。在做跨城的时候,业务是否能接受更高的时延,就需要慎重斟酌了,而这也将影响具体的应对方案。更长的距离意味着更长的时延,通过 ping 工具,测得的时延情况大致是:- 千里之外的深圳上海跨城市耗时大约在 30ms 以内,北京上海,深圳天津的耗时会更长一些。
当时延达到 30ms 的级别,业务可用性面临另一个层面的考验,业务是否能接受数据写入的跨城耗时,这里不单单是 30ms 的问题:- 跨城容灾的场景,涉及到数据写入和副本复制,一次写请求将产生两倍延时,即 60ms;
- 业务写请求是否要串行发起 n次写请求,即 60ms 的 n倍;
- 需要做一些前瞻性设计,当前是能接受 60ms 的 n倍,后续的 n 会不会扩大,会不会扩大到不能接受的地步,在后续的设计中是否能贯彻这一依赖,都需要重点关注;
- 跨城情况下,网络状况可能会差一些,例如容易产生抖动,这个倒不是大问题,一般来说有能力搭建跨城网络的公司,也有能力保障网络的稳定性,在测试的过程中也发现跨城的耗时挺稳定的,在长达近4个半小时的测试中,最大抖动不超过8毫秒,而且最大耗时在30毫秒以内。
如果业务能够接受跨城写时延,那么问题就退化到同城容灾,直接采用跨城同步复制即可。如果不能够接受写入延时,就不能走长距离跨城的同步复制,必须找退而求其次的方案,下面聊聊两种考虑方向。缩短距离,不做千里之外,而是选择做距离较近的跨城,例如做广州-深圳、上海-杭州、北京-天津的跨城,距离在100-200公里,时延在5-7ms,这样依然可以用同步复制的方式,但是,如前面提到的,这种方式是达不到跨城异地多活的真实目标的。不做同步复制,首先要做的就是将数据根据地理位置做分片,异地多活不能接受延时的情况下,不同业务的分片规则可能会有差异,例如某多、某东、某宝的电商业务,和饿某某和某团的外卖业务的分片规则肯定是不同的,但基本上都是基于用户地理位置来做的:让离哪个城市近的用户数据尽量放到对应城市去。如下图所示,针对用户做了就近分片后,数据写入不需要做跨城同步复制,写入主写点后,直接对外返回成功,而不需要等数据同步到异地。采用异步复制,这样必然会出现灾难发生时数据没有及时复制到异地的情况,如下图所示:对于数据一致性要求不高的业务,例如微博、视频等,可以接受数据重复的情况,自由切换即可,结合一些业务层的去重逻辑,例如结合灾难情况,将灾难发生期间的重复数据做一些去重,基本也就够用了。对于数据一致性要求高的业务,例如金融、支付等,就必须要保证在做灾难切换的时候,为了将影响的数据尽量减少,需要根据业务的特点,圈出来可能影响的数据,并针对这些相关数据的所属用户的相关操作拒绝服务。下文的数据复制架构中会提到。写请求量大,单个写入点的容量扛不住,这种情况下,就不能让所有数据的写入都归到同一个写入点来处理,需要做分片,将完整的数据拆分成几部分,各个部分分别有独立的写入点。单纯考虑写量大,并不要求做就近分片,但是就近分片还是能收获一些益处,例如减少 30ms 的耗时等,所以,一般也还是会做就近的分片。下图所示的写量大拆分片的情况,数据写入的时候,等把数据同步复制到异地之后,才认为请求处理成功,给上层返回。另外,写量大的业务产生的数据如果是膨胀型的(例如,电商业务的订单数据),会随着时间累积,数据量不断增加。这类数据往往多呈现为流水型特征,写入一段时间后即不会再次访问或更新;对访问频率很低甚至为 0 的数据,其占用的在线业务库存储空间,造成了大量硬件资源浪费,堆高企业的 IT 成本。这种情况根据膨胀的情况,做分库分表以及老数据存档即可,不会产生数据分片而需要实例隔离的这种影响。做隔离,是为了减少故障/异常情况下对整个业务系统的影响,核心思想就是“不要把鸡蛋放在一个篮子里”。这个对于数据层的影响和上文“写量大拆分片”的效果是一样的,即把全部数据分片拆分成多份,每一份出问题的时候不影响其他数据。做隔离,其实与异地多活的跨城容灾关系不大,在做同城容灾的时候,也是一种常用手段。业务系统,除了自身的数据层之外,往往还涉及其他的依赖,例如相关的基础组件,更底层的一些服务,运营操作平台等。所以,做隔离的时候,往往不局限于数据层的隔离,而是会把各种依赖,甚至上层的逻辑层也统一囊括进来整体考虑。这种串联上下依赖的隔离方案,名字比较多,例如“单元化”、“SET 化”、“条带化”等。下图是示意图:- 数据层,单从隔离角度出发,可以做跨城数据的同步复制;
- 单元化隔离与本文讨论的异地多活跨城容灾的关系不是特别紧密,不过多展开,对于路由的影响在下文中会提到。
在讨论数据模型的时候,常常会聊到“读多写少”、“读写频繁”、“读写分离”等情况,可见,读也是决定数据模型的重要因素。不过,和上面写延时、写量级、隔离性等因素会导致数据分片不同,读的影响主要在副本管理、缓存机制和连接管理上。读是一个后置的二级考虑因素,即首先确定是否要做分片之后,再基于分片的基础来考虑。- 写后立即读,这种情况,要求读到写入之后的最新值,是一种强一致性的诉求,须通过读写点的方式来解决,实际上就归入到写操作的范畴里面去了;
- 适当延迟读,这种情况,可以接受读取到历史旧值,满足最终一致性要求即可,可以通过读写入之后同步数据的副本来应对,是本部分讨论的内容。
一般来说,业务对于读操作的时延要求相较写操作有更严苛的要求,例如,写一条微博,发布一段视频,下定一件商品,发起一笔转账,用户对于适当等待是有所预期,而看微博、刷视频、浏览商品、查看账户余额等操作,用户如果感到卡顿,基本上就要流失了。大部分的读场景,都可以接受适当延迟,看不到最新的内容,用户基本上“刷新一下”就可以了。理清楚场景需要,读时延的解决方案就很明显了:提供离用户更近的副本供读取。如下图所示,从上海到访的用户,访问上海的备份副本即可,不需要到深圳去读取数据。和上文“读时延可就近”类似,这里依然讨论的是能够接受适当延迟读的场景。很多业务都是读多写少,大量的读请求,可以通过扩充副本来满足。不过,需要注意,当副本扩展到一定规模后,由于需要做读副本的数据复制,会增加对写点的负载,可以通过级联同步的方式来解决。另外,还会通过添加缓存的方式来进一步提升读请求的吞吐量,这里不做展开了。总体来说,读量一般都不会像写延时和写量一样产生数据分片而需要实例隔离的这种影响。下图是级联复制的示意。数据层是业务的根本,虽然通过分片、副本、缓存等操作,将落到 DB 的请求量减少到可接受的地步,但是逻辑层作为数据层的调用方,还是不可避免的需要建立和数据层 DB 的连接,如果逻辑层的调用方过多,则会需要和 DB 构建更多的连接数。增加连接数能够增加 DB 的并发度,支持更多的调用方,提升吞吐量。但是,数据库的性能并不是可以无限扩展的,当达到一个阈值以后,由于高并发导致的资源抢占、线程上下文切换,反而会导致数据库的整体性能下降。比较普遍的做法,是为数据层 DB 添加一层代理,避免逻辑层调用方直连 DB,由代理来收拢和 DB 之间的连接。上文的讨论中,对于数据的复制,都是采用了一主一备的简化表述。事实上,要达到容灾的效果,一主一备是不够的,下面来看一下几种典型的数据复制架构。要想达到容灾的效果,基本上都是要用到多数派协议的方式来做,比较经典的模式是三地五中心架构:- 搭建1主4从5实例的架构,分布在3个城市5个 IDC 机房中;
- 写请求要保证对应的数据写入到1主4从中的3个实例中,即写入主后,要同步到另外2个备,达成多数派要求;
- 在发生城市故障的情况下,不管哪个城市发生故障,在该城市以外,都有完整的数据可以满足容灾要求。
三地三中心,可以形成最小的多数派,也能满足容灾需要,不过考虑到下面几点,一般都没有采纳:- 在某个 DB 实例发生故障的时候,尽量让其发生做同城内进行切换,如果是三地三中心,只要有故障就会发生跨城切换;
- 三地五中心相对于三地三中心会有机器资源浪费的情况,对于跑不满资源的情况,可以采用混布的方式,通过一些资源隔离的(例如 CGroup)机制,来提高资源利用率的同时,又可避免混布业务之间互相影响。
在不能接受跨城时延的场景中,会用到同城三中心的复制架构。在多个城市配备多套互为对等的同城三中心,可以做到有损的跨城容灾,如下图所示:- 某城市故障时,将写请求放到异地对等的同城三中心处理,所以,下图中每个同城三中心的实例中都有一部分对等同城三中心实例的数据;
- 这种容灾模式,在发生城市故障的时候,可能会发生产生重复数据的问题,例如用户在蓝色的 set1 种新增一条数据,发生了城市故障,数据来不及同步到异地的异步备,故障切换,用户的请求已经切换到对等的绿色 set2,此时,用户读不到刚刚新增的数据,就会重新操作;
- 数据读取时,可以整合本城市的写点数据和对等同城三中心的异步备数据。
双主互复制的架构,是每一个实例中都有完整的数据,不过,每一个主里面在一个时刻只处理其中一部分数据的写入,规避写冲突。这种模式,在做跨城容灾的时候,通过记录同步时间位点的方式来决定跨城容灾时候的数据写入逻辑,也是有损的:- 维护一个统一的时间位点发生器,每次写操作,都记录时间位点,新增记录记为 Ti(insert);
- 数据做异步复制的时候,记录复制到的时间位点,记为 Ts(sync);
- 发生故障时,将故障所在的实例禁写,禁写时间记为 Tb(ban);
- Ts < Ti < Tb,即在主写新增的记录在没有复制到备的情况下,用户由于读不到之前在主写写入的数据,而重新尝试写操作,就会产生重复数据;
- 对于所有新增时间 Ti < Tb && Ts < Tb 的数据,即在故障禁写之前就存在的数据,不能在切换到新写点之后进行更新,因为在 Tb 之前总有数据写操作没有复制到位,如果直接更新,就可能产生写冲突。
前面提到的对等同城三中心和双主互复制,都有可能产生重复数据,根源在于不知道哪些数据没有同步到。如果可以明确知道哪些数据没有复制到位,那么就可以针对性的拒绝这些没有复制到位的数据的操作。业务接受不了 N 次跨城带来的 N 倍 60ms 的影响,但是一般,能接受一次跨城 30ms 的延时,这种模式的工作机制是:- 在具体进行写操作之前,通过做一次跨城调用记录下该写操作对应的数据属主到未同步名单中;
- 待该写操作同步到跨城实例后,再将写操作的数据属主从未同步名单中清理掉;
- 在做数据写入之前,先检查写操作的数据属主是否存在未同步名单中,如果存在,则拒绝该请求;
- 这种模式,依然是有损的,只是牺牲了极少部分未复制到位的用户,而且数据一致性得到了保障,配合双主互复制使用,可以达到很好的效果。
考虑从上面分析的几种因素,不同业务的数据形态可能不同,可以分为三类:- 跨城全局数据,数据不分片,主从之间做跨城同步复制;
- 就近分片数据,数据需分片,由于业务不能接受跨城串行写入的耗时,只能做同城的同步复制,跨城则采用异步复制;
- 跨城分片数据,数据需分片,每个分片的主从之间做跨城同步复制。
数据不分片,写入点只有一个,多副本的方式提供就近读取。这种情况,路由适合全链路就近的方式,按照机房-城市-全局的优先级就近选取路由。如果考虑逻辑层的隔离,也可以在接入层进行路由分流,不过,意义并不大,因为,对于全局数据来说,就近访问,已经具备了较好的隔离性。就近分片数据,重点在于解决写请求穿行N次写操作的跨城时延问题,所以,需要在业务执行写请求之前,把请求提前路由到数据所在的城市(机房),这样穿行的 N 次写操作就是同城(同机房)操作,免去了跨城的耗时。这就要求在执行具体写请求的逻辑层之上做路由分流,考虑到逻辑层的隔离,一般都会把路由分流放在接入层做。读请求也可以和写请求采用同样的路由策略,这样针对同一个分片的读写请求就都在一处了。每一个分片里面的同城三中心的复制架构,在下图中省略了。跨城分片数据能够接受跨城延时,针对写操作是支持跨城同步的,在已经做了分片的基础上,出于就近和影响隔离的考虑,基本上都会做就近,在拆分片的时候,将用户做聚集,并把各个分片的主写点分布到不同城市和机房。跨城分片数据对于路由的影响和就近分片基本上差不多。差别在于跨城分片需要引入第三个城市做数据的完整容灾,如下图的天津,第三城市一般只是为了形成数据容灾的多数派,不会做流量接入,也不会考虑做分片就近部署。在具体进行架构设计的时候,可以参考如下步骤考量评估。下图中只是聊到了本文描述到的一些比较普遍的关键信息,刨除了很多也许在某些业务场景看来是决定性的因素,例如成本,在做多个跨城三地五中心分片的情况下,比只做就近同城三中心,吞吐量可能下降,而机器资源却上升,也有可能会成为决定采用哪种模型的决定性因素。总之,架构设计是一个非常复杂的过程,要考虑的因素繁杂多样,还是要根据业务具体情况具体分析。
本文由高可用架构转载。技术原创及架构实践文章,欢迎通过公众号菜单「联系我们」进行投稿