数据同步实践

2,630 阅读14分钟

导语

在实际业务中,对外提供查询是我们的核心功能。在C端场景,往往只根据ID查询/批量查询,而对于B端,通常由于场景比较复杂,需要多种条件进行筛选。随着功能的迭代,已有的实现面临了种种问题。例如一些复杂的存储场景,比如比特位存储、列转行以属性的方式存储等,单纯靠DB查询在筛选和范围查询的时候就显得捉襟见肘了;或者一些复杂查询场景,分页,需要跨多张业务表进行关联查询。

针对上述场景,借鉴当前主流的工具,我们综合考虑决定利用ES(elasticsearch)优秀的检索特性来实现我们的功能。

早期的业务系统通常都是用MySQL一类的关系型数据库进行数据存储,这里我们既然要利用ES的特性,就需要在原有基础上进行扩展,不可避免的会涉及到如何将业务数据同步到ES。

本文将重点介绍我们在项目中如何进行数据库到ElasticSearch的同步,以及同步中的一些细节与处理方案的分享。

关于同步

MySQL,ES,就好比武林上两门强力的外功拳法,咏春、洪拳,各有千秋,一个注重刚柔并济,气力消耗少(有事务性,结构化),一个大开大合,气场十足(查询速度快,易扩展);而业务数据就是内功,如何更好的把数据呈现到实际场景中,我们就需要充分利用不同数据存储的不同优点了。我们主要考虑将数据同步到 ES 上,这里的数据同步其本质上也是一种数据去规范化。

数据库规范化 是指关系数据库中通过一系列数据库范式来减少冗余、增强数据一致性的策略。

去除规范化的时机

数据去除规范化的动机多样,出现因数据复杂操作影响系统稳定性、业务响应/并发要求不满足等都是触发因素。

业务稳定性问题:SQL 偏向点查点写,相对简单,但是大范围数据删查、表关联、排序等实时操作,通过去规范化是较合理的选择。

复杂查询性能问题:应用经常涉及分页搜索、聚合、多维筛选、排序等操作,并常常带来性能问题。

数据同步主要功能

数据同步到es,一定绕不开数据收集数据转储这两部分

数据收集, 简单来说就是,我们的数据从哪里来。主流做法从原始业务操作获取数据或者直接mysql获取具体操作落库后的变更

数据转储, 我们知道数据从哪里来了,也知道数据要去到哪里(写入es),但是写入过程也需要进行一些加工。原有业务或数据写入到es中,因为关系型数据转到非关系型数据库存在一定差异性;其次es主要用于一些复杂查询场景 ,需要针对检索进行一定的调整,因此数据转存会根据一些映射关系,做部分转化再写入es

主流同步方案

当前主流同步方案可以分为以下四种:同步双写,异步双写,定时同步,binlog同步。

同步双写

由于对 MySQL 数据的操作也是在业务层完成的,所以在业务变更时同步操作另外的数据源也是很自然的,这里同时写入对应db和es中。

Untitled Diagram.drawio.png

异步双写

业务事件触发,直接写入db,同时将消息转发到rocketMQ等中间件,在对应消费端实现转储逻辑

Untitled Diagram.drawio (1).png

定时同步

借用定时任务,按照期望频次去批量将数据刷入es中;甚至可以做一些变种同步,在对应db表中添加更新时间,定时调度任务只去刷新变更的数据,从而实现增量的更新。

Untitled Diagram.drawio (2).png

binlog同步

业务变更,写入db,直接正常写入。同时这里借助db转出binlog的中间件工具,例如,canal、Debezium等,模拟db的从节点,从主库拉取binlog,直接写入es或者转发出消息,应用消费再写入es中。注意,这里同步工具转出的binlog遵从mysql写入的三种模式:

  • row 模式,binlog 按行的方式去记录数据的变更;
  • statement 模式,binlog 记录的是 SQL 语句;
  • mixed 模式时,混合以上两种,记录的可能是 SQL 语句或者 ROW 模式的每行变更;

mixed和statement模式不是直接的行变化,这种时候是需要我们去根据业务解析这些 SQL 或者每行变更,比如利用正则匹配或者 AST 抽象语法树等,然后根据解析的结果再进行数据的同步,具体实现还是要根据具体场景分析来应用。

Untitled Diagram.drawio (3).png

同步方案总结

数据同步方案优点缺点
同步双写1. 业务逻辑简单
2. 实时性高
1. 可扩展性差,需要写入MySQL的地方都需要添加写入ES的代码。
2. 业务耦合性高。
3. 数据一致性得不到保证,写入存在失败丢失数据的风险。
4. 性能降低,本来MySQL的性能就不算高,再写入ES系统性能必然下降。
异步双写1. 写入性能高
2. 不存在丢数据问题
1. 依然存在业务耦合:
2. 系统中增加了mq的代码;
3. 可能存在时延问题:程序的写入性能提高了,但是由于队列的消费可能由于网络或其它原因造成延时。
4. 引入了队列中间件,增加了维护成本
定时同步1. 不改变原来代码,没有侵入性、没有硬编码;
2. 没有业务强耦合,不改变原来程序的性能;
3. 编写简单不需要考虑增删改查;
4. 写入性能高
1. 时效性较差,由于定时器工作周期不可能设在秒级;
5. 对数据库有一定的轮询压力。
binlog同步1. 不改变原来代码,没有侵入性、没有硬编码;
2. 没有业务强耦合,不改变原来程序的性能;
3. 可以保证一定的实时性
1. 存在时序性问题;
2. 同步工具增加了维护成本;

同步方案各有千秋,对于不同场景,我们需要选取最合适的同步策略,甚至同时使用多种同步,在一些必要场景或功能性下还需要进行改造,下一章节,将详细介绍我们服务中是如何进行数据同步。

数据同步实践

使用策略

其实早先其他服务中曾使用异步同步的方案来进行缓存的数据同步,但是我们发现,随着业务变更,总是需要再单独去处理一遍消息,对后续的维护十分不友好,尤其对于一些新同学,容易疏漏,造成场景不全,导致数据不一致的结果;同时对于一些大规模的数据变更,实时性上也是存在问题的。因此我们结合历史使用数据同步的经验和上述的几种同步方案,综合考虑设计了适合我们场景的同步策略。

Untitled Diagram.drawio (8).png

数据同步设计流程图

我们决定采用binlog同步,虽然我们对写入业务逻辑进行了解耦,但是关于大流量的实时性的问题还是存在;同时因为binlog的转出只维持同表内操作的顺序性,多表间的先后顺序没法保证,对于我们的场景还延伸出了新的问题。并且不同binlog的处理事件等,都会影响先后顺序。就好像张三和李四两个人去买东西,张三先付完钱了,在旁边等着拿货,李四后付的钱,也在旁边等着,结果李四先拿到了东西。即使是写代码,有些场景下还是需要遵守下先来后到的!同时我们辅以定时同步的模式,进行一些运维,备份同步的操作。

整体上看,我们的数据同分为以下两部分

同步触发:

  1. 通过消费cannel封装的binlog消息,来处理数据实时变更
  2. 定时任务支持兜底逻辑,定时触发全量数据的刷新
  3. 手动触发支持运维场景对单独模型的具体实例进行数据刷新

功能模块:

  1. binlog处理引擎:支持bianlog消费动态配置、binlog数据解析、模型ID转换
  2. redis队列:存储binlog提交时间,模型ID,通过增加队列提高实时性,数据进行聚合,这块的详细设计可参考同步策略改造这一章
  3. 模型封装器:通过ID封装模型数据,来提供最终同步至ElasticSearch的数据源

模型ID,业务唯一标识,可以是对应具体DB中某张表的实际ID,或者某几张关联表组合而成的唯一标识,这样方便ES对应模型能够反向关联回我们的实际业务关系表。

这样的设计为我们带来了如下的优势:

时序性, 先查询db,再写入指定记录,这里我们之所以这么做,是因为binlog实时同步涉及到一个时序性的问题,只输出es模型id,每次写入的数据都通过实时查询db,能有效的避免这种前后依赖的关系。(可以考虑一张图,差除)

相互备份,两种同步也可以相互backup,一般应用服务做高可用,又是异地多活,又是同城双活的,我们数据同步弄一个备份也不算过分吧。这里binlog同步我们加了一个redis的队列,也是有一些用处的,下面设计会详细描述下。

易维护,数据异常,或者一些数据初始化,都可以利用定时任务进行数据刷新。(扩展)

可扩展性,在binlog实时同步部分,我们抽出了binlog的匹配规则,进行数据甄别,支持可配置操作,更灵活调整。

可容忍的延迟, 我们实际业务场景是针对B端提供查询能力,binlog实时同步虽然存在少量的延迟,但是足够满足我们的业务场景。

问题与解决

版本覆盖问题

在数据同步设计流程图中可以看到,我们同时结合了定时同步和binlog实时同步,并且针对刷新的效率和吞吐量,我们定时同步时采用了批量刷新,因此在给我们带来了许多的好处的同时,也有一些弊端,可能会出现版本覆盖问题,因此我们引入版本号进行控制,具体如下图所示:

Untitled Diagram.drawio (5).png

版本号说明图

  • t0时刻,由于定时流程先触发,因此首先查询到模型ID=1的v1版本数据
  • t1时刻,由于binlog到达,并且binlog解析后只有一个ID,只消耗很短的时间就查询到模型ID=1的v2版本数据
  • t2时刻将最新的v2版本数据刷新到es
  • t3时刻,定时任务组装全量数据完毕,开始刷新,此时将模型ID=1的旧版本v1数据刷新到了es

重复刷新&数量控制问题

存在问题

因为binlog的引入,在我们实际实现的过程中,遇到了重复消息,es写入瓶颈,或者在一些极端场景下,比如说批量操作或者一些业务数据初始化的操作下,会产生大量的binlog,对于我们写入到es的数据会造成比较大的同步延迟等问题。

Untitled Diagram.drawio (6).png

redis队列设计图

解决

因此我们对于传统的实时同步做了一些改造,在数据同步设计流程图中的第五步流程中转出的binlog消息处理中增加了一层redis的队列的逻辑,如上图所示。

这里我们拆分一个es的索引对应一个实际的业务模型,同时和redis队列也进行一一匹配。大致流程如下:

  1. 我们将需要进行更新的业务模型id存入redis队列,利用zset结构进行数据合并,score存储binlog commit时间,key存储业务模型id
  2. redis consumer模块,利用滑动窗口的进行取数,满足要求的,进行es写入
  3. 单位时间内,超出阈值,如redis队列设计图中模型3,单位时间窗口,允许写入最大量5,超出的数据6,7进入慢队列,进行异步消费再写入es中对应模型

这里的redis consumer模块具体取数逻辑,参考如下:

private void processCurrentWindowQueue ( ModelType modelName ) {

    //获取redis队列当前模型第一个节点

    firstNodeTime = range ( modelName, 0 );

    //计算当前时间窗口范围

    windowStartTime = firstNodeTime - firstNodeTime % windowLengthInMs;

    windowEndTime = windowStartTime + windowLengthInMs;

    //开启事务,取出队列中数据

    excute(modelName, windowStartTime, windowEndTime);

}



 /**

 * 取出时间窗口内待更新模型ID

 */

 

private void excute(ModelType modelName,Long windowStartTime,Long windowEndTime){

    multi () ; //事务开启

    rangeByScore ( modelName, windowStartTime, windowEndTime ) ;

    removeByScore ( modelName, windowStartTime, windowEndTime )

    exec ();//事务执行并关闭

}

阈值确认

redis队列设计图中我们使用慢队列涉及到单位时间阈值的概念,这里的阈值取决于我们redis队列消费写入过程的时间。

写入过程具体又可以拆分为redis数据消费、查询db、批量写入es操作这三个步骤。如下图,像第一个loop1循环,这三者加起来的时间都在一个写入周期内,是满足预期的;而反观loop2循环,三者加起来的时间超过一个完整周期,在单节点场景下,就会导致后一轮数据写入延迟,显然这并不是我们想要看到的结果。

通过链路的追踪,我们发现主要耗时瓶颈集中在批量写入ES这一步,并且经过多次线上压测,取到一个合理的数量阈值。

Untitled Diagram.drawio (7).png

小结

我们采用redis队列,带来的效果如下:

  • 消息合并,同一个时间区间内,例如商品,一般商品模型字段较多,可能存在多个binlog消息的解析结果,都需要更新ES,这里存在一个重复消息的问题。由于我们最终感知的是哪些模型ID需要进行ES数据的刷新,因此需要做好消息的聚合,比如最终是更新同一个模型ID的,扇出到ES写入时也只会有一个请求。
  • 批量操作,一次同时取出一个时间片内待更新的数据,进行批量写入,优化性能。
  • 瞬时大量消息, 除了上述场景,还存在比如一个SQL更新:Update set columnA=x where columnA=y;影响行数10000条,这样产生的binlog row=10000,甚至更高,则瞬时产生大量待更新的ES数据,进行异步处理;同时可以保证下一个时间片内待更新的数据还能正常更新,多时间片数据相互独立。

注意事项

  1. 这里的查询、取数需要一个事务内执行,redis集群不支持事物的开启,这里退而求其次,选取了redis sentinel模式
  2. 写入redis队列的score选取的是sql的commit时间

结尾

现有多种数据同步方案,各有所长。这里我们的场景主要是考虑MYSQL同步到ES,结合定时同步和binlog同步策略,并通过增加redis队列,针对实时性和吞吐量等问题做了改造,满足我们的诉求。触类旁通,日常场景中,比如说缓存的同步,将数据同步到redis等,或者一些同类型转储,例如,mongodb转储到ES中,甚至可以直接进行同步。 这一类数据同步的问题实现思路都是比较类似的,但没有万能的同步方案,还是需要灵活的做一些变化和适配,才能更好的贴合具体使用场景进行数据同步。