1. 背景
随着业务的快速发展,对于很多公司来说,构建于单地域的技术体系架构,会面临诸如下面的多种问题:
- 基础设施的有限性限制了业务的可扩展性。业务扩大到单个数据中心撑不住,主要机房已经不能再添加机器,但业务却不断要求扩展
- 机房、城市级别的故障灾害,影响服务的可持续性。整个机房级别的故障时有发生,每次都引起业务的不可用,对公司的形象与收入都造成严重的影响。如2015年杭州某数据中心光缆被挖断,造成某产品业务几个小时的中断,导致严重的损失
- 跨地域的访问,影响用户体验等。随着全球化脚步的逼近,试想用户客户端与服务一次交互,一次RTT最少需要10ms,若广州到北京网络延迟一般为40ms,当用户客户端需要与服务器产生多次交互时,对用户体验影响就很大,用户体验非常不友好。
OPPO互联网业务发展快速,已经扩展到全球,随之带来的是技术上的多机房架构,对数据的全球多机房同步在一致性、响应时间、吞吐等方面有更高要求。为了解决以上问题带来的影响,本文将从异地多活底层,数据层面探索。如何给上层业务提供一个安全、可靠、稳定的数据环境,让上层业务可以集中精力专注业务开发,减少对数据多活的关注。
1.1 面临的挑战
- 数据多地写入的冲突解决问题
- 远距离两地传输网络问题
- 同步过程中数据一致性问题
- 数据同步的幂等性问题
- 作为同步中心,关于数据复用问题
基于上述挑战,调研对比相关的几个主流开源产品,均有其对应的优劣。如某部分产品针对业务场景变化频繁的业务,数据复用问题无法解决,对源端数据造成巨大的压力。某部分产品针对冲突解决方案无法达成最终的一致状态等。最终我们基于开源产品的基础上自研 JinS 数据同步框架,全面解决上述问题。
1.2 多活原则
异地多活、同城双活、异地灾备等场景,上层业务有几个基本原则需要遵循:
- 业务内聚:每个完整的业务流程,需要在一个机房中完成,不允许跨机房调用。只有保证这样一个前提,才能保证数据没有延迟,业务数据的状态扭转足够快。
- 可用性优先:当发生故障切换机房时,优先保证系统可用。需容忍有限时间段内的数据不一致,在事后做修复
- 数据正确:在确保可用的基础下,需要避免数据出错,在发生切换或故障时,如果发现某些数据异常冲突导致无法覆盖,会阻塞至人工介入,保证数据的正确(后续可直接针对数据锁定跳过操作)
1.3 产品功能
当前产品已实现功能:
- 双向同步:支持数据源间的增量实时同步,轻松完成异地多活等应用场景
- 单向同步:支持MySQL的单向同步,MySQL -> Redis 异构同步等,帮助业务简便完成异地灾备,异构同步等场景
- 数据订阅:用于缓存通知、业务异步解耦,异构数据源的数据实时同步以及复ETL等多种业务场景
- 一致性校验:可完成MySQL端到端的数据一致性校验以及修复
- 数据迁移:可完成MySQL的数据迁移,异构迁移等场景
2. 一致性保障探索
我们将从三个维度的方向去探索数据同步一致性。首先通过架构设计,保障系统的稳定、可靠、可扩展。接着会通过当前业内通用的一致性方案,引出数据同步一致性的基本实现。最后通过具体方案实施的维度,来探索实施过程中的细节。
2.1 架构设计
上图为数据同步的整体架构图。主流程中分为订阅模块和消费模块。订阅模块通过dump协议从源端MySQL 中拉取数据,消费端通过与订阅模块的通讯,获取需要同步的数据,写入到目标端中。
-
订阅模块
订阅模块包含parser(dump拉取)、sink(过滤)、store(存储)、registry(注册)、monitor(监控)、protocol(协议)
-
消费模块
消费模块包含input(与订阅模块交互)、filter(过滤)、output(输出)、registry(注册)、monitor(监控)
-
Manager
管理平台模块,负责完成流程一体化。服务部署,分类,监控,告警等流程管理
-
Consul
注册中心,负责完成订阅、消费节点的服务自动注册,与prometheus完成监控自动发现,并于Manager模块完成kv告警指标存储
-
Consul template
通过consul kv实现告警规则文件生成,并交付至prometheus
-
Prometheus、Granfa、AlertManager
开源监控平台、开源监控平台可视化界面、开源告警平台
2.2 架构实践
由于数据同步中,数据一致性是重中之重。而随着业务的发展,软件需要不断的迭代。如何从架构层面平衡好其中的关系,使软件在快速迭代中还要保持稳定、可靠,是架构实施过程中需要考虑的问题。
在了解数据同步架构实践前,先了解一下微内核架构。
2.2.1 微内核架构
微内核架构,又称为插件化架构。例如Eclipse这类IDE软件、UNIX这类操作系统、淘宝APP客户端软件、甚至到Dubbo,采用的都是微内核架构。
微内核架构的设计核心:
- 插件管理(如何加载插件以及插件加载时机等)
- 插件连接(如何连接到核心系统,如Spring中的依赖注入,eclipse中的OSGI)
- 插件通信(如何完成数据的交互工作)
微内核架构,实际就是面向功能进行拆分的扩展性架构
- 核心系统负责具体业务功能无关的通用功能(如插件加载、插件通信等)
- 插件模块负责实现具体业务逻辑
2.2.2 数据同步架构
数据同步中,消费模块参考微内核架构,将各个模块插件化。registry、monitor、input、filter、output都实现插件可插拔功能。
以Output举例,参考Java SPI、Dubbo SPI机制,基于 “接口 + 策略模式 + 配置文件” 的形式,实现Output的扩展。
配置文件如下:
通过读取配置文件中name的类型,完成对应插件的初始化。主流程中只需要完成接口调用,即可完成插件的调用。
当前消费模块中,已全面实现插件化。订阅模块仍然有一部分未完成插件化改造。
2.3 一致性模型探索
章节2.1,2.2中从架构的维度来保证整体的稳定性和扩展性,平衡了软件开发周期中的扩展与稳定之间的关系。下面从实现一致性的角度来探索数据同步过程中如何达到数据的一致性。
2.3.1 分布式系统问题
随着摩尔定律遇到瓶颈,越来越多情况下要依靠分布式架构,才能实现海量数据处理能力和可扩展计算能力。如果分布式集群无法保证处理结果一致的话,那任何建立于其上的业务系统都无法正常工作。一致性问题是分布式领域最基础也是最重要的问题。
由于我们面临多节点的分布式架构,不可避免会出现上图中所描述的问题,而这些问题正是构成一致性问题的主要成因。
实现绝对理想的严格一致性代价是很大的,通常分为顺序一致性和线性一致性。
- 顺序一致性,笼统的说就是保证一个进程内的顺序性,多个节点之间的一致性
- 线性一致性,难度更大,让一个系统看起来像只有一个数据副本,所有操作都是原子性的,笼统说就是多个进程内也需要保证顺序性
类似Google Spanner,通过 原子时钟 + GPS 的 trueTime 方案,实现分布式数据库的线性一致性。
一致性往往是指分布式系统中多个副本对外呈现的数据状态,而共识算法,则是描述某一个状态达成一致结果的过程。因此我们需要区分,一致性描述的是结果的状态,共识只是实现某一状态一致的手段。分布式数据库在通过共识算法实现状态一致的前提下,还需要对多个状态进行顺序识别排序,这是一个相当复杂的过程。
对应的共识算法有paxos、raft、zab等。
由于当前做的是事后的数据复制,在各个机房做了对应commit的前提下,再做数据复制,类似跨机房的主从复制。因此无须采用类似分布式数据库实现数据的共识。没有了共识以及顺序的引入,整体实现就相对简单,只需要实现数据复制过程中的一致性即可。
2.3.2 CrashSafe的探索
我们参考 MySQL InnoDB 的 redo log 与 binlog 两个日志保证一致性,并实现对应crash safe,运用到数据同步保证一致性的场景中。
2.3.2.1 InnoDB CrashSafe保证
我们先设想一下,MySQL 可以通过binlog恢复到指定某一个时刻的数据,为什么binlog中的数据必定是你所想要的?
通过上图我们看到,binlog 日志是 MySQL server 层实现的,而 InnoDB 存储引擎自己也实现了自己的redo、undo 日志。我们暂时不考虑 InndoDB 是如何通过 redo、undo 日志实现事务的ACD特性。我们先考虑,MySQL 是如何实现 binlog 与 redo、undo 的一致性的。
当一条DML语句到达server层
- 先完成写 redo、undo,后写 binlog
数据写入 redo、undo 日志完成后,此时还未写入binlog,MySQL宕机重启,重启后由于InnoDB通过redo恢复数据,而由于binlog没有写入该数据,若以后通过binlog恢复数据,则造成数据不一致。
- 先完成写 binlog, 后写 redo、undo
数据写入binlog日志完成后,此时未写入redo、undo,MySQL宕机重启,重启后由于InnDB通过redo恢复数据,没有宕机前写入的数据,而以后通过binlog恢复数据,由于binlog已经写入该数据,导致数据不一致。
从上述两种情况看,无论先写入binlog还是redo log,都会造成数据的不一致。因此为了保证redo与binlog的数据一致性,MySQL采用2PC机制保证redo与binlog的一致性(具体2PC机制后续会有介绍),同时保证了两个日志的一致性后,redo log就可以实现其crash safe能力,无论写入在哪一刻宕机,都不会造成数据的不一致。
2.3.2.2 单向一致性
数据同步中,参照 MySQL 机制,通过 2PC 实现数据复制过程中的一致性,同时也实现了crash safe能力,如下图:
如上图所示,消费模块为2PC中的协调者,订阅模块为2PC中参与者
-
消费模块(协调者)通过rpc get请求到订阅模块
-
订阅模块(参与者)接收到消费模块的get请求后,将需要复制的数据发送给消费模块(协调者)完成prepare阶段,同时订阅模块会将此复制的数据写入内存中(redo log)
-
消费模块(协调者)接受到订阅模块(参与者)的数据后,即代表参与者已经认同该操作,消费模块(协调者)即可拿着该数据实现目标数据源的写入操作,若写入成功/失败,都将开启2PC的下一个阶段
-
消费模块(协调者)无论写入目标成功失败,都会返回给订阅模块(参与者,ack/rollback),订阅模块根据协调者的返回结果,决定内存中的redo log数据commit还是rollback,若commit,将该数据持久化
-
订阅模块(参与者)回复消费模块(协调者)成功完成2PC
- 由于2PC主要用于解决分布式事务问题,它是一种阻塞性协议,主要是为了保证数据一致性因而造成的阻塞。而复制的场景中,由于只有一个参与者,因此我们可以引入超时机制,而不会造成非阻塞引起的数据一致性问题
- redo log我们采用内存来实现,是因为MySQL的binlog中已经帮我们实现了对应的持久化存储,因此我们可以借助binlog该机制,简化我们的CrashSafe能力
2.3.2.3 双向一致性
通过2PC的机制,我们可以完成单向同步过程中的数据一致性以及CrashSafe能力。我们再来看看,双向同步中,由于有可能有两边同时修改同一条记录的场景,导致场景更加复杂,我们看如何解决双向过程中的一致性。
对于双向过程中,对同一记录的修改,由于某地的修改,在另外一个地方处于未可见的一段时间范围,那我们如何确认应该保留哪边修改的?
如上图所示,我们可以分为事前控制以及事后处理。
1、事前处理,通常采用共识算法来实现
流程图1
流程图2
我们看通过共识算法的上述两个流程图
- 流程图1中 A地包含共识算法中(paxos)的 Proposer、Acceptor,B地中只包含共识算法中的Learner,假设A地写入,从图中可得数据将做 一次Paxos时间 + 一次网络传输时间
- 流程图1中 A地包含共识算法中(paxos)的 Proposer、Acceptor,B地中只包含共识算法中的Learner,假设B地写入,从图中可得数据将做 一次Paxos时间 + 一次RTT网络传输时间
- 流程图2中,由于A机房的不断扩容,必然造成有一天无法再扩展,因而就会造成 Proposer、Acceptor跨机房,假设A地写入,从图中可得数据将做 一次Paxos时间 + 一次RTT网络传输时间
- 流程图2中,假设B地写入,从图中可得数据将做 一次Paxos时间 + 2次RTT网络传输时间
若北京广州机房网络延迟40ms情况下,通过paxos协议会造成数据写入延迟加大。当前MySQL本机房写入延迟基本在1ms内
2、事后处理,由于数据在各地commit以后才做数据处理,因此肯定会造成一段时间窗口的不一致,主要保证数据的最终一致性
由于CRDT方案需要数据结构的支持,我们不改造MySQL,因此该方案直接略过。
Trusted Source方案:当出现数据不一致时,永远以某一规则来覆盖数据(修改地、timestamp等)
问题如何判断数据不一致呢?业内通用方案会采用构建冲突池
从上图可以看出,如果某端由于延迟或者同步宕机造成构建冲突池失败,就会造成数据不一致。
单向回环方案:
将数据量较少端做一次环状同步,保证数据最终一致
从上述3个时序图可以得出,单向回环会保证数据最终一致性(图1),但是当某地同步延迟或宕机场景下,有可能造成数据的版本交替现象(图2)、版本丢失(图3)情况,但是最终两地数据一定保证一致。
注: 后续为了优化版本交替以及版本丢失的场景,会进行数据超过一定阈值溯源反查,提高同步效率以及双向同步过程中的全局控制锁来解决
3. 最佳实践
前文中介绍了架构以及一致性算法的保证,我们再来看看我们实际运用中,如何将方案更好的落地。
3.1 文件存储
我们知道,当前大部分开源产品,数据订阅、同步为了能更快同步,通常会采用全程不落地on fly同步,随着业务的变更频繁以及发展,有可能造成一个数据库实例需要有多个下游业务使用。这样就需要启多个订阅来实现对应功能。后果随着业务的臃肿,对数据库的压力越来越大。
扩展文件存储的方式,可以通过文件队列做下游业务的分发,减少对数据库的压力。
如上图EventStore模块,通过MMap文件队列,每个MMap文件对应一个索引文件。后续可以通过索引二分查询定位至具体文件位置。写入MMap文件队列为顺序写入。
涉及到文件的读写,就需要考虑到文件的并发控制。当前主流的并发控制有如下:
- 读写锁
- COW(Copy On Write)
- MVCC
由于我们的场景都是顺序读写,MVCC可以首先忽略。采用读写锁的机制理论上是最简单。因此第一版的文件队列采用的读写锁来实现,我们看如下效果:
从上面两个图可以看到,上图的为批量操作数据库,下图为单事务操作数据库。业务更多的场景在单事务操作数据库,性能严重的不足,只有800多的qps,读写锁造成的性能完全不满足需求。
因此我们通过改造,去除读写锁。利用ByteBuffer与MMapByteBuffer同时映射至同一块物理内存来去除读写锁,ByteBuffer负责写入操作,MMapByteBuffer负责读,实现读写分离,如下图所示:
最后通过ByteBuffer实现一个读写指针,完成物理内存的映射,映射后,MMapByteBuffer就可以顺理成章的读取对应写入的数据,而没有了读写锁了,读写指针代码如下:
由于ByteBuffer中包含HeapByteBuffer以及DirectByteBuffer,我们创建时采用DirectByteBuffer,防止数据流动造成过多的young gc。
最后我们通过利用Linux中断机制,来最后优化磁盘的写入操作。
每个JVM进程实际操作的地址都是虚拟内存,而虚拟内存实际会通过MMU与物理内存地址关联起来。当我们操作一个文件时,如果文件内容没有加载至物理内存,会产生一个硬中断(major page fault),若物理内存存在了,虚拟内存没有与物理内存关联,会产生一次软中断(minor page fault),而Linux中断关联,是以page的维度来发生关联的,一个page是4kb。因此我们在写入文件刷盘时,就可以充分利用中断的机制,以page的维度来刷盘,当达到一定的脏页后,我们再发生flush操作,减少硬中断的产生,代码如下:
相关优化完成后,我们再来看单事务的测试效果,基本可达到2W qps的写入:
由于当前一次顺序写入外,还需要写入索引文件,因此索引文件的性能造成整体写入性能不高,后续会继续针对这块持续优化。
3.2 relay log
有了上述的文件存储的优化后,我们再来看同步过程中的网络优化
上图是MySQL主从同步的流程,从节点会通过I/O线程读取binlog的内容写入中继日志(relay)中,实现I/O与SQL Thread的分离。让同步的流程更高,减少网络带来的影响。
参考 MySQL 的relay流程,数据同步也在消费模块中实现了relay log的模块来保证跨网传输的优化,原理大致类似。
在store中实现relay的机制,同时在上层读取时,用ringbuffer来做读取的缓存,提高读取效率。因为ABQ性能虽然高,但是ABQ在读写时,会有锁的竞争,因此两者性能差异较大,性能对比如下:
3.3 两阶段锁
在 InnoDB 事务中,行锁是在需要的时候加上的,加上使用完以后,也并非立刻释放,而是需要等到事务结束以后才会释放。如下图所示:
这样的机制会造成,如果事务需要锁多个行,最好的优化手段是把冲突、并发的锁尽量往后放,减少锁的时间,如下图:
同样是一个事务的操作,只是转换一下顺序,库存的行锁就减少了锁等待的时间。间接的提高了整体的操作效率。
基于两阶段锁的原理,为了减少锁等待的时间,数据同步在针对一个table,同一个pk的情况下,做了数据的合并操作,如将同一个pk的 insert、update语句,合并成一个insert语句等。合并完成后再进行同一个表的batch操作。
因为每个 DML 操作到达 MySQL需要经过server层的分析器去解析SQL,优化器去优化SQL等,通过batch,内部使用缓存的机制,减少了解析等操作,性能会达到很大的提升。
简单测试,insert 1000条记录,采用batch模式大概500ms,而非batch模式需要13000ms。
不过由于引入了batch,也会引入其他问题,譬如当数据库中存在唯一键、外键时,有可能造成数据DML失败。后续会针对这种情况再做深入优化。
3.4 性能指标
主要针对行业主流开源软件与商业产品与 JinS 数据同步框架的对比测试
结论:
- 对比开源产品,性能提高较为显著,基本达到一倍的性能提升。
- 对比商业产品,性能较低,大概降低30%,但是从测试结果看,稳定性比商业产品更高。
4. 未来规划
JinS 数据同步框架上线以来,已支撑众多OPPO业务线的底层数据传输场景,在可用性上达到99.999%,跨机房平均耗时达到秒级传输。解决了业务在不停服的前提下,完成数据迁移、跨地域的实时同步、实时增量分发、异地多活等场景,使业务能轻松构建高可用的数据库容灾架构。