一、背景
飞书需要支持 Multi-Geo 功能。
什么是Multi-Geo?简单的说就是:“将一个租户下不同用户/设备/组织等数据,分散存储在不同的地理位置的能力”,在同一个租下管理员可以配置任意用户的数据驻留地(Preferred Data Location简称PDL)。
该功能主要是解决跨国企业,数据合规存放的问题。支持同一个企业下,不用国家的用户,数据存放在不同的国家的机房。
Multi-Geo的功能涉及到几点核心能力。
- 数据的路由能力。比如,我们服务在
CN收到一个User数据查询需求,首先我们要知道这个User是归属于CN还是i18n(国外)的Unit,然后再把请求转发给相应的Unit的服务。 - 数据的定位能力,管理员更新用户的
PDL时候,我们需要把用户所有的数据(存量和增量)找出来,然后发送到新的数据驻留地。 - 数据传输的能力,数据传输主要包括存量数据和增量数据的传输过程,存量和增量的
overlap怎么处理,业务上是否需要停写,什么时间点修改数据的Unit信息,让后续数据的增删改查请求转发到更新以后的Unit。
二、数据路由
为了支持数据路由的能力,我们引入了Global Meta 和 Edge Proxy两个组件。这个是之前做Unit互通就已经有了的组件,不是本文讨论重点。简单说下大概流程如下:
假设一个用户在CN的发起请求,拉取一个i18n的用户的资料,大概链路应该是这样
- 客户端
Http调用查询GetUserInfo接口查询用户资料。 HttpGateway收到用户的Http请求,转成Thrift请求,RPC调用User服务的GetUserInfo接口。User服务收到请求以后,会有一个通用的Cross-MiddleWare,它会提取出IDL中的打了Cross标记的Tag,当前上下这个Tag是UserID字段,所以Cross-MiddleWare会提取出UserID,然后去Global Meta查询这个UserID的归属于哪个Unit。Cross-MiddleWare查出这个UserID不属于当前Unit以后,它会设置Dst-Unit和Dst-Service,然后当前请求转发给当前Unit的EdgeProxy服务。- 当前
Unit的EdgeProxy取出Dst-Unit,然后就会把这个请求发送Dst-Unit对应的EdgeProxy。 Dst-Unit的EdgeProxy会取出Dst-Service,然后把请求转发给Dst-Service,这里就是转发给i18n的User服务。i18n的User服务发现当前请求是Cross过来的,不会再去请求Global Meta,会直接走本地的查询逻辑,查出User的数据返回给EdgeProxyEdgeProxy拿到Response以后,会走原路返回给客户端。

三、数据定位
数据定位能力是指,我们能快速在MySQL、Redis、Abase这些存储组件中找到归属于User所有的数据的能力。常用的解决方案有两种:正向查询定位、数据打标定位。
3.1 正向查询
一是正向查询,比如我们有一个UserID,我们可以查到User下面所有的Chat,再查到归属于Chat所有的Message,然后查到Message下面的Reaction和File等等资源,依次类推可以查到所有User相关的信息。

正向迁移的方案有几个弊端
- 并不是所有的场景都能正向查询,就比如文件转发场景,有两个表
file_info和file_ref,每次转发的时候,会新产生一个file_key,并把file_key和parent_key关系记录到file_ref中。file_ref表索引只有file_key,如果想通过parent_key拉取改文件所有被转发的记录,是不支持的。 - 部分数据还需要解析内容才能得到,比如
Message和File关系,Message的Content是加密以后存在数据库中的,如果要拿到Message中的file_key,我们必须把内容取出来,解密,然后反序列化为PB的Struct。这样对业务耦合太重,会对系统的扩展性和可维护性造成一定困难。
这个方案不够General,站在架构侧我们希望能够提供更General的方案,而不是去过多的关注业务的数据结构、数据层级。
3.2 数据打标
数据打标,就是有一个专门的数据打标系统,会对系统里面每条产生的数据打上标记(有个前提需要基础组件支持binlog),可以按自己的需求打上User标、Chat标等等,查询的时候,能够按自己想要的方式快速查询出来。打标方式如下:

3.2.1 MySQL Schema 打标规则描述
| 字段 | 描述 |
|---|---|
| repo_name | 数据库名 |
| table_name | 数据表名 |
| index_keys | 唯一索引列,用于存量数据的获取,比如 Message 表唯一索引是[“chat_id”,”message_id”],消费到 Message 表 binlog 时候,我们会记录下这两个列的值。方便后面需要迁移数据的时候,我们能够快速定位到相关数据 |
| need-replace-pk | 是否需要替换PK字段,如果表的 id 字段是用的 MySQL 的自增 id ,所以插入时候可能会冲突,需要插入前替换掉 |
| entity-type | 实体类型,比如 Message 、 Chat 、 File 、 Reaction ,每个数据都可以归属到一个实体类型,实体类型之间也有层级归属关系。 |
| entity-id-field | 哪一列是实体数据,比如 chat 表的 id 字段表示 Chat 这个实体数据 |
MySQL的数据打标匹配比较简单,收到MySQL的Binlog,用DB+Table就能Match到binlog对应的Schema,然后可以找到Schema里面的IndexKeys和Entity数据,就能知道这个binlog表示的DataEntity数据。
3.2.2 NoSQL Schema 打标规则描述
| 字段 | 描述 |
|---|---|
| repo_name | 数据库名 |
| pattern | key的格式,如 :chat_last_msg_id: |
| entity-type | 实体类型,比如 Message |
| entity-id-field | 哪一列是实体数据,比如上面 pattern 中的 message_id |
NoSQL的匹配相对要复杂一点,首先需要根据Repo的纬度构建压缩字典树radix_tree,然后通过key来匹配,找到key对应的Schema。有点类似Http请求的Path匹配,有点不同的是Path都是“/”结束,截取变量的话相对简单一些。
做NoSQL的Key匹配的时候,可能比Path要复杂一点,比如{{table_id}}_{{rev}}_{{rec_id}}和{{table_id}}_{{rev}}_{{rec_id}}_{{level} 用tableStr_1000_abccc_1key是可以同时匹配上面两个的pattern的。
这个时候只能通过增加变量的限制条件,比如rec_id必须包含xx字符串,不包含xx字符串,变量必须是数字、变量长度固定是多少位的方式来做匹配。
3.2.3 打标流程如下

3.2.4 打标库选型

存储选型前,需要明确自己的数据量和需求。
- 存量数据数在
百亿~千亿级别,数据总大小概在几百TB左右。 - 增量
Binlog的QPS在200W左右, 需要打标的增量数据,预期有10W左右TPS左右。 - 写多读少,主要是打标迁移的时候才会查,还会有少量的删除操作。
- 对事务没有要求。
- 有一定的一致性要求,写入打标数据以后,需要能在预期的时间内查到。
| 数据库类型 | 常见数据库 |
|---|---|
| 关系型 | MySQL、Oracle、DB2、SQLServer 等。 |
| 非关系型 | Hbase、Redis、MongodDB 等。 |
| 行式存储 | MySQL、Oracle、DB2、SQLServer 等。 |
| 列式存储 | Hbase、ClickHouse 等。 |
| 分布式存储 | Cassandra、Hbase、MongodDB 等。 |
| 键值存储 | Memcached、Redis、MemcacheDB 等。 |
| 图形存储 | Neo4J、TigerGraph 等。 |
| 文档存储 | MongoDB、CouchDB 等 |
分别调研了公司内部几个代表性的存储
- 关系型,
NDB,对标业界最流行的cloud native的RDS AWS Aurora/Alibaba PolarDB,100%兼容MySQL,计算存储分离,独立扩缩计算/存储,成本较低。DBA给的数据是单台机器20核128G内存,最大写入TPS可以到15~20K左右。 - 非关系型,
Abase,基于RocksDB的分布式KV存储,支持Redis协议、极致高可用、低延迟、大容量的在线存储 & 缓存服务,一个小集群能支持几十万的写入。单库支持PB级别的数据存储。 - 列式存储,
ClickHouse,适用于大批量的数据插入、基本无需修改现有数据、拥有许多列的大宽表、在指定的列进行聚合计算操作、快速执行的SELECT查询等场景。目前只支持从Kafka导入数据,且导入的数据,有一定的延迟(10分钟以内)才能查到。
3.2.4 打标方案总结
打标方案的好处是 方案更General一些,业务方只需要按照我们提供的Schema规则,来我们系统里面注册就好了。
缺点就是,增加了资源的开销,需要额外的存储。
还有一个就是,打标方案有一个假设,就是一定能从下往上查,比如可以从Message查到Chat,再从Chat查到归属的User,但是实际中还有少量表的数据是没办法这样往上查的。
所以这一部分逻辑,我们会在迁移的某个表的数据时候,会正向查出这个数据关联的子表数据。然后一起迁移走。
四、数据传输
4.1 业务实体

Biz Entity: 业务实体类型,是一个逻辑概念,比如一个用户是一个Biz Entity,或者一个租户是一个Biz Entity,暴露给管理员的最小迁移单元。Locating Entity: 简称LE,用于判断数据归属Unit以及数据迁移的最小单元。本身可包含子业务实体。比如一个会话、一篇文档都可以认为是一个LESub Entity: 简称SE,Sub 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 Data 给Dst Unit,告知对端当前的Locating Entity数据已经发送完成。
PS: Locating Entity下的所有迁移数据是需要有序发送,有序消费。
**七、Dst Unit 回写完所有数据以后 **Ack
Dst Unit收到了Last Locating Entity Data消息以后,知道这个Locating Entity之前的所有数据都回写到业务DB成功了,这个时候会回一个Last Data Ack给Source Unit
八、修改 Locating Entity Unit 信息
收到Last Data Ack 以后表示所有数据对端都回写成功了,我们可以修改Locating Entity的Unit信息为Dst Unit。
九、关闭停写
等待所有服务Unit信息同步完成以后,然后关闭Locating Entity的停写标记。至此一个Locating Entity的迁移过程就完成了。
后续所有Locating Entity的读写操作都会写到新的Unit


4.2.1 停写+全量同步
该方案相当于上面方案的精简版,主要流程就是 停写 -> 数据迁移 -> 修改Unit -> 取消停写 -> 结束。
PS:该方案适用于数据量不大场景,数据量大的场景,会导致停写时间过长,对用户体验不友好。
4.2.3 双写方案
双写方案,就是 存量数据迁移 -> 增量数据迁移 -> 实时同步 binlog 数据 -> 修改Unit -> 结束
双写的优点是,不用停写,业务对数据迁移过程无感知,存量数据同步完成以后,可以直接修改Locating Entity 的 Unit 信息,实现无缝切换。
双写的缺点是,数据可能会有一致性的问题。不太适用于对数据有强一致要求的业务。
4.3 跨Unit数据传输通道
这个是用的基建提供的一个Mirror服务。
Mirror是一个分布式的消息同步服务,目前支持kafka→kafka、rmq->rmq之间的数据同步。Mirror集群部署在目标端,跨region消费数据后再写入同region相应的消息队列中。
简单说,就是在Source Unit写入一个 MQ Message,Mirror会自动把这个数据同步到Dst Unit。可以在Dst Unit直接消费这个消息。
4.4 Binloger 模块
binloger 模块,主要负责binlog生成和回写。

五、架构总览

六、数据迁移中踩的坑
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的问题,业务方的生成的ID和DTS生成的ID冲突了。
然后我们这边最早插入的时候是用INSERT ... ON DUPLICATE,结果由于ID冲突(其实是两个条毫不相干的数据,只是因为ID生成方式没有保持一致,导致PK Duplicate),然后就走了Update逻辑,把业务方的其他数据给写脏了(血的教训)。
优化后的逻辑:

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做个去重。保证相同的打标数据只会查出一次。
6.5 GC 问题

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