一、背景

我们的产品需要支持 Multi-Geo 功能。

什么是Multi-Geo?简单的说就是:“将一个租户下不同用户/设备/组织等数据,分散存储在不同的地理位置的能力”,在同一个租下管理员可以配置任意用户的数据驻留地(Preferred Data Location简称PDL)。

该功能主要是解决跨国企业,数据合规存放的问题。支持同一个企业下,不用国家的用户,数据存放在不同的国家的机房。

Multi-Geo的功能涉及到几点核心能力。

  1. 数据的路由能力。比如,我们服务在CN收到一个User数据查询需求,首先我们要知道这个User是归属于CN还是i18n(国外)的Unit,然后再把请求转发给相应的Unit的服务。
  2. 数据的定位能力,管理员更新用户的PDL时候,我们需要把用户所有的数据(存量和增量)找出来,然后发送到新的数据驻留地。
  3. 数据传输的能力,数据传输主要包括存量数据和增量数据的传输过程,存量和增量的overlap怎么处理,业务上是否需要停写,什么时间点修改数据的Unit信息,让后续数据的增删改查请求转发到更新以后的Unit

二、数据路由

为了支持数据路由的能力,我们引入了Global MetaEdge Proxy两个组件。这个是之前做Unit互通就已经有了的组件,不是本文讨论重点。简单说下大概流程如下:

假设一个用户在CN的发起请求,拉取一个i18n的用户的资料,大概链路应该是这样

  1. 客户端Http调用查询GetUserInfo接口查询用户资料。
  2. HttpGateway收到用户的Http请求,转成Thrift请求,RPC调用User服务的GetUserInfo接口。
  3. User服务收到请求以后,会有一个通用的Cross-MiddleWare,它会提出IDL中的打了Cross标记的Tag,当前上下这个TagUserID字段,所以Cross-MiddleWare 会提取出 UserID,然后去Global Meta查询这个UserID的归属于哪个Unit
  4. Cross-MiddleWare查出这个UserID不属于当前Unit以后,它会设置Dst-UnitDst-Service,然后当前请求转发给EdgeProxy
  5. EdgeProxy取出Dst-Unit,然后把这个请求发送Dst-Unit对应的EdgeProxy
  6. Dst-UnitEdgeProxy 会取出Dst-Service,然后把请求转发给Dst-Service,这里就是转发给i18nUser服务。
  7. i18nUser服务发现当前请求是Cross过来的,不会再去请求Global Meta,会直接走本地的查询逻辑,查出User的数据返回给EdgeProxy
  8. EdgeProxy拿到Response以后,会走原路返回给客户端。

image.png

三、数据定位

数据定位能力是指,我们能快速在MySQLRedisAbase这些存储组件中找到归属于User所有的数据的能力。常用的解决方案有两种:正向查询定位、数据打标定位。

3.1 正向查询

一是正向查询,比如我们有一个UserID,我们可以查到User下面所有的Chat,再查到归属于Chat所有的Message,然后查到Message下面的ReactionFile等等资源,依次类推可以查到所有User相关的信息。

image.png

正向迁移的方案有几个弊端

  1. 并不是所有的场景都能正向查询,就比如文件转发场景,有两个表file_infofile_ref,每次转发的时候,会新产生一个file_key,并把file_keyparent_key关系记录到file_ref中。file_ref表索引只有file_key,如果想通过parent_key拉取改文件所有被转发的记录,是不支持的。
  2. 部分数据还需要解析内容才能得到,比如MessageFile关系,MessageContent是加密以后存在数据库中的,如果要拿到Message中的file_key,我们必须把内容取出来,解密,然后反序列化为PBStruct。这样对业务耦合太重,会对系统的扩展性和可维护性造成一定困难。

这个方案不够General,站在架构侧我们希望能够提供更General的方案,而不是去过多的关注业务的数据结构、数据层级。

3.2 数据打标

数据打标,就是有一个专门的数据打标系统,会对系统里面每条产生的数据打上标记(有个前提需要基础组件支持binlog),可以按自己的需求打上User标、Chat标等等,查询的时候,能够按自己想要的方式快速查询出来。打标方式如下:

image.png

3.2.1 MySQL Schema 打标规则描述

字段 描述
repo_name 数据库名
table_name 数据表名
index_keys 唯一索引列,用于存量数据的获取,比如Message表唯一索引是[“chat_id”,”message_id”],消费到Messagebinlog时候,我们会记录下这两个列的值。方便后面需要迁移数据的时候,我们能够快速定位到相关数据
need-replace-pk 是否需要替换PK字段,如果表的id字段是用的MySQL的自增id,所以插入时候可能会冲突,需要插入前替换掉
entity-type 实体类型,比如MessageChatFileReaction,每个数据都可以归属到一个实体类型,实体类型之间也有层级归属关系。
entity-id-field 哪一列是实体数据,比如chat表的id字段表示Chat这个实体数据

MySQL的数据打标匹配比较简单,收到MySQLBinlog,用DB+Table就能Matchbinlog对应的Schema,然后可以找到Schema里面的IndexKeysEntity数据,就能知道这个binlog表示的DataEntity数据。

3.2.2 NoSQL Schema 打标规则描述

字段 描述
repo_name 数据库名
pattern key的格式,如 {{env}}:chat_last_msg_id:{{message_id}}
entity-type 实体类型,比如Message
entity-id-field 哪一列是实体数据,比如上面pattern中的message_id

NoSQL的匹配相对要复杂一点,首先需要根据Repo的纬度构建压缩字典树radix_tree,然后通过key来匹配,找到key对应的Schema。有点类似Http请求的Path匹配,有点不同的是Path都是“/”结束,截取变量的话相对简单一些。

NoSQLKey匹配的时候,可能比Path要复杂一点,比如{{table_id}}_{{rev}}_{{rec_id}}{{table_id}}_{{rev}}_{{rec_id}}_{{level}tableStr_1000_abccc_1key是可以同时匹配上面两个的pattern的。

这个时候只能通过增加变量的限制条件,比如rec_id必须包含xx字符串,不包含xx字符串,变量必须是数字、变量长度固定是多少位的方式来做匹配。

3.2.3 打标流程如下

image.png

3.2.4 打标库选型

image.png

存储选型前,需要明确自己的数据量和需求。

  1. 存量数据数在百亿~千亿级别,数据总大小概在几百TB左右。
  2. 增量BinlogQPS200W左右, 需要打标的增量数据,预期有10W左右TPS左右。
  3. 写多读少,主要是打标迁移的时候才会查,还会有少量的删除操作。
  4. 对事务没有要求。
  5. 有一定的一致性要求,写入打标数据以后,需要能再预期的时间内查到。
数据库类型 常见数据库
关系型 MySQL、Oracle、DB2、SQLServer 等。
非关系型 Hbase、Redis、MongodDB 等。
行式存储 MySQL、Oracle、DB2、SQLServer 等。
列式存储 Hbase、ClickHouse 等。
分布式存储 Cassandra、Hbase、MongodDB 等。
键值存储 Memcached、Redis、MemcacheDB 等。
图形存储 Neo4J、TigerGraph 等。
文档存储 MongoDB、CouchDB 等

分别调研了公司内部几个代表性的存储

  1. 关系型,NDB,对标业界最流行的cloud nativeRDS AWS Aurora/Alibaba PolarDB,100%兼容MySQL,计算存储分离,独立扩缩计算/存储,成本较低。 DBA给的数据是单台机器20128G内存,最大写入TPS可以到 15~20K左右。
  2. 非关系型,Abase,基于RocksDB的分布式KV存储,支持Redis协议、极致高可用、低延迟、大容量的在线存储 & 缓存服务,一个小集群能支持几十万的写入。单库支持PB级别的数据存储。
  3. 列式存储,ClickHouse,适用于大批量的数据插入、基本无需修改现有数据、拥有许多列的大宽表、在指定的列进行聚合计算操作、快速执行的 SELECT 查询等场景。目前只支持从Kafka导入数据,且导入的数据,有一定的延迟(10分钟以内)才能查到。

3.2.4 打标方案总结

打标方案的好处是 方案更General一些,业务方只需要按照我们提供的Schema规则,来我们系统里面注册就好了。

缺点就是,增加了资源的开销,需要额外的存储。

还有一个就是,打标方案有一个假设,就是一定能从下往上查,比如可以从Message查到Chat,再从Chat查到归属的User,但是实际中还有少量表的数据是没办法这样往上查的。

所以这一部分逻辑,我们会在迁移的某个表的数据时候,会正向查出这个数据关联的子表数据。然后一起迁移走。

四、数据传输

4.1 业务实体

image.png

  • Biz Entity : 业务实体类型,是一个逻辑概念,比如一个用户是一个Biz Entity,或者一个租户是一个Biz Entity,暴露给管理员的最小迁移单元。
  • Locating Entity: 简称LE,用于判断数据归属Unit以及数据迁移的最小单元。本身可包含子业务实体。比如一个会话、一篇文档都可以认为是一个LE
  • Sub Entity: 简称SESub Enitty的归属Unit继承于Locating Entity并随Locating Entity一起迁移。

4.2 数据传输方案

4.2.1 存量+停写+增量

一、Locating Entity迁移状态修改

这一步主要是标记Locating Entity状态为迁移中,开始对Locating Entity存量数据扫描,与此同时记录所有Locating Entity相关的增量binlog数据。

二、存量数据同步

在打标库中,查出Locating Entity所有的打标数据,然后根据Schema信息,去业务Repo中查出所有数据,生成binlog发送到对端。

三、增量数据同步

存量数据同步完成以后,我们再开始同步“步骤一”开始记录的增量数据。

四、实时同步状态

发送完增量数据的瞬间,我们需要对这个Locating Entity加锁,然后修改当前Locating Entity的状态为Syncing状态,后续所有的增量binlog可以实时发送到Dst Unit

五、停写

在状态达到Syncing以后,且时间到了我们配置的某个“时间点”,我们会先判断这个LE能否开始停写(检查binlog消费是否有延迟等等),如果可以停写,则设置Locating Entity状态为“停写”。

停写即是暂停对一个Locating Entity的所有数据的写入和修改,主要为了规避以下问题:

  • 防止修改Locating Entity的归属Unit时,各服务读取的Locating Entity Unit 有短暂的不一致,这样造成数据会写入地不一致的问题
  • 保证剩余的增量数据全部同步到目标机房。防止迁移结束后,仍后剩余数据未迁移,亦或是当业务写入数据较快,导致迁移任务无法结束

停写可以发生在“数据层”和“业务层”。我们这里选取“业务层”停写的方案。

优点:性能好,可以实现“fail-fast机制”,出错以后可以尽快返回,能减少无用RPC和数据写入请求。

缺点:需要各业务方识别出所有的“写”接口并引入停写机制,比较难统一处理和维护。

停写的纬度是 Locating Entity。业务方需要在自己服务中接入停写的MiddleWare,停写的 MiddleWare会根据Locating Entity的停写标记来判断是否要返回停写错误。

六、发送结束及标记给对端

设置了Locating Entity状态为停写以后10s,我们可以假设后续不会再产生Locating Entity的数据了,这个时候我们发送一个Last Locating Entity DataDst Unit,告知对端当前的Locating Entity数据已经发送完成。

PS: Locating Entity下的所有迁移数据是需要有序发送,有序消费。

七、Dst Unit 回写完所有数据以后 Ack

Dst Unit收到了Last Locating Entity Data消息以后,知道这个Locating Entity之前的所有数据都回写到业务DB成功了,这个时候会回一个Last Data AckSource Unit

八、修改 Locating Entity Unit 信息

收到Last Data Ack 以后表示所有数据对端都回写成功了,我们可以修改Locating EntityUnit信息为Dst Unit

九、关闭停写

等待所有服务Unit信息同步完成以后,然后关闭Locating Entity的停写标记。至此一个Locating Entity的迁移过程就完成了。

后续所有Locating Entity的读写操作都会写到新的Unit

image.png

image.png

4.2.1 停写+全量同步

改方案相当于上面方案的精简版,主要流程就是 停写 -> 数据迁移 -> 修改Unit -> 回复停写 -> 结束

PS:该方案适用于数据量不大场景,数据量大的场景,会导致停写时间过长,对用户体验不友好。

4.2.3 双写方案

双写方案,就是 存量数据迁移 -> 增量数据迁移 -> 实时同步 binlog 数据 -> 修改Unit -> 结束

双写的优点是,不用停写,业务对数据迁移过程无感知,存量数据同步完成以后,可以直接修改Locating Entity 的 Unit 信息,实现无缝切换。

双写的缺点是,数据可能会有一致性的问题。不太适用于对数据有强一致要求的业务。

4.3 跨Unit数据传输通道

这个是用的基建提供的一个Mirror服务。

Mirror是一个分布式的消息同步服务,目前支持kafka→kafkarmq->rmq之间的数据同步。Mirror集群部署在目标端,跨region消费数据后再写入同region相应的消息队列中。

简单说,就是在Source Unit写入一个 MQ MessageMirror会自动把这个数据同步到Dst Unit。可以在Dst Unit直接消费这个消息。

4.4 Binloger 模块

binloger 模块,主要负责binlog生成和回写。

image.png

五、架构总览

image.png

六、数据迁移中踩的坑

6.1 MySQL 数据迁移时区问题

因为国内和国外时区不一样,MySQL库里面时间都是字符串格式存储,所以传输过程中可能有些问题,详见 MySQL DateTime和Timestamp时区问题

6.2 PK Duplicate Error 问题

本质问题,是业务方写入数据的时候ID用的自增ID(或者自己Local方式生成的ID),DTS这边用的ID是一个中心发号器生成的ID。两个ID有一定的冲突概率(概率比我们想象中的要大)。
详见 MySQL 自增列 Duplicate Error 问题分析

6.3 INSERT … ON DUPLICATE

这个问题,其实根因就是上面6.2的问题,业务方的生成的IDDTS生成的ID冲突了。

然后我们这边最早插入的时候是用INSERT ... ON DUPLICATE,结果由于ID冲突(其实是两个条毫不相干的数据,只是因为ID生成方式没有保持一致,导致PK Duplicate),然后就走了Update逻辑,把业务方的其他数据给写脏了(血的教训)。

优化后的逻辑:

image.png

6.4 唯一索引列有 Null 值

我们正常打标流程,是记录数据的唯一索引,但是业务方有些表的唯一索引的列可能为空,这样就退化为普通索引,导致我们数据迁移的时候会有放大读和放大写。

假设表数据如下,(a,b)是唯一索引

id a b c
1 1 NULL ————-
2 1 NULL ————-
3 1 NULL ————-
4 1 NULL ————-
5 1 NULL ————-

我们这边对这5条数据,DTS会打5个标,打标的数据如下table=xx, idx_data = 1,NULL, 这样我们用5条打标数据会查出25条数据(因为一个打标数据可以查出5条)。发送到对端以后,对端会直接写入25条数据,这个数据重复很多的时候,会导致放大的很厉害,对DB读写性能有一定影响。

优化方式,我们迁移过程中会用table+idx_data做个去重。保证相同的打标数据只会查出一次。

七、名词解释

  • Unit:一个功能自封闭的部署单元,可以为用户提供完整的产品功能。Unit之间的数据存储是隔离的。一个Unit可以包含多个IDC。在本文上下文中可以假设有CNi18n(国际化)两个Unit
  • Tenant:租户,可以认为一个公司就是一个租户。
  • Global Meta:记录所有实体(Entity)归属的一个服务吗,还会记录实体一些其他Meta信息,比如是否处于停写状态
  • EdgeProxy :边缘代理,负责转发Unit之间的请求。
  • Binlog:描述数据实体内容的数据结构,当前上下文中就是指的一个PBStruct