Canal 与 MariaDB 实现数据同步

Canal 与 MariaDB、kafka 实现数据同步


一、架构

1. 服务器节点

服务名IP角色
MariaDB192.168.31.102主节点
Canal192.168.31.101从节点

2.canal 简介

Alt

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 安装

教程:CentOS-7 安装 MariaDB-10.8

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控制台会输出如下内容:



微信公众号名称:Java知者

微信公众号id:JavaZhiZhe

欢迎关注,谢谢!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值