关于使用canal进行双向同步时回流问题的处理

关于使用canal进行双向同步时回流问题的处理

前置条件

仅讨论数据库binlog使用ROW模式(可用 SHOW GLOBAL VARIABLES LIKE ‘binlog_format’ 命令查看)

当两个数据库的同一张表名都被canal订阅并同步到对方库时,如图
在这里插入图片描述

问题产生原因

连续对同一张表的同一个字段修改为不同值时,就会产生数据回流(即相同的两条或两条以上的更新/插入语句不断重复的被执行消费执行消费再执行

不管是对同一个库还是不同的库修改都会产生这个问题

解决方案分析(以下方案都需要修改canal源码)

方案1 基于redis去重(不精准拦截)

既然是相同的sql重复执行消费,那么在写入和发送的节点上增加一个重复判断操作即可
不过该方案数据还是会产生一次循环消费,需要在第二次发送的时候才会被拦截,如图
在这里插入图片描述
该方案只能拦截一段时间内的重复sql,即基于redis内存大小以及内存淘汰机制决定

方案2 基于sql注释方案(精准拦截)

基于 SET GLOBAL binlog_rows_query_log_events=1;(默认0不开启)参数,该参数是MySQL 5.6.2版本之后才有的,设置后会在binlog使用ROW格式时额外把SQL语句也记录到binlog中
基于SQL语句,我们可以在SQL语句中加入自定义标识的注释来起到区分写入源的目的,如果能区分写入源的话,那么就能做到精准拦截,如图
在这里插入图片描述
SQL注释示例 UPDATE /*这里是注释*/ table_name SET file_name = file_value;
要实现这

方案实践

由于方案1实现不能做到精准拦截,这里只对方案2做详细说明
要实现方案2需要对SQL写入方和binlog读出的canal方进行改造封装

  1. MQ消费端也就是同步写入端的所有执行SQL都经过SQL注释封装
    public class SqlCommentUtil {
    
        private static final String SQL_COMMENT = "test_canal_sync_filter_COMMENT";
    
        /**
         * 对sql添加注释标识
         * @param sql 原SQL
         * @return 添加注释标识的sql
         */
        public static String sqlCommentHandler(String sql) {
            int startIndex = sql.indexOf("/*");
            int endIndex = sql.indexOf("*/");
            // 可能SQL本身就存在注释,需要去掉
            if (startIndex != -1 && endIndex != -1) {
                sql = sql.substring(0, startIndex) + sql.substring(endIndex + 2);
            }
    
            String addStr = String.format("/*--%s--*/", SQL_COMMENT);
            int index = sql.indexOf(" ");
            return sql.substring(0, index) + addStr + sql.substring(index);
        }
    }
    
  2. canal源码修改
    2.1. 获取canal源码,地址: https://github.com/alibaba/canal
    2.2. 找到 MQMessageUtils 类所在包路径
    在这里插入图片描述
    2.3. 创建 SimpleCheck 拦截类
    package com.alibaba.otter.canal.connector.core.producer;
    
    import com.alibaba.otter.canal.protocol.CanalEntry;
    import org.apache.commons.lang.StringUtils;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.util.CollectionUtils;
    
    public class SimpleCheck {
    
        private static final String SQL_COMMENT = "test_canal_sync_filter_COMMENT";
    
        protected Logger logger = LoggerFactory.getLogger(this.getClass());
    
        private Boolean sqlFilter;
    
        /**
         * @param entry
         * @return
         */
        public boolean checkFilter(CanalEntry.Entry entry) {
            // 事务开始
            if (entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONBEGIN) {
                sqlFilter = null;
                return true;
            }
    
            // 事务结束
            if (entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONEND) {
                sqlFilter = null;
                return true;
            }
    
            CanalEntry.RowChange rowChange = parseFrom(entry);
            // ddl语句
            if (rowChange.getIsDdl()) {
                refreshComment(rowChange);
                return sqlFilter;
            }
            // sql语句,这里不同步
            if (StringUtils.isNotBlank(rowChange.getSql())) {
                refreshComment(rowChange);
                return true;
            }
            // 具体变更的字段内容
            if (CollectionUtils.isEmpty(rowChange.getRowDatasList())) {
                return true;
            }
            if (sqlFilter == null) {
                // 原则上不会出现该日志,如果出现该日志说明没有精准拦截
                logger.info("tableName[{}], eventType[{}]", entry.getHeader().getTableName(), rowChange.getEventType());
            }
            return sqlFilter == null || sqlFilter;
        }
    
        protected CanalEntry.RowChange parseFrom(CanalEntry.Entry entry) {
            try {
                return CanalEntry.RowChange.parseFrom(entry.getStoreValue());
            } catch (Exception e) {
                throw new RuntimeException(e.getMessage(), e);
            }
        }
    
        private void refreshComment(CanalEntry.RowChange entry) {
            String site = parseComment(entry);
            sqlFilter = StringUtils.equals(SQL_COMMENT, site);
        }
    
        private String parseComment(CanalEntry.RowChange rowChange) {
            String sql = rowChange.getSql();
            return StringUtils.substringBetween(sql, "/*--", "--*/");
        }
    }
    
    
    2.4. 创建 MyMQMessageUtils 类用来替换 MQMessageUtils 类的 messageTopics 方法
    package com.alibaba.otter.canal.connector.core.producer;
    
    import com.alibaba.otter.canal.protocol.CanalEntry;
    import com.alibaba.otter.canal.protocol.CanalEntry.Entry;
    import com.alibaba.otter.canal.protocol.Message;
    import com.google.protobuf.ByteString;
    import com.google.protobuf.InvalidProtocolBufferException;
    
    import java.util.ArrayList;
    import java.util.HashMap;
    import java.util.List;
    import java.util.Map;
    import java.util.function.Consumer;
    
    /**
     *
     */
    public class MyMQMessageUtils {
    
        private static final SimpleCheck SIMPLE_CHECK = new SimpleCheck();
    
        /**
         * @param message             原message
         * @param defaultTopic        默认topic
         * @param dynamicTopicConfigs 动态topic规则
         * @return 分隔后的message map
         */
        public static Map<String, Message> messageTopics(Message message, String defaultTopic, String dynamicTopicConfigs) {
            List<CanalEntry.Entry> entries = getEntryList(message);
            Map<String, Message> messages = new HashMap<>();
            Consumer<Entry> messagePutConsumer = getMessagePutConsumer(messages, message, dynamicTopicConfigs);
            for (CanalEntry.Entry entry : entries) {
                if (SIMPLE_CHECK.checkFilter(entry)) {
                    continue;
                }
                messagePutConsumer.accept(entry);
            }
            return messages;
        }
    
        private static List<CanalEntry.Entry> getEntryList(Message message) {
            if (!message.isRaw()) {
                return message.getEntries();
            }
            List<ByteString> rawEntries = message.getRawEntries();
            List<CanalEntry.Entry> entries = new ArrayList<>(rawEntries.size());
            for (ByteString byteString : rawEntries) {
                CanalEntry.Entry entry;
                try {
                    entry = CanalEntry.Entry.parseFrom(byteString);
                } catch (InvalidProtocolBufferException e) {
                    throw new RuntimeException(e);
                }
                entries.add(entry);
            }
            return entries;
        }
    
        private static Consumer<Entry> getMessagePutConsumer(Map<String, Message> messages, Message message, String topicName) {
            return entry -> {
                Message newMessage = messages.get(topicName);
                if (newMessage == null) {
                    newMessage = new Message(message.getId());
                    newMessage.setRaw(false);
                    messages.put(topicName, newMessage);
                }
                newMessage.addEntry(entry);
            };
        }
    }
    
    2.5. 最后替换原来调用 MQMessageUtils#messageTopics 方法的引用到 MyMQMessageUtils#messageTopics 上即可,以RocketMq的CanalRocketMQProducer 为例
    在这里插入图片描述

方案验证

  1. 创建测试表

    CREATE TABLE `test` (
     `id` bigint NOT NULL AUTO_INCREMENT,
     `age` bigint DEFAULT NULL,
     `name` varchar(20) DEFAULT NULL,
     `create_date` datetime DEFAULT NULL,
     `update_date` datetime DEFAULT NULL,
     PRIMARY KEY (`id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
    
  2. 启动 CanalLauncher 类并配置参数

    -Dcanal.instance.master.address=mysql.yun.com:3306
    -Dcanal.instance.dbPassword=admin
    -Dcanal.instance.dbUsername=root
    -Dcanal.instance.filter.black.regex=
    -Dcanal.instance.filter.regex=.*\..*
    -Dcanal.serverMode=RocketMQ
    -Dcanal.mq.servers=127.0.0.1:9876;
    -Dcanal.mq.dynamicTopic=test-canal-sync-filter-topic
    -Dcanal.mq.producerGroup=test-canal-sync-filter-group
    -Dcanal.mq.partitionHash=.*:$pk$
    -Dcanal.mq.partitionsNum=16
    

    在这里插入图片描述

  3. 先看看没有开启 binlog_rows_query_log_events 配置情况
    在这里插入图片描述
    执行 INSERT INTO test(age, name, create_date, update_date) VALUES(“1”, “1”, NOW(), NOW());
    binlog记录为三条记录,一条事务开始、一条数据行、一条事务结束
    在这里插入图片描述

  4. 打开 binlog_rows_query_log_events 参数后
    在这里插入图片描述
    执行 INSERT INTO test(age, name, create_date, update_date) VALUES(“2”, “2”, NOW(), NOW());
    binlog记录为三条记录,一条事务开始、一条SQL语句、一条数据行、一条事务结束
    在这里插入图片描述

  5. 最后验证以下带注释的SQL

    INSERT /*test_sync_comment*/ INTO test(age, NAME, create_date, update_date) VALUES("3", "3", NOW(), NOW());
    

    在这里插入图片描述
    拿到sql的注释之后就可以基于注释标识来识别写入原来确定是否要同步该条数据

过程中可能踩的坑

  1. idea报错不兼容(Error:(25, 80) java: 不兼容的类型: 无法推断类型变量 K,V)

    问题
    在这里插入图片描述
    解决,添加新版本的JDK,JDK8也有好些版本,建议使用高版本
    在这里插入图片描述
    idea有自带的JDK的
    在这里插入图片描述
    最后更新一下版本
    在这里插入图片描述

  2. 没有日志
    需要手动添加canal源码所在文件夹上级目录logs文件夹内的canal.log文件
    在这里插入图片描述

  3. 报 class could not be found 错误

    2022-02-21 12:42:12.527 [main] ERROR com.alibaba.otter.canal.deployer.CanalLauncher - ## Something goes wrong when starting up the canal Server:
    java.lang.IllegalStateException: Extension instance(name: rocketmq, class: interface com.alibaba.otter.canal.connector.core.spi.CanalMQProducer)  could not be instantiated: class could not be found
    	at com.alibaba.otter.canal.connector.core.spi.ExtensionLoader.createExtension(ExtensionLoader.java:169) ~[classes/:na]
    	at com.alibaba.otter.canal.connector.core.spi.ExtensionLoader.getExtension(ExtensionLoader.java:121) ~[classes/:na]
    	at com.alibaba.otter.canal.deployer.CanalStarter.start(CanalStarter.java:68) ~[classes/:na]
    	at com.alibaba.otter.canal.deployer.CanalLauncher.main(CanalLauncher.java:117) ~[classes/:na]
    

    在canal根目录的pom.xml下执行 mvn package 命令打包即可

扩展

  1. 如果把注释标识进行传递,再对每个sql有个终端进行记录,就可以识别所有sql的发起服务
  2. binlog_rows_query_log_events 参数有会话和全局设置,会话级别设置当前会话之后执行的sql立即生效,全局级别设置断开会话后重新连接生效,如果重启MySQL则会还原默认值,所以生产环境建议在配置文件中设置该值
  3. DDL和DML语句中只有删除表操作的注释捕捉不到即 DROP table xxx
  4. canal会自动记录binlog消费下标,但是长时间下线导致重新上线时获取不到上次binlog文件位置则会报错,一个binlog文件默认500M 根据使用量会影响binlog过期速度

总结

  1. binlog_rows_query_log_events 参数设置ROW模式同时记录执行的SQL语句到binlog文件
  2. DROP 表语句获取不到原始注释,创建/删除视图语句也不行
  3. sql注释格式为: /*这里是注释*/
  4. 拦截的核心思路就是写入方写入唯一标识让监听读出时可识别并进行拦截或分发
  5. SHOW GLOBAL VARIABLES LIKE ‘binlog_format’ 语句结果需要为 ROW模式
  • 4
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值