在异地多活的实现中,数据可以在三个或更多的中心之间双向同步,这是解决真正的异地多活的核心技术。本文基于三中心、跨海外场景,分享了多中心容灾架构和实现方法,介绍了几种分布式ID生成算法,以及数据同步最终一致性的实现过程。一、背景为什么叫真正的异地多居?异地多住并不是一个新鲜词,但真正意义上的异地多住似乎还没有实现。一般有两种形式:一种是应用部署在同一个城市的两个或多个地方,对数据库进行多次读写(主要是为了保证数据的一致性)。这是一个统一的服务。每个单元的数据不是全量数据。如果一个单元发生故障,则不能切换到其他单元。目前,我们仍然可以看到双中心的形式。两个中锋的数据都很全,但是双倍和多倍还是有很大的差距。其实这主要是受限于数据同步能力。数据可以在3个或更多中心之间双向传输。同步是解决多Activity异地问题的核心技术。说到数据同步,就不得不提DTS(DataTransmissionService)。一开始阿里的DTS没有双向同步的能力。后来有了云版,就仅限于两个数据库之间的双向同步。我们以A-B-C的形式,开发了自己的数据同步组件。虽然我们不想重新发明轮子,但也没有办法。我们稍后会介绍一些实现细节。下面说说为什么需要多中心容灾。以我的CDN&Video云团队为例,首先是海外业务的需求。为了让海外用户就近访问我们的服务,我们需要提供一个海外中心。但是大部分业务还是以国内为主,所以我们需要在国内建设双中心,防止核心库挂掉,整个管控挂掉。与此同时,海外环境更为复杂。一旦海外中心出现故障,可以用国内中心代替。国内双中心还有一个很大的优势就是可以通过一些路由策略来分散单中心系统的压力。这种三中心、跨海外的场景应该是目前最难实现的。2.系统CAP面对这种全球性、跨地域的分布式系统,不得不说说CAP理论。为了能够在多个中心提供全数据的服务,必须解决Partitiontolerance(分区容错),但是根据CAP理论,Consistency(一致性)和Availability(可用性)只能满足一个。对于在线应用来说,可用性不言而喻,但面对这样的问题,最终一致性是最好的选择。三、设计原则1、数据分片选择一个数据维度进行数据分片,使得业务可以分别部署在不同的数据中心。主键需要设计成分布式ID的形式,这样在数据同步的时候就不会出现主键冲突的情况。下面介绍几种分布式ID生成算法。1)SnowFlake算法①算法说明+------------------------------------------------------------------------+|1BitUnused|41BitTimestamp|10BitNodeId|12BitSequenceId|+----------------------------------------------------------------------+最高位为符号位,始终为0,不可用。41位时间序列精确到毫秒级,41位的长度可以用69年。时间位的另一个重要作用是可以按照时间排序。10位机器ID,10位长度支持最多部署1024个节点。12位计数序列号,是一系列自增ID,可支持同一节点在同一毫秒内生成多个ID序列号,12位计数序列号支持每个节点生成4096个ID序列号每毫秒的数字。②算法总结优点:完全是无状态机,无网络调用,高效可靠。缺点:依赖于机器时钟。如果时钟错误,比如时钟回调,可能会产生重复的Id。容量有限制,41位的长度可以用69年,一般够用了。由于并发限制,单台机器每毫秒最多可以生成4096个ID。仅适用于int64类型的Id分配,不能使用int32位的Id。③适用场景可以使用一般非Web应用的Int64类型Id。为什么非web应用,为什么web应用不能用,因为JavaScript支持的最大整数是53位,超过这个数,JavaScript会失精度。2)RainDrop算法①算法描述为了解决JavaScript的精度损失问题,由Snowflake算法改造而来的53位分布式Id生成算法。+--------------------------------------------------------------------------+|11位未使用|32位时间戳|7位节点ID|14位序列ID|+-----------------------------------------------------------------------+最高11位为符号位,始终为0,无法解决lossJavaScript中的精度。32位时间序列精确到秒级,32位长度可以使用136年。7位机器ID,7位长度最多支持部署128个节点。14位计数序列号,是一系列自增的Id,可以支持同一个节点在同一秒内产生多个Id,14位计数序列号支持每个节点每秒产生16384个Id一台机器。②算法总结优点:完全是无状态机,无网络调用,高效可靠。缺点:依赖于机器时钟。如果时钟错误,比如时钟不同步或者时钟拨回,就会产生重复的Id。容量有限制,32位的长度可以用136年,一般够用了。并发限制,低于雪花。仅适用于int64类型的Id分配,不能使用int32位的Id。③适用场景一般web应用的int64类型的Id基本够用了。3)分区独立分配算法①算法描述通过将Id段分配给不同的单元进行独立管理。同一个单元内的不同机器然后通过共享redis在单元内集中分配。相当于为每个单元预先分配了一批Id,然后在每个单元内进行集中分配。比如int32的范围是-2147483648到2147483647,Id的范围是[1,2100000000],前两位代表region,那么每个region支持100000000(一亿)个资源,也就是Id的组成格式可以表示为[0-20][0-99999999]。即int32位可以支持20个单位,每个单位支持1亿个Id。②算法总结优点:区域间无状态,无网络调用,唯一性可靠。缺点:分区容量有限制,需要提前评估业务容量。不能从Id判断生成顺序。③适用场景适用于int32类型的Id分配,以及可评估单个区域容量上限的业务使用。4)集中分布算法①算法描述集中分布的方式可以是Redis或者ZooKeeper,也可以利用数据库的自增Id进行集中分布。②算法总结优点:全局递增可靠且唯一的Id无容量和并发限制缺点:增加系统复杂度,需要对中心服务的强依赖。③适用场景可以选择中心服务靠谱的场景,分区独立分配的业务场景不能使用其他int32类型。综上所述,每种分配算法都有其适用的场景,需要根据业务需求选择合适的分配算法。有几个因素需要考虑:Id类型是int64还是int32。业务容量和并发要求。是否需要与JavaScript交互。2.中心封闭,调用尽量在中心进行,尽量避免跨数据中心调用。一方面是为了用户体验,本地调用RT更短,另一方面是防止两个中心同时写入相同的数据,造成数据冲突和覆盖。通常可以选择一种或多种路由方式,比如ADNS基于地域的路由,通过Tengine基于用户属性的路由,或者通过sidecar的路由。具体的实现方法这里不再赘述。3.最终一致性前两个其实是在为最终一致性做铺垫。由于数据同步牺牲了部分实时性,我们需要对数据进行分区和关闭中心,以保证及时响应用户请求和数据安全。实时准确性。前面说到DTS支持并不完善,所以我实现了基于DRC(阿里内部数据订阅组件,类似于canal)的数据同步能力。下面介绍实现一致性的过程,中间也走了一些弯路。1)顺序接收DRC消息为了保证DRC消息的顺序接收,首先想到的是使用单机消费方式,而单机带来的问题就是数据传输效率是慢的。要解决这个问题,就涉及到并发能力。大家可能会想到表级别的并发,但是如果单表的数据变化很大,同样会出现性能瓶颈。这里我们实现了主键级别的并发能力,也就是说在同一个主键上,我们严格保持顺序,不同的主键可以并发同步,将并发能力提升了N个数量级。同时,单机消费的第二个问题是单点。所以我们要实现Failover。这里我们使用Raft协议进行多机选举和master请求。当一台机器挂掉后,剩下的机器会自动选出新的Leader来执行同步任务。2)消息跨单元传输为了很好的支持跨单元的数据同步,我们使用了MNS(阿里云消息服务)。MNS本身是一个分布式组件,不能满足消息的顺序。起初,为了保证强一致性,我采用了消息染色和还原的方法。具体实现如下图所示:通过实践,我们发现这种客户端排序是不可靠的,我们的系统不能无限期地等待一条消息。这里就涉及到了最终一致性的问题,继续在第3点讨论。其实对于顺序消息,RocketMQ是有顺序消息的,只是RocketMQ还没有实现跨单元的能力。对于数据同步来说,我们只需要保证最终的一致性即可。不需要保证强一致性。牺牲性能。同时,如果MNS消息没有消费成功,消息也不会丢失。只有当我们删除显示的消息时,消息才会丢失,所以消息终究会来。3)最终一致性由于MNS不能保证强序,而我们做的是数据同步,只要能保证最终一致性即可。2012年,CAP理论的作者EricBrewer写了一篇回顾CAP的文章,也提到C和A并不完全互斥。推荐使用CRDT保证一致性。CRDT(Conflict-FreeReplicatedDataType)是对各种基础数据结构的最终一致性算法的理论总结,可以按照一定规则自动合并,解决冲突,达到很强的最终一致性效果。通过查阅相关资料得知,CRDT在同步数据时要求我们满足交换律、结合律、幂等律。如果操作本身满足以上三个规律,合并操作只需要重放更新操作即可。这种形式称为基于操作的CRDT。如果操作本身不满足以上三个规律,则操作可以通过附加额外的元信息来满足以上三个规律。这种形式称为基于状态的CRDT。通过DRC的拆解,数据库操作分为插入、更新、删除三种。这三个操作无论哪两个不能满足交换律,都会发生冲突,所以我们在并发层面(主键)添加额外的信息,这里我们使用序号,也就是2中提到的染色过程,而这个过程被保留。主键是并发的,没有顺序。在接收消息的时候,我们不保证强序,采用LWW(LastWriteWins)的方式,也就是执行当前的SQL,放弃之前的SQL,这样就不用考虑交换问题了。同时我们会根据消息的唯一性(实例+单元+数据库+MD5(SQL))对每条消息进行幂等性处理,保证每条SQL不会被重复执行。对于结合律,我们需要分别分析每一个运算。insert插入不满足结合律,可能会出现主键冲突。我们把insert语句改成insertignore,但是这样一条记录在收到insert操作指令之前是不存在的,或者之前有delete操作。删除操作可能还没有到达。此时insertignore操作的返回结果为0,但是这次insert的数据可能和已有的记录内容不一致,所以这里我们把这个insert操作转换成update操作再执行一次。update更新操作自然满足结合律。但是这里还有一个特殊情况要考虑,就是执行结果为0,这说明这条语句之前肯定有insert语句,但是我们还没有收到这条语句。这时候我们就需要利用这条语句中的数据,将update语句转成insert再执行一次。deletedelete也很自然地满足结合律,不管之前进行过什么操作,只要执行了就可以了。在insert和update操作中,有一个转换的过程,这里有一个前提,就是从DRC得到的每条变化数据都是full-field。可能有人会说这里的转换可以用replaceinto代替。为什么replaceinto没有被使用?首先,乱序的情况很少,我们不是简单的复制数据,而是复制操作。对于DRC,replaceinto操作将被解析为update或insert。这样不能保证消息的唯一性,也不能实现防循环广播,所以不推荐。看下面的流程图可能就更清楚了:4.容灾架构多中心双向同步的同步组件。我们还可以制定一些快速恢复策略,比如快速移除一个中心。同时,还有一些细节需要考虑。例如,在移除一个中心的过程中,在被移除的中心的数据同步到其他中心之前,应该禁止写入操作,以防止短时间内出现双写。我们同步的时间是毫秒级的,所以影响很小。5.结束语架构需要不断演进。哪一个更适合你需要详细考虑。以上多中心架构及实现方式,欢迎讨论。BU内部已经使用了我们的数据同步组件hera-dts。数据同步的逻辑还是比较复杂的,尤其是双向同步的实现,涉及到断点续传、故障转移、防丢失数据、防报文重传、双向同步等很多细节问题同步循环复制。我们的同步组件在达到稳定版本之前也经历了一段时间的优化。
