Canal 与 MariaDB、kafka 实现数据同步
一、架构
1. 服务器节点
服务名 | IP | 角色 |
---|---|---|
MariaDB | 192.168.31.102 | 主节点 |
Canal | 192.168.31.101 | 从节点 |
2.canal 简介
canal [kə’næl],译意为水道/管道/沟渠,主要用途是基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费业务,其中包括:
-
数据库镜像、数据库实时备份
-
索引构建和实时维护(拆分异构索引、倒排索引等)
-
业务 cache 刷新、带业务逻辑的增量数据处理
(1)工作原理
- canal 模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送 dump 协议
- MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal )
- canal 解析 binary log 对象(原始为 byte 流)
(2)优缺点
优点: 实时性好、分布式、ACK机制
缺点:
-
只支持增量同步,不支持全量同步
-
MySQl–>ES、RDB,支持的数据源有限
-
一个instance只能有一个消费端消费
-
单点压力过大
二、MariaDB 主节点
1. MariaDB 安装
2. 配置文件 /etc/my.cnf
对于 MariaDB 主节点 , 需要先开启 Binlog 写入功能,配置 binlog-format 为 ROW 模式,my.cnf 中配置如下:
[mysqld]
log-bin=didiok-mysql-bin # 开启 binlog
binlog-format=ROW # 选择 ROW 模式
server_id=102 # 保证唯一,不能和 canal 中的 slaveId 重复
3. 重启服务
重启:
systemctl restart mariadb
mysql -uroot -proot
查看 log_bin 的开启状态:
mariadb> show variables like '%log_bin%';
binlog 日志的存放位置:/var/lib/mysql
4. 在MySQL主节点上创建用于备份的账号
重启 MariaDB 之后,进入 MariaDB,创建用于数据同步的新账号:
#此时创建了名为 canal 的用户,密码为 123456,% 表示任意地址都可远程登录。
mariadb> CREATE USER 'canal'@'%' IDENTIFIED BY '123456';
#给 canal 用户授权同步复制等的权限(REPLICATION SLAVE)
mariadb> GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
mariadb> flush privileges;
-- 授权 canal 链接 MySQL 账号具有作为 MySQL slave 的权限, 如果已有账户可直接 grant
-- CREATE USER canal IDENTIFIED BY 'canal';
-- GRANT ALL PRIVILEGES ON *.* TO 'canal'@'%' IDENTIFIED BY 'canal' WITH GRANT OPTION;
-- GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
三、Canal 从节点
1. 下载
下载地址:Canal下载
共有以下四个安装包,这里只下载 canal.deployer-1.1.7-SNAPSHOT.tar.gz
-
canal.adapter-1.1.7-SNAPSHOT.tar.gz:同步的适配器。相当于canal的客户端,会从canal-server中获取数据(需要配置为tcp方式),然后对数据进行同步,可以同步到MySQL、Elasticsearch和HBase等存储中去。相较于canal-server自带的canal.serverMode,canal-adapter提供的下游数据接受更为广泛。
-
canal.admin-1.1.7-SNAPSHOT.tar.gz:admin 控制台。为canal提供整体配置管理、节点运维等面向运维的功能,提供相对友好的WebUI操作界面,方便更多用户快速和安全的操作。
-
canal.deployer-1.1.7-SNAPSHOT.tar.gz :canal服务。可以直接监听MySQL的binlog,把自己伪装成MySQL的从库,只负责接收数据,并不做处理。接收到MySQL的binlog数据后可以通过配置canal.serverMode:tcp, kafka, rocketMQ, rabbitMQ连接方式发送到对应的下游。其中tcp方式可以自定义canal客户端进行接受数据,较为灵活。
-
canal.example-1.1.7-SNAPSHOT.tar.gz:canal例子
2. 安装 canal
创建文件夹并 解压 canal
mkdir /usr/local/canal
tar -zxvf canal.deployer-1.1.7-SNAPSHOT.tar.gz -C /usr/local/canal/
3. 修改配置文件
(1)修改 canal.properties
vim /usr/local/canal/conf/canal.properties
主要修改内容为:
# java程序连接端口
canal.port = 11111
另外,如果系统是1个 cpu,需要将 canal.instance.parser.parallel 设置为 false
(2)修改 instance.properties
vim /usr/local/canal/conf/example/instance.properties
主要修改内容如下:
## 不能与已有的 mariadb 节点server-id重复
canal.instance.mysql.slaveId=101
## mariadb master的地址
canal.instance.master.address=192.168.31.102:3306
## 指定连接 mariadb 的用户密码
canal.instance.dbUsername=canal
canal.instance.dbPassword=123456
## 字符集
canal.instance.connectionCharset = UTF-8
4. 启动canal
cd /usr/local/canal/bin
./startup.sh
# 查看运行状态
jps
## 关闭命令是 ./stop.sh
5. 验证服务
是否启动成功,我们可以通过查看日志数据
## 查看 server 日志
cat /usr/local/canal/logs/canal/canal.log
## 或者使用如下命令查看 instance 的日志
tail -f -n 100 /usr/local/canal/logs/example/example.log
如果使用 tail -f -n 100 /usr/local/canal/logs/example/example.log
,看到如下报错:
2023-05-22 20:57:43.474 [destination = example , address = /192.168.31.102:3306 , EventParser] ERROR com.taobao.tddl.dbsync.binlog.LogEvent - Query_log_event has unknown status vars (first has code: 129), skipping the rest of them
2023-05-22 20:57:43.474 [destination = example , address = /192.168.31.102:3306 , EventParser] ERROR com.taobao.tddl.dbsync.binlog.LogEvent - Query_log_event has unknown status vars (first has code: 23), skipping the rest of them
2023-05-22 20:57:43.474 [destination = example , address = /192.168.31.102:3306 , EventParser] WARN com.taobao.tddl.dbsync.binlog.LogDecoder - Decoding Query failed from: didiok-mysql-bin.000001:1469
java.io.IOException: Read Q_FLAGS2_CODE error: limit excceed: 67
at com.taobao.tddl.dbsync.binlog.event.QueryLogEvent.unpackVariables(QueryLogEvent.java:715) ~[canal.parse.dbsync-1.1.5.jar:na]
at com.taobao.tddl.dbsync.binlog.event.QueryLogEvent.<init>(QueryLogEvent.java:495) ~[canal.parse.dbsync-1.1.5.jar:na]
at com.taobao.tddl.dbsync.binlog.LogDecoder.decode(LogDecoder.java:168) ~[canal.parse.dbsync-1.1.5.jar:na]
at com.taobao.tddl.dbsync.binlog.LogDecoder.decode(LogDecoder.java:111) ~[canal.parse.dbsync-1.1.5.jar:na]
at com.alibaba.otter.canal.parse.inbound.mysql.MysqlConnection.dump(MysqlConnection.java:179) [canal.parse-1.1.5.jar:na]
at com.alibaba.otter.canal.parse.inbound.AbstractEventParser$1.run(AbstractEventParser.java:276) [canal.parse-1.1.5.jar:na]
at java.lang.Thread.run(Thread.java:750) [na:1.8.0_361]
Caused by: java.lang.IllegalArgumentException: limit excceed: 67
at com.taobao.tddl.dbsync.binlog.LogBuffer.getUint32(LogBuffer.java:562) ~[canal.parse.dbsync-1.1.5.jar:na]
at com.taobao.tddl.dbsync.binlog.event.QueryLogEvent.unpackVariables(QueryLogEvent.java:612) ~[canal.parse.dbsync-1.1.5.jar:na]
... 6 common frames omitted
2023-05-22 20:57:43.474 [destination = example , address = /192.168.31.102:3306 , EventParser] ERROR c.a.o.c.p.inbound.mysql.rds.RdsBinlogEventParserProxy - dump address /192.168.31.102:3306 has an error, retrying. caused by
java.io.IOException: Read Q_FLAGS2_CODE error: limit excceed: 67
at com.taobao.tddl.dbsync.binlog.event.QueryLogEvent.unpackVariables(QueryLogEvent.java:715) ~[canal.parse.dbsync-1.1.5.jar:na]
at com.taobao.tddl.dbsync.binlog.event.QueryLogEvent.<init>(QueryLogEvent.java:495) ~[canal.parse.dbsync-1.1.5.jar:na]
at com.taobao.tddl.dbsync.binlog.LogDecoder.decode(LogDecoder.java:168) ~[canal.parse.dbsync-1.1.5.jar:na]
at com.taobao.tddl.dbsync.binlog.LogDecoder.decode(LogDecoder.java:111) ~[canal.parse.dbsync-1.1.5.jar:na]
at com.alibaba.otter.canal.parse.inbound.mysql.MysqlConnection.dump(MysqlConnection.java:179) ~[canal.parse-1.1.5.jar:na]
at com.alibaba.otter.canal.parse.inbound.AbstractEventParser$1.run(AbstractEventParser.java:276) ~[canal.parse-1.1.5.jar:na]
at java.lang.Thread.run(Thread.java:750) [na:1.8.0_361]
Caused by: java.lang.IllegalArgumentException: limit excceed: 67
at com.taobao.tddl.dbsync.binlog.LogBuffer.getUint32(LogBuffer.java:562) ~[canal.parse.dbsync-1.1.5.jar:na]
at com.taobao.tddl.dbsync.binlog.event.QueryLogEvent.unpackVariables(QueryLogEvent.java:612) ~[canal.parse.dbsync-1.1.5.jar:na]
... 6 common frames omitted
2023-05-22 20:57:43.475 [destination = example , address = /192.168.31.102:3306 , EventParser] ERROR com.alibaba.otter.canal.common.alarm.LogAlarmHandler - destination:example[java.io.IOException: Read Q_FLAGS2_CODE error: limit excceed: 67
at com.taobao.tddl.dbsync.binlog.event.QueryLogEvent.unpackVariables(QueryLogEvent.java:715)
at com.taobao.tddl.dbsync.binlog.event.QueryLogEvent.<init>(QueryLogEvent.java:495)
at com.taobao.tddl.dbsync.binlog.LogDecoder.decode(LogDecoder.java:168)
at com.taobao.tddl.dbsync.binlog.LogDecoder.decode(LogDecoder.java:111)
at com.alibaba.otter.canal.parse.inbound.mysql.MysqlConnection.dump(MysqlConnection.java:179)
at com.alibaba.otter.canal.parse.inbound.AbstractEventParser$1.run(AbstractEventParser.java:276)
at java.lang.Thread.run(Thread.java:750)
Caused by: java.lang.IllegalArgumentException: limit excceed: 67
at com.taobao.tddl.dbsync.binlog.LogBuffer.getUint32(LogBuffer.java:562)
at com.taobao.tddl.dbsync.binlog.event.QueryLogEvent.unpackVariables(QueryLogEvent.java:612)
... 6 more
解决方法:
使用最新版 canal,新版的 canal 已经修复这个问题,我这里安装的是 canal.deployer-1.1.7-SNAPSHOT.tar.gz
没有出现这个问题!
四、SpringBoot 与 Canal 结合实现数据同步
1. 在 SpringBoot 工程中添加依赖
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.client</artifactId>
<version>1.1.5</version>
</dependency>
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.protocol</artifactId>
<version>1.1.5</version>
</dependency>
2. 编写java 类:
public class CanalTest {
/**
* 1. 连接Canal服务器
* 2. 向Master请求dump协议
* 3. 把发送过来的binlog进行解析
* 4. 最后做实际的操作处理...发送到MQ Print...
* @param args
*/
public static void main(String[] args) {
CanalConnector connector = CanalConnectors.newSingleConnector(
new InetSocketAddress("192.168.31.101", 11111), // 192.168.31.101是canal所在节点ip,11111是java连接canal的端口号
"example", "canal", "123456");
int batchSize = 1000; // 拉取数据量
int emptyCount = 0;
try {
// 连接我们的canal服务器
connector.connect();
// 订阅什么内容? 什么库表的内容??
connector.subscribe(".*\\..*"); // 表示订阅所有的库和表
// 出现问题直接进行回滚操作
connector.rollback();
int totalEmptyCount = 1200;
while(emptyCount < totalEmptyCount) {
Message message = connector.getWithoutAck(batchSize); // 一次性拉取 1000 条数据,封装成一个 message
// batchId用于处理完数据后进行ACK提交动作
long batchId = message.getId();
int size = message.getEntries().size();
if(batchId == -1 || size == 0) {
// 没有拉取到数据
emptyCount++;
System.err.println("empty count: " + emptyCount);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// ignore..
}
} else {
// 有数据
emptyCount = 0;
System.err.printf("message[batchId=%s, size=%s] \n", batchId, size);
// 处理解析数据
printEnrty(message.getEntries());
}
// 确认提交处理后的数据
connector.ack(batchId);
}
System.err.println("empty too many times, exit");
} finally {
// 关闭连接
connector.disconnect();
}
}
private static void printEnrty(List<Entry> entries) {
for(Entry entry : entries) {
System.err.println("entry.getEntryType():"+entry.getEntryType());
// 如果EntryType 当前处于事务的过程中 那就不能处理
if(entry.getEntryType() == EntryType.TRANSACTIONBEGIN || entry.getEntryType() == EntryType.TRANSACTIONEND) {
continue;
}
// rc里面包含很多信息:存储数据库、表、binlog
RowChange rc = null;
try {
// 二进制的数据
rc = RowChange.parseFrom(entry.getStoreValue());
} catch (Exception e) {
throw new RuntimeException("parser error!");
}
EventType eventType = rc.getEventType();
System.err.println(String.format("binlog[%s:%s], name[%s,%s], eventType : %s",
entry.getHeader().getLogfileName(),
entry.getHeader().getLogfileOffset(),
entry.getHeader().getSchemaName(),
entry.getHeader().getTableName(),
eventType));
// 真正的对数据进行处理
for(RowData rd : rc.getRowDatasList()) {
if(eventType == EventType.DELETE) {
// delete操作 BeforeColumnsList
List<Column> deleteList = rd.getBeforeColumnsList();
printColumn(deleteList, "删除前");
} else if(eventType == EventType.INSERT) {
// insert操作AfterColumnsList
List<Column> insertList = rd.getAfterColumnsList();
printColumn(insertList, "新增后");
}
// update
else {
List<Column> updateBeforeList = rd.getBeforeColumnsList();
printColumn(updateBeforeList, "修改前");
List<Column> updateAfterList = rd.getAfterColumnsList();
printColumn(updateAfterList, "修改后");
}
}
}
}
private static void printColumn(List<Column> columns, String operationMsg) {
System.err.println("CanalEntry.Column的个数:"+list.size());
for(Column column: columns) {
System.err.println("操作类型:" + operationMsg + "--"
+column.getName()
+ " : "
+ column.getValue()
+ ", update = "
+ column.getUpdated());
}
}
}
3. 测试
启动 SpringBoot 项目后,在 MariaDB 主节点修改数据库中的表数据,然后观察 java 控制台的输出,这里java代码只是在获取到数据后打印出来,如果有需要的话可以自己将数据发送到 MQ 中、MySQL/MariaDB、或者Redis。
MariaDB 主节点新增数据库中的表数据:
java 控制台的输出:
四、Canal 与 Kafka 结合实现数据同步
第三步中的 java与 canal 结合的操作是单线程,性能上不是很好,没有消息堆积能力,并发量一上来性能支撑不住,所以需要将 canal 与 kafka 整合,将 mysql binlog 数据投递到 kafka 上,再经过消费端去处理。kafka的优点:稳定性好,性能好,高吞吐量,可以做流量削峰,缓存数据。使用kafka可以有消息堆积,缓存消息,高性能高吞吐。在处理大规模的数据时有很大优势。
MariaDB的binlog 有变更时,canal 会解析 binlog 日志,将解析的数据发送给 kafka,然后编写 消费端代码,处理 kafka 的消息。比如可以输出到 ES。
1. Canal 与 Kafka整合,需要配置 canal
(1)修改 canal.properties
vim /usr/local/canal/conf/canal.properties
主要修改内容为:
canal.serverMode = kafka # 选择kafka的推送模式,发送kafka消息,默认是 tcp
kafka.bootstrap.servers = 192.168.31.101:9092
kafka.acks = all
kafka.compression.type = none
kafka.batch.size = 16384
kafka.linger.ms = 100
kafka.max.request.size = 1048576
kafka.buffer.memory = 33554432
kafka.max.in.flight.requests.per.connection = 1
kafka.retries = 3
(2)修改 instance.properties
vim /usr/local/canal/conf/example/instance.properties
主要修改内容如下:
# table regex 这个是比较重要的参数,匹配库表白名单,比如我只要test库的user表的增量数据,则这样写 test.user
canal.instance.filter.regex=.*\\..*
####### mq config ######
# canal.mq.topic=example
# 动态topic,根据库名和表名生成 dynamic topic route by schema or table regex
canal.mq.dynamicTopic=.*\\..*
# table black regex
canal.instance.filter.black.regex=
#canal.mq.partition=0
# hash partition config
#canal.mq.enableDynamicQueuePartition=false
# 根据 kafka 创建 topic时,默认生成的 partition 数量来设置,一般小于等于 kafka 的 partition 数量,我的kafka设置的为5,所以这里也设置5
canal.mq.partitionsNum=5
#canal.mq.dynamicTopicPartitionNum=test.*:4,mycanal:6
# .*\\..*:$pk$ 正则匹配,指定所有正则匹配的表对应的hash字段为表主键(自动查找)
canal.mq.partitionHash=.*\\..*:$pk$
2. 编写 kafka 的消费端代码
canal监听 MariaDB 主节点,当有数据变更时,即 binlog 有更新时,就会发消息给 kafka,然后我们现在编写一个消费者,来监听指定的topic,获取到 kafka 的消息,进行消费。
public class CollectKafkaConsumer {
private final KafkaConsumer<String, String> consumer;
private final String topic;
public CollectKafkaConsumer(String topic) {
Properties props = new Properties();
// 链接kafka集群
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.31.101:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "demo-group-id");
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
// props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, "1000");
//
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "latest");//"latest"
//latest,earliest
props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "30000");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
consumer = new KafkaConsumer<>(props);
this.topic = topic;
// 订阅主题
consumer.subscribe(Collections.singletonList(topic));
}
private void receive(KafkaConsumer<String, String> consumer) {
while (true) {
// 拉取结果集
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
for (TopicPartition partition : records.partitions()) {
List<ConsumerRecord<String, String>> partitionRecords = records.records(partition);
String topic = partition.topic();
int size = partitionRecords.size();
// 获取topic: test-db.demo, 分区位置: 2, 消息数为:1
System.err.println("获取topic: " + topic + ", 分区位置: " + partition.partition() + ", 消息数为:" + size);
for (int i = 0; i< size; i++) {
/**
* {
* ----> "data":[{"id":"010","name":"z100","age":"35"}],
* ----> "database":"test-db",
* "es":1605269364000,
* ----> "id":2,
* "isDdl":false,
* ----> "mysqlType":{"id":"varchar(32)","name":"varchar(40)","age":"int(8)"},
* ----> "old":[{"name":"z10","age":"32"}],
* ----> "pkNames":["id"],
* "sql":"",
* "sqlType":{"id":12,"name":12,"age":4},
* ----> "table":"demo",
* ----> "ts":1605269365135,
* ----> "type":"UPDATE"}
*/
System.err.println("-----> value: " + partitionRecords.get(i).value());
long offset = partitionRecords.get(i).offset() + 1;
// consumer.commitSync();
consumer.commitSync(Collections.singletonMap(partition, new OffsetAndMetadata(offset)));
System.err.println("同步成功, topic: " + topic+ ", 提交的 offset: " + offset);
}
//
//System.err.println("msgList: " + msgList);
}
}
}
public static void main(String[] args) {
String topic = "didiok_users";
CollectKafkaConsumer collectKafkaConsumer = new CollectKafkaConsumer(topic);
collectKafkaConsumer.receive(collectKafkaConsumer.consumer);
}
}
随后启动springboot项目,然后在 192.168.31.102 节点上的数据库didiok的表users中新增数据进行测试,java控制台会输出如下内容: