阿里Canal框架(数据同步中间件)初步实践

背景介绍

早期,阿里巴巴B2B公司因为存在杭州和美国双机房部署,存在跨机房同步的业务需求。不过早期的数据库同步业务,主要是基于trigger的方式获取增量变更,不过从2010年开始,阿里系公司开始逐步的尝试基于数据库的日志解析,获取增量变更进行同步,由此衍生出了增量订阅&消费的业务,从此开启了一段新纪元。

适用版本

支持mysql5.7及以下版本

传统的主从同步原理

master将数据记录到了binlog日志里面,然后slave会通过一个io线程去读取master那边指定位置点开始的binlog日志内容,并将相应的信息写会到slave这边的relay日志里面,最后slave会有单独的sql线程来读取这些master那边执行的sql语句记录,达成两端的数据同步。

传统的mysql主从同步实现的原理图如下所示:

Canal中间件功能

基于纯java语言开发,可以用于做增量数据订阅和消费功能。

相比于传统的数据同步,我们通常需要进行先搭建主从架构,然后使用binlog日志进行读取,然后指定需要同步的数据库,数据库表等信息。但是随着我们业务的不断复杂,这种传统的数据同步方式以及开始变得较为繁琐,不够灵活。

canal模拟mysql slave的交互协议,伪装自己为mysql slave,向mysql master发送dump协议mysql master收到dump请求,开始推送binary log给slave(也就是canal),canal解析binary log对象(原始为byte流),通过对binlog数据进行解析即可获取需要同步的数据,在进行同步数据的过程中还可以加入开发人员的一些额外逻辑处理,比较开放。

Binlog的三种基本类型分别为:

STATEMENT模式只记录了sql语句,但是没有记录上下文信息,在进行数据恢复的时候可能会导致数据的丢失情况

ROW模式除了记录sql语句之外,还会记录每个字段的变化情况,能够清楚的记录每行数据的变化历史,但是会占用较多的空间,需要使用mysqlbinlog工具进行查看。

MIX模式比较灵活的记录,例如说当遇到了表结构变更的时候,就会记录为statement模式。当遇到了数据更新或者删除情况下就会变为row模式

Canal环境搭建

需要先登录mysql数据库,检查binlog功能是否有开启。

mysql> show variables like 'log_bin';

+---------------+-------+

| Variable_name | Value |

+---------------+-------+

| log_bin | OFF |

+---------------+-------+

1 row in set (0.00 sec)

如果显示状态为OFF表示该功能未开启,那么这个时候就需要到my.ini里面进行相关配置了,在原来的my.ini配置底部插入以下内容:

server-id=192

log-bin=mysql-bin

binlog_format = ROW

当再次通过客户端查看log_bin状态为ON的时候,就表示binlog已经开启:

mysql> show variables like 'log_bin';

+---------------+-------+

| Variable_name | Value |

+---------------+-------+

| log_bin | ON |

+---------------+-------+

1 row in set (0.00 sec)

然后在mysql里面添加以下的相关用户和权限:

CREATE USER 'canal'@'%' IDENTIFIED BY 'canal';

GRANT SHOW VIEW, SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';

FLUSH PRIVILEGES;

开启之后,我们可以前往canal的官方地址进行相应版本的安装包进行下载:

https://github.com/alibaba/canal/releases

下载好指定的版本之后,找到里面的bin目录底下的startup脚本,启动。

启动之后会发现黑窗停止在这样一行的内容上,然后就不动了

Java HotSpot(TM) 64-Bit Server VM warning: ignoring option PermSize=128m; support was removed in 8.0

Listening for transport dt_socket at address: 9099

这时候需要前往日志文件夹底下canallogs,查看canal日志文件是否已经开启,如果显示以下内容,就表示启动已经成功

2019-05-06 10:41:56.116 [main] INFO com.alibaba.otter.canal.deployer.CanalLauncher - ## set default uncaught exception handler

2019-05-06 10:41:56.144 [main] INFO com.alibaba.otter.canal.deployer.CanalLauncher - ## load canal configurations

2019-05-06 10:41:56.145 [main] INFO com.alibaba.otter.canal.deployer.CanalLauncher - ## start the canal server.

2019-05-06 10:41:56.233 [main] INFO com.alibaba.otter.canal.deployer.CanalController - ## start the canal server[192.168.164.1:11111]

2019-05-06 10:41:58.179 [main] INFO com.alibaba.otter.canal.deployer.CanalLauncher - ## the canal server is running now .....

canal server的默认端口号为:11111,如果需要调整的话,可以去到conf目录底下的canal.properties文件中进行修改。

启动了canal的server之后,便是基于java的客户端搭建了。

首先在canalconf目录底下创建一个独立的文件夹(文件命名 idea_user_data),用于做额外的数据源配置:

然后创建一份特定的properties文件:(名称最好为:instance.properties),这里面只需要创建properties文件即可,其余几份文件会自动生成,instance.properties可以直接从example文件夹里面进行copy。

首先是导入相应的依赖文件:

com.alibaba.otter

canal.client

1.1.0

单机版本的canal连接案例

单机版本的环境比较好搭建,相应的代码如下:

首先是canal客户端的配置类

/**

* @author idea

* @date 2019/5/6

* @Version V1.0

*/

public class CanalConfig {

public static String CANAL_ADDRESS="127.0.0.1";

public static int PORT=11111;

public static String DESTINATION="idea_user_data";

public static String FILTER=".*\..*";

}

客户端代码:

package com.sise.client;

import com.alibaba.otter.canal.client.CanalConnector;

import com.alibaba.otter.canal.client.CanalConnectors;

import com.alibaba.otter.canal.protocol.CanalEntry.*;

import com.alibaba.otter.canal.protocol.Message;

import com.google.protobuf.InvalidProtocolBufferException;

import java.net.InetSocketAddress;

import java.util.List;

import java.util.Queue;

import java.util.concurrent.ConcurrentLinkedQueue;

import static com.sise.config.CanalConfig.*;

/**

* @author idea

* @date 2019/5/6

* @Version V1.0

*/

public class CanalClient {

private static Queue SQL_QUEUE = new ConcurrentLinkedQueue<>();

public static void main(String args[]) {

CanalConnector connector = CanalConnectors.newSingleConnector(new InetSocketAddress(CANAL_ADDRESS,

PORT), DESTINATION, "", "");

int batchSize = 1000;

try {

connector.connect();

connector.subscribe(FILTER);

connector.rollback();

try {

while (true) {

//尝试从master那边拉去数据batchSize条记录,有多少取多少

Message message = connector.getWithoutAck(batchSize);

long batchId = message.getId();

int size = message.getEntries().size();

if (batchId == -1 || size == 0) {

Thread.sleep(1000);

} else {

dataHandle(message.getEntries());

}

connector.ack(batchId);

//当队列里面堆积的sql大于一定数值的时候就模拟执行

if (SQL_QUEUE.size() >= 10) {

executeQueueSql();

}

}

} catch (InterruptedException e) {

e.printStackTrace();

} catch (InvalidProtocolBufferException e) {

e.printStackTrace();

}

} finally {

connector.disconnect();

}

}

/**

* 模拟执行队列里面的sql语句

*/

public static void executeQueueSql() {

int size = SQL_QUEUE.size();

for (int i = 0; i < size; i++) {

String sql = SQL_QUEUE.poll();

System.out.println("[sql]----> " + sql);

}

}

/**

* 数据处理

*

* @param entrys

*/

private static void dataHandle(List entrys) throws InvalidProtocolBufferException {

for (Entry entry : entrys) {

if (EntryType.ROWDATA == entry.getEntryType()) {

RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());

EventType eventType = rowChange.getEventType();

if (eventType == EventType.DELETE) {

saveDeleteSql(entry);

} else if (eventType == EventType.UPDATE) {

saveUpdateSql(entry);

} else if (eventType == EventType.INSERT) {

saveInsertSql(entry);

}

}

}

}

/**

* 保存更新语句

*

* @param entry

*/

private static void saveUpdateSql(Entry entry) {

try {

RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());

List rowDatasList = rowChange.getRowDatasList();

for (RowData rowData : rowDatasList) {

List newColumnList = rowData.getAfterColumnsList();

StringBuffer sql = new StringBuffer("update " + entry.getHeader().getSchemaName() + "." + entry.getHeader().getTableName() + " set ");

for (int i = 0; i < newColumnList.size(); i++) {

sql.append(" " + newColumnList.get(i).getName()

+ " = '" + newColumnList.get(i).getValue() + "'");

if (i != newColumnList.size() - 1) {

sql.append(",");

}

}

sql.append(" where ");

List oldColumnList = rowData.getBeforeColumnsList();

for (Column column : oldColumnList) {

if (column.getIsKey()) {

//暂时只支持单一主键

sql.append(column.getName() + "=" + column.getValue());

break;

}

}

SQL_QUEUE.add(sql.toString());

}

} catch (InvalidProtocolBufferException e) {

e.printStackTrace();

}

}

/**

* 保存删除语句

*

* @param entry

*/

private static void saveDeleteSql(Entry entry) {

try {

RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());

List rowDatasList = rowChange.getRowDatasList();

for (RowData rowData : rowDatasList) {

List columnList = rowData.getBeforeColumnsList();

StringBuffer sql = new StringBuffer("delete from " + entry.getHeader().getSchemaName() + "." + entry.getHeader().getTableName() + " where ");

for (Column column : columnList) {

if (column.getIsKey()) {

//暂时只支持单一主键

sql.append(column.getName() + "=" + column.getValue());

break;

}

}

SQL_QUEUE.add(sql.toString());

}

} catch (InvalidProtocolBufferException e) {

e.printStackTrace();

}

}

/**

* 保存插入语句

*

* @param entry

*/

private static void saveInsertSql(Entry entry) {

try {

RowChange rowChange = RowChange.parseFrom(entry.getStoreValue());

List rowDatasList = rowChange.getRowDatasList();

for (RowData rowData : rowDatasList) {

List columnList = rowData.getAfterColumnsList();

StringBuffer sql = new StringBuffer("insert into " + entry.getHeader().getSchemaName() + "." + entry.getHeader().getTableName() + " (");

for (int i = 0; i < columnList.size(); i++) {

sql.append(columnList.get(i).getName());

if (i != columnList.size() - 1) {

sql.append(",");

}

}

sql.append(") VALUES (");

for (int i = 0; i < columnList.size(); i++) {

sql.append("'" + columnList.get(i).getValue() + "'");

if (i != columnList.size() - 1) {

sql.append(",");

}

}

sql.append(")");

SQL_QUEUE.add(sql.toString());

}

} catch (InvalidProtocolBufferException e) {

e.printStackTrace();

}

}

}

启动程序之后,我们对数据库表进行10次左右的修改操作之后,便可以从控制台中看到sql的打印信息。

关于canal集群搭建的一些坑

在实际开发中,如果只有一台canal机器作为server,当该台机器挂掉之后,服务就会终止,那么这个时候我们便需要引入集群部署的方式了。

搭建canal集群的环境需要先搭建好相应的zk集群模式。zk的集群搭建网上资料很多,这里就不进行讲解了。

canal搭建集群的一些资料可以参考以下链接:

https://github.com/alibaba/canal/wiki/AdminGuide

canal在搭建HA模式的时候有几个容易掉坑的步骤:

canal.properties配置里面需要添加zk的地址,同时canal.instance.global.spring.xml

需要修改为classpath:spring/default-instance.xml

每台机子的canal里面的具体instance所在目录的名称需要统一,每个实例都有对应的slaveId,他们的id需要保证不重复。搭建好了canal集群环境之后,然后代码部分需要在链接的那个模块进行稍微的调整:

CanalConnector connector = CanalConnectors.newClusterConnector(CLUSTER_ADDRESS, DESTINATION, "", "");

为了保证master在某些特殊场景下挂掉,mysql需要搭建为双M模式,那么我们这个时候可以在每个canal机器的instance配置文件中加入master的地址和standby的地址:

canal.instance.master.address=******

canal.instance.standby.address = ******

同时对于detecing也需要进行配置修改

canal.instance.detecting.enable = true ## 需要开启心跳检查

canal.instance.detecting.sql = insert into retl.xdual values(1,now()) on duplicate key update x=now() ##心跳检查sql

canal.instance.detecting.interval.time = 3 ##心跳检查频率

canal.instance.detecting.retry.threshold = 3 ## 心跳检查失败次数阀值,当超过这个次数之后,就会自动切换到standby上边的机器进行binlog的订阅读取

canal.instance.detecting.heartbeatHaEnable = true ## 是否开启master和standby的主动切换

ps: master和standby进行切换机器的时候可能会有时间延迟。

启动2台canal机器,可以在zk里面查看到canal注册的节点信息:

通过模拟测试,关闭当前端口为11111的canal机器,节点信息会自动更换为第二台canal进行替换:

ClusterCanalConnector和SimpleCanalConnector类发现了username和password的参数,但是似乎具体配置中并没有做具体的设置,这是为什么呢?

后来也在github上边查看到了一些网友的相关讨论:

canal结合kafka发送sql数据案例

pom依赖:

org.apache.kafka

kafka_2.11

1.0.1

org.apache.kafka

kafka-clients

1.0.1

kafka的配置类:

public class KafkaProperties

{

public final static String ZK_CONNECTION = "XXX.XXX.XXX.XXX:2181";

public final static String BROKER_LIST_ADDRESS = "XXX.XXX.XXX.XXX:9092";

public final static String GROUP_ID = "group1";

public final static String TOPIC = "USER-DATA";

}

关于kafka的环境搭建步骤比较简单,网上有很多的资料,这里就不多一一介绍了。

首先是kafka的producer部分代码:

import org.apache.kafka.clients.producer.*;

import org.apache.kafka.common.serialization.StringSerializer;

import org.apache.log4j.Logger;

import java.util.Properties;

import static com.sise.kafka.KafkaProperties.TOPIC;

/**

* @author idea

* @date 2019/5/7

* @Version V1.0

*/

public class KafkaProducerDemo extends Thread {

public static Logger log = Logger.getLogger(KafkaProducerDemo.class);

//kafka的链接地址要使用hostname 默认9092端口

private static final String BROKER_LIST = BROKER_LIST_ADDRESS;

private static KafkaProducer producer = null;

static {

Properties configs = initConfig();

producer = new KafkaProducer(configs);

}

/*

初始化配置

*/

private static Properties initConfig() {

Properties properties = new Properties();

properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, BROKER_LIST);

properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());

properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());

return properties;

}

public static void sendMsg(String msg) {

ProducerRecord record = new ProducerRecord<>(TOPIC, msg);

producer.send(record, new Callback() {

@Override

public void onCompletion(RecordMetadata recordMetadata, Exception e) {

if (null != e) {

log.info("send error" + e.getMessage());

} else {

System.out.println("send success");

}

}

});

}

}

接着是consumer部分的代码:

import kafka.consumer.ConsumerConfig;

import kafka.consumer.ConsumerIterator;

import kafka.consumer.KafkaStream;

import kafka.javaapi.consumer.ConsumerConnector;

import java.util.HashMap;

import java.util.List;

import java.util.Map;

import java.util.Properties;

/**

* @author idea

* @date 2019/5/7

* @Version V1.0

*/

public class KafkaConsumerDemo extends Thread {

private final ConsumerConnector consumer;

private final String topic;

public KafkaConsumerDemo(String topic) {

consumer = kafka.consumer.Consumer.createJavaConsumerConnector(

createConsumerConfig());

this.topic = topic;

}

private static ConsumerConfig createConsumerConfig() {

Properties props = new Properties();

props.put("zookeeper.connect", KafkaProperties.ZK_CONNECTION);

props.put("group.id", KafkaProperties.GROUP_ID);

props.put("zookeeper.session.timeout.ms", "40000");

props.put("zookeeper.sync.time.ms", "200");

props.put("auto.commit.interval.ms", "1000");

return new ConsumerConfig(props);

}

@Override

public void run() {

Map topicCountMap = new HashMap();

topicCountMap.put(topic, new Integer(1));

Map>> consumerMap = consumer.createMessageStreams(topicCountMap);

KafkaStream stream = consumerMap.get(topic).get(0);

ConsumerIterator it = stream.iterator();

while (it.hasNext()) {

System.out.println("【receive】" + new String(it.next().message()));

}

}

}

然后需要在CanalClient 的executeQueueSql函数出进行部分功能的修改:

/**

* 给kafka发送sql语句

*/

public static void executeQueueSql() {

int size = SQL_QUEUE.size();

for (int i = 0; i < size; i++) {

String sql = SQL_QUEUE.poll();

//发送sql给kafka

KafkaProducerDemo.sendMsg(sql);

}

}

为了验证程序是否正常,启动canal和kafka之后,对canal监听的数据库里面的表进行数据信息的修改,然后canal会将修改的binlog里面的sql放入队列中,当队列满了之后便向kafka中进行发送:

consumer端接受到数据之后控制台便打印出相应内容:

如果想学习Java工程化、高性能及分布式、深入浅出。微服务、Spring,MyBatis,Netty源码分析的朋友可以加我的Java高级交流:787707172,群里有阿里大牛直播讲解技术,以及Java大型互联网技术的视频免费分享给大家。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,723评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,485评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,998评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,323评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,355评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,079评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,389评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,019评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,519评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,971评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,100评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,738评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,293评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,289评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,517评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,547评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,834评论 2 345

推荐阅读更多精彩内容