基于Canal、MQ的异构数据源数据同步代码实践

canal 介绍、搭建、配置

canal [kə'næl] ,译意为水道/管道/沟渠,主要用途是基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费。下载地址:github.com/alibaba/can...

安装 canal admin

管理canal server的WebUI,借助于它我们可以轻松运维canal。

  • conf 目录下有 canal admin.sql 文件,是 canal admin的数据库脚本,需要在我们的数据中执行
  • 配置 application.yml 文件。

安装 canal server

Canal将自己伪装成从库,监听并接收数据库的Binlog,再同步到其他中间件系统。

  • 当使用canal-admin来管理canal-server时,使用的配置文件为 conf/canal_local.properties
  • 当手动维护canal-server的时,使用 conf/canal.properties配置文件
Sh 复制代码
# 配置文件中的重要参数
vim canal_local.properties

# register ip 这⾥的ip选择您本机的ip(也就是启动canal server机器的所在ip地址)
canal.register.ip = 192.168.0.222 
# canal admin config 这⾥是部署canal admin的所在机器的ip,当然也可以把canal admin 和canal server部署到⼀台机器,这⾥的端⼝表示 http访问的端⼝
canal.admin.manager = 192.168.0.221:8089 
# 表示canal-admin与canal-server内部通信的TCP端⼝
canal.admin.port = 11110 
# canal-admin的⽤户名和密码,canal-server在进⾏访问的时候,需要⽤他们来进⾏认证,如果填写不正确,则会出现⽆法连接
canal.admin.user = admin canal.admin.passwd =6BB4837EB74329105EE4568DDA7DC67ED2CA2AD9 # admin auto register 这⾥⼀定得是true,否则⽆法在启动canal server时候注册到 canal admin上 
# 这个表示,启动canal-server时是否⾃动将canal-server的信息 注册到canal-admin上,默认是true,我们不⽤改。
canal.admin.register.auto = true 
# 这个表示我们要注册到哪个集群中,我们没有采⽤集群的⽅式部署 canal-server,所以这⾥什么都不⽤写。如果采⽤集群的⽅式这⾥写集群的名称即可。当然我们这 ⾥没有使⽤集群的⽅式来部署canal-server,因为即使我们使⽤集群的⽅式,同⼀时间,也只有⼀ 个canal-server在⼯作,canal-server的集群模式,类似于hdfs中的NameNode的⼯作原理,两个 canal-server同时机器,都会注册到zk上,谁先注册到zk上,谁就会是active节点,剩下的那个作 为standby节点。
canal.admin.register.cluster = 
# canal server注册到canal admin控制台的名称 这⾥⽤canal server的ip地址。注意改ip 
canal.admin.register.name = 192.168.0.222

canal admin 配置 canal instance 监听数据库

  1. ⼀个canal-server服务⾥可以有多个canal-instance,⼀般⼀个canal-instance实例作⽤在⼀个数据库实例上。
  2. 我们根据业务情况,如果我们监听的数据库的负载很大,那么我们在canal server上启动⼀个 canal instalce实例就行。如果负载不大,可以酌情启动多个实例来监听相关的数据库的binlog日志。
  1. 填写canal-instance的名称
  2. 选择当前创建的canal-instance所属的canal-server。
  3. 点击载⼊模板,填写canal-instance的配置

重要配置项:

YAML 复制代码
# 需要监听数据库的 ip加端⼝
canal.instance.master.address=127.0.0.1:3306
# 需要监听数据库的⽤户名
canal.instance.tsdb.dbUsername=canal 
# 需要监听数据库的密码
canal.instance.tsdb.dbPassword=canal

# 这⾥填写topic名称即可,前提是 canal.serverMode 选择的是kafka, rocketMQ, rabbitMQ其中之⼀,(注意这个配置项在canal server的canal.properties配置⽂件下)。 同时如果我们配置了canal.mq.dynamicTopic的配置,则canal.mq.topic 只有在找不到相关topic的情况下,才会发送到canal.mq.topic所指定的topic下,⽤于兜底。
canal.mq.topic=example

# 这⾥填写动态topic的相关配置;
# 冒号前⾯是topic的名称,冒号后⾯是库名.表名,多个动态topic的规则,采⽤逗号分隔。
# 例如我们要把订单的正向操作的binlog 相关表发送到order-forward topic下,把逆向操作产⽣的binlog发送到order-reverse这个topic 下,我们可以这么写:
# order-forward:order\\.order_info, orderreverse:order\\.after_sale_refund 表示,把order数据库下的 order_info表的binlog⽇志发送到名为order-forward的topic下,把order数据库下的 after_sale_refund表的binlog⽇志发送到名为order-reverse的topic下
canal.mq.dynamicTopic=order-forward:order\\.order_info,orderforward:order\\.order_delivery_detail,orderforward:order\\.order_payment_detail,orderreverse:order\\.after_sale_info,orderreverse:order\\.after_sale_refund
       
# 这里填写订阅的数据库的库与表的相关正则表达式,语法如下:
# 1. 所有表:.* or .*\\..* 
# 2. canal schema下所有表: canal\\..* 
# 3. canal下的以canal打头的表:canal\\.canal.*
# 4. canal schema下的⼀张表:canal.test1
# 5. 多个规则组合使⽤:canal\\..*,mysql.test1,mysql.test2 (逗号分隔)
# 例如:order.order_info,order.order_delivery_detail,order.or der_payment_detail,order.after_sale_info,order.after_sale_refund 表示我们只关注上⾯这些表的binlog⽇志。
canal.instance.filter.regex=monomer_order\\.order.*

PS:如果要让canal将binlog⽇志投递到mq,那么需要在canal_local.properties中加⼊ RocketMQ 相关配置,这个在canal admin中修改就⾏。

配置如下

sh 复制代码
# tcp, kafka, RocketMQ,数据同步⽅式 不写则默认使⽤tcp的⼯作⽅式 
canal.serverMode=RocketMQ 
# 如果是 RocketMQ 是nameSrv的地址 如:xxx.xxx.xxx.xxx:9876 
canal.mq.servers=192.168.0.222:9876

开启 mysql binlog

sh 复制代码
show variables like 'binlog_format';
show variables like 'log_bin';

基于读写队列&定时任务实现高效写入

在增量同步环节,我们是通过canal监听mysql中的binlog日志,然后canal再将监听到的binlog⽇志,将 binlog日志放到RocketMQ中的。那么现在面临几个问题:

  • 定时任务消费RocketMQ中的binlog消息时,如何保证从Rocket中获取到的binlog 消息,一定不会丢失呢?
  • 如何尽可能的提高写入效率呢?

让我们一起看下吧!

防止增量同步数据丢失

数据库中执行了增删改操作,但是由于消息丢失了,导致其他数据源中却没有对应的增量数据操作,产生数据不一致问题。为此,我们引入了消息拉取和消息提交机制,主要由两个定时任务完成:

  • 定时任务1先从RocketMQ中主动拉取消息,然后在旧库中会有⼀张消费记录表,每次从RocketMQ中拉取并消费⼀条消息时,都会对应在消费记录表中,新增⼀条消费记录,每条消费记录的初始状态为未消费。

  • 定时任务1再将获取到的binlog消息,在其他数据源中重做binlog⽇志,重做完成之后,回过头来更新刚才添加的消费记录的状态,从未消费更新为已消费状态。

  • 消费了RocketMQ的binlog消息之后,并不会主动向RocketMQ提交消息,而是手动提交。所以,为了保证binlog消息不丢失,我们不会自动提交消息,⽽是将提交消息的任务,交给定时任务2来处理,定时任务2会专门从消费记录表中,查询已消费的那些记录,然后向RocketMQ提交消息,下次就不会从RocketMQ中消费到了。

  • 向RocketMQ提交完消息之后,同时会将消费记录表中的记录状态,从已消费更新为已提交。通过消息拉取和消息提交机制,我们就可以保证binlog消息,是⼀定不会丢失的。

提高写入效率

为了提高写⼊效率,我们引入数据合并、过滤、读写队列机制。

  • 添加完消费记录之后,并不会立马重做,先放到写队列中,与之相应的有一个读队列,读队列专专门给定时任务3,负责处理消息写入操作;
  • 数据合并:假设在订单数据1、2在短时间进行了多次操作,我们可以对订单数据1和订单数据2,只保留更新时间为最新的binlog日志。
  • 主要是避免旧库中的那些旧数据,覆盖新数据源的新数据,另一方面避免那些没必要执行的删除和更新操作在新库执行

部分代码

java 复制代码
@Component
public class CanalConsumeTask implements ApplicationRunner {
    /**
     * rocketmq的nameServer地址
     */
    @Value("${rocketmq.name-server:127.0.0.1:9876}")
    private String nameServerUrl;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        // 直接会在这里创建一个线程池
        ExecutorService executors = Executors.newFixedThreadPool(1);
        // 执行拉取任务
        executors.execute(new CanalPullRunner("ORDER_TOPIC", nameServerUrl));
        // 执行提交任务
        executors.execute(new CanalPullCommitRunner("ORDER_TOPIC", nameServerUrl));
    }
}
java 复制代码
private void pullRun() {
    try {
        DefaultLitePullConsumer litePullConsumer = new DefaultLitePullConsumer("binlogPullConsumer");
        litePullConsumer.setAutoCommit(false); 
        // 此时offset已经被提交了,处理失败了,rocketmq不给我们再次重复消费了
        litePullConsumer.setNamesrvAddr(nameServerUrl);
        litePullConsumer.subscribe(topic, "*");
        litePullConsumer.start();

        try {
            while (true) {
                // 拉取未消费消息
                List<MessageExt> messageExts = litePullConsumer.poll();
                if (CollUtil.isNotEmpty(messageExts)) {
                    for (MessageExt messageExt : messageExts) {
                        byte[] body = messageExt.getBody();
                        String msg = new String(body);
                        // 记录queueId和offset
                        int queueId = messageExt.getQueueId();
                        long offset = messageExt.getQueueOffset();
                        String topic = messageExt.getTopic();

                        // 判断该消息是否已经存在消费记录,如果存在则跳过执行
                        EtlBinlogConsumeRecord existsRecord = consumeRecordMapper.getExistsRecord(queueId, offset, topic);
                        if (null == existsRecord) {
                            // 新增消费记录
                            processNewMsg(messageExt, msg);
                        } else {
                            // 处理已经存在的消费记录
                            proccessExistsRecord(litePullConsumer, msg, existsRecord);
                        }
                    }
                } else {
                    Thread.sleep(5000);
                }
            }
        } finally {
            litePullConsumer.shutdown();
        }
    } catch (InterruptedException | MQClientException e) {
        try {
            // 假设要拉取消息的主题还不存在,则会抛出异常,这种情况下休眠五秒再重试
            Thread.sleep(5000);
            pullRun();
        } catch (InterruptedException ignored) {

        }
    }
}

private void processNewMsg(MessageExt messageExt, String msg) {
    try {
        // 拿到的msg值一个字符串,字符串格式的binlog,但是我们需要对字符串格式的binlog做一个解析
        // 把这个解析后的binlog的信息封装到我们自定义的BinlogData对象里去
        BinlogData binlogData = BinlogUtils.getBinlogDataMap(msg);
        Boolean targetOperateType = BinlogType.INSERT.getValue().equals(binlogData.getOperateType())
                || BinlogType.DELETE.getValue().equals(binlogData.getOperateType())
                || BinlogType.UPDATE.getValue().equals(binlogData.getOperateType());
        if (!targetOperateType
                || null == binlogData
                || null == binlogData.getDataMap()) {
            return;
        }


        EtlBinlogConsumeRecord consumeRecord = new EtlBinlogConsumeRecord();
        consumeRecord.setQueueId(messageExt.getQueueId());
        consumeRecord.setOffset(messageExt.getQueueOffset());
        consumeRecord.setTopic(messageExt.getTopic());
        consumeRecord.setBrokerName(messageExt.getBrokerName());
        consumeRecord.setConsumeStatus(ConsumerStatus.NOT_CONSUME.getValue());
        consumeRecord.setCreateTime(new Date());
        consumeRecordMapper.insert(consumeRecord);

    
        LocalQueue.getInstance().submit(binlogData, consumeRecord);
    } catch (Exception e) {
        log.error("新增消费记录失败", e);
    }
}

private void proccessExistsRecord(DefaultLitePullConsumer litePullConsumer, String msg, EtlBinlogConsumeRecord existsRecord) {
    // 已经存在的消费记录状态为已提交,说明mq里的对应消息修改提交状态失败了
    try {
        if (ConsumerStatus.COMMITTED.getValue().equals(existsRecord.getConsumeStatus())) {
            litePullConsumer.seek(new MessageQueue(existsRecord.getTopic(), existsRecord.getBrokerName(), existsRecord.getQueueId()), existsRecord.getOffset());
            //这一步必须,不然手动提交的东西不对
            List<MessageExt> committedFaildmessageExts = litePullConsumer.poll();
            // 再次提交已消费的消息
            litePullConsumer.commitSync();
        } else {
            BinlogData binlogData = BinlogUtils.getBinlogDataMap(msg);
            if (null == binlogData) {
                return;
            }
            LocalQueue.getInstance().submit(binlogData, existsRecord);
        }
    } catch (Exception e) {
        log.error("消息重新消费失败", e);
    }
}
java 复制代码
/**
 * 数据缓存阻塞队列类
 *
 * @author zhonghuashishan
 */
@Slf4j
public class LocalQueue {

    private static volatile LocalQueue localQueue;

    /**
     * 数据同步的写队列
     */
    private volatile LinkedList<BinlogData> writeQueue = new LinkedList<>();
    /**
     * 数据同步的 读队列
     */
    private volatile LinkedList<BinlogData> readQueue = new LinkedList<>();
    /**
     * 提供锁的实例对象
     */
    private final PutBinlogLock lock = new PutBinlogLock();
    /**
     * 是否正在读取数据
     * 可能是多线程并发读和写,volatile,保证线程之间的可见性
     */
    private volatile boolean isRead = false;


    private LocalQueue() {

    }

    /**
     * 构建一个单例模式对象
     *
     * @return LocalQueue实例
     */
    public static LocalQueue getInstance() {
        if (null == localQueue) {
            synchronized (LocalQueue.class) {
                if (null == localQueue) {
                    localQueue = new LocalQueue();
                }
            }
        }
        return localQueue;
    }

    /**
     * 数据写入队列
     *
     * @param binlogData    MySQL的binlog对象
     * @param consumeRecord 消费记录
     */
    public void submit(BinlogData binlogData, EtlBinlogConsumeRecord consumeRecord) {
        lock.lock();
        try {
            binlogData.setConsumeRecord(consumeRecord);
            writeQueue.add(binlogData);
        } finally {
            lock.unlock();
        }
    }

    /**
     * 交换队列
     */
    private void swapRequests() {
        lock.lock();
        try {
            log.info("本次同步数据写入:" + writeQueue.size() + "条数");
            LinkedList<BinlogData> tmp = writeQueue;
            writeQueue = readQueue;
            readQueue = tmp;
        } finally {
            lock.unlock();
        }
    }

    /**
     * 将读队列缓存的数据,进行数据合并处理,并写入存储落地
     */
    public void doCommit() {
        // 标记目前正在读取数据同步落地
        isRead = true;
        // 读取数据,并写入完成以后,交互一下读 写队列
        swapRequests();

        if (!readQueue.isEmpty()) {
           
            MergeBinlogWrite mergeBinlogWrite = new MergeBinlogWrite();
            // 遍历存储在读队列的数据,进行数据合并,保留时间最新的操作
            for (BinlogData binlogData : readQueue) {
                // 对数据进行合并处理
                mergeBinlogWrite.mergeBinlog(binlogData);
            }
            mergeBinlogWrite.filterBinlogAging(OperateType.ADD, null);
            // 数据写入,按表分组写入
            mergeBinlogWrite.write(OperateType.ADD, null);
        }

        readQueue.clear();
        isRead = false;
    }

    /**
     * 获取是否正在读取数据解析落地
     *
     * @return 是否正在读取
     */
    public Boolean getIsRead() {
        return this.isRead;
    }
}
相关推荐
.生产的驴7 分钟前
Electron Vue框架环境搭建 Vue3环境搭建
java·前端·vue.js·spring boot·后端·electron·ecmascript
爱学的小涛14 分钟前
【NIO基础】基于 NIO 中的组件实现对文件的操作(文件编程),FileChannel 详解
java·开发语言·笔记·后端·nio
爱学的小涛16 分钟前
【NIO基础】NIO(非阻塞 I/O)和 IO(传统 I/O)的区别,以及 NIO 的三大组件详解
java·开发语言·笔记·后端·nio
北极无雪20 分钟前
Spring源码学习:SpringMVC(4)DispatcherServlet请求入口分析
java·开发语言·后端·学习·spring
爱码少年26 分钟前
springboot工程中使用tcp协议
spring boot·后端·tcp/ip
2401_857622668 小时前
SpringBoot框架下校园资料库的构建与优化
spring boot·后端·php
2402_857589368 小时前
“衣依”服装销售平台:Spring Boot框架的设计与实现
java·spring boot·后端
哎呦没9 小时前
大学生就业招聘:Spring Boot系统的架构分析
java·spring boot·后端
_.Switch10 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
杨哥带你写代码11 小时前
足球青训俱乐部管理:Spring Boot技术驱动
java·spring boot·后端