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分支)