Flink1.12基于Flip-27的新KafkaSource源码浅析(二)——Reader与Enumerator自定义通信

2,632 阅读4分钟

前情提要

Flink1.12基于Flip-27的新KafkaSource源码浅析(一)——Bounded Or UnBounded

背景知识

准备工作

Reader与Enumerator

在Flink1.12的KafkaSource中,一个运行起来的Source会有1+n个线程,Enumerator运行在单独的一个线程内,负责split对应的topic和partition,n个Task线程内运行着Reader,他们从Enumerator拿到自己要去读取的partition,两者之间通过akka进行RPC通讯。

Flink1.12基于Flip-27的新KafkaSource源码浅析(一)——Bounded Or UnBounded 的最后部分,我们看到在启动阶段,Enumerator向Reader发送读取哪一部分partition的通知,这节我们就用一个例子来讲如何通过修改源码去发送自定义的事件。 我会先从Reader的读取讲起,这是上次没有讲解的基础,然后讲解Reader端如何发送消息,接收消息,Enumerator端如何发送、接受消息。

KafkaReader的读取

如图,KafkaSource是通过运行中SourceOperator调取KafkaSource的createReader方法。

对应在KafkaSource的方法是这个

在Flink1.12中,kafka实际上就是使用的KafkaReader对数据进行的读取,依据以往的的经验,应该会有一个pollNext方法负责从Kafka循环拉取数据,但是打开后我们发现并没有。

那我们只好继续网上找,这个类继承了 SingleThreadMultiplexSourceReaderBase,SingleThreadMultiplexSourceReaderBase又继承了SourceReaderBase

SourceReaderBase中出现了pollNext(),所以KafkaReader实际上是使用的源类里的拉取方法。

我们可以简单写个测试,查看一下调用栈 可以看到SourceOperator调取的确实是这个方法。

Reader如何向Enumerator发送、接受消息

首先我们来看一下SourceReaderBase的实例方法

需要关注的是SourceReaderContext context,我们来看一下内部的方法

关注这个sendSourceEventToCoordinator方法,这个方法负责发送SourceEvent到Enumerator中,我们可以在SourceReaderBase中直接调,举个例子。

那如何接受Enumerator发送的消息呢?,可以在SourceReaderBase中重写handleSourceEvents方法,这个方法来自于SourceReader里,以我的代码为例,我通过handleSourceEvents接受Enumerator发送过来的Event

如果想做自定义操作的话我们可以继承SourceEvent来写一个新类,然后在新类中加入我们需要的值


Enumerator如何向Reader发送、接受消息

KafkaSourceEnumerator相较于KafkaSourceReader而言更加的友好,并不需要更改继承的方法,我们来看一下KafkaEnumerator的构造方法。

需要关注的还是这个context,他的构造与Reader差不多,可以看到,他也有一个send方法,调用的是operatorCoordinatorContext的sendEvent,传入对应的readerId+SourceEvent

对应的接受方法也是这样的,我们可以在KafkaSourceEnumerator中重写handleSourceEvent,来实现接受由SourceReader发送的消息。


例子:对同一source内多个topic或partition进行排序读取

我们首先创建两个新的SourceEvent类

EnumeratorEvent.java

import lombok.Data;
import org.apache.flink.api.connector.source.SourceEvent;

@Data
public class EnumeratorEvent implements SourceEvent {
    public Long timestamp ;
    public EnumeratorEvent(Long timestamp ){
        this.timestamp = timestamp;
    }
    @Override
    public String toString() {
        return timestamp.toString();
    }
}

ReaderEvent.java

import lombok.Data;
import org.apache.flink.api.connector.source.SourceEvent;
@Data
public class ReaderEvent implements SourceEvent {
    public Long timestamp ;
    public ReaderEvent(Long timestamp ){
        this.timestamp = timestamp;
    }
    @Override
    public String toString() {
        return timestamp.toString();
    }
}

修改SourceReaderBase

修改SourceReaderBase的pollNext(),在pollNext中我们使用三个全局变量,timeArea、nowTime和newRecord,分别是允许读取的最大边界时间、最新一条时间、最新一条数据。

// 增加一个时间范围变量
private Long timeArea=0L;

private Long nowTime;

private E nowRecord;

修改pollNext(),判断最新一条数据是否小于最大允许读取边界,如果小于正常读取,如果大于则停止读取,将已读取的一条存入newRecord。

@Override
public InputStatus pollNext(ReaderOutput<T> output) throws Exception {
    if(nowTime!=null&&nowTime>timeArea){
        return trace(InputStatus.NOTHING_AVAILABLE);
    }else if(this.nowRecord!=null){
//            System.out.println(this.nowRecord);
        recordEmitter.emitRecord(this.nowRecord, currentSplitOutput, currentSplitContext.state);
        this.nowRecord=null;
    }
    // make sure we have a fetch we are working on, or move to the next
    RecordsWithSplitIds<E> recordsWithSplitId = this.currentFetch;
    if (recordsWithSplitId == null) {
        recordsWithSplitId = getNextFetch(output);
        if (recordsWithSplitId == null) {
            return trace(finishedOrAvailableLater());
        }
    }

    // we need to loop here, because we may have to go across splits
    while (true) {
        // Process one record.
        final E record = recordsWithSplitId.nextRecordFromSplit();

        if (record != null) {
            this.nowTime = ((Tuple3<FlinkSkew.TimeAndValue,Long,Long>)record).f0.getTimestamp();
            if(nowTime<=timeArea) {
                // emit the record.
                recordEmitter.emitRecord(record, currentSplitOutput, currentSplitContext.state);
                LOG.trace("Emitted record: {}", record);

                // We always emit MORE_AVAILABLE here, even though we do not strictly know whether
                // more is available. If nothing more is available, the next invocation will find
                // this out and return the correct status.
                // That means we emit the occasional 'false positive' for availability, but this
                // saves us doing checks for every record. Ultimately, this is cheaper.
                return trace(InputStatus.MORE_AVAILABLE);
            }else {
                this.context.sendSourceEventToCoordinator(new ReaderEvent(((Tuple3<FlinkSkew.TimeAndValue,Long,Long>)record).f0.getTimestamp()));
                this.nowRecord = record;
                return trace(InputStatus.NOTHING_AVAILABLE);
                // return pollNext(output);
            }
        }
        else if (!moveToNextSplit(recordsWithSplitId, output)) {
            // The fetch is done and we just discovered that and have not emitted anything, yet.
            // We need to move to the next fetch. As a shortcut, we call pollNext() here again,
            // rather than emitting nothing and waiting for the caller to call us again.
            return pollNext(output);
        }
        // else fall through the loop
    }
}

重写handleSourceEvent,接受Enumerator发送的数据,更新timeArea

@Override
public void handleSourceEvents(SourceEvent sourceEvent) {
    EnumeratorEvent enumeratorEvent = (EnumeratorEvent)sourceEvent;
    this.timeArea = enumeratorEvent.getTimestamp();
}

修改KafkaSourceEnumerator

首先增加两个全局变量,timestampMap内存的是每一个readerId对应的timestamp,timeArea则是最大读取时间边界

private HashMap<Integer,Long> timestampMap = new HashMap<>();
private Long timeArea=0L;

在KafkaSourceEnumerator中我们重写handleSourceEvent方法,接受每个id对应的最新时间,更新timestampMap,获取这个map的最小值,如果大于时间边界则更新时间边界。

@Override
public void handleSourceEvent(int subtaskId, SourceEvent sourceEvent) {
    Long timestamp = ((ReaderEvent)sourceEvent).getTimestamp();
    timestampMap.put(subtaskId,timestamp);
    if(timestampMap.size()==readerIdToSplitAssignments.size()){
        if(timeArea==null||timeArea<(Long) getMinValue(timestampMap)){
            timeArea = (Long) getMinValue(timestampMap)+60L;
            for(Integer key : timestampMap.keySet()){
                this.context.sendEventToSourceReader(key,new EnumeratorEvent(timeArea));
            }
        }
    }
}
public static Object getMinValue(Map<Integer, Long> map) {
    if (map == null)
        return null;
    Collection<Long> c = map.values();
    Object[] obj = c.toArray();
    Arrays.sort(obj);
    return obj[0];
}

搞定!

例子里的一点问题

在这个例子里,有一点需要注意,这是task与JobMaster的通信,如果想保证一致的话,需要Source每一个partition都分到一个一个task中,在启动时Enumerator会根据他里面的这个方法来将每一个线程分给task,在上一个浅析里提到过,如果需要不重复的话,需要去修改这个方法。