Flink運行時之生產端結果分區

生產端結果分區

生產者結果分區是生產端任務所產生的結果。以一個簡單的MapReduce程序爲例,從靜態的角度來看,生產端的算子(Map)跟消費端的算子(Reduce),兩者之間交換數據通過中間結果集(IntermediateResult)。形如下圖:

mapreduce-static-dataexchange

而IntermediateResult只是在靜態表述時的一種概念,在運行時,算子會被分佈式部署、執行,我們假設兩個算子的並行度都爲2,那麼對應的運行時模型如下圖:

IntermediateResult-at-runtime

生產端的Map算子會產生兩個子任務實例,它們各自都會產生結果分區(ResultPartition)。但ResultPartition並不會直接被處於消費端的Reduce的子任務實例消費,它會再次進行分區從而產生結果子分區(ResultSubpartition),ResultSubpartition是最終保存Buffer的地方。接下來的這一篇,我們就來詳細分析生產者分區。

結果分區

在運行時,Flink使用結果分區(ResultPartition)來表示單一任務的子任務實例所生產的數據,這在其作業圖中等價於中間結果分區(IntermediateResultPartition)。需要避免對這兩個概念產生混淆,IntermediateResultPartition主要用於JobManager組織作業圖的一種邏輯數據結構,ResultPartition是運行時的一種邏輯概念,兩者處於不同的層面。

每個ResultPartition擁有一個BufferPool並且是被其包含的ResultSubPartition共享的。ResultSubPartition個數主要取決於消費任務的數目以及數據的分發模式(DistributionPattern)。任何想消費ResultPartition的任務,最終都是請求ResultPartition的某個ResultSubPartition。而請求要麼是同一TaskManager中的本地請求要麼是來自另外一個TaskManager中的消費子任務實例發起的遠程請求。

每個ResultPartition的生命週期都有三個階段:生產、消費和釋放。

結果分區類型(ResultPartitionType)是一個枚舉類型,指定了ResultPartition的不同屬性,這些屬性包括是否可被持久化、是否支持管道以及是否會產生反壓。ResultPartitionType有三個枚舉值:

  • BLOCKING:持久化、非管道、無反壓;
  • PIPELINED:非持久化、支持管道、有反壓;
  • PIPELINED_PERSISTENT(當前暫不支持)

其中管道屬性會對消費端任務的消費行爲產生很大的影響。如果是管道型的,那麼在結果分區接收到第一個Buffer時,消費者任務就可以進行準備消費(如果還沒有部署則會先部署),而如果非管道型,那麼消費者任務將等到生產端任務生產完數據之後纔會着手進行消費。

結果分區編號(ResultPartitionID)用來標識ResultPartition。ResultPartitionID關聯着IntermediateResultPartitionID(也即調度時的分區編號)和ExecutionAttemptID(部署時的生產者子任務實例編號)。在任務失敗時,單靠IntermediateResultPartitionID無法鑑別ResultPartition,必須結合ExecutionAttemptID一起鑑別。

一個ResultPartition有多少個ResultSubPartition,是在構建ResultPartition就確定了的。當生產端任務調用記錄寫入器寫入一個記錄時,該記錄先被序列化器序列化並放入Buffer中,然後通過ResultPartitionWriter加入到ResultPartition,具體被加入哪個子分區中取決於ChannelSelector,該加入方法實現如下:

public void add(Buffer buffer, int subpartitionIndex) throws IOException {
    boolean success = false;

    try {
        //確認生產狀態處於未完成狀態
        checkInProduceState();

        //獲取指定索引的子分區
        final ResultSubpartition subpartition = subpartitions[subpartitionIndex];

        synchronized (subpartition) {
            //如果Buffer被加入子分區,則success被置爲true
            success = subpartition.add(buffer);

            //更新統計信息
            totalNumberOfBuffers++;
            totalNumberOfBytes += buffer.getSize();
        }
    } finally {
        //如果Buffer被加入成功,且當前的模式是管道模式,則立即通知消費者任務
        if (success) {
            notifyPipelinedConsumers();
        }
        //如果加入失敗,則回收Buffer
        else {
            buffer.recycle();
        }
    }
}

在notifyPipelinedConsumers方法中,會通過分區可消費通知器(ResultPartitionConsumableNotifier)間接通知消費者任務(經過JobManager轉發通知),它會攜帶兩個信息:

  • JobID
  • ResultPartitionID

ResultPartition有一個標識變量hasNotifiedPipelinedConsumers,用來表示當前是否已通知過消費者,在notifyPipelinedConsumers中,一旦通知過,該標識將會被設置爲true,所以該通知只會發生在第一個被成功加入的Buffer之後,後續便不再通知。

ResultPartitionConsumableNotifier當前只有一個實現JobManagerResultPartitionConsumableNotifier(位於NetworkEnvironment中的一個靜態內部類),它會通過Actor網關向JobManager發送一條請求消息(ask模式,需要應答)。

這是針對管道模式的ResultPartition而言的,而針對阻塞模式的ResultPartition的通知時機卻需要等到數據生產完成之後(ResultPartition的finish方法被調用),任務會向JobManager報告其狀態變更爲FINISHED。JobGraph根據執行圖(ExecutionGraph)找到完成任務對應的IntermediateResultPartition的消費者任務並調度它們進行消費:

for (IntermediateResultPartition finishedPartition : getVertex().finishAllBlockingPartitions()) {
    IntermediateResultPartition[] allPartitions = finishedPartition
        .getIntermediateResult().getPartitions();

    for (IntermediateResultPartition partition : allPartitions) {
        scheduleOrUpdateConsumers(partition.getConsumers());
    }
}

當每個子分區中的緩衝區數據被消費完後,它們會通知ResultPartition。因爲一個ResultPartition包含若干個ResultSubPartition,那麼ResultPartition如何判斷所有的ResultSubPartition都被消費完了呢?它基於原子計數器(AtomicInteger),每個ResultSubPartition被消費完成之後都會回調ResultPartition的實例方法onConsumedSubpartition:

void onConsumedSubpartition(int subpartitionIndex) {
    //已被釋放,則直接返回
    if (isReleased.get()) {
        return;
    }

    //計數器減一後獲得未完成的子分區計數
    int refCnt = pendingReferences.decrementAndGet();

    //如果全部都已完成,則通知ResultPartitionManager,它會將ResultPartition直接釋放
    if (refCnt == 0) {
        partitionManager.onConsumedPartition(this);
    }
    //異常
    else if (refCnt < 0) {
        throw new IllegalStateException("All references released.");
    }

    LOG.debug("{}: Received release notification for subpartition {} (reference count now at: {}).",
                this, subpartitionIndex, pendingReferences);
}

我們在add方法中看到,ResultPartition其實不保存Buffer,它只是起到一個分配或者轉發的作用,Buffer真正會被保存到ResultSubPartition中。

ResultPartition會被消費端任務消費,但對消費者而言,其跟待消費的ResultPartition之間不同的位置消費方式卻不一樣。ResultPartitionLocation對不同的位置進行了定義和封裝。目前支持三種位置類型:

  • LOCAL:表示消費者任務被部署在跟生產該ResultPartition的生產者任務相同的實例上;
  • REMOTE:表示消費者任務被部署在跟生產該ResultPartition的生產者任務不同的實例上;
  • UNKNOWN:表示ResultPartition未被註冊到生產者任務,當部署消費者任務時,其實例可能是確定的也可能是不確定的。

對ResultPartition進行管理的部件是結果分區管理器(ResultPartitionManager)。一個NetworkEnvironment對應一個ResultPartitionManager。

ResultPartitionManager會對某個TaskManager中已生產和已被消費的ResultPartition進行跟蹤。具體而言,它採用Guava庫裏的Table這一集合類型來維護其所管理的ResultPartition。

Table是Guava集合庫提供的一個多級映射容器類型。效仿了關係型數據庫中的數據表結構,支持”row”、“column”、“value”。其結構等價於Map<R, Map<C,V>>且提供了針對多個Map的實現。

該數據結構的完整定義如下:

public final Table<ExecutionAttemptID, IntermediateResultPartitionID, ResultPartition> 
    registeredPartitions = HashBasedTable.create();

在NetworkEnvironment中註冊Task時,會獲取該Task所生產的ResultPartition數組。然後用ResultPartitionManager的registerResultPartition方法進行註冊。同樣,在對Task解除註冊時,會調用ResultPartitionManager的releasePartitionsProducedBy方法,將相應的ExecutionAttemptID對應的信息從registeredPartitions表中移除。在releasePartitionsProducedBy方法中,所有的ResultPartition都會調用其release以釋放各自佔用的資源。

結果子分區

ResultPartition可以看作結果子分區(ResultSubpartition)的容器,而ResultSubpartition是真正存儲供消費者消費Buffer的地方。ResultSubPartition是一個抽象類,針對不同類型的ResultPartition提供了兩個實現:

  • PipelinedSubpartition:基於內存的管道模式的結果子分區;
  • SpillableSubpartition:基礎模式下是基於內存的阻塞式的結果子分區,但數據量過大時可以將數據溢出到磁盤;

在ResultPartition的構造器中,會根據ResultPartitionType來實例化特定的結果子分區:

switch (partitionType) {
    case BLOCKING:
        for (int i = 0; i < subpartitions.length; i++) {
            subpartitions[i] = new SpillableSubpartition(i, this, ioManager, defaultIoMode);
        }
        break;

    case PIPELINED:
        for (int i = 0; i < subpartitions.length; i++) {
            subpartitions[i] = new PipelinedSubpartition(i, this);
        }
        break;

    default:
        throw new IllegalArgumentException("Unsupported result partition type.");
}

PipelinedSubpartition會將數據保存在雙端隊列中(ArrayDeque),在ResultPartition完成數據生產時,其finish方法會得到調用,該方法會依次觸發它所包含的所有的ResultSubpartition的finish方法,作爲結束的標記,一個EndOfPartitionEvent事件會作爲一個特殊的Buffer加入到雙端隊列中去。

SpillableSubpartition結合了內存緩存和磁盤持久化的能力。最初,Buffer被加入進來時是以一個ArrayList來緩存,當BufferPoolOwner也就是SpillableSubpartition的父容器ResultPartition因爲Buffer資源緊張決定釋放一定數量的Buffer時,其releaseMemory方法會間接被觸發(這對於SpillableSubpartition來說意味着將沒有足夠的內存資源來容納生產者的數據了)。這時,它會通過IOManager的createBufferFileWriter方法來創建一個BufferFileWriter(通過該寫入器可以以阻塞的模式將Buffer寫到磁盤上),這時所有ArrayList內的Buffer都將被寫入磁盤。注意,BufferFileWriter的實例一旦被創建之後,所有再加入進來的Buffer都將被直接寫入磁盤,而不再加入到ArrayList。維護兩個“Buffer源”並不是一個明智的選擇,並且當BufferFileWriter被創建,也意味着內存不再寬裕。同樣,其finish方法也會加入一個EndOfPartitionEvent來標記結束。

結果子分區視圖

ResultSubpartition負責容納Buffer,但考慮到它對Buffer提供了不同的存儲實現,所以又提供了結果子分區視圖(ResultSubpartitionView)抽象出從不同的存儲機制中讀取Buffer的方式。因此,ResultSubpartitionView纔是對接數據消費端的對象。當前的ResultSubpartitionView的實現有:

ResultSubpartitionView-class-diagram

其中跟SpillableSubpartition相關的就有三個,它們的差異如下:

  • SpillableSubpartitionView:通用的基於內存、磁盤的讀取視圖,如果數據溢出到磁盤,則藉助於另外兩個基於磁盤的讀取視圖;
  • SpilledSubpartitionViewSyncIO:溢出到磁盤以同步模式讀取的視圖;
  • SpilledSubpartitionViewAsyncIO:溢出到磁盤以異步模式讀取的視圖;

這些視圖的選擇邏輯封裝在SpillableSubpartition的createReadView方法中。而對於PipelinedSubpartitionView,很顯然它是關聯着PipelinedSubpartition的。

ResultSubpartitionView提供了獲取Buffer的接口方法getNextBuffer。因爲每個ResultSubpartition存儲這些Buffer的機制不一,這纔是爲什麼需要ResultSubpartitionView的原因。

其實,ResultSubpartitionView並不是針對PipelinedSubpartition而構建的,更主要的是針對SpillableSubpartition。

PipelinedSubpartitionView的實現我們就不多說了,它就是從PipelinedSubpartition存儲Buffer的隊列出隊一條記錄。

我們的重點將會放在由SpillableSubpartition所衍生出的三個視圖對象上。

對於SpilledSubpartitionViewSyncIO,其以同步的形式(SynchronousBufferFileReader)從磁盤讀取,讀取器讀取的單位就是Buffer。SpilledSubpartitionViewSyncIO自己在內部實現了一個緩衝池SpillReadBufferPool,其緩衝池裏的內存段並非池化的,而是直接申請,緩衝池被銷燬時所有的內存段隨即被釋放。

SpilledSubpartitionViewAsyncIO採用的是AsynchronousBufferFileReader這一異步Buffer讀取器,該讀取器採用的是批量讀取的模式從磁盤讀取,默認單批次讀取數量爲2。該讀取器會啓動一個獨立的I/O線程來讀取,讀取完成之後會觸發一個RequestDoneCallback類型的異步回調,SpilledSubpartitionViewAsyncIO內部實現了這一接口,在讀取完成之後會觸發returnBufferFromIOThread方法,它會把讀取到的Buffer加入到ConcurrentLinkedQueue<Buffer>類型的隊列中去。SpilledSubpartitionViewAsyncIO中用於填充從文件讀取到數據的Buffer是從ResultPartition的BufferPool中獲取到的。既然是從池中獲取Buffer,那麼就會存在沒有Buffer可用的情況,這裏還是通過事件回調的機制在有可用Buffer時觸發處理邏輯。

最後再講一下SpillableSubpartitionView,它依賴於上面兩個基於文件讀取的視圖。具體而言,跟SpillableSubpartition類似,它會判斷其對應的SpillableSubpartition實例的spillWriter變量以及用於支持基於文件讀取的視圖對象spilledView是否爲空,以將讀取的場景劃分爲三種:在內存中、已溢出到磁盤、正在溢出到磁盤。在內存中,直接從集合中返回;已溢出到磁盤,則直接調用基於文件的讀取視圖返回;正在溢出則返回null,因爲在寫磁盤沒有完全結束時,不會進行消費。


微信掃碼關注公衆號:Apache_Flink

apache_flink_weichat


QQ掃碼關注QQ羣:Apache Flink學習交流羣(123414680)

qrcode_for_apache_flink_qq_group

發佈了173 篇原創文章 · 獲贊 765 · 訪問量 171萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章