mysql到es的同步
Elasticsearch(es)使用场景,正如es一直强调的“一切为了搜索”,作为一款分布式的搜索引擎,使用场景较为宽泛,可以任意查询条件组合完成数据的检索。这正是mysql等关系型数据库的痛点所在。但对于每家公司的核心系统(toB公司)来说,它的核心业务数据都是需要各种组合查询才能完成业务需求。而为了数据的完整性和强一致性等目的,都采用被验证多年的关系型数据库来存储数据,互联网公司一般是mysql。那么这就是一个矛盾,使用了mysql就不能完成灵活的检查,且数据量一大查询就会变得越来越困难,这正是es所善长的事情。
现在的问题就变成了,怎么保证mysql的数据和es数据实时同步的问题,数据量稍大的项目,为了提高并发和简单查询效率,也会来做分库分表,所以mysql到es同步也需要考虑分库分表情形。
(一)数据同步方案
数据同步较为传统的方案是双写模式,也就是说这种方式有什么错,但双写模式是一种较强侵入性方案,有几个问题需要研发人员考虑的,一是事务,如何保证写mysql和写es能做到在一个事务中完成?特别是较复杂的mysql大事务(toB项目往往大概率出现)和不支持事务的es如何能做到兼容?下大量耗资源的事务出事对高并发情况下的影响也需要考虑。二是业务写的逻辑会不会遗漏的问题,写mysql是分散到系统的各模块各功能点去了,后面需要变更业务逻辑和增加新的功能时,那么就得满世界去找双写的点来进行修改。这需要非常了解业务,即使所有的代码是一个人开发的,也难保不会忘了某个点没改,特别是在较多ifelse逻辑情形下。
而duckula就是通过更底层的binlog来完成mysql到es的同步的,binlog是mysql数据库的主从同步机制,可以说是较成熟的方案,不会有数据丢失的问题。且它是毫秒级(10毫秒以内),对于人工完成查询是足够了,除非程序查询反应不过来。
方案定下来使用binlog,实施的时候就需要考虑如下几个问题:
- binlog是实例级别的,肯定要过滤到表级别
- 过滤的表级别数据有没有权限考虑,行过滤(saas系统的分租户),纵过滤(系统内部字段等不需要过滤),删除的binlog要不要执行等(操作过滤)。
- 我监听的表的数据,别的应用(如ETL,flink等)会不会使用?
- 我的全量数据怎么做,不可能全同步到消息队列吧?
- 在后面出问题了、需求变更需要修复数据,加索引字段和同义词需要重建索引,这种全量和部分全量如何实施。
- 我的同步逻辑是写到应用程序里部署还是独立中间件部署?如果是中间件部署,我的HA如何做?应用程序一般会起几个相同的实例来做负载的,那我的分布式锁如何做(同步2份相同的数据让费资源)?
- 索引字段如何建?有2表关联怎么处理?甚至我有多表如何处理?
- mysql添加了字段如何不停机动态生成es的字段?
上面的问题就是duckula所关注和试图解决的问题。duckula由库名表名支持正则表达试的表级binlog过滤,也支持行、列过滤和操作过滤,更进一步的是支持子表通过父表的字段来做行过滤。索引的自动创建和索引字段的动态添加(mysql加字段后),
通过duckula,你可以走kafka消息再到es这条路,也可以直接到es这条路。这点在duckula的登陆页面会有较明确的指示:
(二)同步方案配置
现在我们以两个例子来演示一下如何通过duckula3来配置监听,一个是mysql单表数据直接同步到ES的一个索引中,第二个例子是具有父子关系的两张表同步到一个索引中。在做这两个示例前呢,需要做一些准备工作
mysql配置
在使用duckula前必需确认mysql是否开启了row模式的binlog和是否开启了gtid(mysql5.6以上),duckula支持两种模式的高可用(HA),一:filename+offset模式,它是mysql5.6版本以下必须要走的模式,且现在大部分的binlog监听方案都是采用这种模式,二:gtid模式 ,这是mysql5.6以上新版本才能使用的模式,它比旧的模式好的地方就是精准的做HA定位。duckula 默认的HA方案为gtid模式。
查看配置
我们可以通过如下命令来看是否配置成功了。
show variables like 'binlog_format'; #ROW
show variables like 'log_bin'; #ON
show variables like 'gtid_mode'; #ON
后面的注解是这些变量配置正确的情况下的结果。
mysql5.6配置
#开启binlog日志
log_bin=mysql-bin
binlog_format=ROW
server_id = 1
#开启gtid
gtid-mode=on
enforce-gtid-consistency=1
log-slave-updates=1
mysql8配置
mysql8默认开启了binlog,所以配置好gtid配置:
#开启gtid
server_id=1
gtid_mode=on
enforce_gtid_consistency=on
log_slave_updates=on
duckula3的配置
duckula3的任何配置都有界面支持配置,需要安装好一个控制台(ops),可以参考duckula3的使用手册,“安装”一节。
同步配置前准备
在配置监听任务前需要配置好一些资源,用于辅助监听任务,需要配置的资源有:
-
布署管理:用于支持任务的发布环境。
-
实例管理:用于配置需要进行监听的数据库
-
存储中间件管理:用于任务的数据流向的目的地
-
版本管理:用于配置duckula的版本,在安装ops时会有初始化的脚本。对它进行配置。
-
检查点服务管理:用于做HA的元数据存储的配置
具体的配置可以参考duckula3的使用手册的第八节资源配置章节。
(三)父子两表join关联直连ES示例配置
附加了几条示例数据,建表语句如下:
DROP TABLE IF EXISTS `user_info`;
CREATE TABLE `user_info` (
`id` int(11) NOT NULL,
`name` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL,
`age` int(11) NULL DEFAULT NULL,
`birthday` date NULL DEFAULT NULL,
`money` double NULL DEFAULT NULL,
`update_time` datetime(0) NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP(0),
`create_time` timestamp(3) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
);
-- ----------------------------
-- Records of user_info
-- ----------------------------
INSERT INTO `user_info` VALUES (1, '张三', 13, '2020-05-26', 3000, '2021-02-18 16:24:35', '2020-08-27 19:28:13.000');
INSERT INTO `user_info` VALUES (2, '李四', 14, '2020-05-27', 4000, '2021-02-19 16:24:53', NULL);
INSERT INTO `user_info` VALUES (3, '王五', 15, '2020-05-27', 5000, '2021-02-20 16:24:58', NULL);
INSERT INTO `user_info` VALUES (4, '刘六', 16, '2020-05-27', 6000, '2021-02-21 16:25:02', NULL);
INSERT INTO `user_info` VALUES (5, '周七', 17, '2020-05-27', 7000, '2021-03-01 16:25:05', NULL);
INSERT INTO `user_info` VALUES (6, '李太保', 18, '2020-05-27', 8000, '2021-03-02 16:25:09', NULL);
INSERT INTO `user_info` VALUES (7, 'andy.zhou', NULL, '2020-05-27', 9000, '2021-02-03 16:25:13', NULL);
INSERT INTO `user_info` VALUES (8, 'rjzjh', NULL, '2020-05-27', 10000, '2021-02-04 16:25:22', NULL);
DROP TABLE IF EXISTS `user_addr`;
CREATE TABLE `user_addr` (
`id` int(11) NOT NULL,
`user_id` int(11) NULL DEFAULT NULL,
`addr` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL,
`postNo` varchar(20) CHARACTER SET latin1 COLLATE latin1_swedish_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
);
-- ----------------------------
-- Records of user_addr
-- ----------------------------
INSERT INTO `user_addr` VALUES (1, 1, '上海市徐汇区桂箐路7号', '344125');
INSERT INTO `user_addr` VALUES (2, 2, '上海市宝山区聚丰园路34号', '344000');
INSERT INTO `user_addr` VALUES (3, 2, '上海市徐汇区桂箐路8号', '344520');
INSERT INTO `user_addr` VALUES (4, 1, '上海市宝山区聚丰园路35号', '344140');
为了测试各种数据的兼容情况,创建如上用户表,不同字段的类型都不同样。随意填写一些测试数据。
注意:user_addr表是子表,它的“user_id”字段指向user_info表的主键“id”字段,这两张表就是最常见的父子join关系。
监听任务配置
具体配置说明需要查看duckula3的使用手册,第五节监听任务章节。
上面这些配置都是“同步配置前准备”这个章节准备好, 需要说明的几个字段有:
-
附加配置:主要完成任务监听的其它配置,此类是为了自动生成es的索引:
{'common.es.auto.create.index_join.demo': 'db:test|tb:user_info|db1:test|tb1:user_addr|rela1:user_id|shardsNum:12'}
common.es.auto.create是固定值,index_join表示要创建join类型的索引,index_noJoin表示要创建单表索引, “demo”是索引名,它的值是用"|"分隔,db:是主表所在的库名,tb:是主表表名,db1:从表所在库名,tb1:是从表的表名,rela1是指从表的哪个字段将关联主表的主键,shardsNum:指表示创建几个分区,复制份数为默认值0,因为设置为0同步的性能最高,但只能用于测试环境或demo环境,在生产环境还需要在做完全量同步后通过相关工具设置一下复制份数。
-
版本:这个在初始化ops时,将自动把duckula的最新版本设置好,在此界面,只需下拉选择就可以了,但后面duckula如果有新版本,则需要手工去ops界面“系统配置”->“版本管理”界面进行配置。
-
注意点:自动创建的索引名要与规则里所配置的es索引名相同。
全量任务配置
具体配置说明需要查看duckula3的使用手册,第七节全量任务章节
上面的配置说明点:
-
监听实例、目的中间件、布署环境与前面的“任务配置”相关字段一样的。
-
规则编辑和过滤规则的配置,详细说明见duckula3的使用手册,第九节规则配置章节
(四)父子两表join关联通过kafka同步ES示例配置
监听任务配置
与第三节的“直接ES”监听配置大同小异
需要说明的几个字段:
- 目的中间件选择kafka、附加配置设置为空,不自动产生索引
- 子表user_addr的SQL过滤中,它的select的表是父表user_info,其中@{user_id}会替换为子表user_addr的关联字段user_id的值。
kafka消费任务配置
这个任务是用于监听kafka数据,然后导入到es集群中
需要说明的几个字段:
- 反查实例:当kafka的数据是幂等数据时(只传一个id字段,可以无序,这样提高并发性能),duckula使用它来反查db拿最终数据。
- 目的中间件:与“监听任务”一样,但在此它会过滤掉kafka的目的中间件的配置,暂时没有场景需要kafka到kafka传输
- topic:为了实现方便,现在duckula设置只支持一个topic的消费。
全量任务配置
这个与第三节的“全量任务配置”相同
(五)索引同步顺序及风险
要做一个索引的同步,分为增量同步和全量同步,分为有kafka和无kafaka的这二条路来说。
有kafka的同步顺序
-
开启binlog监听的增量同步到kafka。
-
开启全量dump同步,把mysql数据全量同步到es
-
开启kafka消费任务,把kafka的数据同步到es。
考虑点:
由于全量同步是按id顺序来全量扫表的。由于全量同步需要耗费大量时间,在此期间内会有新增数据和修改数据进来,对于id为自增长的整型或长整型,且只有新增,没有修改,这问题不算大,在全量任务快完成时启动增量同步就可以了。但这种场景实在太少了,且主键有些还是字符型,新增数据的主健顺序没办法保证,所在在全量同步期间修改和新增的数据就极大可能丢失了,所以我们在做全量同步前必须要把增量同步打开。
那为什么增量同步要分2步,先入kafka缓存,等全量同步完了再消费kafka入es呢,这里有个先后顺序的问题,全量任务是利用了disrupter无锁环来存储数据的,跟据经验,往往是环的生产速度远远大于消费速度,如果有数据先被放入到了disrupter环中,然后又被修改了,这时如果数据直接入ES(binlog是毫秒级的),那么后面在消费disrupter环中的此数据入es时,就会覆盖了已在es的最新数据(binlog同步的)。所以我们可以先缓存到kafka,等全量数据跑完了,再用kafka的数据去“滚”一遍已入es的全量数据,这样就避免了旧数据覆盖新数据。
无kafka的同步顺序
-
开启binlog监听的增量同步到es。
-
开启全量dump同步,把mysql数据全量同步到es
风险及解决方案:
可能会有部分数据不是最新的,这取决于disrupter的消费速度。原因见上节“有kafka的同步顺序”的考虑点,但这也有方案避免,可以采用如下步骤
1、拿到当前mysql的位点信息(gtid)
2、开启全量dump同步,把mysql数据全量同步到es
3、通过第一步拿到的位点信息,从指定的位点来启动binlog监听,而不是默认的从当前位点进行监听。
(六)duckula对join类型的支持
使用场景
duckula可以做到mysql到ES的增量和全量的同步,但如果仅限制于做一张表对一个索引的同步,那它的使用场景就大大的限制了,有很多的场景都需要有2表关联及多表关联,这就需要duckula可以做到有关联关系时也能跟据规则进行增量和全量的同步。
ES父子关系模式的选择
在ES6.0以后,索引的type只能有一个,使得父子结构变的不那么清晰,毕竟对于java开发者来说,index->db,type->table的结构比较容易理解。而且明确不支持父子关系了,但是可以有2种方式达到我们所说的父子关系模型 ,nested类型和join类型,nested类型就是采用一个大json存放父表字段,里面可以含有许多的小json存放子表的数据,join类型则更像我们数据库存放的数据形态,在ES里面会有多种数据,其中每条记录会有一个附加的join类型字段,指示它是父表的数据还是子表的数据,它的值可以是一个json,其中有parent字段表示它中哪条记录的子表,这样一级级的指示父子关系,最后就是一棵父子关系树。
网上会有许多的文章来说明2者关系的特点,我从自身理解的方向来说明一下duckula为什么选择join类型:
1、join类型更像之前老版本ES用type来做父子关系,对于旧系统的改造会有优势
2、join类型所做查询是细粒度的,比如: 1父10000子,如果其中只有1个子记录命中查询,join类型只会查询这一条子记录,但nested类型是大的json,不管你如何查,只要一1条命中了,也会把整个大json给查出来。这对网络和ES也带来了不小的开销。
3、对于频繁做新增修改的场景,join类型也是细粒度的,只变更有变化的父或子数据就可以了,而nested类型不论父还是子数据有变更都需要修改整个大的记录,代价非常大。
4、使用ES来做查询都是千万级以上的数据,甚至亿级,对于上亿的主表,采用nested类型时,需主表每条记录又要到上亿甚至10亿数量的子表去查询,最后再组装为一个大的json,可想而知对于ES的全量dump导入会是什么样的性能。而对于join类型它可以快速的batch插入,它的全量导入的速度是nested类型全量导入的速度快几个数量级一点不为过。注意:全量的dump导入不是一次性的动作,在以后漫长的维护过程中,不管由于什么原因大批量的丢数据了,修改字段类型, 甚至es与mysql的数据不一致了,都需要再次做全量dump操作。如果不能快速的完成全量dump操作,将对业务造成很大的困扰。
duckula对join模式的支持
由于不是项目形式的,做完能用就算了,duckula是以产品的模式来设计对join类型的支持,只要有类似的需求,运维只需要在duckula的ops上做相应的配置,duckula收集到配置后才能做全量与增量的导入。那么duckula必需要设计一套规则来满足可配置的需求。
index的mapping设计
duckula设计附加的join类型的mapping定义为(mapping片断):
"tams_relations": {
"type": "join",
"eager_global_ordinals": true,
"relations": {
"user_info": "user_addr:user_id"
}
}
字段“tams_relations”是固定的。
eager_global_ordinals:字段预先加载,全局序数会在一个新的段可进行搜索之前进行构建。
relations:定义父子关系。
“user_info”: “user_addr:user_id” 表示user_info为父表user_addr为子表关联字段是user_addr表的user_id字段
ES的数据示例
{
"_index": "demo_join",
"_type": "_doc",
"_id": "6",
"_score": 1,
"_source": {
"birthday": 1531238400000,
"update_time": 1552181688000,
"money": 6678000,
"name": "fff6644",
"id": 6,
"tams_relations": "user_info",
"age": 567666
}
},
{
"_index": "demo_join",
"_type": "_doc",
"_id": "user_addr:6",
"_score": 1,
"_routing": "6",
"_source": {
"postNo": "366528",
"user_id": 6,
"id": 2,
"tams_relations": {
"parent": "6",
"name": "user_addr:user_id"
},
"addr": "aaaa"
}
}
第一条记录是表示主表记录:它的tams_relations值 为join类型的前半部分 ,它的id是原样id不做任何处理。
第二条记录是子表记录,它的tams_relations的name值为mapping定义的join类型的后半部分:user_addr:user_id,注意,它的_id值是加了子表表名做前缀“user_addr:”的,目的为了避免父子表同一个id的记录互相覆盖。tams_relations字段还有一个部分是"parent",表示它的父记录的id,ES的routing也是跟据它来做路由的,这样就保证了存在父子关系的数据一定落到同一个分区中。