最近在做的项目中有用到canal实时同步MySQL的数据,并且写入es的场景,总结了一些心得,以备后查。
总体同步的流程图如下:
链路中的环节稍微解释下:
binlog
MySQL的自身的操作日志,用来记录数据的变更操作及变更后的数据。需要开启并配置 binlog-format 为 ROW 模式。具体可查看canal文档。canal
alibaba开源的用来同步MySQL binlog的工具,简单来说canal就是把自己伪装成了MySQL的一个slave,然后同步其binlog。具体使用方法和原理可以查看github主页https://github.com/alibaba/canal,里面写的很清楚了。P.S.这里表扬一下阿里巴巴,最近几年拥抱开源社区,开源了好多好用的工具,如datax,canal,druid等。rocketMQ
阿里巴巴出品的消息中间件,现已孵化为apache顶级项目。具体文档可参考官网http://rocketmq.apache.org/。application
自己实现的一个应用,主要的作用是消费mq中的消息,并且将其写入es。Elasticsearch
基于Lucene的一款全文搜索引擎,在MySQL无法处理的海量数据的查询场景中发挥着重要的作用。具体文档可参考官网https://www.elastic.co/。
为什么要保证有序性?原因很简单:更新和删除的操作,如果出现了先发后至/后发先至的情况,就会导致es里的数据是比较旧的数据。
对于如何保证有序性,我们首先要分析一下到底是哪一步破坏了有序性。一步步从头看:
- MySQL => binlog,显然它一定是有序的;
- binlog => canal,canal的原理是伪装成slave来dump binlog,这也是有序的,否则MySQL主从就不同步了;
- canal => rocketMQ,通过查阅canal的官方文档中的mq顺序性问题一节,发现只有指定了pkhash消息投递方式,且pk出现变更才可能出现无序,由于我们并不会出现这种情况,所以这步也是有序的;
- rocketMQ => application, 通过查阅rocketMQ的官方文档中的consumer一节,发现消息的消费是否有序取决与使用的是OrderlyListener还是ConcurrentlyListener,顾名思义,前者是保证有序消费的,后者是不保证但吞吐量更高的,所以这里是一个可以设置的点;
-
application => es, 通过查阅Elasticsearch的官方文档中的versioning
一节,发现es是通过版本号来控制更新/删除操作的有序性的,版本控制又分为两种:internal和external,内部版本号是默认使用的,即操作时传入的版本号必须与文档当前版本号一致才可以操作成功,而外部版本号则是操作时传入的版本号必须大于文档当前版本号才可以操作成功,这里又是一个可以设置的点。
从上面的分析可得,我们可以在两个环节进行有序性的保证,一种是在第4部消费消息的时候保证有序性,另一种是在第5步es写入的时候保证有序性(更准确地说,应该是保证更新的数据总是能覆盖旧的数据)。
第一种实现方式很简单,只要在你的application中使用OrderlyListener来消费rocketMQ的消息,并且在写入es异常时返回ConsumeConcurrentlyStatus.RECONSUME_LATER,就能保证消息消费的有序性及消息消费成功(写入es)才会消费下一条消息。但由于这里用的是OrderlyListener,牺牲了并发的性能。而实际场景中我们关心其实是对于同一条数据的操作的有序性,而非整张表操作的有序性,在一个表一个topic的配置下所有的消息都变得串行了。所以为了达到更高吞吐量可以考虑下面一种方式。
第二种实现方式也是我比较推崇的,这需要表结构的配合,并在操作es时使用external外部版本号。首先需要为每条操作设置一个版本号,且新的操作的版本号一定要大于旧的操作,很显然,数据的变更时间戳很适合作这个版本号。那么我们就需要在创建表的时候为表增加一个updated_time并且由数据库来维护:
updated_time datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
其实这个应该是每个表都默认有的字段,我在《设计之道-数据库设计》也有提过。这里和当时不同的是将精度设置为了3(毫秒),datetime不指定的话默认是秒。因为在实际业务场景中,同一秒钟同一条数据发生多次更新是很有可能的,如果设置成秒的话,生成的版本号就相同了,那么就无法正确写入es了。当然如果你的业务够牛逼,同一毫秒都能有多次更新,那么你可以设置为6(微秒)。这样我们在消费消息的时候就可以使用性能更好的ConcurrentlyListener并发地消费消息,只要在es写入时指定使用数据的updated_time时间戳作为外部版本号,这样即使出现了先发后至的情况,较老的数据由于版本号一定比新的数据更小,也无法覆盖es中已经存在的数据。
这里可以稍微展示下ES中指定外部版本号后更新的现象:
-
创建一个索引,这时我们能看到它的版本号是1。
-
将这个索引做一次全量替换,我们发现它的版本号变为了2。
-
接下来我们继续更新索引,这时我们指定外部版本号,并将版本号设置为1,便会出现
version confict
,原因也很明确:当前版本号2大于或等于传入的版本号1,写入失败。
-
接下来我们使用版本号3进行更新,看到es返回更新成功,并且版本号也变为了我们指定的3。
上面的步骤3、4,确保了ES中即使出现了先发后至/后发先至的情况,在使用数据库update_time作为版本号时也不会出现老数据覆盖新数据的情况。