100Wqps异地多活,得物是怎么架构的?

Posted 40岁资深老架构师尼恩

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了100Wqps异地多活,得物是怎么架构的?相关的知识,希望对你有一定的参考价值。

说在前面

40岁老架构师尼恩的数千读者群中,一直在指导大家简历和职业升级,前几天,指导了一个华为老伙伴的简历,小伙伴的优势在异地多活,但是在简历指导的过程中,尼恩发现: 异地多活的概念、异地多活的架构、非常重要,但是小伙伴却对整个异地多活的体系,不是太清晰。

异地多活的概念有很杂乱,像什么同城双活、两地三中心、三地五中心等等

这里 ,尼恩 站在 得物 异地多活架构(得物架构师 YINJIHUAN)的基础上, 给大家对异地多活,做一个起底式的 、系统化、穿透式的介绍。

并且,把此文的异地多活架构,和尼恩其他的架构文章一起,组成一个架构知识系统,帮助大家实现你的 架构自由

吃透8图1模板,人人可以做架构

10Wqps评论中台,如何架构?B站是这么做的!!!

阿里二面:千万级、亿级数据,如何性能优化? 教科书级 答案来了

峰值21WQps、亿级DAU,小游戏《羊了个羊》是怎么架构的?

100亿级订单怎么调度,来一个大厂的极品方案

2个大厂 100亿级 超大流量 红包 架构方案

以上的架构系列文章,非常重要,建议大家多多看看。

以上文章的PDF版本,都可以在《技术自由圈》公众号找尼恩来获取。

言归正传。

本文目录

文章目录

1. 什么是异地多活

异地多活的概念很多,像什么同城双活、两地三中心、三地五中心等等概念。

要想理解异地多活,需要从架构设计的3高原则说起。

架构设计的3高原则

现如今,开发一个软件系统,对其要求越来越高,如果你了解一些「架构设计」的要求,就知道一个好的软件架构应该遵循以下 3 个原则:

  1. 高性能
  2. 高并发
  3. 高可用

高性能意味着系统拥有更大流量的处理能力,更低的响应延迟。

例如 1 秒可处理 10W 并发请求,接口响应时间 5 ms 等等。

高并发表示系统在迭代新功能时,能以最小的代价去扩展,系统遇到流量压力时,可以在不改动代码的前提下,去扩容系统。

高可用通常用 2 个指标来衡量:

  • 平均故障间隔 MTBF(Mean Time Between Failure):表示两次故障的间隔时间,也就是系统「正常运行」的平均时间,这个时间越长,说明系统稳定性越高
  • 故障恢复时间 MTTR(Mean Time To Repair):表示系统发生故障后「恢复的时间」,这个值越小,故障对用户的影响越小

可用性与这两者的关系:

可用性(Availability)= MTBF / (MTBF + MTTR) * 100%

这个公式得出的结果是一个「比例」,通常我们会用「N 个 9」来描述一个系统的可用性。

从这张图你可以看到,要想达到 4 个 9 以上的可用性,一年的不可以时间为 52分钟,平均每天故障时间必须控制在 10 秒以内。

系统发生故障其实是不可避免的,尤其是规模越大的系统,发生问题的概率也越大。

这些故障一般体现在 3 个方面:

  1. 硬件故障:CPU、内存、磁盘、网卡、交换机、路由器
  2. 软件问题:代码 Bug、版本迭代
  3. 不可抗力:地震、水灾、火灾、战争

这些风险随时都有可能发生。所以,在面对故障时,我们的系统能否以「最快」的速度恢复,就成为了可用性的关键。

常见的多活方案

4 个 9 高可用的核心方案就是异地多活

异地多活指分布在异地的多个站点同时对外提供服务的业务场景。

异地多活是高可用架构设计的一种,与传统的灾备设计的最主要区别在于“多活”,即所有站点都是同时在对外提供服务的。

常见的多活方案有同城双活、两地三中心、三地五中心等多种技术方案,

常见方案1:同城双活

同城双活是在同城或相近区域内建立两个机房。同城双机房距离比较近,通信线路质量较好,比较容易实现数据的同步复制 ,保证高度的数据完整性和数据零丢失。

同城两个机房各承担一部分流量,一般入口流量完全随机,内部RPC调用尽量通过就近路由闭环在同机房,相当于两个机房镜像部署了两个独立集群,数据仍然是单点写到主机房数据库,然后实时同步到另外一个机房。

下图展示了同城双活简单部署架构,当然一般真实部署和考虑问题要远远比下图复杂。

服务调用基本在同机房内完成闭环,数据仍然是单点写到主机房数据储存,然后实时同步复制到同城备份机房。

当机房A出现问题时候运维人员只需要通过GSLB或者其他方案手动更改路由方式将流量路由到B机房。

同城双活可有效用于防范火灾、建筑物破坏、供电故障、计算机系统及人为破坏引起的机房灾难。

同城双活中的核心组件GSLB的原理,可以参见 尼恩的高并三部曲 之三《Java高并发核心编程 卷3 加强版》PDF。

常见方案2:两地三中心

所谓两地三中心是指 同城双中心 + 异地灾备中心。

异地灾备中心是指在异地的城市建立一个备份的灾备中心,用于双中心的数据备份,数据和服务平时都是冷的,

当双中心所在城市或者地区出现异常而都无法对外提供服务的时候,异地灾备中心可以用备份数据进行业务的恢复。

两地三中心方案特点

优势

  • 服务同城双活,数据同城灾备,同城不丢失数据情况下跨机房级别容灾。
  • 架构方案较为简单,核心是解决底层数据双活,由于双机房距离近,通信质量好,底层储存例如mysql可以采用同步复制,有效保证双机房数据一致性。
  • 灾备中心能防范同城双中心同时出现故障时候利用备份数据进行业务的恢复。

劣势

  • 数据库写数据存在跨机房调用,在复杂业务以及链路下频繁跨机房调用增加响应时间,影响系统性能和用户体验。
  • 服务规模足够大(例如单体应用超过万台机器),所有机器链接一个主数据库实例会引起连接不足问题。
  • 出问题不敢轻易将流量切往异地数据备份中心,异地的备份数据中心是冷的,平时没有流量进入,因此出问题需要较长时间对异地灾备机房进行验证。

同城双活和两地三中心建设方案建设复杂度都不高,两地三中心相比同城双活有效解决了异地数据灾备问题,但是依然不能解决同城双活存在的多处缺点,想要解决这两种架构存在的弊端就要引入更复杂的解决方案去解决这些问题。

常见方案2:三地五中心

三地五中心和两地三中心 的架构差不太多,这里不做展开,

有兴趣的小伙伴,可以来尼恩的疯狂创客圈 高并发社群交流。

异地多活3大挑战

1、数据同步延迟挑战

(1)应用要走向异地,首先要面对的便是物理距离带来的延时。

如果某个应用请求需要在异地多个单元对同一行记录进行修改,为满足异地单元间数据库数据的一致性和完整性,需要付出高昂的时间成本。

(2)解决异地高延时即要做到单元内数据读写封闭,不能出现不同单元对同一行数据进行修改,所以我们需要找到一个维度去划分单元。

(3)某个单元内访问其他单元数据需要能正确路由到对应的单元,例如A用户给B用户转账,A用户和B用户数据不在一个单元内,对B用户的操作能路由到相应的单元。

(4)面临的数据同步挑战,对于单元封闭的数据需全部同步到对应单元,对于读写分离类型的,我们要把中心的数据同步到单元。

2、单元化解耦挑战

所谓单元(下面我们用RZone代替),是指一个能完成所有业务操作的自包含集合,在这个集合中包含了所有业务所需的所有服务,以及分配给这个单元的数据。

单元化架构就是把单元作为系统部署的基本单位,在全站所有机房中部署数个单元,每个机房里的单元数目不定,任意一个单元都部署了系统所需的所有的应用。

单元化架构下,服务仍然是分层的,不同的是每一层中的任意一个节点都属于且仅属于某一个单元,上层调用下层时,仅会选择本单元内的节点。

选择什么维度来进行流量切分,要从业务本身入手去分析。

例如电商业务和金融的业务,最重要的流程即下单、支付、交易流程,通过对用户id进行数据切分拆分是最好的选择,买家的相关操作都会在买家所在的本单元内完成。

对于商家相关操作则无法进行单元化,需要按照下面介绍的非单元化模式去部署。

当然用户操作业务并非完全能避免跨单元甚至是跨机房调用,例如两个买家A和B转账业务,A和B所属数据单元不一致的时候,对B进行操作就需要跨单元去完成,后面我们会介绍跨单元调用服务路由问题。

3、流量的路由挑战

  • 流量调度,系统部署过去后流量怎么跟着怎么过去。
  • 流量自闭环。由于距离的原因,跨地域的物理延时是没法避免的,流量过去之后怎么保证所有的操作都在本地完成,如果做不到那怎么将这种延时影响降到最低。
  • 容灾切流。当某个机房出现故障时,如何快速把流量无损地切至其他机房。这里并不是说简单把流量切过去就完事,由于数据在多区域同步,流量切过去之后能否保证数据的一致性?

2. 得物APP的异地多活改造

2.1得物APP异地多活基础改造

改造之前的单机房架构

了解改造点之前我们先来看下目前单机房的现状是什么样子,才能更好的帮助大家去理解为什么要做这些改造。

如上图所示,客户端的请求进来会先到SLB(负载均衡),然后到我们内部的网关,通过网关再分发到具体的业务服务。

业务服务会依赖Redis, Mysql, MQ, Nacos等中间件。

改造之后的目标

既然做异地多活,那么必然是在不同地区有不同的机房,比如中心机房,单元机房。

所以我们要实现的效果如下图所示:

得物APP机房改造

得物多活改造一期目前有两个机房,分别是机房A和机房B。

A机房我们定义为中心机房,也就是多活上线之前正在使用的机房。

另一个B机房,在描述的时候可能会说成单元机房,那指的就是B机房。

得物APP单元化改造

得物多活进行了业务的单元改造,他们的业务比较单一,就是电商业务,所以:一个机房就是一个单元,或者说,一个单元就是一个机房,在这个单元内能够完成业务的闭环。

比如说用户进入APP,浏览商品,选择商品确认订单,下单,支付,查看订单信息,这整个流程都在一个单元中能够完成,并且数据也是存储在这个单元里面。

这块对他们的难度不大。

得物APP流量调度

用户的请求,从客户端发出,这个用户的请求该到哪个机房,这是得物APP要改造的第一个点。

没做多活之前,域名会解析到一个机房内,做了多活后,域名会随机解析到不同的机房中。

如果按照这种随机的方式是肯定有问题的,对于服务的调用是无所谓的,因为没有状态。

但是服务内部依赖的存储是有状态的呀。

得物APP是电商业务,用户在中心机房下了一个单,然后跳转到订单详情,这个时候请求到了单元机房,底层数据同步有延迟,一访问报个错:订单不存在。 用户当场就懵了,钱都付了,订单没了。

所以针对同一个用户,尽可能在一个机房内完成业务闭环。

为了解决流量调度的问题,得物APP基于OpenResty二次开发出了DLB流量网关,DLB会对接多活控制中心,

DLB流量网关能够知道当前访问的用户是属于哪个机房,如果用户不属于当前机房,DLB会直接将请求路由到该用户所属机房内的DLB。

如果每次都随机到固定的机房,再通过DLB去校正,必然会存在跨机房请求,耗时加长。

所以在这块得物APP也是结合客户端做了一些优化,在DLB校正请求后,得物APP会将用户对应的机房IP直接通过Header响应给客户端。

这样下次请求的时候,客户端就可以直接通过这个IP访问。

如果用户当前访问的机房挂了,客户端需要降级成之前的域名访问方式,通过DNS解析到存活的机房。

2.2 RPC框架的异地多活改造

当用户的请求达到了单元机房内,理论上后续所有的操作都是在单元机房完成。

这就要求RPC请求落在就近的机房,那么,怎么知道单元机房的服务信息

所以得物APP的注册中心(Nacos)要做双向同步,这样才能拿到所有机房的服务信息。

不同的机房的Nacos,服务的注册信息采用双向复制,进行同步。

前面也提到了,用户的请求尽量在一个机房内完成闭环,当然,只是尽量,没有说全部。

这是因为有的业务场景不适合划分单元,比如库存扣减。

所以在得物APP的划分里面,有一个机房是中心机房,那些不做多活的业务只会部署在中心机房里面,那么库存扣减的时候就需要跨机房调用。

对于单元服务会存在多个机房的服务信息,如果不进行控制,则会出现调用其他机房的情况,所以RPC框架要进行改造。

2.2.1 定义RPC路由类型

  1. 默认路由

请求到中心机房,会优先调用中心机房内的服务,如果中心机房无此服务,则调用单元机房的服务,如果单元机房没有此服务则直接报错。

  1. 单元路由

请求到单元机房,那么说明此用户的流量规则是在单元机房,接下来所有的RPC调用都只会调用单元机房内的服务,没有服务则报错。

  1. 中心路由

请求到单元机房,那么直接调用中心机房的服务,中心机房没有服务则报错。请求到中心机房,那么就本机房调用。

2.2.2 业务RPC改造

业务方需要对自己的接口(Java interface)进行标记是什么类型,是单元路由,还是中心路由,通过@HARoute加在接口上面。

标记完成后,在Dubbo接口进行注册的时候,会把路由类型放入到这个接口的元数据里面。

在Nacos后台可以查看Dubbo接口的路由类型,这些数据,也是RPC路由异地多活改造的核心参数。

后面通过RPC调用接口内部所有的方法都会按照标记类型进行路由。

比如,单元路由的RPC,RPC在路由的时候会根据这个值判断用户所在的机房。

路由逻辑如下:

RPC 接口复制一份,命名为UnitApi,带上路由参数。在新接口的实现里面调用老接口,新旧接口共存。

2.2.4 遇到的问题

1 其他场景切单元接口

除了RPC直接调用的接口,还有一大部分是通过Dubbo泛化过来的,这块在上线后也需要将流量切到UnitApi,等老接口没有请求量之后才能下线。

2 接口分类整改

接口进行分类,之前没有多活的约束,一个Java interface中的方法可能各种各样,所以需要进行rpc

接口的分类整改

3 业务层面调整

业务层面调整,比如之前查询订单只需要一个订单号,但是现在需要路由参数,所以接入这个接口的上游都需要调整。

2.3 数据库的异地多活

请求顺利的到达了服务层,接下来要跟数据库打交道了。

数据库得物APP定义了不同的类型,定义如下:

1 单元化

此库为单元库,会同时在两个机房部署,每个机房都有完整的数据,数据采用双向同步。

2 中心化

此库为中心库,只会在中心机房部署。

3 中心单元化

此库为中心单元库,会同时在两个机房部署,中心可以读写,其他机房只能读。

中心写数据后单向复制到另一个机房。

2.3.1 DB-Proxy代理中间件

异地多活之前,得物内部的各大服务, 都是客户端形式的Sharding中间件,客户端模式访问分库分表,

要命的是,每个业务方的版本还不一致。

在多活切流的过程中需要对数据库禁写来保证业务数据的准确性,如果没有统一的中间件,这将是一件很麻烦的事情。

所以得物APP调整为 proxy模式,去掉 client模式的分库分表访问。

得物APP 通过对ShardingSphere进行深度定制,二次开发数据库代理proxy中间件 ,彩虹桥。

有了proxy组件之后,各业务方替换之前的Sharding Client方式。

2.3.2 分布式ID

单元化的库,数据层面会做双向同步复制操作。如果直接用表的自增ID则会出现下面的冲突问题:

得物APP采用了一种一劳永逸的方式,接入全局唯一的分布式ID来避免主键的冲突。

所以,分布式ID绝对是 分库分表的核心 技术要点,如果做到 高并发、高性能、防止倾斜,绝对是一大核心的技术难题,

这里,强烈建议大家去看看尼恩 分析了百度ID、推特snowflake ID,shardingjdbc ID三大ID源码之后,定义的异步高并发、防止倾斜、防止时间回拨的高并发ID,一定带给大家N多的启发。

2.3.3 使用OTTER进行数据同步

OTTER是阿里巴巴公司为了解决杭州/美国机房数据间同步研发的一个开源软件。

OTTER基于数据库增量日志解析,准实时同步到本机房或异地机房的mysql/oracle数据库,是一个分布式数据库同步系统。

工作原理图:

原理描述:

  1. 基于Canal开源产品,获取数据库增量日志数据。
  2. 典型管理系统架构,manager(web管理)+node(工作节点)
    a. manager运行时推送同步配置到node节点
    b. node节点将同步状态反馈到manager上
  3. 基于zookeeper,解决分布式状态调度的,允许多node节点之间协同工作

2.3.4 业务改造

在Dao层对表进行操作的时候,会通过ThreadLocal设置当前方法的ShardingKey,然后通过Mybatis拦截器机制,将ShardingKey通过Hint的方式放入SQL中,带给彩虹桥。

彩虹桥会判断当前的ShardingKey是否属于当前机房,如果不是直接禁写报错。

这里跟大家简单的说明下为什么切流过程中要禁写,这个其实跟JVM的垃圾回收有点相似。如果不对操作禁写,那么就会不断的产生数据,而得物APP切流,一定要保证当前机房的数据全部同步过去了之后才开始生效流量规则,否则用户切到另一个机房,数据没同步完,就会产生业务问题。除了彩虹桥会禁写,RPC框架内部也会根据流量规则进行阻断。

2.3.5 遇到的问题

1 单元接口中不能访问中心数据库

如果接口标记成了单元接口,那么只能操作单元库。

在以前没有做多活改造的时候,基本上没有什么中心和单元的概念,所有的表也都是放在一起的。

多活改造后,得物APP会根据业务场景对数据库进行划分。

划分后,中心库只会被中心机房的程序使用,在单元机房是不允许连接中心库。

所以单元接口里面如果涉及到对中心库的操作,必定会报错。

这块需要调整成走中心的RPC接口。

2 中心接口不能访问单元数据库

跟上面同样的问题,如果接口是中心的,也不能在接口里面操作单元库。中心接口的请求都会强制走到中心机房,如果里面有涉及到另一个机房的操作,也必须走RPC接口进行正确的路由,

因为你中心机房不能操作另一个机房的数据库。

3 批量查询调整

比如批量根据订单号进行查询,但是这些订单号不是同一个买家。

如果随便用一个订单的买家作为路由参数,那么其他一些订单其实是属于另一个单元的,这样就有可能存在查询到旧数据的问题。

这样批量查询的场景,只能针对同一个买家可用,如果是不同的买家需要分批调用。

2.4 Redis 的异地多活

Redis在业务中用的比较多,在多活的改造中也有很多地方需要调整。

对于Redis首先得物APP明确几个定义:

不做双向同步

Redis不会和数据库一样做双向同步,也就是中心机房一个Redis集群,单元机房一个Redis集群。

每个机房的集群中只存在一部分用户的缓存数据,不是全量的。

Redis类型

Redis分为中心和单元,中心只会在中心机房部署,单元会在中心和单元两个机房部署。

2.4.1 业务改造

1 Redis多数据源支持

多活改造前,每个应用都有一个单独的Redis集群,

多活改造后,由于应用没有进行单元化和中心的拆分,所以一个应用中会存在需要连接两个Redis的情况。

一个中心Redis,一个单元Redis。

基础架构组提供的专用Redis Client包,需要支持多数据源的创建,

基础包中并且定义通用的配置格式,业务方只需要在自己 的配置里面指定集群和连接模式即可完成接入。

spring.redis.sources.carts.mode=unit 
spring.redis.sources.carts.cluster-name=cartsCuster 

具体的Redis实例信息会在配置中心统一维护,不需要业务方关心,

在做机房扩容的时候,业务方是不需要调整的

2 数据一致性

缓存和缓存之间,不进行同步,没有数据一致性问题

缓存和DB之间,使用binlog 进行同步

这里得物APP的方案是采用订阅数据库的binlog来进行缓存的失效操作,可以订阅本机房的binlog,也可以订阅其他机房的binlog来实现所有机房的缓存失效。

使用 binlog 进行同步的实操,非常重要, 具体请参见尼恩的 100wQps 三级缓存组件实操,建议大家一定认真看看。

2.4.2 遇到的问题

1 序列化协议兼容

在接入新的Redis Client包后,测试环境出现了老数据的兼容问题。

有个别应用自己定制了序列化方式,导致Redis按新的方式装配后没有用到自定义的协议,这块也是进行了改造,支持多数据源的协议自定义。

2 分布式锁的使用

目前项目中的分布式锁是基于Redis实现,当Redis有多个数据源之后,分布式锁也需要进行适配。

在使用的地方要区分场景,默认都是用的中心Redis来加锁。

但是单元接口里面的操作都是买家场景,所以这部分需要调整为单元Redis锁对象进行加锁,这样能够提高性能。其他的一些场景有涉及到全局资源的锁定,那就用中心Redis锁对象进行加锁。

2.5 RocketMQ异地多活

所以MQ跟数据库一样,也要做同步,将消息同步到另一个机房的MQ中,至于另一个机房的消费者要不要消费,这就要让业务场景去决定。

2.5.1 定义消费类型

1 中心订阅

中心订阅指的是消息无论是在中心机房发出的还是单元机房发出的,都只会在中心机房进行消费。

如果是单元机房发出的,会将单元的消息复制一份到中心进行消费。

2 普通订阅

普通订阅就是默认的行为,指的是就近消费。在中心机房发送的消息就由中心机房的消费者进行消费,在单元机房发送的消息就由单元机房的消费进行消费。

3 单元订阅

单元订阅指的是消息会根据ShardingKey进行消息的过滤,无论你在哪个机房发送消息,消息都会复制到另一个机房,此时两个机房都有该消息。通过ShardingKey判断当前消息应该被哪个机房消费,符合的才会进行消费,不符合的框架层面会自动ACK。

4 全单元订阅

全单元订阅指的是消息无论在哪个机房发出,都会在所有的机房进行消费。

2.5.2 业务改造

1 消息发送方调整

消息发送方,需要结合业务场景进行区分。如果是买家场景的业务消息,在发消息的时候需要将"多活路由Key"放入消息中,具体怎么消费由消费方决定。

如果消费方是单元消费的话那么必须依赖发送方的"多活路由Key",否则无法知道当前消息应该在哪个机房消费。

2 消息消费方指定消费模式

前面提到了中心订阅,单元订阅,普通订阅,全单元订阅多种模式,到底要怎么选就是要结合业务场景来定的,定好后在配置MQ信息的时候指定即可。

比如中心订阅就适合你整个服务都是中心的,其他机房都没部署,这个时候肯定适合中心订阅。

比如你要对缓存进行清除,就比较适合全单元订阅,一旦数据有变更,所有机房的缓存都清除掉。

2.5.3 遇到的问题

1 消息幂等消费

就算不做多活,消息消费场景,肯定是要做幂等处理的,因为消息本身就有重试机制。

单独拎出来说是在切流的过程中,属于切流这部分用户的消息会被复制到另一个机房重新进行消费,

解释下为什么切流过程中会有消息消费失败以及需要复制到另一个机房去处理,如下图所示:

用户在当前机房进行业务操作后,会产生消息。由于是单元订阅,所以会在当前机房进行消费。

消费过程中,发生了切流操作,消费逻辑里面对数据库进行读写,但是单元表的操作都携带了ShardingKey,彩虹桥会判断ShardingKey是否符合当前的规则,发现不符合直接禁写报错。

这批切流用户的消息就全部消费失败。

等到流量切到另一个机房后,如果不进行消息的重新投递,那么这部分消息就丢失了,这就是为什么要复制到另一个机房进行消息的重新投递。

2 切流场景的消息顺序问题

上面讲到了在切流过程中,会将消息复制到另一个机房进行重新消费,然后是基于时间点去回放的,如果你的业务消息本身就是普通的Topic,

在消息回放的时候如果同一个场景的消息有多条,这个顺序并不一定是按照之前的顺序来消费,所以这里涉及到一个消费顺序的问题。

如果你之前的业务场景本身就是用的顺序消息,那么是没问题的,如果之前不是顺序消息,这里就有可能有问题,我举个例子说明下:

解决方案有下面几种:

  1. Topic换成顺序消息,以用户进行分区,这样就能保证每个用户的消息严格按照发送顺序进行消费
  2. 对消息做幂等,已消费过就不再消费。但是这里跟普通的消息不同,会有N条消息,如果对msgId进行存储,这样就可以判断是否消费过,但是这样存储压力太大,当然也可以只存储最近N条来减小存储压力。
  3. 消息幂等的优化方式,让消息发送方每发送一次,都带一个version,version必须是递增。消费方消费消息后把当前version存储起来,消费之前判断消息的version是否大于存储的version,满足条件才进行消费,这样既避免了存储的压力也能满足业务的需求。

3. 得物异地多活的半单元化

得物异地多活的没有做全单元化,而是半单元化

3.1 整体方向

首先要根据整个多活的一个整体目标和方向去梳理,

比如得物APP的整体方向就是买家交易的核心链路必须实现单元化改造。那么这整个链路所有依赖的上下游都需要改造。

用户浏览商品,进入确认订单,下单,支付,查询订单信息。这个核心链路其实涉及到了很多的业务域,比如:商品,出价,订单,支付,商家等等。

在这些已经明确了的业务域下面,可能还有一些其他的业务域在支撑着,所以要把整体的链路都梳理出来,一起改造。

当然也不是所有的都必须做单元化,还是得看业务场景,比如库存,肯定是在交易核心链路上,但是不需要改造,必须走中心。

3.2 服务类型

3.2.1 中心服务

中心服务只会在中心机房部署,并且数据库也一定是中心库。

可以对整个应用进行打标成中心,这样外部访问这个服务的接口时都会被路由到中心机房。

3.2.2 单元服务

单元服务会在中心机房和单元机房同时部署,并且数据库也一定是单元库。

单元服务是买家维度的业务,比如确认订单,下单。

买家维度的业务,在接口定义上,第一个参数必须是"多活路由Key",因为要进行路由。

用户的请求已经根据规则进行分流到不同的机房,只会操作对应机房里面的数据库。

3.2.3 中心单元服务

中心单元服务也就是说这个服务里面既有中心的接口也有单元的接口,并且数据库也是有两套。

所以这种服务其实也是要在两个机房同时部署的,只不过是单元机房只会有单元接口过来的流量,中心接口是没有流量的。

一些底层的支撑业务,比如商品,商家这些就属于中心单元服务。

支撑维度的业务是没有"多活路由Key"的,商品是通用的,并不属于某一个买家。

而支撑类型的业务底层的数据库是中心单元库,也就是中心写单元读,写请求是在中心进行,比如商品的创建,修改等。

操作后会同步到另一个机房的数据库里面。这样的好处就是可以减少得物APP在核心链路中的耗时,如果商品不做单元化部署,那么浏览商品或者下单的时候查询商品信息都必须走中心机房进行读取。

而现在则会就近路由进行接口的调用,请求到中心机房就调中心机房的服务,请求到单元机房就调单元机房的服务,单元机房也是有数据库的,不需要跨机房。

从长远考虑,还是需要进行拆分,把中心的业务和单元的业务拆开,这样会比较清晰。

4. 异地多活切流方案

所谓切流,就是在⼀个数据中心发生故障或灾难的情况下,将流量切换到其他数据中心,其他数据中心可以正常运行并对关键业务或全部业务进行接管,实现用户的故障无感知。

前面得物APP也提到了再切流过程中,会禁写,会复制MQ的消息到另一个机房重新消费。

接下来给大家介绍下得物APP的切流方案,能够帮助大家更深刻的理解整个多活的异常场景下处理流程。

  • 下发禁写规则

当需要切流的时候,操作人员会通过双活控制中心的后台进行操作。

切流之前需要先进行已有流量的清理,需要下发禁写规则。

禁写规则会下发到中心和单元两个机房对应的配置中心里面,通过配置中心去通知需要监听的程序。

  • 彩虹桥执行禁写逻辑

彩虹桥会用到禁写规则,当禁写规则在配置中心修改后,彩虹桥能立马感知到,然后会根据SQL中携带的shardingkey进行规则的判断,看当前shardingkey是否属于这个机房,如果不属于则进行拦截。

  • 反馈禁写生效结果

当配置变更后会推送到彩虹桥,配置中心会感知到配置推送的结果,然后将生效的结果反馈给双活控制中心。

  • 推送禁写生效时间给Otter

双活控制中心收到所有的反馈后,会将全部生效的时间点通过MQ消息告诉Otter。

  • Otter进行数据同步

Otter收到消息会根据时间点进行数据同步。

  • Otter同步完成反馈同步结果

生效时间点之前的数据全部同步完成后会通过MQ消息反馈给双活控制中心。

  • 下发最新流量规则

双活中心收到Otter的同步完成的反馈消息后,会下发流量规则,流量规则会下发到DLB,RPC,彩虹桥。

后续用户的请求就会直接被路由到正确的机房。

5. 得物异地多活的总结

多活是一个高可用的容灾手段,但实现的成本和对技术团队的要求非常高。但是异地多活改造的范围实在是太大了。

本篇主要讲的是中间件层面和业务层面的一些改造点和过程,同时还有其他的一些点都没有提到。

比如:机房网络的建设,发布系统支持多机房,监控系统支持多机房的整个链路监控,数据巡检的监控等等。

没有100%的可用性,异地多活只是在极端场景下对业务的一些取舍罢了,优先保证核心功能。

在实现多活的时候,得物APP应该结合业务场景去进行设计,所以,也不是所有系统,所有功能都要满足多活的条件。

得物异地多活的方案很多很多, 大家有什么具体的问题,也可以来尼恩的高并发社群(50+)里边交流。

后续,尼恩会给大家结合各大互联网的行业案例,分析出更多,更加劲爆的异地多活,大家可以找尼恩来一次性获取这些方案的PDF。

当然,如果大家遇到这类高可用的面试难题,也可以找尼恩求助。

技术自由的实现路径:

实现你的 架构自由:

吃透8图1模板,人人可以做架构

10Wqps评论中台,如何架构?B站是这么做的!!!

阿里二面:千万级、亿级数据,如何性能优化? 教科书级 答案来了

峰值21WQps、亿级DAU,小游戏《羊了个羊》是怎么架构的?

100亿级订单怎么调度,来一个大厂的极品方案

2个大厂 100亿级 超大流量 红包 架构方案

… 更多架构文章,正在添加中

实现你的 响应式 自由:

响应式圣经:10W字,实现Spring响应式编程自由

这是老版本 《Flux、Mono、Reactor 实战(史上最全)

实现你的 spring cloud 自由:

Spring cloud Alibaba 学习圣经》 PDF

分库分表 Sharding-JDBC 底层原理、核心实战(史上最全)

一文搞定:SpringBoot、SLF4j、Log4j、Logback、Netty之间混乱关系(史上最全)

实现你的 linux 自由:

Linux命令大全:2W多字,一次实现Linux自由

实现你的 网络 自由:

TCP协议详解 (史上最全)

网络三张表:ARP表, MAC表, 路由表,实现你的网络自由!!

实现你的 分布式锁 自由:

Redis分布式锁(图解 - 秒懂 - 史上最全)

Zookeeper 分布式锁 - 图解 - 秒懂

实现你的 王者组件 自由:

队列之王: Disruptor 原理、架构、源码 一文穿透

缓存之王:Caffeine 源码、架构、原理(史上最全,10W字 超级长文)

缓存之王:Caffeine 的使用(史上最全)

Java Agent 探针、字节码增强 ByteBuddy(史上最全)

实现你的 面试题 自由:

4000页《尼恩Java面试宝典 》 40个专题

搞懂异地多活,看这篇就够了

阅读本文大约需要 20 分钟。

你好,我是 Kaito。

在软件开发领域,「异地多活」是分布式系统架构设计的一座高峰,很多人经常听过它,但很少人理解其中的原理。

异地多活到底是什么?为什么需要异地多活?它到底解决了什么问题?究竟是怎么解决的?

这些疑问,想必是每个程序看到异地多活这个名词时,都想要搞明白的问题。

有幸,我曾经深度参与过一个中等互联网公司,建设异地多活系统的设计与实施过程。所以今天,我就来和你聊一聊异地多活背后的的实现原理。

认真读完这篇文章,我相信你会对异地多活架构,有更加深刻的理解。

这篇文章干货很多,希望你可以耐心读完。

01 系统可用性

要想理解异地多活,我们需要从架构设计的原则说起。

现如今,我们开发一个软件系统,对其要求越来越高,如果你了解一些「架构设计」的要求,就知道一个好的软件架构应该遵循以下 3 个原则:

  1. 高性能
  2. 高可用
  3. 易扩展

其中,高性能意味着系统拥有更大流量的处理能力,更低的响应延迟。例如 1 秒可处理 10W 并发请求,接口响应时间 5 ms 等等。

易扩展表示系统在迭代新功能时,能以最小的代价去扩展,系统遇到流量压力时,可以在不改动代码的前提下,去扩容系统。

而「高可用」这个概念,看起来很抽象,怎么理解它呢?通常用 2 个指标来衡量:

  • 平均故障间隔 MTBF(Mean Time Between Failure):表示两次故障的间隔时间,也就是系统「正常运行」的平均时间,这个时间越长,说明系统稳定性越高
  • 故障恢复时间 MTTR(Mean Time To Repair):表示系统发生故障后「恢复的时间」,这个值越小,故障对用户的影响越小
  • 可用性与这两者的关系:

    可用性(Availability)= MTBF / (MTBF + MTTR) * 100%

    这个公式得出的结果是一个「比例」,通常我们会用「N 个 9」来描述一个系统的可用性。

    从这张图你可以看到,要想达到 4 个 9 以上的可用性,平均每天故障时间必须控制在 10 秒以内。

    也就是说,只有故障的时间「越短」,整个系统的可用性才会越高,每提升 1 个 9,都会对系统提出更高的要求。

    我们都知道,系统发生故障其实是不可避免的,尤其是规模越大的系统,发生问题的概率也越大。这些故障一般体现在 3 个方面:

    1. 硬件故障:CPU、内存、磁盘、网卡、交换机、路由器
    2. 软件问题:代码 Bug、版本迭代
    3. 不可抗力:地震、水灾、火灾、战争

    这些风险随时都有可能发生。所以,在面对故障时,我们的系统能否以「最快」的速度恢复,就成为了可用性的关键。

    可如何做到快速恢复呢?

    这篇文章要讲的「异地多活」架构,就是为了解决这个问题,而提出的高效解决方案。

    下面,我会从一个最简单的系统出发,带你一步步演化出一个支持「异地多活」的系统架构。

    在这个过程中,你会看到一个系统会遇到哪些可用性问题,以及为什么架构要这样演进,从而理解异地多活架构的意义。

    02 单机架构

    我们从最简单的开始讲起。

    假设你的业务处于起步阶段,体量非常小,那你的架构是这样的:

    这个架构模型非常简单,客户端请求进来,业务应用读写数据库,返回结果,非常好理解。

    但需要注意的是,这里的数据库是「单机」部署的,所以它有一个致命的缺点:一旦遭遇意外,例如磁盘损坏、操作系统异常、误删数据,那这意味着所有数据就全部「丢失」了,这个损失是巨大的。

    如何避免这个问题呢?我们很容易想到一个方案:备份

    你可以对数据做备份,把数据库文件「定期」cp 到另一台机器上,这样,即使原机器丢失数据,你依旧可以通过备份把数据「恢复」回来,以此保证数据安全。

    这个方案实施起来虽然比较简单,但存在 2 个问题:

    1. 恢复需要时间:业务需先停机,再恢复数据,停机时间取决于恢复的速度,恢复期间服务「不可用」
    2. 数据不完整:因为是定期备份,数据肯定不是「最新」的,数据完整程度取决于备份的周期

    很明显,你的数据库越大,意味故障恢复时间越久。那按照前面我们提到的「高可用」标准,这个方案可能连 1 个 9 都达不到,远远无法满足我们对可用性的要求。

    那有什么更好的方案,既可以快速恢复业务?还能尽可能保证数据完整性呢?

    这时你可以采用这个方案:主从副本

    03 主从副本

    你可以在另一台机器上,再部署一个数据库实例,让这个新实例成为原实例的「副本」,让两者保持「实时同步」,就像这样:

    我们一般把原实例叫作主库(master),新实例叫作从库(slave)。这个方案的优点在于:

  • 数据完整性高:主从副本实时同步,数据「差异」很小
  • 抗故障能力提升:主库有任何异常,从库可随时「切换」为主库,继续提供服务
  • 读性能提升:业务应用可直接读从库,分担主库「压力」读压力
  • 这个方案不错,不仅大大提高了数据库的可用性,还提升了系统的读性能。

    同样的思路,你的「业务应用」也可以在其它机器部署一份,避免单点。因为业务应用通常是「无状态」的(不像数据库那样存储数据),所以直接部署即可,非常简单。

    因为业务应用部署了多个,所以你现在还需要部署一个「接入层」,来做请求的「负载均衡」(一般会使用 nginx 或 LVS),这样当一台机器宕机后,另一台机器也可以「接管」所有流量,持续提供服务。

    从这个方案你可以看出,提升可用性的关键思路就是:冗余

    没错,担心一个实例故障,那就部署多个实例,担心一个机器宕机,那就部署多台机器。

    到这里,你的架构基本已演变成主流方案了,之后开发新的业务应用,都可以按照这种模式去部署。

    但这种方案还有什么风险吗?

    04 风险不可控

    现在让我们把视角下放,把焦点放到具体的「部署细节」上来。

    按照前面的分析,为了避免单点故障,你的应用虽然部署了多台机器,但这些机器的分布情况,我们并没有去深究。

    而一个机房有很多服务器,这些服务器通常会分布在一个个「机柜」上,如果你使用的这些机器,刚好在一个机柜,还是存在风险。

    如果恰好连接这个机柜的交换机 / 路由器发生故障,那么你的应用依旧有「不可用」的风险。

    虽然交换机 / 路由器也做了路线冗余,但不能保证一定不出问题。

    部署在一个机柜有风险,那把这些机器打散,分散到不同机柜上,是不是就没问题了?

    这样确实会大大降低出问题的概率。但我们依旧不能掉以轻心,因为无论怎么分散,它们总归还是在一个相同的环境下:机房

    那继续追问,机房会不会发生故障呢?

    一般来讲,建设一个机房的要求其实是很高的,地理位置、温湿度控制、备用电源等等,机房厂商会在各方面做好防护。但即使这样,我们每隔一段时间还会看到这样的新闻:

  • 2015 年 5 月 27 日,杭州市某地光纤被挖断,近 3 亿用户长达 5 小时无法访问支付宝
  • 2021 年 7 月 13 日,B 站部分服务器机房发生故障,造成整站持续 3 个小时无法访问
  • 2021 年 10 月 9 日,富途证券服务器机房发生电力闪断故障,造成用户 2 个小时无法登陆、交易
  • ...
  • 可见,即使机房级别的防护已经做得足够好,但只要有「概率」出问题,那现实情况就有可能发生。虽然概率很小,但一旦真的发生,影响之大可见一斑。

    看到这里你可能会想,机房出现问题的概率也太小了吧,工作了这么多年,也没让我碰上一次,有必要考虑得这么复杂吗?

    但你有没有思考这样一个问题:不同体量的系统,它们各自关注的重点是什么?

    体量很小的系统,它会重点关注「用户」规模、增长,这个阶段获取用户是一切。等用户体量上来了,这个阶段会重点关注「性能」,优化接口响应时间、页面打开速度等等,这个阶段更多是关注用户体验。

    等体量再大到一定规模后你会发现,「可用性」就变得尤为重要。像微信、支付宝这种全民级的应用,如果机房发生一次故障,那整个影响范围可以说是非常巨大的。

    所以,再小概率的风险,我们在提高系统可用性时,也不能忽视。

    分析了风险,再说回我们的架构。那到底该怎么应对机房级别的故障呢?

    没错,还是冗余

    05 同城灾备

    想要抵御「机房」级别的风险,那应对方案就不能局限在一个机房内了。

    现在,你需要做机房级别的冗余方案,也就是说,你需要再搭建一个机房,来部署你的服务。

    简单起见,你可以在「同一个城市」再搭建一个机房,原机房我们叫作 A 机房,新机房叫 B 机房,这两个机房的网络用一条「专线」连通。

    有了新机房,怎么把它用起来呢?这里还是要优先考虑「数据」风险。

    为了避免 A 机房故障导致数据丢失,所以我们需要把数据在 B 机房也存一份。最简单的方案还是和前面提到的一样:备份

    A 机房的数据,定时在 B 机房做备份(拷贝数据文件),这样即使整个 A 机房遭到严重的损坏,B 机房的数据不会丢,通过备份可以把数据「恢复」回来,重启服务。

    这种方案,我们称之为「冷备」。为什么叫冷备呢?因为 B 机房只做备份,不提供实时服务,它是冷的,只会在 A 机房故障时才会启用。

    但备份的问题依旧和之前描述的一样:数据不完整、恢复数据期间业务不可用,整个系统的可用性还是无法得到保证。

    所以,我们还是需要用「主从副本」的方式,在 B 机房部署 A 机房的数据副本,架构就变成了这样:

    这样,就算整个 A 机房挂掉,我们在 B 机房也有比较「完整」的数据。

    数据是保住了,但这时你需要考虑另外一个问题:如果 A 机房真挂掉了,要想保证服务不中断,你还需要在 B 机房「紧急」做这些事情

    1. B 机房所有从库提升为主库
    2. 在 B 机房部署应用,启动服务
    3. 部署接入层,配置转发规则
    4. DNS 指向 B 机房,接入流量,业务恢复

    看到了么?A 机房故障后,B 机房需要做这么多工作,你的业务才能完全「恢复」过来。

    你看,整个过程需要人为介入,且需花费大量时间来操作,恢复之前整个服务还是不可用的,这个方案还是不太爽,如果能做到故障后立即「切换」,那就好了。

    因此,要想缩短业务恢复的时间,你必须把这些工作在 B 机房「提前」做好,也就是说,你需要在 B 机房提前部署好接入层、业务应用,等待随时切换。架构就变成了这样:

    这样的话,A 机房整个挂掉,我们只需要做 2 件事即可:

    1. B 机房所有从库提升为主库
    2. DNS 指向 B 机房,接入流量,业务恢复

    这样一来,恢复速度快了很多。

    到这里你会发现,B 机房从最开始的「空空如也」,演变到现在,几乎是「镜像」了一份 A 机房的所有东西,从最上层的接入层,到中间的业务应用,到最下层的存储。

    两个机房唯一的区别是,A 机房的存储都是主库,而 B 机房都是从库

    这种方案,我们把它叫做「热备」。

    热的意思是指,B 机房处于「待命」状态,A 故障后 B 可以随时「接管」流量,继续提供服务。热备相比于冷备最大的优点是:随时可切换

    无论是冷备还是热备,因为它们都处于「备用」状态,所以我们把这两个方案统称为:同城灾备

    同城灾备的最大优势在于,我们再也不用担心「机房」级别的故障了,一个机房发生风险,我们只需把流量切换到另一个机房即可,可用性再次提高,是不是很爽?(后面还有更爽的)

    06 同城双活

    我们继续来看这个架构。

    虽然我们有了应对机房故障的解决方案,但这里有个问题是我们不能忽视的:A 机房挂掉,全部流量切到 B 机房,B 机房能否真的如我们所愿,正常提供服务?

    这是个值得思考的问题。

    这就好比有两支军队 A 和 B,A 军队历经沙场,作战经验丰富,而 B 军队只是后备军,除了有军人的基本素养之外,并没有实战经验,战斗经验基本为 0。

    如果 A 军队丧失战斗能力,需要 B 军队立即顶上时,作为指挥官的你,肯定也会担心 B 军队能否真的担此重任吧?

    我们的架构也是如此,此时的 B 机房虽然是随时「待命」状态,但 A 机房真的发生故障,我们要把全部流量切到 B 机房,其实是不敢百分百保证它可以「如期」工作的。

    你想,我们在一个机房内部署服务,还总是发生各种各样的问题,例如:发布应用的版本不一致、系统资源不足、操作系统参数不一样等等。现在多部署一个机房,这些问题只会增多,不会减少。

    另外,从「成本」的角度来看,我们新部署一个机房,需要购买服务器、内存、硬盘、带宽资源,花费成本也是非常高昂的,只让它当一个后备军,未免也太「大材小用」了!

    因此,我们需要让 B 机房也接入流量,实时提供服务,这样做的好处,一是可以实时训练这支后备军,让它达到与 A 机房相同的作战水平,随时可切换,二是 B 机房接入流量后,可以分担 A 机房的流量压力。这才是把 B 机房资源优势,发挥最大化的最好方案!

    那怎么让 B 机房也接入流量呢?很简单,就是把 B 机房的接入层 IP 地址,加入到 DNS 中,这样,B 机房从上层就可以有流量进来了。

    但这里有一个问题:别忘了,B 机房的存储,现在可都是 A 机房的「从库」,从库默认可都是「不可写」的,B 机房的写请求打到本机房存储上,肯定会报错,这还是不符合我们预期。怎么办?

    这时,你就需要在「业务应用」层做改造了。

    你的业务应用在操作数据库时,需要区分「读写分离」(一般用中间件实现),即两个机房的「读」流量,可以读任意机房的存储,但「写」流量,只允许写 A 机房,因为主库在 A 机房。

    这会涉及到你用的所有存储,例如项目中用到了 MySQL、Redis、MongoDB 等等,操作这些数据库,都需要区分读写请求,所以这块需要一定的业务「改造」成本。

    因为 A 机房的存储都是主库,所以我们把 A 机房叫做「主机房」,B 机房叫「从机房」。

    两个机房部署在「同城」,物理距离比较近,而且两个机房用「专线」网络连接,虽然跨机房访问的延迟,比单个机房内要大一些,但整体的延迟还是可以接受的。

    业务改造完成后,B 机房可以慢慢接入流量,从 10%、30%、50% 逐渐覆盖到 100%,你可以持续观察 B 机房的业务是否存在问题,有问题及时修复,逐渐让 B 机房的工作能力,达到和 A 机房相同水平。

    现在,因为 B 机房实时接入了流量,此时如果 A 机房挂了,那我们就可以「大胆」地把 A 的流量,全部切换到 B 机房,完成快速切换!

    到这里你可以看到,我们部署的 B 机房,在物理上虽然与 A 有一定距离,但整个系统从「逻辑」上来看,我们是把这两个机房看做一个「整体」来规划的,也就是说,相当于把 2 个机房当作 1 个机房来用。

    这种架构方案,比前面的同城灾备更「进了一步」,B 机房实时接入了流量,还能应对随时的故障切换,这种方案我们把它叫做「同城双活」。

    因为两个机房都能处理业务请求,这对我们系统的内部维护、改造、升级提供了更多的可实施空间(流量随时切换),现在,整个系统的弹性也变大了,是不是更爽了?

    那这种架构有什么问题呢?

    07 两地三中心

    还是回到风险上来说。

    虽然我们把 2 个机房当做一个整体来规划,但这 2 个机房在物理层面上,还是处于「一个城市」内,如果是整个城市发生自然灾害,例如地震、水灾(河南水灾刚过去不久),那 2 个机房依旧存在「全局覆没」的风险。

    真是防不胜防啊?怎么办?没办法,继续冗余。

    但这次冗余机房,就不能部署在同一个城市了,你需要把它放到距离更远的地方,部署在「异地」。

    通常建议两个机房的距离要在 1000 公里以上,这样才能应对城市级别的灾难。

    假设之前的 A、B 机房在北京,那这次新部署的 C 机房可以放在上海。

    按照前面的思路,把 C 机房用起来,最简单粗暴的方案还就是做「冷备」,即定时把 A、B 机房的数据,在 C 机房做备份,防止数据丢失。

    这种方案,就是我们经常听到的「两地三中心」。

    两地是指 2 个城市,三中心是指有 3 个机房,其中 2 个机房在同一个城市,并且同时提供服务,第 3 个机房部署在异地,只做数据灾备。

    这种架构方案,通常用在银行、金融、政企相关的项目中。它的问题还是前面所说的,启用灾备机房需要时间,而且启用后的服务,不确定能否如期工作。

    所以,要想真正的抵御城市级别的故障,越来越多的互联网公司,开始实施「异地双活」。

    08 伪异地双活

    这里,我们还是分析 2 个机房的架构情况。我们不再把 A、B 机房部署在同一个城市,而是分开部署,例如 A 机房放在北京,B 机房放在上海。

    前面我们讲了同城双活,那异地双活是不是直接「照搬」同城双活的模式去部署就可以了呢?

    事情没你想的那么简单。

    如果还是按照同城双活的架构来部署,那异地双活的架构就是这样的:

    注意看,两个机房的网络是通过「跨城专线」连通的。

    此时两个机房都接入流量,那上海机房的请求,可能要去读写北京机房的存储,这里存在一个很大的问题:网络延迟

    因为两个机房距离较远,受到物理距离的限制,现在,两地之间的网络延迟就变成了「不可忽视」的因素了。

    北京到上海的距离大约 1300 公里,即使架设一条高速的「网络专线」,光纤以光速传输,一个来回也需要近 10ms 的延迟。

    况且,网络线路之间还会经历各种路由器、交换机等网络设备,实际延迟可能会达到 30ms ~ 100ms,如果网络发生抖动,延迟甚至会达到 1 秒。

    不止是延迟,远距离的网络专线质量,是远远达不到机房内网络质量的,专线网络经常会发生延迟、丢包、甚至中断的情况。总之,不能过度信任和依赖「跨城专线」。

    你可能会问,这点延迟对业务影响很大吗?影响非常大!

    试想,一个客户端请求打到上海机房,上海机房要去读写北京机房的存储,一次跨机房访问延迟就达到了 30ms,这大致是机房内网网络(0.5 ms)访问速度的 60 倍(30ms / 0.5ms),一次请求慢 60 倍,来回往返就要慢 100 倍以上。

    而我们在 App 打开一个页面,可能会访问后端几十个 API,每次都跨机房访问,整个页面的响应延迟有可能就达到了秒级,这个性能简直惨不忍睹,难以接受。

    看到了么,虽然我们只是简单的把机房部署在了「异地」,但「同城双活」的架构模型,在这里就不适用了,还是按照这种方式部署,这是「伪异地双活」!

    那如何做到真正的异地双活呢?

    09 真正的异地双活

    既然「跨机房」调用延迟是不容忽视的因素,那我们只能尽量避免跨机房「调用」,规避这个延迟问题。

    也就是说,上海机房的应用,不能再「跨机房」去读写北京机房的存储,只允许读写上海本地的存储,实现「就近访问」,这样才能避免延迟问题。

    还是之前提到的问题:上海机房存储都是从库,不允许写入啊,除非我们只允许上海机房接入「读流量」,不接收「写流量」,否则无法满足不再跨机房的要求。

    很显然,只让上海机房接收读流量的方案不现实,因为很少有项目是只有读流量,没有写流量的。所以这种方案还是不行,这怎么办?

    此时,你就必须在「存储层」做改造了。

    要想上海机房读写本机房的存储,那上海机房的存储不能再是北京机房的从库,而是也要变为「主库」。

    你没看错,两个机房的存储必须都是「主库」,而且两个机房的数据还要「互相同步」数据,即客户端无论写哪一个机房,都能把这条数据同步到另一个机房。

    因为只有两个机房都拥有「全量数据」,才能支持任意切换机房,持续提供服务。

    怎么实现这种「双主」架构呢?它们之间如何互相同步数据?

    如果你对 MySQL 有所了解,MySQL 本身就提供了双主架构,它支持双向复制数据,但平时用的并不多。而且 Redis、MongoDB 等数据库并没有提供这个功能,所以,你必须开发对应的「数据同步中间件」来实现双向同步的功能。

    此外,除了数据库这种有状态的软件之外,你的项目通常还会使用到消息队列,例如 RabbitMQ、Kafka,这些也是有状态的服务,所以它们也需要开发双向同步的中间件,支持任意机房写入数据,同步至另一个机房。

    看到了么,这一下子复杂度就上来了,单单针对每个数据库、队列开发同步中间件,就需要投入很大精力了。

    业界也开源出了很多数据同步中间件,例如阿里的 Canal、RedisShake、MongoShake,可分别在两个机房同步 MySQL、Redis、MongoDB 数据。

    很多有能力的公司,也会采用自研同步中间件的方式来做,例如饿了么、携程、美团都开发了自己的同步中间件。

    我也有幸参与设计开发了 MySQL、Redis/Codis、MongoDB 的同步中间件,有时间写一篇文章详细聊聊实现细节,欢迎持续关注。:)

    现在,整个架构就变成了这样:

    注意看,两个机房的存储层都互相同步数据的。有了数据同步中间件,就可以达到这样的效果:

  • 北京机房写入 X = 1
  • 上海机房写入 Y = 2
  • 数据通过中间件双向同步
  • 北京、上海机房都有 X = 1、Y = 2 的数据
  • 这里我们用中间件双向同步数据,就不用再担心专线问题,专线出问题,我们的中间件可以自动重试,直到成功,达到数据最终一致。

    但这里还会遇到一个问题,两个机房都可以写,操作的不是同一条数据那还好,如果修改的是同一条的数据,发生冲突怎么办?

  • 用户短时间内发了 2 个修改请求,都是修改同一条数据
  • 一个请求落在北京机房,修改 X = 1(还未同步到上海机房)
  • 另一个请求落在上海机房,修改 X = 2(还未同步到北京机房)
  • 两个机房以哪个为准?
  • 也就是说,在很短的时间内,同一个用户修改同一条数据,两个机房无法确认谁先谁后,数据发生「冲突」。

    这是一个很严重的问题,系统发生故障并不可怕,可怕的是数据发生「错误」,因为修正数据的成本太高了。我们一定要避免这种情况的发生。解决这个问题,有 2 个方案。

    第一个方案,数据同步中间件要有自动「合并」数据、解决「冲突」的能力。

    这个方案实现起来比较复杂,要想合并数据,就必须要区分出「先后」顺序。我们很容易想到的方案,就是以「时间」为标尺,以「后到达」的请求为准。

    但这种方案需要两个机房的「时钟」严格保持一致才行,否则很容易出现问题。例如:

  • 第 1 个请求落到北京机房,北京机房时钟是 10:01,修改 X = 1
  • 第 2 个请求落到上海机房,上海机房时钟是 10:00,修改 X = 2
  • 因为北京机房的时间「更晚」,那最终结果就会是 X = 1。但这里其实应该以第 2 个请求为准,X = 2 才对。

    可见,完全「依赖」时钟的冲突解决方案,不太严谨。

    所以,通常会采用第二种方案,从「源头」就避免数据冲突的发生。

    10 如何实施异地双活

    既然自动合并数据的方案实现成本高,那我们就要想,能否从源头就「避免」数据冲突呢?

    这个思路非常棒!

    从源头避免数据冲突的思路是:在最上层接入流量时,就不要让冲突的情况发生。

    具体来讲就是,要在最上层就把用户「区分」开,部分用户请求固定打到北京机房,其它用户请求固定打到上海 机房,进入某个机房的用户请求,之后的所有业务操作,都在这一个机房内完成,从根源上避免「跨机房」。

    所以这时,你需要在接入层之上,再部署一个「路由层」(通常部署在云服务器上),自己可以配置路由规则,把用户「分流」到不同的机房内。

    但这个路由规则,具体怎么定呢?有很多种实现方式,最常见的我总结了 3 类:

    1. 按业务类型分片
    2. 直接哈希分片
    3. 按地理位置分片

    1、按业务类型分片

    这种方案是指,按应用的「业务类型」来划分。

    举例:假设我们一共有 4 个应用,北京和上海机房都部署这些应用。但应用 1、2 只在北京机房接入流量,在上海机房只是热备。应用 3、4 只在上海机房接入流量,在北京机房是热备。

    这样一来,应用 1、2 的所有业务请求,只读写北京机房存储,应用 3、4 的所有请求,只会读写上海机房存储。

    这样按业务类型分片,也可以避免同一个用户修改同一条数据。

    这里按业务类型在不同机房接入流量,还需要考虑多个应用之间的依赖关系,要尽可能的把完成「相关」业务的应用部署在同一个机房,避免跨机房调用。

    例如,订单、支付服务有依赖关系,会产生互相调用,那这 2 个服务在 A 机房接入流量。社区、发帖服务有依赖关系,那这 2 个服务在 B 机房接入流量。

    2、直接哈希分片

    这种方案就是,最上层的路由层,会根据用户 ID 计算「哈希」取模,然后从路由表中找到对应的机房,之后把请求转发到指定机房内。

    举例:一共 200 个用户,根据用户 ID 计算哈希值,然后根据路由规则,把用户 1 - 100 路由到北京机房,101 - 200 用户路由到上海机房,这样,就避免了同一个用户修改同一条数据的情况发生。

    3、按地理位置分片

    这种方案,非常适合与地理位置密切相关的业务,例如打车、外卖服务就非常适合这种方案。

    拿外卖服务举例,你要点外卖肯定是「就近」点餐,整个业务范围相关的有商家、用户、骑手,它们都是在相同的地理位置内的。

    针对这种特征,就可以在最上层,按用户的「地理位置」来做分片,分散到不同的机房。

    举例:北京、河北地区的用户点餐,请求只会打到北京机房,而上海、浙江地区的用户,请求则只会打到上海机房。这样的分片规则,也能避免数据冲突。

    提醒:这 3 种常见的分片规则,第一次看不太好理解,建议配合图多理解几遍。搞懂这 3 个分片规则,你才能真正明白怎么做异地多活。

    总之,分片的核心思路在于,让同一个用户的相关请求,只在一个机房内完成所有业务「闭环」,不再出现「跨机房」访问。

    阿里在实施这种方案时,给它起了个名字,叫做「单元化」。

    当然,最上层的路由层把用户分片后,理论来说同一个用户只会落在同一个机房内,但不排除程序 Bug 导致用户会在两个机房「漂移」。

    安全起见,每个机房在写存储时,还需要有一套机制,能够检测「数据归属」,应用层操作存储时,需要通过中间件来做「兜底」,避免不该写本机房的情况发生。(篇幅限制,这里不展开讲,理解思路即可)

    现在,两个机房就可以都接收「读写」流量(做好分片的请求),底层存储保持「双向」同步,两个机房都拥有全量数据,当任意机房故障时,另一个机房就可以「接管」全部流量,实现快速切换,简直不要太爽。

    不仅如此,因为机房部署在异地,我们还可以更细化地「优化」路由规则,让用户访问就近的机房,这样整个系统的性能也会大大提升。

    这里还有一种情况,是无法做数据分片的:全局数据。例如系统配置、商品库存这类需要强一致的数据,这类服务依旧只能采用写主机房,读从机房的方案,不做双活。

    双活的重点,是要优先保证「核心」业务先实现双活,并不是「全部」业务实现双活。

    至此,我们才算实现了真正的「异地双活」!

    到这里你可以看出,完成这样一套架构,需要投入的成本是巨大的。

    路由规则、路由转发、数据同步中间件、数据校验兜底策略,不仅需要开发强大的中间件,同时还要业务配合改造(业务边界划分、依赖拆分)等一些列工作,没有足够的人力物力,这套架构很难实施。

    11 异地多活

    理解了异地双活,那「异地多活」顾名思义,就是在异地双活的基础上,部署多个机房即可。架构变成了这样:

    这些服务按照「单元化」的部署方式,可以让每个机房部署在任意地区,随时扩展新机房,你只需要在最上层定义好分片规则就好了。

    但这里还有一个小问题,随着扩展的机房越来越多,当一个机房写入数据后,需要同步的机房也越来越多,这个实现复杂度会比较高。

    所以业界又把这一架构又做了进一步优化,把「网状」架构升级为「星状」:

    这种方案必须设立一个「中心机房」,任意机房写入数据后,都只同步到中心机房,再由中心机房同步至其它机房。

    这样做的好处是,一个机房写入数据,只需要同步数据到中心机房即可,不需要再关心一共部署了多少个机房,实现复杂度大大「简化」。

    但与此同时,这个中心机房的「稳定性」要求会比较高。不过也还好,即使中心机房发生故障,我们也可以把任意一个机房,提升为中心机房,继续按照之前的架构提供服务。

    至此,我们的系统彻底实现了「异地多活」!

    多活的优势在于,可以任意扩展机房「就近」部署。任意机房发生故障,可以完成快速「切换」,大大提高了系统的可用性。

    同时,我们也再也不用担心系统规模的增长,因为这套架构具有极强的「扩展能力」。

    怎么样?我们从一个最简单的应用,一路优化下来,到最终的架构方案,有没有帮你彻底理解异地多活呢?

    总结

    好了,总结一下这篇文章的重点。

    1、一个好的软件架构,应该遵循高性能、高可用、易扩展 3 大原则,其中「高可用」在系统规模变得越来越大时,变得尤为重要

    2、系统发生故障并不可怕,能以「最快」的速度恢复,才是高可用追求的目标,异地多活是实现高可用的有效手段

    3、提升高可用的核心是「冗余」,备份、主从副本、同城灾备、同城双活、两地三中心、异地双活,异地多活都是在做冗余

    4、同城灾备分为「冷备」和「热备」,冷备只备份数据,不提供服务,热备实时同步数据,并做好随时切换的准备

    5、同城双活比灾备的优势在于,两个机房都可以接入「读写」流量,提高可用性的同时,还提升了系统性能。虽然物理上是两个机房,但「逻辑」上还是当做一个机房来用

    6、两地三中心是在同城双活的基础上,额外部署一个异地机房做「灾备」,用来抵御「城市」级别的灾害,但启用灾备机房需要时间

    7、异地双活才是抵御「城市」级别灾害的更好方案,两个机房同时提供服务,故障随时可切换,可用性高。但实现也最复杂,理解了异地双活,才能彻底理解异地多活

    8、异地多活是在异地双活的基础上,任意扩展多个机房,不仅又提高了可用性,还能应对更大规模的流量的压力,扩展性最强,是实现高可用的最终方案

    后记

    这篇文章我从「宏观」层面,向你介绍了异地多活架构的「核心」思路,整篇文章的信息量还是很大的,如果不太好理解,我建议你多读几遍。

    因为篇幅限制,很多细节我并没有展开来讲。这篇文章更像是讲异地多活的架构之「道」,而真正实施的「术」,要考虑的点其实也非常繁多,因为它需要开发强大的「基础设施」才可以完成实施。

    不仅如此,要想真正实现异地多活,还需要遵循一些原则,例如业务梳理、业务分级、数据分类、数据最终一致性保障、机房切换一致性保障、异常处理等等。同时,相关的运维设施、监控体系也要能跟得上才行。

    宏观上需要考虑业务(微服务部署、依赖、拆分、SDK、Web 框架)、基础设施(服务发现、流量调度、持续集成、同步中间件、自研存储),微观上要开发各种中间件,还要关注中间件的高性能、高可用、容错能力,其复杂度之高,只有亲身参与过之后才知道。

    我曾经有幸参与过,存储层同步中间件的设计与开发,实现过「跨机房」同步 MySQL、Redis、MongoDB 的中间件,踩过的坑也非常多。当然,这些中间件的设计思路也非常有意思,有时间单独分享一下这些中间件的设计思路。

    值得提醒你的是,只有真正理解了「异地双活」,才能彻底理解「异地多活」。在我看来,从同城双活演变为异地双活的过程,是最为复杂的,最核心的东西包括,业务单元化划分、存储层数据双向同步、最上层的分片逻辑,这些是实现异地多活的重中之重。

    希望我分享的架构经验,对你有所启发。

    在写这篇文章时,我又仔细阅读了阿里、饿了么、微博等公司,关于异地多活架构设计的相关资料,如果你想更深入地学习异地多活架构,可以在我的公众号后台回复「异地多活」获取。


    我是 Kaito,是一个对于技术有思考的资深后端程序员,在我的文章中,我不仅会告诉你一个技术点是什么,还会告诉你为什么这么做?我还会尝试把这些思考过程,提炼成通用的方法论,让你可以应用在其它领域中,做到举一反三。

    如果我的文章对你有所帮助,还请帮忙点赞、在看、转发一下,你的支持会激励我输出更高质量的文章,非常感谢!

    精选文章推荐:
    缓存和数据库一致性问题,看这篇就够了
    读懂Redis源码,我总结了这7点心得
    我做了一场技术演讲,学到了这些东西


    我是 Kaito,点击这里了解我。这里不仅有硬核的技术干货,还有我对技术的思考和感悟,欢迎关注我的公众号,期待和你一起成长!
    你还可以扫描下面的二维码,添加我的个人微信,交流技术、围观朋友圈,一起学习和成长~

    以上是关于100Wqps异地多活,得物是怎么架构的?的主要内容,如果未能解决你的问题,请参考以下文章

    全球异地多活架构设计: 数据层的支持

    牛人说11.11特辑 | 异地多活-广域分布式架构的开端

    分布式系统架构-----异地多活架构

    分布式系统架构-----异地多活架构

    全球异地多活架构设计: Why and How

    异地多活架构的3种模式