异地多活的概念以及为什么要做异地多活这里就不进行概述了。概念性的很多,像什么同城双活、两地三中心、三地五中心等等概念。如果有对这些容灾架构模式感兴趣的可以阅读下这篇文章进行了解: 《浅谈业务级灾备的架构模式》。
阅读本篇文章之前,我们先明确一下背景,这样大家后续在看的时候就不会产生困惑。
得物多活改造一期目前有两个机房,分别是机房A和机房B。文章中大部分图中都会有标识,这就说明是两个不同的机房。
A机房我们定义为中心机房,也就是多活上线之前正在使用的机房。如果说到中心机房那指的就是A机房。另一个B机房,在描述的时候可能会说成单元机房,那指的就是B机房。
单元化简单点我们直接就可以认为是一个机房,在这个单元内能够完成业务的闭环。比如说用户进入APP,浏览商品,选择商品确认订单,下单,支付,查看订单信息,这整个流程都在一个单元中能够完成,并且数据也是存储在这个单元里面。
做单元化无非就两个原因,容灾和提高系统并发能力。但是也得考虑机房建设的规模和技术,硬件等投入的成本。具体的就不多讲了,大家大概理解了就行。
了解改造点之前我们先来看下目前单机房的现状是什么样子,才能更好的帮助大家去理解为什么要做这些改造。
如上图所示,客户端的请求进来会先到SLB(负载均衡),然后到我们内部的网关,通过网关再分发到具体的业务服务。业务服务会依赖Redis, Mysql, MQ, Nacos等中间件。
既然做异地多活,那么必然是在不同地区有不同的机房,比如中心机房,单元机房。所以我们要实现的效果如下图所示:
大家看上面这张图可能会感觉很简单,其实也就是一些常用的中间件,再多一个机房部署罢了,这有什么难度。如果你这样想我只能说一句: 格局小了啊。
用户的请求,从客户端发出,这个用户的请求该到哪个机房,这是我们要改造的第一个点。
没做多活之前,域名会解析到一个机房内,做了多活后,域名会随机解析到不同的机房中。如果按照这种随机的方式是肯定有问题的,对于服务的调用是无所谓的,因为没有状态。但是服务内部依赖的存储是有状态的呀。
我们是电商业务,用户在中心机房下了一个单,然后跳转到订单详情,这个时候请求到了单元机房,底层数据同步有延迟,一访问报个错:订单不存在。 用户当场就懵了,钱都付了,订单没了。
所以针对同一个用户,尽可能在一个机房内完成业务闭环。为了解决流量调度的问题,我们基于OpenResty二次开发出了DLB流量网关,DLB会对接多活控制中心,能够知道当前访问的用户是属于哪个机房,如果用户不属于当前机房,DLB会直接将请求路由到该用户所属机房内的DLB。
如果每次都随机到固定的机房,再通过DLB去校正,必然会存在跨机房请求,耗时加长。所以在这块我们也是结合客户端做了一些优化,在DLB校正请求后,我们会将用户对应的机房IP直接通过Header响应给客户端。这样下次请求的时候,客户端就可以直接通过这个IP访问。
如果用户当前访问的机房挂了,客户端需要降级成之前的域名访问方式,通过DNS解析到存活的机房。
当用户的请求达到了单元机房内,理论上后续所有的操作都是在单元机房完成。前面我们也提到了,用户的请求尽量在一个机房内完成闭环,只是尽量,没有说全部。
这是因为有的业务场景不适合划分单元,比如库存扣减。所以在我们的划分里面,有一个机房是中心机房,那些不做多活的业务只会部署在中心机房里面,那么库存扣减的时候就需要跨机房调用。
请求在中心机房,怎么知道单元机房的服务信息?所以我们的注册中心(Nacos)要做双向同步,这样才能拿到所有机房的服务信息。
当我们的注册信息采用双向复制后,对于中心服务,直接跨机房调用。对于单元服务会存在多个机房的服务信息,如果不进行控制,则会出现调用其他机房的情况,所以RPC框架要进行改造。
请求到中心机房,会优先调用中心机房内的服务,如果中心机房无此服务,则调用单元机房的服务,如果单元机房没有此服务则直接报错。
请求到单元机房,那么说明此用户的流量规则是在单元机房,接下来所有的RPC调用都只会调用单元机房内的服务,没有服务则报错。
请求到单元机房,那么直接调用中心机房的服务,中心机房没有服务则报错。请求到中心机房,那么就本机房调用。
业务方需要对自己的接口(Java interface)进行标记是什么类型,通过@HARoute加在接口上面。标记完成后,在Dubbo接口进行注册的时候,会把路由类型放入到这个接口的元数据里面,在Nacos后台可以查看。后面通过RPC调用接口内部所有的方法都会按照标记类型进行路由。
如果标记为单元路由,目前我们内部的规范是方法的第一个参数为小写的long buyerId,RPC在路由的时候会根据这个值判断用户所在的机房。
路由逻辑如下:
除了RPC直接调用的接口,还有一大部分是通过Dubbo泛化过来的,这块在上线后也需要将流量切到UnitApi,等老接口没有请求量之后才能下线。
接口进行分类,之前没有多活的约束,一个Java interface中的方法可能各种各样,如果现在你的interface为单元路由,那么里面的方法第一个参数都必须加buyerId,其他没有buyerId场景的方法要挪出去。
业务层面调整,比如之前查询订单只需要一个订单号,但是现在需要buyerId进行路由,所以接入这个接口的上游都需要调整。
请求顺利的到达了服务层,接下来要跟数据库打交道了。数据库我们定义了不同的类型,定义如下:
此库为单元库,会同时在两个机房部署,每个机房都有完整的数据,数据采用双向同步。
此库为中心库,只会在中心机房部署。
此库为中心单元库,会同时在两个机房部署,中心可以读写,其他机房只能读。中心写数据后单向复制到另一个机房。
目前各个业务方用的都是客户端形式的Sharding中间件,每个业务方的版本还不一致。在多活切流的过程中需要对数据库禁写来保证业务数据的准确性,如果没有统一的中间件,这将是一件很麻烦的事情。
所以我们通过对ShardingSphere进行深度定制,二次开发数据库代理中间件 彩虹桥。各业务方需要接入彩虹桥来替换之前的Sharding方式。在切换过程中,如何保证稳定平滑迁移,出问题如何快速恢复,我们也有一套成功的实践,大家可以看下我之前写的这篇文章 《客户端分片到Proxy分片,如丝般顺滑的平稳迁移》,里面有实现方式。
单元化的库,数据层面会做双向同步复制操作。如果直接用表的自增ID则会出现下面的冲突问题:
这个问题可以通过设置不同机房id有不同的自增步长来解决,但比较麻烦,后续可能会增加更多的机房。我们采用了一种一劳永逸的方式,接入全局唯一的分布式ID来避免主键的冲突。
目前,接入分布式ID有两种方式,一种是应用内通过基础架构提供的jar包接入,具体逻辑如下:
另一种就是在彩虹桥中对具体的表配置ID的生成方式,支持对接分布式ID服务。
在Dao层对表进行操作的时候,会通过ThreadLocal设置当前方法的ShardingKey,然后通过Mybatis拦截器机制,将ShardingKey通过Hint的方式放入SQL中,带给彩虹桥。彩虹桥会判断当前的ShardingKey是否属于当前机房,如果不是直接禁写报错。
这里跟大家简单的说明下为什么切流过程中要禁写,这个其实跟JVM的垃圾回收有点相似。如果不对操作禁写,那么就会不断的产生数据,而我们切流,一定要保证当前机房的数据全部同步过去了之后才开始生效流量规则,否则用户切到另一个机房,数据没同步完,就会产生业务问题。除了彩虹桥会禁写,RPC框架内部也会根据流量规则进行阻断。
连接模式的定义有两种,分别是中心和单元。
如果应用的数据源指定了连接模式为中心,那么在中心机房可以正常初始化数据源。在单元机房不会初始化数据源。
如果应用的数据源指定了连接模式为单元,那么在中心机房和单元机房都可以正常初始化数据源。
这里解释下为什么要有连接模式这个 设计 ?
在我们的项目中,会出现同时连接2个库的情况,一个单元库,一个中心库。如果没有连接模式,上层代码是一份,这个项目会在中心和单元两个机房同时部署,也就是两个地方都会去创建数据源。
但实际上,我的中心库只需要在中心机房连接就可以了,因为中心库所有的操作都是中心接口,流量必定会走中心,我在单元机房去连接是没有意义的。另一个问题就是我不需要在单元机房维护中心库的数据库信息,如果没有连接模式,那么单元机房的彩虹桥也必须要有中心库的信息,因为项目会进行连接。
如果接口标记成了单元接口,那么只能操作单元库。在以前没有做多活改造的时候,基本上没有什么中心和单元的概念,所有的表也都是放在一起的。多活改造后,我们会根据业务场景对数据库进行划分。
划分后,中心库只会被中心机房的程序使用,在单元机房是不允许连接中心库。所以单元接口里面如果涉及到对中心库的操作,必定会报错。这块需要调整成走中心的RPC接口。
跟上面同样的问题,如果接口是中心的,也不能在接口里面操作单元库。中心接口的请求都会强制走到中心机房,如果里面有涉及到另一个机房的操作,也必须走RPC接口进行正确的路由,因为你中心机房不能操作另一个机房的数据库。
比如批量根据订单号进行查询,但是这些订单号不是同一个买家。如果随便用一个订单的买家作为路由参数,那么其他一些订单其实是属于另一个单元的,这样就有可能存在查询到旧数据的问题。
这样批量查询的场景,只能针对同一个买家可用,如果是不同的买家需要分批调用。
Redis在业务中用的比较多,在多活的改造中也有很多地方需要调整。对于Redis首先我们明确几个定义:
不做双向同步
Redis不会和数据库一样做双向同步,也就是中心机房一个Redis集群,单元机房一个Redis集群。每个机房的集群中只存在一部分用户的缓存数据,不是全量的。
Redis类型
Redis分为中心和单元,中心只会在中心机房部署,单元会在中心和单元两个机房部署。
多活改造之前,每个应用都有一个单独的Redis集群,多活改造后,由于应用没有进行单元化和中心的拆分,所以一个应用中会存在需要连接两个Redis的情况。一个中心Redis,一个单元Redis。
基础架构提供的Redis包需要支持多数据源的创建,并且定义通用的配置格式,业务方只需要在自己 的配置里面指定集群和连接模式即可完成接入。此处的连接模式跟数据库的一致。
具体的Redis实例信息会在配置中心统一维护,不需要业务方关心,这样在做机房扩容的时候,业务方是不需要调整的,配置如下:
spring.redis.sources.carts.mode=unit
spring.redis.sources.carts.cluster-name=cartsCuster
同时我们在使用Redis的时候要指定对应的数据源,如下:
@Autowired
@Qualifier(RedisTemplateNameConstants.REDIS_TEMPLATE_UNIT)
private RedisTemplate redisTemplate;
数据库缓存场景,由于Redis不会双向同步,就会存在数据的不一致性问题。比如用户一开始在中心机房,然后缓存了一份数据。进行切流,切到单元机房,单元机房又缓存了一份数据。再进行切回中心机房的操作,此时中心机房里的缓存是旧的数据,不是最新的数据。
所以在底层数据变更的时候,我们需要对缓存进行失效操作,这样才能保证数据的最终一致性。单纯依靠缓存的失效时间来达到一致性不是一个合适的方案。
这里我们的方案是采用订阅数据库的binlog来进行缓存的失效操作,可以订阅本机房的binlog,也可以订阅其他机房的binlog来实现所有机房的缓存失效。
在接入新的Redis Client包后,测试环境出现了老数据的兼容问题。大部分应用都没问题,有个别应用虽然用了统一的底层包,但是自己定制了序列化方式,导致Redis按新的方式装配后没有用到自定义的协议,这块也是进行了改造,支持多数据源的协议自定义。
目前项目中的分布式锁是基于Redis实现,当Redis有多个数据源之后,分布式锁也需要进行适配。在使用的地方要区分场景,默认都是用的中心Redis来加锁。
但是单元接口里面的操作都是买家场景,所以这部分需要调整为单元Redis锁对象进行加锁,这样能够提高性能。其他的一些场景有涉及到全局资源的锁定,那就用中心Redis锁对象进行加锁。
请求到达服务层后,跟数据库和缓存都进行了交互,接下来的逻辑是要发一条消息出去,其他业务需要监听这个消息做一些业务处理。
如果是在单元机房发出的消息,发到了单元机房的MQ中,单元机房的程序进行消费,是没有问题的。但如果中心机房的程序要消费这个消息怎么办?所以MQ跟数据库一样, 也要做同步,将消息同步到另一个机房的MQ中,至于另一个机房的消费者要不要消费,这就要让业务场景去决定。
中心订阅指的是消息无论是在中心机房发出的还是单元机房发出的,都只会在中心机房进行消费。如果是单元机房发出的,会将单元的消息复制一份到中心进行消费。
普通订阅就是默认的行为,指的是就近消费。在中心机房发送的消息就由中心机房的消费者进行消费,在单元机房发送的消息就由单元机房的消费进行消费。
单元订阅指的是消息会根据ShardingKey进行消息的过滤,无论你在哪个机房发送消息,消息都会复制到另一个机房,此时两个机房都有该消息。通过ShardingKey判断当前消息应该被哪个机房消费,符合的才会进行消费,不符合的框架层面会自动ACK。
全单元订阅指的是消息无论在哪个机房发出,都会在所有的机房进行消费。
消息发送方,需要结合业务场景进行区分。如果是买家场景的业务消息,在发消息的时候需要将buyerId放入消息中,具体怎么消费由消费方决定。如果消费方是单元消费的话那么必须依赖发送方的buyerId,否则无法知道当前消息应该在哪个机房消费。
前面提到了中心订阅,单元订阅,普通订阅,全单元订阅多种模式,到底要怎么选就是要结合业务场景来定的,定好后在配置MQ信息的时候指定即可。
比如中心订阅就适合你整个服务都是中心的,其他机房都没部署,这个时候肯定适合中心订阅。比如你要对缓存进行清除,就比较适合全单元订阅,一旦数据有变更,所有机房的缓存都清除掉。
这个点其实根据多活没有多大关系,就算不做多活,消息消费场景,肯定是要做幂等处理的,因为消息本身就有重试机制。单独拎出来说是因为在多活场景下除了消息本身的重试会导致消息重复消费,另外在切流的过程中,属于切流这部分用户的消息会被复制到另一个机房重新进行消费,在重新消费的时候,会基于时间点进行消息的重新投放,所以有可能会消费到之前已经消费了的消息,这点必须注意。
再解释下为什么切流过程中会有消息消费失败以及需要复制到另一个机房去处理,如下图所示:
用户在当前机房进行业务操作后,会产生消息。由于是单元订阅,所以会在当前机房进行消费。消费过程中,发生了切流操作,消费逻辑里面对数据库进行读写,但是单元表的操作都携带了ShardingKey,彩虹桥会判断ShardingKey是否符合当前的规则,发现不符合直接禁写报错。这批切流用户的消息就全部消费失败。等到流量切到另一个机房后,如果不进行消息的重新投递,那么这部分消息就丢失了,这就是为什么要复制到另一个机房进行消息的重新投递。
上面讲到了在切流过程中,会将消息复制到另一个机房进行重新消费,然后是基于时间点去回放的,如果你的业务消息本身就是普通的Topic,在消息回放的时候如果同一个场景的消息有多条,这个顺序并不一定是按照之前的顺序来消费,所以这里涉及到一个消费顺序的问题。
如果你之前的业务场景本身就是用的顺序消息,那么是没问题的,如果之前不是顺序消息,这里就有可能有问题,我举个例子说明下:
有个业务场景,触发一次功能就会产生一条消息,这个消息是用户级别的,也就是一个用户会产生N条消息。消费方会消费这些消息进行存储,不是来一次消息就存储一条数据,而是同一个用户的只会存储一条,消息里面有个状态,会根据这个状态进行判断。
比如下面的消息总共投递了3条,按正常顺序消费最终的结果是status=valid。
10:00:00 status=valid
10:00:01 status=invalid
10:00:02 status=valid
如果消息在另一个机房重新投递的时候,消费顺序变成了下面这样,最终结果就是status=invalid。
10:00:00 status=valid
10:00:02 status=valid
10:00:01 status=invalid
解决方案有下面几种:
Job在我们这边用的不多,而且都是老的逻辑在用,只有几个凌晨统计数据的任务,新的都接入了我们自研的TOC(超时中心)来管理。
由于Job是老的一套体系,目前也只有个位数的任务在执行,所以在底层框架层面并没有支持多活的改造。后续会将Job的逻辑迁移到TOC中。
所以我们必须在业务层面进行改造来支持多活,改造方案有两种,分别介绍下:
但是这种方式需要去梳理Job里的数据操作,如果有对中心库操作的,没关系,本身就是在中心机房跑。如果有对单元库操作的,需要调整为走RPC接口。
TOC是我们内部用的超时中心,当我们有需求需要在某个时间点进行触发业务动作的时候都可以接入超时中心来处理。
举个例子:订单创建后,N分钟内没有支付就自动取消。如果业务方自己实现,要么定时扫表进行处理,要么用MQ的延迟消息。有了TOC后,我们会在订单创建之后,往TOC注册一个超时任务,指定某个时间点,你要回调我。在回调的逻辑逻辑里去判断订单是否已完成支付,如果没有则取消。
在注册超时中心任务的时候,业务方需要识别任务是否要符合单元化的标准。如果此任务只是对中心数据库进行操作,那么这个任务回调在中心机房即可。如果此任务是对单元数据库操作,那么在注册任务的时候就需要指定buyerId,超时中心在触发回调的时候会根据buyerId进行路由到用户所属机房进行处理。
目前超时中心是只会在中心机房进行部署,也就是所有的任务都会在中心机房进行调度。如果任务注册的时候没有指定buyerId,超时中心在回调的时候就不知道要回调哪个机房,默认回调中心机房。要想让超时中心根据多活的路由规则进行回调,那么注册的时候必须指定buyerId。
阅读完上面的改造内容,相信大家还有一个疑惑点就是我的服务该怎么划分呢?我要不要做单元化呢?
首先要根据整个多活的一个整体目标和方向去梳理,比如我们的整体方向就是买家交易的核心链路必须实现单元化改造。那么这整个链路所有依赖的上下游都需要改造。
用户浏览商品,进入确认订单,下单,支付,查询订单信息。这个核心链路其实涉及到了很多的业务域,比如:商品,出价,订单,支付,商家等等。
在这些已经明确了的业务域下面,可能还有一些其他的业务域在支撑着,所以要把整体的链路都梳理出来,一起改造。当然也不是所有的都必须做单元化,还是得看业务场景,比如库存,肯定是在交易核心链路上,但是不需要改造,必须走中心。
中心服务只会在中心机房部署,并且数据库也一定是中心库。可以对整个应用进行打标成中心,这样外部访问这个服务的接口时都会被路由到中心机房。
单元服务会在中心机房和单元机房同时部署,并且数据库也一定是单元库。单元服务是买家维度的业务,比如确认订单,下单。
买家维度的业务,在接口定义上,第一个参数必须是buyerId,因为要进行路由。用户的请求已经根据规则进行分流到不同的机房,只会操作对应机房里面的数据库。
中心单元服务也就是说这个服务里面既有中心的接口也有单元的接口。并且数据库也是有两套。所以这种服务其实也是要在两个机房同时部署的,只不过是单元机房只会有单元接口过来的流量,中心接口是没有流量的。
一些底层的支撑业务,比如商品,商家这些就属于中心单元服务。支撑维度的业务是没有buyerId的,商品是通用的,并不属于某一个买家。
而支撑类型的业务底层的数据库是中心单元库,也就是中心写单元读,写请求是在中心进行,比如商品的创建,修改等。操作后会同步到另一个机房的数据库里面。这样的好处就是可以减少我们在核心链路中的耗时,如果商品不做单元化部署,那么浏览商品或者下单的时候查询商品信息都必须走中心机房进行读取。而现在则会就近路由进行接口的调用,请求到中心机房就调中心机房的服务,请求到单元机房就调单元机房的服务,单元机房也是有数据库的,不需要跨机房。
从长远考虑,还是需要进行拆分,把中心的业务和单元的业务拆开,这样会比较清晰。对于后面新同学在定义接口,操作数据库,缓存等都有好处,因为现在是混合在一起的,你必须要知道当前这个接口的业务属于单元还是中心。
拆分也不是绝对的,还是那句话得从业务场景出发。像订单里面的买家和卖家的业务,我觉得可以拆分,后续维护也比较方便。但是像商品这种,并不存在两种角色,就是商品,对商品的增删改成在一个项目中也方便维护,只不过是要进行接口的分类,将新增,修改,删除的接口标记为中心。
前面我们也提到了再切流过程中,会禁写,会复制MQ的消息到另一个机房重新消费。接下来给大家介绍下我们的切流方案,能够帮助大家更深刻的理解整个多活的异常场景下处理流程。
当需要切流的时候,操作人员会通过双活控制中心的后台进行操作。切流之前需要先进行已有流量的清理,需要下发禁写规则。禁写规则会下发到中心和单元两个机房对应的配置中心里面,通过配置中心去通知需要监听的程序。
彩虹桥会用到禁写规则,当禁写规则在配置中心修改后,彩虹桥能立马感知到,然后会根据SQL中携带的shardingkey进行规则的判断,看当前shardingkey是否属于这个机房,如果不属于则进行拦截。
当配置变更后会推送到彩虹桥,配置中心会感知到配置推送的结果,然后将生效的结果反馈给双活控制中心。
双活控制中心收到所有的反馈后,会将全部生效的时间点通过MQ消息告诉Otter。
Otter收到消息会根据时间点进行数据同步。
生效时间点之前的数据全部同步完成后会通过MQ消息反馈给双活控制中心。
双活中心收到Otter的同步完成的反馈消息后,会下发流量规则,流量规则会下发到DLB,RPC,彩虹桥。
后续用户的请求就会直接被路由到正确的机房。
相信大家看了这篇文章,对多活的改造应该有了一定的了解。当然本篇文章并没有把所有多活相关的改造都解释清楚,因为整个改造的范围实在是太大了。本篇主要讲的是中间件层面和业务层面的一些改造点和过程,同时还有其他的一些点都没有提到。比如:机房网络的建设,发布系统支持多机房,监控系统支持多机房的整个链路监控,数据巡检的监控等等。
多活是一个高可用的容灾手段,但实现的成本和对技术团队的要求非常高。在实现多活的时候,我们应该结合业务场景去进行设计,不是所有系统,所有功能都要满足多活的条件,也没有100%的可用性,有的只是在极端场景下对业务的一些取舍罢了,优先保证核心功能。
以上就是得物订单域在参与多活改造中的一些经验,分享出来希望可以对正在阅读的你有一些帮助。
文/YINJIHUAN
关注得物技术,做最潮技术人!