在 Flink 運算元中使用多執行緒如何保證不丟資料?

芊寶寶最可愛發表於2019-12-30

分析痛點

筆者線上有一個 Flink 任務消費 Kafka 資料,將資料轉換後,在 Flink 的 Sink 運算元內部呼叫第三方 api 將資料上報到第三方的資料分析平臺。這裡使用批次同步 api,即:每 50 條資料請求一次第三方介面,可以透過批次 api 來提高請求效率。由於呼叫的外網介面,所以每次呼叫 api 比較耗時。假如批次大小為 50,且請求介面的平均響應時間為 50ms,使用同步 api,因此第一次請求響應以後才會發起第二次請求。請求示意圖如下所示:


在 Flink 運算元中使用多執行緒如何保證不丟資料?


平均下來,每 50 ms 向第三方伺服器傳送 50 條資料,也就是每個並行度 1 秒鐘處理 1000 條資料。假設當前業務資料量為每秒 10 萬條資料,那麼 Flink Sink 運算元的並行度需要設定為 100 才能正常處理線上資料。從 Flink 資源分配來講,100 個並行度需要申請 100 顆 CPU,因此當前 Flink 任務需要佔用叢集中 100 顆 CPU 以及不少的記憶體資源。請問此時 Flink Sink 運算元的 CPU 或者記憶體壓力大嗎?

上述請求示意圖可以看出 Flink 任務發出請求到響應這 50ms 期間,Flink Sink 運算元只是在 wait,並沒有實質性的工作。因此,CPU 使用率肯定很低,當前任務的瓶頸明顯在網路 IO。最後結論是 Flink 任務申請了 100 顆 CPU,導致 yarn 或其他資源排程框架沒有資源了,但是這 100 顆 CPU 的使用率並不高,這裡能不能最佳化透過提高 CPU 的使用率,從而少申請一些 CPU 呢?

同步批次請求最佳化為非同步請求

首先可以想到的是將同步請求改為非同步請求,使得任務不會阻塞在網路請求這一環節,請求示意圖如下所示。


在 Flink 運算元中使用多執行緒如何保證不丟資料?


非同步請求相比同步請求而言,最佳化點在於每次發出請求時,不需要等待請求響應後再傳送下一次請求,而是當下一批次的 50 條資料準備好之後,直接向第三方伺服器傳送請求。每次傳送請求後,Flink Sink 運算元的客戶端需要註冊監聽器來等待響應,當響應失敗時需要做重試或者回滾策略。

透過非同步請求的方式,可以最佳化網路瓶頸,假如 Flink Sink 運算元的單個並行度平均 10ms 接收到 50 條資料,那麼使用非同步 api 的方式平均 1 秒可以處理 5000 條資料,整個 Flink 任務的效能提高了 5 倍。對於每秒 10 萬資料量的業務,這裡僅需要申請 20 顆 CPU 資源即可。關於非同步 api 的具體使用,可以根據場景具體設計,這裡不詳細討論。

多執行緒 Client 模式

對於一些不支援非同步 api 的場景,可能並不能使用上述最佳化方案,同樣,為了提高 CPU 使用率,可以在 Flink Sink 端使用多執行緒的方案。如下圖所示,可以在 Flink Sink 端開啟 5 個請求第三方伺服器的 Client 執行緒:Client1、Client2、Client3、Client4、Client5。

這五個執行緒內分別使用同步批次請求的 Client,單個 Client 還是保持 50 條記錄為一個批次,即 50 條記錄請求一次第三方 api。請求第三方 api 耗時主要在於網路 IO(效能瓶頸在於網路請求延遲),因此如果變成 5 個 Client 執行緒,每個 Client 的單次請求平均耗時還能保持在 50ms,除非網路請求已經達到了頻寬上限或整個任務又遇到其他瓶頸。所以,多執行緒模式下使用同步批次 api 也能將請求效率提升 5 倍。


在 Flink 運算元中使用多執行緒如何保證不丟資料?


說明:多執行緒的方案,不僅限於請求第三方介面,對於非 CPU 密集型的任務也可以使用該方案,在降低 CPU 數量的同時,單個 CPU 承擔多個執行緒的工作,從而提高 CPU 利用率。例如:請求 HBase 的任務或磁碟 IO 是瓶頸的任務,可以降低任務的並行度,使得每個並行度內處理多個執行緒。

Flink 運算元內多執行緒實現

Sink 運算元的單個並行度內現在有 5 個 Client 用於消費資料,但 Sink 運算元的資料都來自於上游運算元。如下圖所示,一個簡單的實現方式是 Sink 運算元接收到上游資料後透過輪循或隨機的策略將資料分發給 5 個 Client 執行緒。


在 Flink 運算元中使用多執行緒如何保證不丟資料?


但是輪循或者隨機策略會存在問題,假如 5 個 Client 中 Client3 執行緒消費較慢,會導致給 Client3 分發資料時被阻塞,從而使得其他正常消費的執行緒 Client1、2、4、5 也被分發不到資料。

為了解決上述問題,可以在 Sink 運算元內申請一個資料緩衝佇列,佇列有先進先出(FIFO)的特性。Sink 運算元接收到的資料直接插入到佇列尾部,五個 Client 執行緒不斷地從隊首取資料並消費,即:Sink 運算元先接收的資料 Client 先消費,後接收的資料 Client 後消費。

  • 若佇列一直是滿的,說明 Client 執行緒消費較慢、Sink 運算元上游生產資料較快。
  • 若佇列一直為空,說明 Client 執行緒消費較快、Sink 運算元的上游生產資料較慢。

五個執行緒共用同一個佇列完美地解決了單個執行緒消費慢的問題,當 Client3 執行緒阻塞時,不影響其他執行緒從佇列中消費資料。這裡使用佇列還起到了削峰填谷的作用。


在 Flink 運算元中使用多執行緒如何保證不丟資料?


程式碼實現

原理明白了,具體程式碼如下所示,首先是消費資料的 Client 執行緒程式碼,程式碼邏輯很簡單,一直從 bufferQueue 中 poll 資料,取出資料後,執行相應的消費邏輯即可,在本案例中消費邏輯便是 Client 積攢批次並呼叫第三方 api。

public class MultiThreadConsumerClient implements Runnable {
    private LinkedBlockingQueue<String> bufferQueue;
    public MultiThreadConsumerClient(LinkedBlockingQueue<String> bufferQueue) {
        this.bufferQueue = bufferQueue;
    }
    @Override
    public void run() {
        String entity;
        while (true){
            // 從 bufferQueue 的隊首消費資料
            entity = bufferQueue.poll();
            // 執行 client 消費資料的邏輯
            doSomething(entity);
        }
    }
    // client 消費資料的邏輯
    private void doSomething(String entity) {
        // client 積攢批次並呼叫第三方 api
    }
}

Sink 運算元程式碼如下所示,在 open 方法中需要初始化執行緒池、資料緩衝佇列並建立開啟消費者執行緒,在 invoke 方法中只需要往 bufferQueue 的隊尾新增資料即可。

public class MultiThreadConsumerSink extends RichSinkFunction<String> {
    // Client 執行緒的預設數量
    private final int DEFAULT_CLIENT_THREAD_NUM = 5;
    // 資料緩衝佇列的預設容量
    private final int DEFAULT_QUEUE_CAPACITY = 5000;
    private LinkedBlockingQueue<String> bufferQueue;
    @Override
    public void open(Configuration parameters) throws Exception {
        super.open(parameters);
        // new 一個容量為 DEFAULT_CLIENT_THREAD_NUM 的執行緒池
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(DEFAULT_CLIENT_THREAD_NUM, DEFAULT_CLIENT_THREAD_NUM,
                0L,TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
        // new 一個容量為 DEFAULT_QUEUE_CAPACITY 的資料緩衝佇列
        this.bufferQueue = Queues.newLinkedBlockingQueue(DEFAULT_QUEUE_CAPACITY);
        // 建立並開啟消費者執行緒
        MultiThreadConsumerClient consumerClient = new MultiThreadConsumerClient(bufferQueue);
        for (int i=0; i < DEFAULT_CLIENT_THREAD_NUM; i++) {
            threadPoolExecutor.execute(consumerClient);
        }
    }
    @Override
    public void invoke(String value, Context context) throws Exception {
        // 往 bufferQueue 的隊尾新增資料
        bufferQueue.put(value);
    }
}

程式碼邏輯相對比較簡單,請問上述 Sink 能保證 Exactly Once 嗎?

答:不能保證 Exactly Once,Flink 要想端對端保證 Exactly Once,必須要求外部元件支援事務,這裡第三方介面明顯不支援事務。

那麼上述 Sink 能保證 At Lease Once 嗎?言外之意,上述 Sink 會丟資料嗎?

答:會丟資料。因為上述案例中使用的批次 api 來消費資料,假如批次 api 是每積攢 50 條資料請求一次第三方介面,當做 Checkpoint 時可能只積攢了 30 條資料,所以做 Checkpoint 時記憶體中可能還有資料未傳送到外部系統。而且資料緩衝佇列中可能還有快取的資料,因此上述 Sink 在做 Checkpoint 時會出現 Checkpoint 之前的資料未完全消費的情況。

例如,Flink 任務消費的 Kafka 資料,當做 Checkpoint 時,Flink 任務消費到 offset 為 10000 的位置,但實際上 offset 10000 之前的一小部分資料可能還在資料緩衝佇列中尚未完全消費,或者因為沒積攢夠一定批次所以資料快取在 client 中,並未請求到第三方。當任務失敗後,Flink 任務從 Checkpoint 處恢復,會從 offset 為 10000 的位置開始消費,此時 offset 10000 之前的一小部分快取在記憶體緩衝佇列中的資料不會再被消費,於是就出現了丟資料情況。


在 Flink 運算元中使用多執行緒如何保證不丟資料?


處理丟資料情況

如何保證資料不丟失呢?很簡單,可以在 Checkpoint 時強制將資料緩衝區的資料全部消費完,並對 client 執行 flush 操作,保證 client 端不會快取資料。

實現思路:Sink 運算元可以實現 CheckpointedFunction 介面,當做 Checkpoint 時,會呼叫 snapshotState 方法,方法內可以觸發 client 的 flush 操作。但 client 在 MultiThreadConsumerClient 對應的五個執行緒中,需要考慮執行緒同步的問題,即:Sink 運算元的 snapshotState 方法中做一個操作,要使得五個 Client 執行緒感知到當前正在執行 Checkpoint,此時應該把資料緩衝區的資料全部消費完,並對 client 執行過 flush 操作。

如何實現呢?需要藉助 CyclicBarrier。CyclicBarrier 會讓所有執行緒都等待某個操作完成後才會繼續下一步行動。在這裡可以使用 CyclicBarrier,讓 Checkpoint 等待所有的 client 將資料緩衝區的資料全部消費完並對 client 執行過 flush 操作,言外之意,offset 10000 之前的資料必須全部消費完成才允許 Checkpoint 執行完成。這樣就可以保證 Checkpoint 時不會有資料被快取在記憶體,可以保證資料來源 offset 10000 之前的資料都消費完成。

MultiThreadConsumerSink 具體程式碼如下所示:

public class MultiThreadConsumerSink extends RichSinkFunction<String> {
    // Client 執行緒的預設數量
    private final int DEFAULT_CLIENT_THREAD_NUM = 5;
    // 資料緩衝佇列的預設容量
    private final int DEFAULT_QUEUE_CAPACITY = 5000;
    private LinkedBlockingQueue<String> bufferQueue;
    @Override
    public void open(Configuration parameters) throws Exception {
        super.open(parameters);
        // new 一個容量為 DEFAULT_CLIENT_THREAD_NUM 的執行緒池
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(DEFAULT_CLIENT_THREAD_NUM, DEFAULT_CLIENT_THREAD_NUM,
                0L,TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
        // new 一個容量為 DEFAULT_QUEUE_CAPACITY 的資料緩衝佇列
        this.bufferQueue = Queues.newLinkedBlockingQueue(DEFAULT_QUEUE_CAPACITY);
        // 建立並開啟消費者執行緒
        MultiThreadConsumerClient consumerClient = new MultiThreadConsumerClient(bufferQueue);
        for (int i=0; i < DEFAULT_CLIENT_THREAD_NUM; i++) {
            threadPoolExecutor.execute(consumerClient);
        }
    }
    @Override
    public void invoke(String value, Context context) throws Exception {
        // 往 bufferQueue 的隊尾新增資料
        bufferQueue.put(value);
    }
}

MultiThreadConsumerSink 實現了 CheckpointedFunction 介面,在 open 方法中增加了 CyclicBarrier 的初始化,CyclicBarrier 預期容量設定為 client 執行緒數加一,表示當 client 執行緒數加一個執行緒都執行了 await 操作時,所有的執行緒的 await 方法才會執行完成。這裡為什麼要加一呢?因為除了 client 執行緒外, snapshotState 方法中也需要執行過 await。

當做 Checkpoint 時 snapshotState 方法中執行 clientBarrier.await(),等待所有的 client 執行緒將緩衝區資料消費完。snapshotState 方法執行過程中 invoke 方法不會被執行,即:Checkpoint 過程中資料緩衝佇列不會增加資料,所以 client 執行緒很快就可以將緩衝佇列中的資料消費完。

MultiThreadConsumerClient 具體程式碼如下所示:

public class MultiThreadConsumerSink extends RichSinkFunction<String> implements CheckpointedFunction {
    private Logger LOG = LoggerFactory.getLogger(MultiThreadConsumerSink.class);
    // Client 執行緒的預設數量
    private final int DEFAULT_CLIENT_THREAD_NUM = 5;
    // 資料緩衝佇列的預設容量
    private final int DEFAULT_QUEUE_CAPACITY = 5000;
    private LinkedBlockingQueue<String> bufferQueue;
    private CyclicBarrier clientBarrier;
    @Override
    public void open(Configuration parameters) throws Exception {
        super.open(parameters);
        // new 一個容量為 DEFAULT_CLIENT_THREAD_NUM 的執行緒池
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(DEFAULT_CLIENT_THREAD_NUM, DEFAULT_CLIENT_THREAD_NUM,
                0L,TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
        // new 一個容量為 DEFAULT_QUEUE_CAPACITY 的資料緩衝佇列
        this.bufferQueue = Queues.newLinkedBlockingQueue(DEFAULT_QUEUE_CAPACITY);
        // barrier 需要攔截 (DEFAULT_CLIENT_THREAD_NUM + 1) 個執行緒
        this.clientBarrier = new CyclicBarrier(DEFAULT_CLIENT_THREAD_NUM + 1);
        // 建立並開啟消費者執行緒
        MultiThreadConsumerClient consumerClient = new MultiThreadConsumerClient(bufferQueue, clientBarrier);
        for (int i=0; i < DEFAULT_CLIENT_THREAD_NUM; i++) {
            threadPoolExecutor.execute(consumerClient);
        }
    }
    @Override
    public void invoke(String value, Context context) throws Exception {
        // 往 bufferQueue 的隊尾新增資料
        bufferQueue.put(value);
    }
    @Override
    public void snapshotState(FunctionSnapshotContext functionSnapshotContext) throws Exception {
        LOG.info("snapshotState : 所有的 client 準備 flush !!!");
        // barrier 開始等待
        clientBarrier.await();
    }
    @Override
    public void initializeState(FunctionInitializationContext functionInitializationContext) throws Exception {
    }
}

從資料緩衝佇列中 poll 資料時,增加了 timeout 時間為 50ms。如果從佇列中拿到資料,則執行消費資料的邏輯,若拿不到資料說明資料緩衝佇列中資料消費完了。此時需要判斷是否有等待的 CyclicBarrier,如果有等待的 CyclicBarrier 說明此時正在執行 Checkpoint,所以 client 需要執行 flush 操作。flush 完成後,Client 執行緒執行 barrier.await() 操作。當所有的 Client 執行緒都執行到 await 時,所有的 barrier.await() 都會被執行完。此時 Sink 運算元的 snapshotState 方法就會執行完。透過這種策略可以保證 Checkpoint 時將資料緩衝區中的資料消費完,client 執行 flush 操作可以保證 client 端不會快取資料。

總結

分析到這裡,我們設計的 Sink 終於可以保證不丟失資料了。對 CyclicBarrier 不瞭解的同學請 Google 或百度查詢。再次強調這裡多執行緒的方案,不僅限於請求第三方介面,對於非 CPU 密集型的任務都可以使用該方案來提高 CPU 利用率,且該方案不僅限於 Sink 運算元,各種運算元都適用。本文主要希望幫助大家理解 Flink 中使用多執行緒的最佳化及在 Flink 運算元中使用多執行緒如何保證不丟資料。

原文連結

本文為阿里雲原創內容,未經允許不得轉載。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69949601/viewspace-2671256/,如需轉載,請註明出處,否則將追究法律責任。

相關文章