【Disruptor技术调研之开源组件Canal如何应用Disruptor组件】

Disruptor实战系列之阿里开源组件Canal如何应用Disruptor

hello 各位小伙伴们,大家好,我是爱抄中间件代码的路人丙!

最近由于有想跳槽的打算,所以准备开始复盘自己的项目经历,由于之前在生产项目上做过基于Disruptor组件二次开发,所以想分享一下自己是如何学习、研究Disruptor组件的,以及最终能够自主的基于Disruptor做二次开发(定制开发),虽然二次开发的功能很简单,但也算是自己第一次独立自主做的基础组件(必经以前都是喜欢抄开源代码,果然,屠龙人终成恶龙!),后面笔者如果有时间的话,会把Disruptor二次开发的内容开源出来(剔除业务代码),或者说将其在特定场景下完善的更通用以及更好用

简单说一下这篇文章的内容:这篇文章主要分享通过阿里开源组件Canal源码去剖析大佬们(或者说前辈们)是如何去应用Disruptor组件的,以及我们在实际业务场景有哪些是我们可以借鉴和使用的!

前置条件:这篇文章不适合小白,需要你有一定的开发基础以及Disruptor使用经验(看过Disruptor源码更佳,如果你还没看过Disruptor的源码,那赶快看看我的另一篇文章)

阅读完本篇文章,你将有以下收获:

  • 了解开源组件Canal是如何使用Disruptor
  • 了解开源组件Canal使用Disruptor的场景

1、通过gitee或者github拉取canal源码

大家可以自己去github或者gitee上搜索“Canal”关键词,即可找到官方仓库

或者说你可以直接clone笔者fork的地址:https://gitee.com/li-zhongwei/canal

2、通过pom.xml查找Disruptor使用位置

大家代码拉下来之后,有些小伙伴肯定跟笔者一样困惑,这么多包,怎么去找Disruptor相关的包哦?
其实很简单,既然要用Disruptor组件,肯定会用到其依赖,所以直接看pom文件即可,如下:
在这里插入图片描述
废话不多说,Canal使用Disruptor的位置主要在MysqlMultiStageCoprocessor类
在这里插入图片描述
接下来我们就开始分析canal是如何使用Disruptor组件的了!

题外话:笔者实际上并没有在生产上使用过这个组件(Canal),只是听说这个Canal组件使用Disruptor,所以就想来看看它是怎么使用、有不有能借鉴和学习的地方!

3、剖析Canal是如何使用Disruptor组件以及其使用场景

Canal关于Disruptor 使用的源码位置如下:

MysqlMultiStageCoprocessor类的start()方法,如下图所示:
在这里插入图片描述以下是笔者针对MysqlMultiStageCoprocessor类以及其他贡献者留下的代码注释,画了一个Disruptor的消费者、生产者链路图:
在这里插入图片描述

ps 上图中画的时候出了一点问题,图中的二级消费者应该是DmlParserStage实列
这里简单解释一下:

MysqlMultiStageCoprocessor主要涉及3个级别的消费者以及单生产者:

1级消费者:simpleParseStage (根据源码可知:单消费者)

2级消费者:workerPool (DmlParserStage) (根据源码可知:存在多消费者)

3级消费者:sinkStoreStage (根据源码可知:单消费者)

生产者

以下中为了简单,会以1、2、3级消费者来分别代替simpleParseStage 、DmlParserStage、sinkStoreStage 消费者

简单描述一下他们之间的关系:

  • 1级消费者的当前消费序号不能超过当前生产者序号
  • 2级消费者的当前消费序号不能超过1消费者的当前消费序号
  • 3级消费者的当前消费序号不能超过2级消费者的当前最小消费序号
  • 生产者的当前生产序号不能超过3级消费者的当前消费序号

以上的保证都是由Disruptor组件来控制的
所以我们通过源码可以知道MysqlMultiStageCoprocessor类的消费链路如下图:

simpleParseStage (串)-> workerPool (并)-> sinkStoreStage (串)

看到这里,不知道小伙伴们是不是跟笔者有一样的疑问:为什么Canal要这样使用Disruptor 组件呢?

根据笔者目前的认知理解,原因如下:

  • 第一,Canal应用场景
  • 第二,Disruptor的高性能能力
3.1 Canal应用场景:

关于canal的应用场景,官方描述还是很清晰的,伪装成salve节点,基于MySQL增量数据订阅和消费,因为MySQL是一个事务性数据库,所以MySQL的增量数据是存在顺序性的语义的,所以canal在订阅和消费的时候肯定是要去解决顺序语义

那么如何解决消息顺序消费呢?

最简单且靠谱的方案就是单线程串行,不过这种方案得牺牲性能;事实上canal也是这样做的,不过canal把binglog的处理分成了一条流水线上的多个阶段,其中“DML事件数据的完整解析”阶段采用的是并行处理,这样在一定程度上可以提升canal的订阅消费吞吐能力
所以看到这里应该大概能明白上面描述的3级消费者链路了以及为什么Canal要这样使用Disruptor 组件了

3.2 Disruptor的高性能能力:
  • 无锁编程
  • 环形队列(数组)
  • 破除伪共享等

Disruptor 组件的高性能,笔者就不多说了,感兴趣的小伙伴自己去看看源码、测试验证一下就明白了!当然也可以参考笔者的另外一篇文章:Disruptor(3.4.2)源码解读

接下来,笔者直接贴MysqlMultiStageCoprocessor使用Disruptor的核心代码(含笔者的注释,基本只涉及Disruptor相关)

4、源码剖析:Canal如何使用Disruptor组件

首先简单介绍一下关键的几个属性,以下属性熟悉Disruptor的小伙伴应该秒懂


    private int                               parserThreadCount; // 2级消费者并行数量
    private int                               ringBufferSize;    // 环形buffer容量
    // disruptor的环形队列:ringbuffer
    private RingBuffer<MessageEvent>          disruptorMsgBuffer;// 环形buffer实例
    private ExecutorService                   parserExecutor; 
    private ExecutorService                   stageExecutor; 

    private WorkerPool<MessageEvent>          workerPool; // 2级消费者处理逻辑
    private BatchEventProcessor<MessageEvent> simpleParserStage; // 1级消费者处理逻辑
    private BatchEventProcessor<MessageEvent> sinkStoreStage; // 3级别消费者处理逻辑

start()方法

    @Override
public void start() {
        // 父级启动  (控制canal生命周期,写的比较优雅,代码可以抄)
        super.start();
        this.exception = null;
        // 生成了一个单生产者环形队列实列
        this.disruptorMsgBuffer = RingBuffer.createSingleProducer(new MessageEventFactory(),
            ringBufferSize,
            new BlockingWaitStrategy()); // 采用阻塞等待策略,ReentrantLock + condition实现
        int tc = parserThreadCount > 0 ? parserThreadCount : 1; // 2级消费者并发数判断
        // 固定线程池1
        this.parserExecutor = Executors.newFixedThreadPool(tc, new NamedThreadFactory("MultiStageCoprocessor-Parser-"
                                                                                      + destination));
        // 固定线程池2
        this.stageExecutor = Executors.newFixedThreadPool(2, new NamedThreadFactory("MultiStageCoprocessor-other-"
                                                                                     + destination));
        // 生产者的序号屏障
        SequenceBarrier sequenceBarrier = disruptorMsgBuffer.newBarrier();
        ExceptionHandler exceptionHandler = new SimpleFatalExceptionHandler();
        // stage 2
        this.logContext = new LogContext();
        // 初始化1级消费者实列
        simpleParserStage = new BatchEventProcessor<>(disruptorMsgBuffer,
            sequenceBarrier, // 生产者的序号屏障
            new SimpleParserStage(logContext));
        simpleParserStage.setExceptionHandler(exceptionHandler); // 默认的异常逻辑为,继续向上抛异常
        disruptorMsgBuffer.addGatingSequences(simpleParserStage.getSequence()); // 生产者添加1级消费者作为门禁  笔者认为这里没有必要,生产者添加3级消费者的消费序号作为门禁即可(这里加了也行,因为Disruptor内部也是通过最小的消费序号为准)

        // stage 3
        // 初始化1级消费者屏障
        SequenceBarrier dmlParserSequenceBarrier = disruptorMsgBuffer.newBarrier(simpleParserStage.getSequence());
        // 初始化2级消费者实例
        WorkHandler<MessageEvent>[] workHandlers = new DmlParserStage[tc];
        for (int i = 0; i < tc; i++) {
            workHandlers[i] = new DmlParserStage();
        }
        workerPool = new WorkerPool<MessageEvent>(disruptorMsgBuffer,
            dmlParserSequenceBarrier, // 1级消费者屏障
            exceptionHandler,
            workHandlers);
        Sequence[] sequence = workerPool.getWorkerSequences();
        disruptorMsgBuffer.addGatingSequences(sequence);// 生产者添加2级消费者序号作为门禁

        // stage 4
        // 初始化2级消费者屏障
        SequenceBarrier sinkSequenceBarrier = disruptorMsgBuffer.newBarrier(sequence);
        // 初始化三级消费者实例
        sinkStoreStage = new BatchEventProcessor<>(disruptorMsgBuffer, sinkSequenceBarrier, new SinkStoreStage());
        sinkStoreStage.setExceptionHandler(exceptionHandler);
        disruptorMsgBuffer.addGatingSequences(sinkStoreStage.getSequence());// 生产者添加3级消费者序号作为门禁

        // * 2. 事件基本解析 (单线程,事件类型、DDL解析构造TableMeta、维护位点信息)
        // * 3. 事件深度解析 (多线程, DML事件数据的完整解析)
        // * 4. 投递到store (单线程)
        // 以下就是启动消费者
        stageExecutor.submit(simpleParserStage); // 简单解析 -> 事件基本解析
        stageExecutor.submit(sinkStoreStage);  // 目标存储 -> 投递到store
        workerPool.start(parserExecutor); // 批处理 -> 事件深度解析
    }

通过start()方法的源码阅读,我们大概知道Canal是如何初始化Disruptor的,其中笔者认为可以优化的地方就是,生产者添加消费的门禁代码处,可以省略添加1、2级消费者的消费序号作为门禁,只添加3级消费者的消费序号即可(当然不处理也没有什么关系,因为Disruptor源码关于生产者使用门禁都是取门禁最小的序号)

接下来,我们看一下stop()方法,关于stop方法,大家可以直接抄这块代码即可(看看Canal如何优雅的停机)

    @Override
    public void stop() {
        // fix bug #968,对于pool与
        workerPool.halt(); // 手动关闭
        simpleParserStage.halt();
        sinkStoreStage.halt();
        try {
            parserExecutor.shutdownNow();
            while (!parserExecutor.awaitTermination(1, TimeUnit.SECONDS)) {
                if (parserExecutor.isShutdown() || parserExecutor.isTerminated()) {
                    break;
                }

                parserExecutor.shutdownNow();
            }
        } catch (Throwable e) {
            // ignore
        }

        try {
            stageExecutor.shutdownNow();
            while (!stageExecutor.awaitTermination(1, TimeUnit.SECONDS)) {
                if (stageExecutor.isShutdown() || stageExecutor.isTerminated()) {
                    break;
                }

                stageExecutor.shutdownNow();
            }
        } catch (Throwable e) {
            // ignore
        }
        super.stop();
    }

关于publish()方法,笔者就不分析了,生产者生产消息的方法,不过感兴趣的小伙伴可以去感受一下Canal如何去保证代码的健壮性的!

   private boolean publish(LogBuffer buffer, LogEvent event) {
        // 可以学习一下使用Disruptor的异常处理
        if (!isStart()) {
            if (exception != null) {
                throw exception;
            }
            return false;
        }

        boolean interupted = false;
        long blockingStart = 0L;
        int fullTimes = 0;
        do {
            /**
             * 由于改为processor仅终止自身stage而不是stop,那么需要由incident标识coprocessor是否正常工作。
             * 让dump线程能够及时感知
             */
            if (exception != null) {
                throw exception;
            }
            try {
                long next = disruptorMsgBuffer.tryNext();
                MessageEvent data = disruptorMsgBuffer.get(next);
                if (buffer != null) {
                    data.setBuffer(buffer);
                } else {
                    data.setEvent(event);
                }
                disruptorMsgBuffer.publish(next);
                if (fullTimes > 0) {
                    eventsPublishBlockingTime.addAndGet(System.nanoTime() - blockingStart);
                }
                break;
            } catch (InsufficientCapacityException e) {
                if (fullTimes == 0) {
                    blockingStart = System.nanoTime();
                }
                // park
                // LockSupport.parkNanos(1L);
                applyWait(++fullTimes);
                interupted = Thread.interrupted();
                if (fullTimes % 1000 == 0) {
                    long nextStart = System.nanoTime();
                    eventsPublishBlockingTime.addAndGet(nextStart - blockingStart);
                    blockingStart = nextStart;
                }
            }
        } while (!interupted && isStart());
        return isStart();
    }
4、总结:Canal使用Disruptor值得借鉴以及再优化的地方
  • 可借鉴的地方
    (1)Canal使用场景值得借鉴:顺序消息 + 性能
    比如小伙伴们在业务上遇到类似的场景,都可以考虑使用Disruptor来做性能瓶颈突破,具体方案可参考Canal的源码
    (2)Canal使用Disruptor时,是如何处理消费者event事件中的异常的,这个也值得借鉴,Canal为了避免消息丢失,直接向上抛异常,且每次生产的时候都会去判断是否存在异常
    (3)Disruptor在Canal中的优雅停机
  • Canal可以再优化的地方
    其中笔者认为可以优化的地方仅代码层面,生产者添加消费的门禁代码处,可以省略添加1、2级消费者的消费序号作为门禁,只添加3级消费者的消费序号即可(当然不处理也没有什么关系,因为Disruptor源码关于生产者使用门禁都是取门禁最小的序号)
参考资料

Canal源码(master分支)

  • 41
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值