Canal同步ES瞬时重复数据过滤方案

2,284 阅读7分钟

作者:正则

背景

业务数据接入ES的技术实现上,我们尝尝会碰到实时变量数据同步的问题。目前,公司内统一的处理方案是通过Canal + MQ形式,将binlog信息同步至应用服务将变量数据dump至ES。

详见流程图,如下: image.png

  1. 当Mysql数据有变更,会记录binlog日志。
  2. canal伪装为mysql的slave,监听到变更数据,并将变更数据发送至MQ顺序消息)。
  3. 数据处理服务(dump service),对该topic进行监听,并拉取(基于长轮询)消费数据。
  4. 数据处理后,将最新的单据数据同步至ES,便于搜索使用。

其中,为保障ES内的数据条目是最新的,数据处理过程会根据canal同步来的数据ID(主键),经数据库查询出完整的最新数据,并将该数据同步至ES的索引。 image.png 如图(索引结构,常用的策略就是将多表数据平铺至单Index) ​

问题

  • 瞬时时间段内,Canal可能会监听到统一单据(即单据主表的id一样)的多次变更。
  • 当一条单据创建时,很有可能会产生多条数据(主表数据 + 相关额外表数据),此时若canal监听了多张表的数据变更,就会尝试重复dump的场景,即创建一条单据,产生多次dump的命令。

上述两种场景,都会发生同一条单据数据在某一时间段内多次dump至ES的操作,对数据库查询和ES访问无形中产生了没必要的请求压力。链路流程如下: 【1】T1时间:main_table表中** id = 1 的数据 A字段发生了变更,由canal监听到,并最终通知到DumpService; 【2】T1时间:main_table表 id = 1 的数据 的相关表ext_table中B字段发生了变更,由canal监听到,并最终通知到DumpService; 【3】DumpService在同一时间段(微秒级别),收到两条关于 id = 1 的数据 变更的通知,并通过 id = 1 的数据 查询mysql获取完整单据,再同步至ES; 由上述问题,实际程序只需要完成最终一次的单据数据同步即可,无需进行多余的数据同步操作。但是数据变更的通知是顺序消息产生的,如何判断上下关联的单据变更通知来源于同一个事务**?如果能确认变更通知消息来源同一事务,我们就可以知道实际到dump多少次了(因为业务处理是,一个事务即一条完整单据的处理)。 image.png 如图,事务A、B都进行id = 1的数据变更,由于顺序消息DumpService无法辨别是否来自同一事务,即数据要同步1次还是多次?(理论上是只需同步两次的,实际这里同步了3次) ​

解决思路

  • 是否可以截取某一时间段(如1秒内)的数据做重复值过滤?
    • :1秒的数据刷新再同步至ES,就会产生搜索数据1秒的延迟?
      • :实际ES在做数据存储时,也会按秒级刷盘来完成,只能说是**“准”实时**,所以这里产生1秒的延迟在业务上基本也可以接受。
      • :如果真的在乎这1秒的间隔时间,可以允许首次同步和最终同步两次同步操作,即第一次收到变更通知时,直接dump,第二次及后面收到的变更通知纳入重复值过滤的时间区间内。
    • :1秒时间段内的数据如何存放?要考虑数据顺序性吗?
      • :可以存入JVM,使用Set即可。但是考虑到数据恢复的容灾情况,可以存入Redis内(也用Set集合)。(ps:redis挂了数据丢了,不考虑)
      • :这里无需考虑数据顺序性问题,Set集合内存储的都是需要变更的单据主ID,每次同步都是拉取最新的数据库数据进行dump。(即一种最终一致性的处理手段
    • :1秒时间段的数据怎么进行处理?
      • :可以采用定时器,按秒级执行,每次执行时把Set集合内当前所有数据条目获取,再进行dump。即,Set集合作为一个1秒钟数据注入的容器,每个1秒就会清空一次。
      • 补充:可以考虑,Set集合1秒产生的数据量偏大,最好设置limit值来取定量(实际在合同业务量上,每秒的数据量没有特别高,另外都是存放不重复数据的)。
    • 紧接问:定量以外未被取走的数据,就不会是1秒时间段的时效性了,怎么办?
      • :可以忽略,因为这里是最终一致性策略;如果非要时效性,参考第一个问题的回答2,进行两次同步。
  • :1秒钟时间窗口的数据过滤方案,是解决了上述问题中事务AB无法辨别的问题(因为这是产生这个问题的本质原因)了吗?
    • :并没有解决这个本质原因,但是解决了这个问题。其实,这里我们把原先的异步消息事务关系无法辨别的问题转换成了数据最终一致性问题。虽然本质原因是程序无法辨别消息的事务来源,但是我们要达到的目的是数据最终一致性。

方案设计

image.png

  1. canal监听到的变更数据,统一放入收集容器内(redis的set集合)。
  2. 程序启动一个定时器,按每秒执行一次,每次从收集容器内拉取需要执行数据。
  3. 收集完数据后(此时为需要1秒内处理的数据),将其放入执行容器(redis的set集合)内,同时将获取的数据从收集容器内删除。
  4. 异步执行一个线程,用于处理执行容器内的数据,处理完的数据从执行容器内删除。
  • :为什么会用异步线程处理数据?
    • :每次取出的数据,在处理时是需要一定耗时的,所以这里只要获取到1秒内产生的数据,即可用线程单独处理,当前定时任务的线程可直接返回。
  • :为什么会用两个容器存放数据?
    • :因为整个过程都处于并发状态,如果同一单据数据在前后一秒都有不同的变更,此时只有一个容器,即在前一秒处理完数据后便删除了该数据变更通知,那么ES里保持的数据并不会是最新的。用收集容器,只关注1秒内产生的变更数据,执行容器只关注需要进行dump的数据。

伪代码实现

处理cancal监听的变更数据,推送至redis的收集容器:

image.png

定时任务(可使用EJ),1秒钟执行一次。收集时间区间内要处理的容器,并放入执行容器内待执行:

image.png

执行处理数据的异步线程,处理完数据后,执行容器内清除已处理的数据:

image.png

开箱即用

CanalPreventDuplicateDumpJob.java 内涵:addDumpKey方法,将变更数据id推送至redis的收集容器。 ​

CanalPreventDuplicateDumpJobImpl.java 对上述接口类的实现,其次,内部使用了EJ提供的分布式定时任务,进行数据收集、执行动作。 ​

ICanalPreventDuplicateExecuteDump.java 数据dump接口,在本地继承后,实现** void canalDump(String[] dumpKey);** 方法 ​

使用时,修改mainKeyNameredis的key进行唯一性打标,修改serviceName指向自己工程内处理数据dump的class(命名为spring的beanName),同时你的class需继承ICanalPreventDuplicateExecuteDump接口。 image.png

使用步骤

  1. 将上述三个java分别copy至自己的工程(ICanalPreventDuplicateExecuteDump.java、CanalPreventDuplicateDumpJob.java、CanalPreventDuplicateDumpJobImpl.java
  2. 在Canal数据监听,数据变更方法处使用CanalPreventDuplicateDumpJob##addDumpKey方法,将需dump的数据推送至Redis的收集容器内。如下图所示:

image.png

  1. 修改CanalPreventDuplicateDumpJobImpl内的mainKeyNameserviceName两个变量。
  2. 其中beanName为serviceName变量的class,继承ICanalPreventDuplicateExecuteDump接口,实现void canalDump(String[] dumpKey); 方法,如下图:

image.png image.png

优化后效果

优化前:

image.png

优化后:

image.png