【源码】canal和otter的高可靠性分析

一般来说,我们对于数据库最主要的要求就是:数据不丢。不管是主从复制,还是使用类似otter+canal这样的数据库同步方案,我们最基本的需求是,在数据不丢失的前提下,尽可能的保证系统的高可用,也就是在某个节点挂掉,或者数据库发生主从切换等情况下,我们的数据同步系统依然能够发挥它的作用--数据同步。本文讨论的场景是数据库发生主从切换,本文将从源码的角度,来看看otter和canal是如何保证高可用和高可靠的。

一、EventParser

通过阅读文档和源码,我们可以知道,对于一个canal server,基础的框架包括以下几个部分:MetaManager、EventParser、EventSink和EventStore。其中EventParser的作用就是发送dump命令,从mysql数据库获取binlog文件。发送dump命令,可以指定时间戳或者position,从指定的时间或者位置开始dump。我们来看看过程:

首先是CanalServer启动。otter默认使用的是内置版的canal server,所以我们主要看CanalServerWithEmbedded这个类。来看下他的启动过程:

    public void start(final String destination) {
        final CanalInstance canalInstance = canalInstances.get(destination);
        if (!canalInstance.isStart()) {
            try {
                MDC.put("destination", destination);
                canalInstance.start();//启动实例
                logger.info("start CanalInstances[{}] successfully", destination);
            } finally {
                MDC.remove("destination");
            }
        }
    }

我们看下实例启动那一行,跟到AbstractCanalInstance类中

    public void start() {
        super.start();
        if (!metaManager.isStart()) {
            metaManager.start();//源数据管理启动
        }

        if (!alarmHandler.isStart()) {
            alarmHandler.start();//报警处理器启动
        }

        if (!eventStore.isStart()) {
            eventStore.start();//数据存储器启动
        }

        if (!eventSink.isStart()) {
            eventSink.start();//数据过滤器启动
        }

        if (!eventParser.isStart()) {//数据解析器启动
            beforeStartEventParser(eventParser);
            eventParser.start();
            afterStartEventParser(eventParser);
        }
        logger.info("start successful....");
    }

我们主要看下eventParser.start()方法里面的内容。我们主要关注的是EventParser使如何在主从切换的条件下,进行dump节点的确定的。我们跟踪到AbstractEventParser类中的start()方法,重点看下

// 4. 获取最后的位置信息
EntryPosition position = findStartPosition(erosaConnection);

这块有两个实现,但是canal目前使用的是MysqlEventParser,也就是基于Mysql的Binlog文件来进行数据同步。我们看下代码:

protected EntryPosition findStartPosition(ErosaConnection connection) throws IOException {
    EntryPosition startPosition = findStartPositionInternal(connection);
    if (needTransactionPosition.get()) {
        logger.warn("prepare to find last position : {}", startPosition.toString());
        Long preTransactionStartPosition = findTransactionBeginPosition(connection, startPosition);
        if (!preTransactionStartPosition.equals(startPosition.getPosition())) {
            logger.warn("find new start Transaction Position , old : {} , new : {}",
                    startPosition.getPosition(),
                    preTransactionStartPosition);
            startPosition.setPosition(preTransactionStartPosition);
        }
        needTransactionPosition.compareAndSet(true, false);
    }
    return startPosition;
}

对于第一行findStartPositionInternal(connection),我们重点关注的情况是数据库连接地址发生变化,也就是进行了主从切换的情况。

boolean case2 = (standbyInfo == null || standbyInfo.getAddress() == null)
                && logPosition.getPostion().getServerId() != null
                && !logPosition.getPostion().getServerId().equals(findServerId(mysqlConnection));
if (case2) {
    long timestamp = logPosition.getPostion().getTimestamp();
    long newStartTimestamp = timestamp - fallbackIntervalInSeconds * 1000;
    logger.warn("prepare to find start position by last position {}:{}:{}", new Object[]{"", "",
                logPosition.getPostion().getTimestamp()});
    EntryPosition findPosition = findByStartTimeStamp(mysqlConnection, newStartTimestamp);
    // 重新置为一下
    dumpErrorCount = 0;
    return findPosition;
}

我们分析下case2这个条件,其实就是表示的就是配置了主从切换,而且发生了serverId变化的情况,在这种情况下,首先需要获取到事件发生的时间戳,然后将这个事件发生的时间减去60s,也就是向前推一分钟之后,在新的binlog文件中根据新的时间戳来找到当时对应的事件。

这块根据时间戳来寻找事件的过程比较简单,首先根据binglog-index文件找到所有的binlog文件名,然后遍历binlog文件的头,找到binlog文件的写入时间,与新的时间戳进行对比,定位到binlog文件。定位到文件后,直接根据时间戳来进行遍历,找到新的时间戳之前发生的那个事务起始位置。

/**
 * 根据给定的时间戳,在指定的binlog中找到最接近于该时间戳(必须是小于时间戳)的一个事务起始位置。
 * 针对最后一个binlog会给定endPosition,避免无尽的查询
 */
private EntryPosition findAsPerTimestampInSpecificLogFile(MysqlConnection mysqlConnection,
                                                              final Long startTimestamp,
                                                              final EntryPosition endPosition,
                                                              final String searchBinlogFile) {

    final LogPosition logPosition = new LogPosition();
    try {
        mysqlConnection.reconnect();
        // 开始遍历文件
        mysqlConnection.seek(searchBinlogFile, 4L, new SinkFunction<LogEvent>() {

            private LogPosition lastPosition;

            public boolean sink(LogEvent event) {
                EntryPosition entryPosition = null;
                try {
                    CanalEntry.Entry entry = parseAndProfilingIfNecessary(event);
                    if (entry == null) {
                        return true;
                    }

                    String logfilename = entry.getHeader().getLogfileName();
                    Long logfileoffset = entry.getHeader().getLogfileOffset();
                    Long logposTimestamp = entry.getHeader().getExecuteTime();

                    if (CanalEntry.EntryType.TRANSACTIONBEGIN.equals(entry.getEntryType())
                            || CanalEntry.EntryType.TRANSACTIONEND.equals(entry.getEntryType())) {
                        logger.debug("compare exit condition:{},{},{}, startTimestamp={}...", new Object[]{
                                logfilename, logfileoffset, logposTimestamp, startTimestamp});
                        // 事务头和尾寻找第一条记录时间戳,如果最小的一条记录都不满足条件,可直接退出
                        if (logposTimestamp >= startTimestamp) {
                            return false;
                        }
                    }

                    if (StringUtils.equals(endPosition.getJournalName(), logfilename)
                            && endPosition.getPosition() <= (logfileoffset + event.getEventLen())) {
                        return false;
                    }

                    // 记录一下上一个事务结束的位置,即下一个事务的position
                    // position = current +
                    // data.length,代表该事务的下一条offest,避免多余的事务重复
                    if (CanalEntry.EntryType.TRANSACTIONEND.equals(entry.getEntryType())) {
                        entryPosition = new EntryPosition(logfilename,
                                logfileoffset + event.getEventLen(),
                                logposTimestamp);
                        logger.debug("set {} to be pending start position before finding another proper one...",
                                entryPosition);
                        logPosition.setPostion(entryPosition);
                    } else if (CanalEntry.EntryType.TRANSACTIONBEGIN.equals(entry.getEntryType())) {
                        // 当前事务开始位点
                        entryPosition = new EntryPosition(logfilename, logfileoffset, logposTimestamp);
                        logger.debug("set {} to be pending start position before finding another proper one...",
                                entryPosition);
                        logPosition.setPostion(entryPosition);
                    }

                    lastPosition = buildLastPosition(entry);
                } catch (Throwable e) {
                    processSinkError(e, lastPosition, searchBinlogFile, 4L);
                }

                return running;
            }
        });

    } catch (IOException e) {
        logger.error("ERROR ## findAsPerTimestampInSpecificLogFile has an error", e);
    }

    if (logPosition.getPostion() != null) {
        return logPosition.getPostion();
    } else {
        return null;
    }
}

这块的逻辑如下:

  • 发送dump命令,起始位置为4L,也就是跳过了binlog的第一个标志事件。
  • canal收到binlog,开始进行对binlog文件进行解析。
  • 主要我们看的是事务开始和事务提交的事件,判断事务开始或结束的时间,是否小于我们要找的时间戳,如果大于等于,直接遍历下一个事件。
  • 传入了一个endPosition,防止无限扫描。
  • 虽说是从头开始扫描的,但是要想跳出遍历,需要满足一定的条件。在跳出遍历之前,最后一次设置的logPosition才是我们要招的logPosition。
  • 如果是一个事务提交的事件,我们要找的position就是这个事件的position+event.length。如果是事务开始,position就是当前事件的position。其他的事件都忽略。

至此,我们已经找到了我们想要的binlog文件名和对应的事务开始position,我们继续下面的步骤即可。

二、EventStore

这块内容的主要思想如下:

  • 维护一个类似于Disruptor的RingBuffer,同时维护三个序列,put/get/ack。
  • EventSink之后的数据,调用put接口,将数据放入环形队列中。
  • Canal client获取数据,调用get方法。
  • 异步调用ack方法,清除ack之前的数据。
  • 值得注意的是,这块get和ack采用了流式API的模式,get和ack异步进行,可以先get,然后异步调用ack。
  • ack是有序的,不允许跳跃式的提交。

三、Binlog的Row模式

至此,我们基本上知道了canal是如何在发生数据库主从切换时保证高可用和高可靠的,我们可能还有疑惑:为什么要回退60s,来解析binlog,这样不会导致数据重复吗?还有一些自增的update语句(不具备幂等性),不会产生数据错误吗?要想回答这些问题,就需要我们了解Binlog的Row模式了。

Mysql Binlog的Row模式记录的,是数据库中每一行的数据变化,而不仅仅是sql语句。比如我们对数据库中的多行,使用一条sql语句进行了修改。在这种情况下,如果Binlog模式为Statement,只会记录一条sql语句。而Row模式下,会对每一行的数据变化进行记录,以及变化前后每个字段的值。这也就是为什么Row模式的binlog文件如此之大的原因。

对于一些具备幂等性的sql语句,采用Row语句进行Binlog解析时,也是可以通过重复执行,来保证我们数据的最终一致性的。这也就解释了,为什么要回退60s来进行Binlog位点定位、解析的问题。考虑到Mysql主从的数据复制的延迟性(60s,一般来说的延迟没有这么久),我们可以在主节点挂掉的情况下,回退60s到从节点上继续进行binlog的解析。

当然,也需要考虑一些极端的情况,也就是主从复制确实超过了60s的延迟,在这种情况下,就需要otter登场了。基本思路是:反查数据库同步 (以数据库最新版本同步,解决交替性,比如设置一致性反查数据库延迟阀值为60秒,即当同步过程中发现数据延迟超过了60秒,就会基于PK反查一次数据库,拿到当前最新值进行同步,减少交替性的问题)。

转载于:https://www.cnblogs.com/f-zhao/p/7681960.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
在上一篇文章中,我们介绍了使用 Canal 分析 Binlog 的基本流程和使用方法。本文将继续深入探讨 Canal源码实现。 Canal 的架构设计 Canal 的整体架构可以分为三个部分:Client、Server、Store。其中,Client 是客户端,用于连接 MySQL 数据库并获取 Binlog;Server 是服务端,用于接收 Client 发送的 Binlog,并进行解析处理;Store 是存储层,用于保存解析后的 Binlog 信息。 在 Client 端,Canal 使用了阿里开源的 Cobar 连接池,保证了高并发的连接请求。对于每一个连接,Canal 都会为其分配一个线程,用于读取 Binlog 并发送到 Server 端。 在 Server 端,Canal 采用 NIO 的方式进行网络通信。当收到 Binlog 数据后,Canal 会将其解析为一条条的事件,并将事件发送给所有监听该实例的客户端。同时,Canal 还支持对 Binlog 进行过滤和转换,以满足不同的业务需求。 在 Store 层,Canal 提供了多种存储方式,包括内存、文件、Kafka、MQ 等。用户可以根据自己的需求进行选择。 Canal 的核心实现 在分析 Canal 的核心实现之前,我们需要先了解一下 Binlog 的结构。Binlog 是 MySQL 用于记录数据库变更的一种日志格式,其主要由 event header 和 event data 两部分构成。其中,event header 包含了事件类型、时间戳、server id 等信息,event data 则包含了具体的事件内容。 Canal 的核心实现主要包括两个部分:BinlogParser 和 CanalEventParser。 BinlogParser 用于解析 Binlog,并将其转化为事件对象。在解析 Binlog 时,BinlogParser 首先会读取 event header,然后根据 event header 中的事件类型选择相应的 CanalEventParser 进行处理。CanalEventParser 则负责将 event data 解析为具体的事件对象。 CanalEventParser 实现了一系列的事件解析器,包括 QueryEventParser、TableMapEventParser、WriteRowsEventParser 等。每个事件解析器都负责将 event data 转化为相应的事件对象,并填充事件对象中的各个字段。例如,WriteRowsEventParser 将 event data 解析为 WriteRowsEvent,并设置其对应的表名、列名、新插入的行等信息。 Canal 的事件模型 Canal 的事件模型主要由 CanalEvent 和 CanalEventSink 两部分构成。CanalEvent 表示一个数据库事件,包括事件类型、事件数据、表名、库名等信息。CanalEventSink 则表示一个事件处理器,用于接收并处理 CanalEvent。 在 Canal 中,每个客户端都会创建一个对应的 CanalEventSink,并将其注册到 Server 端。当 Server 端接收到 Binlog 数据并解析为 CanalEvent 后,会将 CanalEvent 发送给所有注册的 CanalEventSink。CanalEventSink 则根据事件类型进行相应的处理,例如将 InsertEvent 存储到数据库中。 Canal 的优点和缺点 Canal 的主要优点在于其高效、可扩展和灵活的架构设计。Canal 使用了阿里开源的 Cobar 连接池和 NIO 网络通信,保证了高并发的连接请求和网络数据传输。同时,Canal 的存储层也支持多种存储方式,可以根据用户需求进行选择。此外,Canal 还支持对 Binlog 进行过滤和转换,以满足不同的业务需求。 Canal 的缺点在于其对 MySQL 版本有一定的限制。由于 Binlog 格式在不同的 MySQL 版本之间存在差异,因此 Canal 只支持特定版本的 MySQL,且需要用户手动配置相应的 Binlog 格式。此外,Canal 无法保证数据的完整性,如果在解析 Binlog 过程中出现异常,可能会导致部分数据丢失。 总结 Canal 是一款高效、可扩展和灵活的 Binlog 解析工具,可以帮助用户实现对 MySQL 数据库变更的监控和同步。Canal 的核心实现包括 BinlogParser 和 CanalEventParser,其中 BinlogParser 用于解析 Binlog,CanalEventParser 则负责将 event data 转化为具体的事件对象。Canal 的事件模型主要由 CanalEvent 和 CanalEventSink 两部分构成,可以根据用户需求进行灵活配置。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值