通用异地双活组件:
简单描述一下上图的数据流:
- 用户在Region A 对存储在DB cluster 中的数据进行修改
- Collector 拉取对应的oplog,然后发送给对端的 Replayer
- Replayer 根据oplog 还原DML语句,回放到Region B的DB Cluster
- Region A 和 Region B 的两个 DB cluster 达到最终一致
组件功能介绍
DB Cluster
DB cluster 就是普通的 数据库 集群。 比如 MySQL,redis,Abase 等等
Collector
- Collector 负责收集对应 DB cluster oplog,压缩发送给replayer
- Collector 根据gid 过掉过非本region 产生的oplog,防止回环
- 支持三种模式
- 全量
- 增量
- ALL(全量+增量)
Replayer
- Replayer 负责处理collector 发送过来的oplog 信息,还原为DML 语句进行回访
- 同时负责冲突检测和冲突解决
- 全量同步和增量同步时,
Conflict Resolver
- replayer 无法解决的冲突,发送到这里进行解决。 通常使用 CRDT 等算法进行解决(后文会详细介绍这个算法)
Consistency Checker
- Checker 模块负载在低峰期检查Region A 和 Region B 开启异地多活集群的数据一致性,计算数据一致率。
- 对齐集群的clusterTime
- ClusterTime 由mongod 负责推进和产生;并带有hash 来验证
- 两个集群hash 策略一致就可以,对齐
- 需要改驱动代码,设置clusterTime
检查策略
针对不同的数据量,采取不同的check 策略:
- 数据量大的集群
- 比对count是否一致
- 随机抽取一部分数据(5w)进行比对是否一致(相同主键,update timestamp 的差值超过一定时间,field 不一致则认为数据不一致)
- 数据量小的集群
- 比如小于5w,那么全量比较
防回环
为了解决回环问题,每一条 数据 都会带上 gid 信息,用来记录本次修改是哪一个reigon 修改的。
- 每一个数据 都会加上一个 gid 字段,表示数据本次修改是由哪一个region 修改的
- 每一次insert/update DB 需要修改 数据.gid 字段
- Replayer 回放时会自动上 数据.gid 字段
- 业务修改,由 DB 自动添加
防回环策略
- Oplog 的数据中带有 数据.gid 字段
- Collector 判断,如果 数据.gid 不是本地region,则不发送给replayer
CRDT
CRDT简介
先简单统一一下概念和名词:
- object: 可以理解为“副本”
- operation: 操作接口,由客户端调用,分为两种,读操作query和写操作update
- query: 查询操作,仅查询本地副本
- update: 更新操作,先尝试进行本地副本更新,若更新成功则将本地更新同步至远端副本
- merge: update在远端副本的合并操作
一个数据结构符合CRDT的条件是update操作和merge操作需满足交换律、结合律和幂等律,具体证明见[1],在此不做赘述。如果update操作本身满足以上三律,merge操作仅需要对update操作进行回放即可,这种形式称为op-based CRDT,最简单的例子是集合求并集。
如果update操作无法满足条件,则可以考虑同步副本数据,同时附带额外元信息,通过元信息让update和merge操作具备以上三律,这种形式称为state-based CRDT。让元信息满足条件的方式是让其更新保持__单调__,这个关系一般被称为__偏序关系__。举一个简单例子,每次update操作都带上时间戳,在merge时对本地副本时间戳及同步副本时间戳进行比对,取更新的结果,这样总能保证结果最新并且最终一致,这种方式称为Last Write Wins:
有两点值得注意的地方:
- update操作无法满足三律,如果能将元信息附加在操作或者增量上,会是一个相对state-based方案更优化的选择
- 如果同步过程能确保exactly once的语义,幂等律条件是可以被放宽掉,比如说加法本身满足交换律结合律但不幂等,如果能保证加法操作只回放一次,其结果还是最终一致的。
有了以上的理论基础后,我们可以看看各种数据结构如何设计,才能满足CRDT,达到最终一致。
CRDTs一览
以下展示一些典型的CRDT数据结构的例子,每一种数据类型都会给出示意图,必要时给出伪代码说明,证明略过,有兴趣可参见[2]。
Counter
counter是最简单的例子,为了说明state-based和op-based的差异,在此分别给出两种形式的描述。
Op-based counter
counter的op-based形式支持两种写操作:increment和decrement,由于加法天然满足交换律和结合律,所以非常容易实现,直接转发操作即可:
但要注意的是加法不幂等,所以同步过程中需要保证不丢不重。
G-Counter (Grow-only Counter)
counter的state-based形式并非那么的显而易见,为了简化问题,我们先从一个只有increment的counter开始看起。
由于同步的是全量,如果每个副本单独进行累加,在进行merge的时候无法知道每个副本具体累加了多少,更不能简单的取一个max作为最终结果,比如A做一次INCR 1同时B做一次INCR 2,副本全量同步之后,A和B都取max以2做为结果并最终一致,但正确的结果应该是3。
所以一种可行的方式是在每个副本上都使用一个数组保留其它所有副本的值,update时只操作当前副本在数组中对应项即可,merge时对数组每一项求max进行合并,query时返回数组的和,即为counter的当前结果。
update increment()
let g = myID()
P[g] := P[g] + 1
query value(): integer v
let v = sum(P)
merge (X, Y): Z
let Z.P[i] = max(X.P[i], Y.P[i]) (i in [0, n - 1])
易见update和merge均能保证单调的递增,所以G-Counter是state-based CRDT。
PN-Counter
带有decrement的state-based CRDT也并非像G-Counter那样显而易见,带有减法之后,不能满足update时单调的偏序关系。 所以正确的方式是构造两个G-Counter,一个存放increment的累加值,一个存放decrement的累加值。
Register
register本质是一个string,仅支持一种写操作assign。并发assign是不存在交换律的,所以需要考虑附加上偏序关系。
Last-Writer-Wins Register (LWW Register)
一种简单的做法是后assign的覆盖先assign的(last write wins),方式是每次修改都附带时间戳,update时通过时间戳生成偏序关系,merge时只取较大时间戳附带的结果。示意图前文已经给出。
Set
Set一共有两种写操作,add和remove,多节点并发进行add和remove操作是无法满足交换律的, 会产生冲突:
所以必须附加一些额外信息,可以从一个只做添加的set开始看起。
Grow-Only Set (G-Set)
set的add操作本质上是求并,天然满足交换律、结合律和幂等律, 满足Op-based CRDT:
交换律: X U Y = Y U X
结合律: (X U Y) U Z = X U (Y U Z)
幂等律: X U X = X
2P-Set
考虑删除操作,思路和PN-Counter一致,使用两个G-Set, set A只负责添加,对于从set A中remove的元素不做实际删除,只是复制到set R中,如下:
query时如果元素在set A且不在set R中,则表示该元素存在。
query lookup(e): bool b
let b = (e in A && e not in R)
由于只同步操作,且两个set只添加不减少,易证其为op-based CRDT。但2P-Set十分不实用,一方面已经被删除的元素不能再次被添加,一方面删除的元素还会保留在原set中,占用大量空间。
LWW-element-Set
为了解决删除元素不能再次添加的问题,可以考虑给2P-Set中A和R的每个元素加一个更新时间戳,其它操作保持不变,只要在查询的时候做如下处理:
query lookup(e): bool b
let b = (t1 < t2): (e, t1) in A && (e, t2) not in R
一个更优化的实现是不要R集合,而A集合中每一个元素除了维护一个更新时间戳之外,还有一个删除标志位。
Observed-Remove Set (OR-Set)
还有一种想法不太相同的设计,核心思想是每次add(e)的时候都为元素e加一个唯一的tag,remove(e)将当前节点上的所有e和对应的tag都删除,这样在remove(e)同时其它节点又有并发add(e)的情况下e是能够最终保证添加成功,此种语义称为add wins。如图,A上做remove e时仅有A一个tag,所以在C收到A同步过来的remove时,只删除tag A,tag B保留e在C上仍然存在,最终ABC三个节点是一致的,都有e及tag B。
虽然在remove时看似存在并不能保证交换律的删除操作出现,但删除的元素是全局唯一的,所以并不破坏语义,故仍然是为CRDT。
ORSet相对来说是一种比较实用的结构,但实现上仍然有几个问题要解决:
- 重复add和remove的场景下会产生大量的tag,空间需要优化
- 在考虑空间优化的前提下如何生成全局唯一的tag
- 需要考虑如何进行垃圾回收
学术界有多篇论文都是在探讨对此种算法的优化。但OR-Set在实践中最严重的问题是一旦同步通道出现延迟或者中断,很可能出现用户认为早已删除掉的字段在同步恢复之后再次出现。从工程实践角度讲,更优化的方案是使用时间戳作为unique tag,好处是易保证唯一性,同时自带单调递增属性,重复删除添加时不会生成大量tag。
Kafka-Mirror
Mirror是一个分布式的消息同步服务,目前支持kafka→kafka、rmq->rmq之间的数据同步。Mirror集群部署在目标端,跨region消费数据后再写入同region相应的消息队列中。
\
可以将一个topic的数据mirror到多个topic中,也可以将多个topic的数据mirror到同一个topic中;创建mirror job时注意检查是否存在反向mirror,避免形成环路。
Mirror架构如上图所示,主要分为proxy和worker集群两部分。
Proxy
Proxy一般每个区域部署两台,一主一备,通过zk选主,每组proxy可以管理多个worker集群。目前Proxy主要提供以下几个功能:
- 对外提供RESTful的接口用于同步任务增删改查
- 监控告警:对mirror任务延迟、堵塞、失败及worker负载、宕机等异常状态进行监控并告警
- 任务调度
Worker
同步任务主要由Worker集群执行,为了数据尽量不重复且提高同步速度,worker集群一般会部署在目标集群侧。从某个集群的某个topic到另一个集群的某个topic的同步作业我们称为job,一个同步job会按照partition或messageQueue数拆分成多个task,由proxy根据负载分配到多个worker上并发执行。
Worker会配置一个固定的workerId,worker中的TaskManger根据workerId到abase上查询分配给该worker的所有task,然后根据task配置启动所有task或停止已删除的task。每个task主要可分为source与sink两部分,根据源与目标的不同会有对应的具体实现类。source用于从源端读取数据,sink用于向目标集群生产。
FlowControlMonitor定时到zk上查询限流配置,每个task根据当前流量与限流配置确定是否需要进行流量控制。
元数据存储
Mirror相关的数据会保存在zk和abase中,但二者保存的数据有所不同。
zk主要保存mirror集群相关的元数据:
- proxy主从节点配置
- mirror worker cluster配置
- job元数据及rebalance中间信息
abase主要保存mirror任务的配置数据:
- task配置信息
- partition的消费位置
- task的运行状态信息(速度、lag、流量等)