基于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;
    }
}
相关推荐
小飞Coding几秒前
MyBatis Mapper 实现原理彻底解密——从动态代理到 JDBC 执行全链路剖析
后端·mybatis
Mr.456712 分钟前
Spring Boot 集成 PostgreSQL 表级备份与恢复实战
java·spring boot·后端·postgresql
LucianaiB12 分钟前
王炸组合!腾讯云 OpenClaw X 飞书 CLI,开启 Agent 基建狂潮!
后端
白露与泡影16 分钟前
探索springboot程序打包docker的最佳方式
spring boot·后端·docker
开心就好202517 分钟前
本地执行 IPA 混淆 无需上传致云端且不修改工程的方案
后端·ios
架构师沉默31 分钟前
为什么一个视频能让全国人民同时秒开?
java·后端·架构
掘金码甲哥1 小时前
同样都是九年义务教育,他知道的AI算力科普好像比我多耶
后端
sthnyph1 小时前
SpringBoot Test详解
spring boot·后端·log4j
饼干哥哥2 小时前
搭建一个云端Skills系统,随时随地记录TikTok爆款
前端·后端
IT 行者2 小时前
LangChain4j 集成 Redis 向量存储:我踩过的坑和选型建议
java·人工智能·redis·后端