全網最詳細4W字Flink全面解析與實踐(下)

Booksea發表於2023-11-04

本文已收錄至GitHub,推薦閱讀 ? Java隨想錄
微信公眾號:Java隨想錄

原創不易,注重版權。轉載請註明原作者和原文連結

承接上篇未完待續的話題,我們一起繼續Flink的深入探討

Flink是一個有狀態的流式計算引擎,所以會將中間計算結果(狀態)進行儲存,預設儲存到TaskManager的堆記憶體中。

但是當Task掛掉,那麼這個Task所對應的狀態都會被清空,造成了資料丟失,無法保證結果的正確性,哪怕想要得到正確結果,所有資料都要重新計算一遍,效率很低。

想要保證 At -least-onceExactly-once,則需要把資料狀態持久化到更安全的儲存介質中,Flink提供了堆內記憶體、堆外記憶體、HDFS、RocksDB等儲存介質。

先來看下Flink提供的狀態有哪些,Flink中狀態可以分為兩種型別:

  • Keyed State

    基於KeyedStream上的狀態,這個狀態是跟特定的Key繫結,KeyedStream流上的每一個Key都對應一個State,每一個Operator可以啟動多個Thread處理,但是相同Key的資料只能由同一個Thread處理,因此一個Keyed狀態只能存在於某一個Thread中,一個Thread會有多個Keyed State。

  • Non-Keyed State(Operator State)

    Operator State與Key無關,而是與Operator繫結,整個Operator只對應一個State。比如:Flink中的Kafka Connector就使用了Operator State,它會在每個Connector例項中,儲存該例項消費Topic的所有(partition, offset)對映。

Flink針對Keyed State提供了以下可以儲存State的資料結構:

  • ValueState:型別為T的單值狀態,這個狀態與對應的Key繫結,最簡單的狀態,透過update更新值,透過value獲取狀態值。
  • ListState:Key上的狀態值為一個列表,這個列表可以透過add()方法往列表中新增值,也可以透過get()方法返回一個Iterable來遍歷狀態值。
  • ReducingState:每次呼叫add()方法新增值的時候,會呼叫使用者傳入的reduceFunction,最後合併到一個單一的狀態值。
  • MapState:狀態值為一個Map,使用者透過put()putAll()方法新增元素,get(key)透過指定的key獲取value,使用entries()keys()values()檢索。
  • AggregatingState:保留一個單值,表示新增到狀態的所有值的聚合。和 ReducingState 相反的是, 聚合型別可能與新增到狀態的元素的型別不同。使用 add(IN) 新增的元素會呼叫使用者指定的 AggregateFunction 進行聚合。
  • FoldingState:已過時,建議使用AggregatingState 保留一個單值,表示新增到狀態的所有值的聚合。 與 ReducingState 相反,聚合型別可能與新增到狀態的元素型別不同。 使用add(T)新增的元素會呼叫使用者指定的 FoldFunction 摺疊成聚合值。

案例1:使用ValueState統計每個鍵的當前計數

public static void main(String[] args) throws Exception {
       final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.fromElements(Tuple2.of("user1", 1), Tuple2.of("user2", 1), Tuple2.of("user1", 1), Tuple2.of("user2", 1))
                .keyBy(0)
                .flatMap(new CountWithKeyedState())
                .print();
        env.execute("Flink ValueState example");
    }

    public static class CountWithKeyedState extends RichFlatMapFunction<Tuple2<String, Integer>, Tuple2<String, Integer>> {
        private transient ValueState<Integer> countState;
        @Override
        public void open(Configuration parameters) throws Exception {
            ValueStateDescriptor<Integer> descriptor =
                    new ValueStateDescriptor<>("countState", Integer.class, 0);
            countState = getRuntimeContext().getState(descriptor);
        }

        @Override
        public void flatMap(Tuple2<String, Integer> value, Collector<Tuple2<String, Integer>> out) throws Exception {
            Integer currentCount = countState.value();
            currentCount += value.f1;
            countState.update(currentCount);
            out.collect(Tuple2.of(value.f0, currentCount));
        }
    }

在這段程式碼中,我們首先建立了一個 StreamExecutionEnvironment,然後產生一些元素,每個元素都是指定使用者的一個事件。keyBy(0) 表示我們以元組的第一個欄位(即使用者ID)為鍵進行分組。

然後,我們使用 flatMap 運算元應用了 CountWithKeyedState 函式。這個函式使用了 Flink 的 ValueState 來儲存和更新每個鍵的當前計數。

open 方法中,我們定義了一個名為 "countState" 的 ValueState,並把它初始化為 0。在 flatMap 方法中,我們從 ValueState 中獲取當前計數,增加輸入元素的值,然後更新 ValueState,併發出帶有當前總數的元組。

注意:在真實的生產環境中,你可能需要從資料來源(如 Kafka, HDFS等)讀取資料,而不是使用 fromElements 方法

案例2:使用MapState 統計單詞出現次數

public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.socketTextStream("localhost", 9999)
                .flatMap(new Tokenizer())
                .keyBy(value -> value.f0)
                .flatMap(new RichFlatMapFunction<Tuple2<String, Integer>, Tuple2<String, Integer>>() {
                    private transient MapState<String, Integer> wordState;
                    @Override
                    public void open(Configuration parameters){
                        MapStateDescriptor<String, Integer> descriptor =
                                new MapStateDescriptor<>("wordCount", String.class, Integer.class);
                        wordState = getRuntimeContext().getMapState(descriptor);
                    }
                    @Override
                    public void flatMap(Tuple2<String, Integer> value, Collector<Tuple2<String, Integer>> out) throws Exception {
                        Integer count = wordState.get(value.f0);
                        if (count == null) {
                            count = 0;
                        }
                        count += value.f1;
                        wordState.put(value.f0, count);
                        out.collect(Tuple2.of(value.f0, count));
                    }
                })
                .print();
        env.execute("Word Count with MapState");
    }

    public static final class Tokenizer extends RichFlatMapFunction<String, Tuple2<String, Integer>> {
        @Override
        public void flatMap(String value, Collector<Tuple2<String, Integer>> out) {
            String[] words = value.toLowerCase().split("\\W+");
            for (String word : words) {
                if (word.length() > 0) {
                    out.collect(new Tuple2<>(word, 1));
                }
            }
        }
    }

在這個例子中,我們首先透過 socketTextStream 方法從本地的 socket 獲取輸入資料流。然後我們用 flatMap 操作將每行輸入分解為單個單詞,並且為每個單詞賦予基礎計數值(基數)1。

我們建立一個使用 RichFlatMapFunction 的 operator,它可以訪問 MapState。在 open() 方法中,我們定義了 MapStateDescriptor,然後用這個 descriptor 建立 MapState

flatMap() 函式中,我們獲取當前單詞的計數值,如果不存在則設定為0。然後我們增加計數值,更新 MapState,並且輸出當前單詞和它的出現次數。

案例3:使用ReducingState統計輸入流中每個鍵的最大值

public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        DataStream<Tuple2<String, Integer>> dataStream = env.fromElements(
                Tuple2.of("A", 6),
                Tuple2.of("B", 5),
                Tuple2.of("C", 4),
                Tuple2.of("A", 3),
                Tuple2.of("B", 2),
                Tuple2.of("C", 1)
        );
        dataStream.keyBy(0).flatMap(new MaxValueReducer()).print();
        env.execute("ReducingState Example");
    }
    public static class MaxValueReducer extends RichFlatMapFunction<Tuple2<String, Integer>, Tuple2<String, Integer>> {
        private transient ReducingState<Integer> maxState;
        @Override
        public void open(Configuration config) {
            ReducingStateDescriptor<Integer> descriptor = new ReducingStateDescriptor<>(
                    "maxValue", // state的名字
                    Math::max, // ReduceFunction,這裡取兩者的最大值
                    TypeInformation.of(Integer.class)); // 型別資訊
            maxState = getRuntimeContext().getReducingState(descriptor);
        }
        @Override
        public void flatMap(Tuple2<String, Integer> input, Collector<Tuple2<String, Integer>> out) throws Exception {
            maxState.add(input.f1); // 更新state的值
            out.collect(Tuple2.of(input.f0, maxState.get())); // 輸出當前key的最大值
        }
    }

在上述程式碼中,我們首先建立了一個新的MaxValueReducer類,該類擴充套件了RichFlatMapFunction。然後定義了一個ReducingState變數,用於在每個key上維護最大值。在open()方法中,我們初始化了這個狀態變數。在flatMap()方法中,我們簡單地將新的值新增到狀態中,並輸出當前key的最大值。

輸出如下:

7> (A,6)
7> (A,6)
2> (B,5)
2> (C,4)
2> (B,5)
2> (C,4)

案例4:使用AggregatingState統計輸入流中每個鍵的平均值

public static void main(String[] args) throws Exception {
      final   StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
      DataStream<Tuple2<String, Integer>> input =  env.fromElements(
                Tuple2.of("A", 6),
                Tuple2.of("B", 5),
                Tuple2.of("C", 4),
                Tuple2.of("A", 3),
                Tuple2.of("B", 2),
                Tuple2.of("C", 1)
        );
        input.keyBy(x -> x.f0)
                .process(new AggregatingProcessFunction())
                .print();
        env.execute();
    }
    public static class AverageAggregate implements AggregateFunction<Integer, Tuple2<Integer, Integer>, Double> {
        @Override
        public Tuple2<Integer, Integer> createAccumulator() {
            return new Tuple2<>(0, 0);
        }
        @Override
        public Tuple2<Integer, Integer> add(Integer value, Tuple2<Integer, Integer> accumulator) {
            return new Tuple2<>(accumulator.f0 + value, accumulator.f1 + 1);
        }
        @Override
        public Double getResult(Tuple2<Integer, Integer> accumulator) {
            return ((double) accumulator.f0) / accumulator.f1;
        }
        @Override
        public Tuple2<Integer, Integer> merge(Tuple2<Integer, Integer> a, Tuple2<Integer, Integer> b) {
            return new Tuple2<>(a.f0 + b.f0, a.f1 + b.f1);
        }
    }

    public static class AggregatingProcessFunction extends KeyedProcessFunction<String, Tuple2<String, Integer>, Tuple2<String, Double>> {
        private AggregatingState<Integer, Double> avgState;
        @Override
        public void open(Configuration parameters) {
            AggregatingStateDescriptor<Integer, Tuple2<Integer, Integer>, Double> descriptor =
                    new AggregatingStateDescriptor<>("average", new AverageAggregate(), TypeInformation.of(new TypeHint<Tuple2<Integer, Integer>>() {
                    }));
            avgState = getRuntimeContext().getAggregatingState(descriptor);
        }
        @Override
        public void processElement(Tuple2<String, Integer> value, Context ctx,
                                   Collector<Tuple2<String, Double>> out) throws Exception {
            avgState.add(value.f1);
            out.collect(new Tuple2<>(value.f0, avgState.get()));
        }
    }

輸入如下:

7> (A,6.0)
2> (B,5.0)
2> (C,4.0)
7> (A,4.5)
2> (B,3.5)
2> (C,2.5)

這段程式碼主要是計算每個鍵對應的值的平均數。程式碼中定義了:AverageAggregateAggregatingProcessFunction

AverageAggregate類實現了AggregateFunction介面,用於計算平均值:

  • createAccumulator方法返回一個新的累加器,這裡是一個包含兩個整數的元組,表示當前的總數和元素的數量。
  • add方法向累加器新增一個元素的值,將其新增到總數中,並增加元素數量。
  • getResult方法根據累加器計算平均值。
  • merge方法合併兩個累加器,將他們的總數和元素數量相加。

AggregatingProcessFunction類擴充套件了KeyedProcessFunction,在接收到一個元素時新增到狀態中的平均值,並輸出當前的平均值:

  • open方法中,建立了一個AggregatingStateDescriptor,描述要儲存的狀態,這裡儲存的是平均值。
  • processElement方法在接收到一個新元素時,將其值新增到狀態中的平均值,然後輸出包含鍵和當前平均值的元組。

以上案例程式碼都經過本地執行和測試,建議大家自行執行以便更深入地理解。

CheckPoint & SavePoint

有狀態流應用中的檢查點(CheckPoint),其實就是所有任務的狀態在某個時間點的一個快照(一份複製)

簡單來講,就是一次「存檔」,讓我們之前處理資料的進度不要丟掉。在一個流應用程式執行時,Flink 會定期儲存檢查點,在檢查點中會記錄每個運算元的 id 和狀態。

如果發生故障,Flink 就會用最近一次成功儲存的檢查點來恢復應用的狀態,重新啟動處理流程,就如同「讀檔」一樣。

預設情況下,檢查點是被禁用的,需要在程式碼中手動開啟。直接呼叫執行環境的enableCheckpointing()方法就可以開啟檢查點。

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.enableCheckpointing(1000);

這裡傳入的引數是檢查點的間隔時間,單位為毫秒。

除了檢查點之外,Flink 還提供了「儲存點(SavePoint)」的功能。

儲存點在原理和形式上跟檢查點完全一樣,也是狀態持久化儲存的一個快照。

儲存點與檢查點最大的區別,就是觸發的時機。檢查點是由 Flink 自動管理的,定期建立,發生故障之後自動讀取進行恢復,這是一個「自動存檔」的功能。而儲存點不會自動建立,必須由使用者明確地手動觸發儲存操作,所以就是「手動存檔」。

因此兩者儘管原理一致,但用途就有所差別了。

檢查點主要用來做故障恢復,是容錯機制的核心;儲存點則更加靈活,可以用來做有計劃的手動備份和恢復

檢查點具體的持久化儲存位置,取決於「檢查點儲存(CheckPointStorage)」的設定。

預設情況下,檢查點儲存在 JobManager 的堆(heap)記憶體中。而對於大狀態的持久化儲存,Flink也提供了在其他儲存位置進行儲存的介面,這就是「 CheckPointStorage」。

具體可以透過呼叫檢查點配置的 setCheckpointStorage()來配置,需要傳入一個CheckPointStorage 的實現類。例如:

public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        // 設定檢查點時間間隔為1000ms
        env.enableCheckpointing(1000);
        // 設定checkpoint儲存路徑, 注意路徑需要是可訪問且有寫許可權的HDFS或本地路徑
        URI checkpointPath = URI.create("hdfs://localhost:9000/flink-checkpoints");
        FileSystemCheckpointStorage storage = new FileSystemCheckpointStorage(checkpointPath, 10000);
        // 應用配置
        env.getCheckpointConfig().setCheckpointStorage(storage);
        // 設定重啟策略,這裡我們設定為固定延時無限重啟
        //Flink的重啟策略是用來決定如何處理作業執行過程中出現的失敗情況的。如果Flink作業在執行時出錯,比如由於程式碼錯誤、硬體故障或 					網路問題等,那麼重啟策略就會決定是否和如何重啟作業。
        env.setRestartStrategy(RestartStrategies.fixedDelayRestart(
          			// 嘗試重啟次數
                3, 
          			//每次嘗試重啟的固定延遲時間為 10 秒
                org.apache.flink.api.common.time.Time.of(10, java.util.concurrent.TimeUnit.SECONDS) 
        ));
        env.execute("Flink Checkpoint Example");
    }

Flink 主要提供了兩種 CheckPointStorage:

  • 作業管理器的堆記憶體(JobManagerCheckpointStorage)
  • 檔案系統(FileSystemCheckpointStorage)

對於實際生產應用,我們一般會將 CheckPointStorage 配置為高可用的分散式檔案系統(HDFS,S3 等)。

CheckPoint原理

Flink會在輸入的資料集上間隔性地生成CheckPoint Barrier,透過柵欄(Barrier)將間隔時間段內的資料劃分到相應的CheckPoint中。

當程式出現異常時,Operator就能夠從上一次快照中恢復所有運算元之前的狀態,從而保證資料的一致性。

例如在Kafka Consumer運算元中維護offset狀態,當系統出現問題無法從Kafka中消費資料時,可以將offset記錄在狀態中,當任務重新恢復時就能夠從指定的偏移量開始消費資料。

預設情況Flink不開啟檢查點,使用者需要在程式中透過呼叫方法配置來開啟檢查點,另外還可以調整其他相關引數

  • CheckPoint 開啟和時間間隔指定

    開啟檢查點並且指定檢查點時間間隔為1000ms,根據實際情況自行選擇,如果狀態比較大,則建議適當增加該值

    env.enableCheckpointing(1000)
    
  • Exactly-once 和 At-least-once語義選擇

    選擇Exactly-once語義保證整個應用內端到端的資料一致性,這種情況比較適合於資料要求比較高,不允許出現丟資料或者資料重複,與此同時,Flink的效能也相對較弱。

    而At-least-once語義更適合於時廷和吞吐量要求非常高但對資料的一致性要求不高的場景。如下透過setCheckpointingMode()方法來設定語義模式,預設情況下使用的是Exactly-once模式

    env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
    
  • CheckPoint 超時時間

    超時時間指定了每次CheckPoint執行過程中的上限時間範圍,一旦CheckPoint執行時間超過該閾值,Flink將會中斷CheckPoint過程,並按照超時處理。該指標可以透過setCheckpointTimeout()方法設定,預設為10分鐘

    env.getCheckpointConfig().setCheckpointTimeout(5 * 60 * 1000);
    
  • CheckPoint 最小時間間隔

    該引數主要目的是設定兩個CheckPoint之間的最小時間間隔,防止Flink應用密集地觸發CheckPoint操作,會佔用了大量計算資源而影響到整個應用的效能

    env.getCheckpointConfig().setMinPauseBetweenCheckpoints(600)
    
  • CheckPoint 最大並行執行數量

    在預設情況下只有一個檢查點可以執行,根據使用者指定的數量可以同時觸發多個CheckPoint,進而提升CheckPoint整體的效率

    env.getCheckpointConfig().setMaxConcurrentCheckpoints(1)
    
  • 任務取消後,是否刪除 CheckPoint 中儲存的資料

    RETAIN_ON_CANCELLATION:表示一旦Flink處理程式被cancel後,會保留CheckPoint資料,以便根據實際需要恢復到指定的CheckPoint。

    DELETE_ON_CANCELLATION:表示一旦Flink處理程式被cancel後,會刪除CheckPoint資料,只有Job執行失敗的時候才會儲存CheckPoint。

    env.getCheckpointConfig().enableExternalizedCheckpoints(CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION)
    
  • 容忍的檢查的失敗數

    設定可以容忍的檢查的失敗數,超過這個數量則系統自動關閉和停止任務。

    env.getCheckpointConfig().setTolerableCheckpointFailureNumber(1)
    

SavePoint原理

SavePoint 底層實現其實也是使用CheckPoint的機制。

SavePoint是使用者以手工命令的方式觸發Checkpoint,並將結果持久化到指定的儲存路徑中,其主要目的是幫助使用者在升級和維護叢集過程中儲存系統中的狀態資料,避免因為停機運維或者升級應用等正常終止應用的操作而導致系統無法恢復到原有的計算狀態的情況,從而無法實現從端到端的 Excatly-Once 語義保證。

要使用SavePoint,需要按照以下步驟進行:

  1. 配置狀態後端: 在Flink中,狀態可以儲存在不同的後端儲存中,例如記憶體、檔案系統或分散式儲存系統(如HDFS)。要啟用SavePoint,需要在Flink配置檔案中配置合適的狀態後端。

    通常,使用分散式儲存系統作為狀態後端是比較常見的做法,因為它可以提供更好的可靠性和容錯性。

  2. 生成SavePoint: 在Flink應用程式執行時,可以透過以下方式手動觸發生成SavePoint:

    bin/flink savepoint <jobID> [targetDirectory]
    

    其中,<jobID>是要儲存狀態的Flink作業的Job ID,[targetDirectory]是可選的目標目錄,用於儲存SavePoint資料。如果沒有提供targetDirectory,SavePoint將會儲存到Flink配置中所配置的狀態後端中。

  3. 恢復SavePoint: 要恢復到SavePoint狀態,可以透過以下方式提交作業:

    bin/flink run -s :savepointPath [:runArgs]
    

    其中,savepointPath是之前生成的SavePoint的路徑,runArgs是提交作業時的其他引數。

  4. 確保應用程式狀態的相容性: 在使用SavePoint時,應用程式的狀態結構和程式碼必須與生成SavePoint的版本保持相容。這意味著在更新應用程式程式碼後,可能需要做一些額外的工作來保證狀態的向後相容性,以便能夠成功恢復到舊的SavePoint。

StateBackend狀態後端

在Flink中提供了StateBackend來儲存和管理狀態資料。

Flink一共實現了三種型別的狀態管理器:MemoryStateBackendFsStateBackendRocksDBStateBackend

MemoryStateBackend

基於記憶體的狀態管理器,將狀態資料全部儲存在JVM堆記憶體中。

基於記憶體的狀態管理具有非常快速和高效的特點,但也具有非常多的限制,最主要的就是記憶體的容量限制,一旦儲存的狀態資料過多就會導致系統記憶體溢位等問題,從而影響整個應用的正常執行。

同時如果機器出現問題,整個主機記憶體中的狀態資料都會丟失,進而無法恢復任務中的狀態資料。因此從資料安全的角度建議使用者儘可能地避免在生產環境中使用MemoryStateBackend。

MemoryStateBackend是Flink的預設狀態後端管理器

env.setStateBackend(new MemoryStateBackend(100*1024*1024));

注意:聚合類運算元的狀態會同步到 JobManager 記憶體中,因此對於聚合類運算元比較多的應用會對 JobManager 的記憶體造成一定的壓力,進而影響叢集。

FsStateBackend

和MemoryStateBackend有所不同的是,FsStateBackend是基於檔案系統的一種狀態管理器,這裡的檔案系統可以是本地檔案系統,也可以是HDFS分散式檔案系統。

env.setStateBackend(new FsStateBackend("path",true));

如果path是本地檔案路徑,格式為:file:///;如果path是HDFS檔案路徑,格式為:hdfs://

第二個引數代表是否非同步儲存狀態資料到HDFS,非同步方式能夠儘可能避免ChecPoint的過程中影響流式計算任務。

FsStateBackend更適合任務量比較大的應用,例如:包含了時間範圍非常長的視窗計算,或者狀態比較大的場景。

RocksDBStateBackend

RocksDBStateBackend是Flink中內建的第三方狀態管理器,和前面的狀態管理器不同,RocksDBStateBackend需要單獨引入相關的依賴包到工程中。

<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-statebackend-rocksdb_2.12</artifactId>
    <version>1.14.4</version>
    <scope>test</scope>
</dependency>
env.setStateBackend(new RocksDBStateBackend("file:///tmp/flink-backend"));

RocksDBStateBackend採用非同步的方式進行狀態資料的Snapshot,任務中的狀態資料首先被寫入本地RockDB中,這樣在RockDB僅會儲存正在進行計算的熱資料,而需要進行CheckPoint的時候,會把本地的資料直接複製到遠端的FileSystem中。

與FsStateBackend相比,RocksDBStateBackend在效能上要比FsStateBackend高一些,主要是因為藉助於RocksDB在本地儲存了最新熱資料,然後透過非同步的方式再同步到檔案系統中,但RocksDBStateBackend和MemoryStateBackend相比效能就會較弱一些。

RocksDB克服了State受記憶體限制的缺點,同時又能夠持久化到遠端檔案系統中,推薦在生產中使用。

叢集級配置StateBackend

全域性配置需要修改叢集中的配置檔案flink-conf.yaml

  • 配置FsStateBackend
state.backend: filesystem
state.checkpoints.dir: hdfs://namenode-host:port/flink-checkpoints
  • 配置MemoryStateBackend
state.backend: jobmanager
  • 配置RocksDBStateBackend
#同時操作RocksDB的執行緒數
state.backend.rocksdb.checkpoint.transfer.thread.num: 1
#RocksDB儲存狀態資料的本地檔案路徑
state.backend.rocksdb.localdir: 本地path

Window

在流處理中,我們往往需要面對的是連續不斷、無休無止的無界流,不可能等到所有資料都到齊了才開始處理。

所以聚合計算其實在實際應用中,我們往往更關心一段時間內資料的統計結果,比如在過去的 1 分鐘內有多少使用者點選了網頁。在這種情況下,我們就可以定義一個視窗,收集最近一分鐘內的所有使用者點選資料,然後進行聚合統計,最終輸出一個結果就可以了。

視窗實質上是將無界流切割為一系列有界流,採用左開右閉的原則

Flink中的視窗分為兩類:基於時間的視窗(Time-based Window)和基於數量的視窗(Count-based Window)

  • 時間視窗(Time Window):按照時間段去擷取資料,這在實際應用中最常見。
  • 計數視窗(Count Window):由資料驅動,也就是說按照固定的個數,來擷取一段資料集。

時間視窗中又包含了:滾動時間視窗、滑動時間視窗、會話視窗

計數視窗包含了:滾動計數視窗、滑動計數視窗

時間視窗、計數視窗只是對視窗的一個大致劃分。在具體應用時,還需要定義更加精細的規則,來控制資料應該劃分到哪個視窗中去。不同的分配資料的方式,就可以有不同的功能應用。

根據分配資料的規則,視窗的具體實現可以分為 4 類:滾動視窗(Tumbling Window)、滑動視窗(Sliding Window)、會話視窗(Session Window)、全域性視窗(Global Window)

滾動視窗

滾動視窗每個視窗的大小固定,且相鄰兩個視窗之間沒有重疊

滾動視窗可以基於時間定義,也可以基於資料個數定義,需要的引數只有視窗大小。

我們可以定義一個大小為1小時的滾動時間視窗,那麼每個小時就會進行一次統計;或者定義一個大小為10的滾動計數視窗,就會每10個數進行一次統計。

基於時間的滾動視窗:

public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        DataStream<Tuple2<Integer, Integer>> randomKeyedStream = env
                .fromSequence(1, Long.MAX_VALUE)
                // 將每個數對映為一個二元組,第一個元素是隨機鍵,第二個元素是數本身
                .map(new MapFunction<Long, Tuple2<Integer, Integer>>() {
                    private final Random rnd = new Random();
                    @Override
                    public Tuple2<Integer, Integer> map(Long value) {
                        return new Tuple2<>(rnd.nextInt(10), value.intValue());
                    }
                });
        // 對流進行滾動視窗操作,視窗大小為5秒
        // 應用視窗函式,求每個視窗的和
        DataStream<Integer> sum = randomKeyedStream
                .assignTimestampsAndWatermarks(WatermarkStrategy
                        .<Tuple2<Integer, Integer>>forBoundedOutOfOrderness(Duration.ofSeconds(5))
                        .withTimestampAssigner((event, timestamp) -> event.f1))
                .keyBy(0)
                .timeWindow(Time.seconds(5))
                .apply(new WindowFunction<Tuple2<Integer, Integer>, Integer, Tuple, TimeWindow>() {
                    @Override
                    public void apply(Tuple key,
                                      TimeWindow window,
                                      Iterable<Tuple2<Integer, Integer>> values,
                                      Collector<Integer> out){
                        int sum1 = 0;
                        for (Tuple2<Integer, Integer> val: values) {
                            sum1 += val.f1;
                        }
                        out.collect(sum1);
                    }
                });
        sum.print();
        env.execute("Tumbling Window Example");
    }

這個程式的主要功能是從1到Long.MAX_VALUE產生一個序列,併為每個生成的數字建立一個二元組(Tuple2),然後在5秒大小的視窗上對二元組進行操作並輸出每個視窗中所有值的總和。

詳細解釋如下:

  1. StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();: 獲取Flink的執行環境。
  2. 產生一個無限長的序列(從1開始到最大的Long型數),每個數字都對映成一個二元組,第一個元素(f0)是一個0-9的隨機整數(作為鍵用於之後的keyBy操作),第二個元素(f1)是數字本身。
  3. 使用assignTimestampsAndWatermarks來定義事件時間和水位線。這裡設定了最大延遲時間為5秒(forBoundedOutOfOrderness),並將二元組的第二個元素作為時間戳。
  4. 使用keyBy(0)按照二元組的第一個元素進行分割槽,這樣保證了相同鍵的元素會被髮送到同一個任務中。
  5. 定義了一個5秒的滾動視窗timeWindow(Time.seconds(5))
  6. 使用apply函式應用在每個視窗上,計算每個視窗中所有二元組的第二個元素(f1)的總和,並收集結果。最終,每個視窗計算的總和都會被輸出。
  7. sum.print();: 命令將處理後的資料列印出來。
  8. env.execute("Tumbling Window Example");: 啟動Flink任務。

基於計數的滾動視窗:

public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        DataStream<String> text = env.socketTextStream("localhost", 9999);
        DataStream<Tuple2<String, Integer>> counts = text
                .flatMap(new Tokenizer())
                .keyBy(0)
                .countWindow(5) // Count window of 5 elements
                .sum(1);
        counts.print().setParallelism(1);
        env.execute("Window WordCount");
    }

    public static final class Tokenizer implements FlatMapFunction<String, Tuple2<String, Integer>> {
        @Override
        public void flatMap(String value, Collector<Tuple2<String, Integer>> out) {
            String[] words = value.toLowerCase().split("\\W+");
            for (String word : words) {
                if (word.length() > 0) {
                    out.collect(new Tuple2<>(word, 1));
                }
            }
        }
    }

這段程式從本地9999埠讀取資料流,對每一行的單詞進行小寫處理和分割,然後在滑動視窗中(大小為5個元素)計算出各個單詞的出現次數。

滑動視窗

滑動視窗的大小固定,但視窗之間不是首尾相接,會有部分重合。同樣,滑動視窗也可以基於時間和計數定義。

滑動視窗的引數有兩個:視窗大小和滑動步長

基於時間的滑動視窗:

public static void main(String[] args) throws Exception {
      final  StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        DataStream<String> input = env.socketTextStream("localhost", 9999);
        DataStream<Tuple2<String, Integer>> processedInput = input.map(new MapFunction<String, Tuple2<String, Integer>>() {
            @Override
            public Tuple2<String, Integer> map(String value){
                String[] words = value.split(",");
                return new Tuple2<>(words[0], Integer.parseInt(words[1]));
            }
        });
        // 指定視窗型別為滑動視窗,視窗大小為10分鐘,滑動步長為5分鐘
        DataStream<Tuple2<String, Integer>> windowCounts = processedInput
                .keyBy(0)
                .window(SlidingProcessingTimeWindows.of(Time.seconds(10), Time.seconds(5)))
                .reduce(new ReduceFunction<Tuple2<String, Integer>>() {
                    @Override
                    public Tuple2<String, Integer> reduce(Tuple2<String, Integer> value1, Tuple2<String, Integer> value2){
                        return new Tuple2<>(value1.f0, value1.f1 + value2.f1);
                    }
                });
        windowCounts.print().setParallelism(1);
        env.execute("Time Window Example");
    }

這段程式從一個套接字埠讀取輸入資料,將每行輸入按照“,”切分並對映為tuple(字串,整數)。然後,它按照第一個元素(即字串)進行分組,並使用滑動視窗(視窗大小為10秒,滑動步長為5秒)進行聚合 - 在每個視窗內,所有具有相同鍵的值的整數部分被相加。最終結果會在控制檯上列印。

基於計數的滑動視窗:

public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        DataStream<String> text = env.socketTextStream("localhost", 9999);
        DataStream<Tuple2<String, Integer>> counts = text
                .flatMap(new Tokenizer())
                .keyBy(0)
                .countWindow(5, 1) 
                .sum(1);
        counts.print().setParallelism(1);
        env.execute("Sliding Window WordCount");
    }

    public static final class Tokenizer implements FlatMapFunction<String, Tuple2<String, Integer>> {
        @Override
        public void flatMap(String value, Collector<Tuple2<String, Integer>> out) {
            String[] words = value.toLowerCase().split("\\W+");
            for (String word : words) {
                if (word.length() > 0) {
                    out.collect(new Tuple2<>(word, 1));
                }
            }
        }
    }

這段程式碼是實時滑動視窗詞頻統計程式。它從本地9999埠讀取資料流,將接收到的每行文字拆分為單詞然後輸出為(單詞,1)的形式,接著按照單詞分組,使用大小為5,步長為1的滑動視窗,並對每個視窗中的同一單詞出現次數進行求和,最後列印結果。

會話視窗

會話視窗是Flink中一種基於時間的視窗型別,每個視窗的大小不固定,且相鄰兩個視窗之間沒有重疊,“會話”終止的標誌就是隔一段時間沒有資料進來

public static void main(String[] args) throws Exception {
       final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        DataStream<Tuple2<String, Long>> inputStream = env.fromElements(
                new Tuple2<>("user1", 1617229200000L),
                new Tuple2<>("user1", 1617229205000L),
                new Tuple2<>("user2", 1617229210000L),
                new Tuple2<>("user1", 1617229215000L),
                new Tuple2<>("user2", 1617229220000L)
        );
        SingleOutputStreamOperator<Tuple2<String, Long>> resultStream = inputStream
                .keyBy(value -> value.f0)
                .window(EventTimeSessionWindows.withGap(Time.minutes(5)))
                .sum(1);
        resultStream.print();
        env.execute("Session Window Example");
    }

這段程式碼從一個資料流中讀取使用者活動資料(包含使用者ID和Unix時間戳),然後根據使用者ID將資料進行分組,並應用了一個會話視窗(當使用者五分鐘內無活動則關閉該使用者的視窗)。

然後,它對每個使用者在各自視窗內的活動時間戳求和,並列印出結果。最後執行的名為"Session Window Example"的任務即完成了這一流式計算過程。

按鍵分割槽視窗和非按鍵分割槽視窗

在Flink中,資料流可以按鍵分割槽(keyed)和非按鍵分割槽(non-keyed)。

按鍵分割槽是指將資料流根據特定的鍵值進行分割槽,使得相同鍵值的元素被分配到同一個分割槽中。這樣可以保證相同鍵值的元素由同一個worker例項處理。只有按鍵分割槽的資料流才能使用鍵分割槽狀態和計時器。

非按鍵分割槽是指資料流沒有根據特定的鍵值進行分割槽。這種情況下,資料流中的元素可以被任意分配到不同的分割槽中。

在定義視窗操作之前,首先需要確定,到底是基於按鍵分割槽(Keyed)來開窗,還是直接在沒有按鍵分割槽的DataStream上開窗。也就是在呼叫視窗運算元之前是否有keyBy操作。

按鍵分割槽視窗:

public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        DataStream<String> text = env.socketTextStream("localhost", 9999);
        DataStream<Tuple2<String, Integer>> counts =
                // 將輸入字串拆分為tuple型別,包含word和數量
                text.map(new MapFunction<String, Tuple2<String, Integer>>() {
                            @Override
                            public Tuple2<String, Integer> map(String value) {
                                return new Tuple2<>(value, 1);
                            }
                        })
                        // 根據元組的第一欄位(word)進行分割槽鍵
                        .keyBy(0)
                        // 定義一個滾動視窗,時間間隔為5秒
                        .window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
                        // 應用reduce函式,累加各個視窗中同一單詞的數量
                        .reduce(new ReduceFunction<Tuple2<String, Integer>>() {
                            @Override
                            public Tuple2<String, Integer> reduce(Tuple2<String, Integer> value1, Tuple2<String, Integer> value2) {
                                return new Tuple2<>(value1.f0, value1.f1 + value2.f1);
                            }
                        });
        counts.print();
        env.execute("Window WordCount");

這段程式碼從 localhost 的 9999 埠接收資料流,將輸入的每個字串作為一個單詞和數字 1 的 tuple 物件,然後根據單詞進行分割槽,建立一個滾動視窗(間隔為5秒),並在每個視窗中對同一單詞的數量進行累加統計,最後列印出結果。

非按鍵分割槽視窗:

public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        DataStream<String> text = env.socketTextStream("localhost", 9999);
        DataStream<Integer> parsed = text.map(new MapFunction<String, Integer>() {
            @Override
            public Integer map(String value) {
                return Integer.parseInt(value);
            }
        });
        DataStream<Integer> windowCounts = parsed
                .windowAll(TumblingProcessingTimeWindows.of(Time.seconds(5)))
                .reduce(new ReduceFunction<Integer>() {
                    @Override
                    public Integer reduce(Integer value1, Integer value2) {
                        return value1 + value2;
                    }
                });
        windowCounts.print().setParallelism(1);
        env.execute("Non keyed Window example");
    }

這段程式從localhost的9999埠讀取資料流,把每條資料轉化為整數,然後在5秒的滾動視窗內將所有的整數值進行累加,並列印出結果。

視窗函式(WindowFunction)

所謂的“視窗函式”(window functions),就是定義視窗如何進行計算操作的函式

視窗函式根據處理的方式可以分為兩類:「增量視窗聚合函式」和「全視窗聚合函式」。

增量視窗聚合函式

增量視窗聚合函式每來一條資料就立即進行計算,中間保持著聚合狀態,但是不立即輸出結果,等到視窗到了結束時間需要輸出計算結果的時候,取出之前聚合的狀態直接輸出。

常見的增量聚合函式有:reduce()aggregate()sum()min()max()

下面是一個使用增量聚合函式的程式碼示例:

public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        DataStream<Long> data = env.fromSequence(1,Long.MAX_VALUE);
        DataStream<Long> result = data.keyBy(value -> value % 2)
                .window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
                .aggregate(new SumAggregator());
        result.print();
        env.execute("Incremental Aggregation Job");
    }

    public static class SumAggregator implements AggregateFunction<Long, Long, Long> {
        @Override
        public Long createAccumulator() {
            return 0L;
        }
        @Override
        public Long add(Long value, Long accumulator) {
            return value + accumulator;
        }
        @Override
        public Long getResult(Long accumulator) {
            return accumulator;
        }
        @Override
        public Long merge(Long a, Long b) {
            return a + b;
        }
    }

這段程式碼從1到Long.MAX_VALUE產生一個連續的資料流。接著,它將資料按照奇偶性進行分類,並在每個5秒的時間視窗內對相同類別的數值進行累加操作。最後列印出累加結果。

全視窗函式

全視窗函式是指在整個視窗中的所有資料都準備好後才進行計算。

Flink中的全視窗函式有兩種: WindowFunctionProcessWindowFunction

與增量視窗函式不同,全視窗函式可以訪問視窗中的所有資料,因此可以執行更復雜的計算。例如,可以計算視窗中資料的中位數,或者對視窗中的資料進行排序。

WindowFunction接收一個Iterable型別的輸入,其中包含了視窗中所有的資料。ProcessWindowFunction則更加強大,它不僅可以訪問視窗中的所有資料, 還可以獲取到一個“上下文物件”(Context)。

這個上下文物件非常強大,不僅能夠獲取視窗資訊,還可以訪問當前的時間和狀態資訊。這裡的時間就包括了處理時間(Processing Time)和事件時間水位線(Event Time Watermark)。

這就使得 ProcessWindowFunction 更加靈活、功能更加豐富,WindowFunction作用可以被 ProcessWindowFunction 全覆蓋。

不過這種額外的功能可能會帶來一些效能上的損失,因此只有當你確實需要這些額外功能時,才應該使用ProcessWindowFunction,如果你不需要這些功能,“簡單”的WindowFunction可能會更有效率。

下面是使用 WindowFunction 計算視窗內資料總和的程式碼示例:

public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        DataStream<String> text = env.fromElements("a", "b", "c", "a", "b", "b");
        DataStream<String> withTimestampsAndWatermarks = text.assignTimestampsAndWatermarks(
                WatermarkStrategy.<String>forBoundedOutOfOrderness(Duration.ofMillis(100))
                        .withTimestampAssigner((event, timestamp) -> System.currentTimeMillis())
        );
        DataStream<Tuple2<String, Integer>> mapped = withTimestampsAndWatermarks.map(
                new MapFunction<String, Tuple2<String, Integer>>() {
                    @Override
                    public Tuple2<String, Integer> map(String value) {
                        return new Tuple2<>(value, 1);
                    }
                });
        mapped.keyBy(0)
                .timeWindow(Time.seconds(5))
                .apply(new SumWindowFunction())
                .print();
        env.execute("Window Sum");
    }

下面是一個使用ProcessWindowFunction統計網站1天UV的程式碼示例:

public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        DataStream<Tuple2<String, Integer>> data = env.fromElements(
                new Tuple2<>("user1", 1),
                new Tuple2<>("user2", 1),
                new Tuple2<>("user1", 1));
        data = data.assignTimestampsAndWatermarks(WatermarkStrategy
                .<Tuple2<String,Integer>>forMonotonousTimestamps()
                .withTimestampAssigner((event, timestamp) -> System.currentTimeMillis())
        );
        data.keyBy(0)
                .window(TumblingEventTimeWindows.of(Time.days(1)))
                .process(new UVProcessWindowFunction())
                .print();
        env.execute("Daily User View Count");
    }

    public static class UVProcessWindowFunction extends ProcessWindowFunction<Tuple2<String, Integer>, Tuple2<String, Long>, Tuple, TimeWindow> {
        @Override
        public void process(Tuple key, Context context, Iterable<Tuple2<String, Integer>> input, Collector<Tuple2<String, Long>> out){
            long count = 0;
            BloomFilter<CharSequence> bloomFilter = BloomFilter.create(Funnels.stringFunnel(), 100000, 0.01);
            for (Tuple2<String, Integer> in: input) {
                if (!bloomFilter.mightContain(in.f0)) {
                    count += 1;
                    bloomFilter.put(in.f0);
                }
            }
            out.collect(new Tuple2<>(key.getField(0), count));
        }
    }

這段程式碼從資料流中讀取使用者檢視資料(資料為("user", view_count)),然後對每個使用者的觀看次數實現了基於時間視窗(一天)的統計。利用布隆過濾器並在視窗內去重,可以避免重複計數。最後,每個視窗結束時,它會輸出每個使用者的id和相應的不重複觀看次數。

增量視窗函式和全視窗函式結合使用

全視窗函式為處理提供了更多的背景資訊,因為它需要等到收集完所有視窗內的資料才進行計算,但是全視窗函式可能會增加系統的複雜性和執行時間。

另一方面,增量視窗函式可以在資料進入視窗時進行部分聚合計算,從而提高效率,但是它可能不適用於所有型別的計算,例如中位數或者標準差這種需要全部資料的計算就無法使用增量聚合。

在實際應用中,我們往往希望兼具這兩者的優點,把它們結合在一起使用。Flink 的Window API 就給我們實現了這樣的用法。

之前在呼叫 WindowedStream 的reduce()aggregate()方法時,只是簡單地直接傳入了一個 ReduceFunction 或 AggregateFunction 進行增量聚合。除此之外,其實還可以傳入第二個引數:一個全視窗函式,可以是 WindowFunction 或者ProcessWindowFunction

// ReduceFunction 與 WindowFunction 結合
public <R> SingleOutputStreamOperator<R> reduce(ReduceFunction<T> reduceFunction, WindowFunction<T, R, K, W> function)

// ReduceFunction 與 ProcessWindowFunction 結合
public <R> SingleOutputStreamOperator<R> reduce(ReduceFunction<T> reduceFunction, ProcessWindowFunction<T, R, K, W> function)

// AggregateFunction 與 WindowFunction 結合
public <ACC, V, R> SingleOutputStreamOperator<R> aggregate(AggregateFunction<T, ACC, V> aggFunction, WindowFunction<V, R, K, W> windowFunction)

// AggregateFunction 與 ProcessWindowFunction 結合
public <ACC, V, R> SingleOutputStreamOperator<R> aggregate(AggregateFunction<T, ACC, V> aggFunction, ProcessWindowFunction<V, R, K, W> windowFunction)

這樣呼叫的處理機制是:基於第一個引數(增量聚合函式)來處理視窗資料,每來一個資料就做一次聚合;等到視窗需要觸發計算時,則呼叫第二個引數(全視窗函式)的處理邏輯輸出結果。

需要注意的是,這裡的全視窗函式就不再快取所有資料了,而是直接將增量聚合函式的結果拿來當作了 Iterable 型別的輸入。一般情況下,這時的可迭代集合中就只有一個元素了

下面我們舉一個具體的例項來說明:

在網站的各種統計指標中,一個很重要的統計指標就是熱門的連結,想要得到熱門的 url,前提是得到每個連結的“熱門度”。一般情況下,可以用url 的瀏覽量(點選量)表示熱門度。我們這裡統計 10 秒鐘的 url 瀏覽量,每 5 秒鐘更新一次。

我們可以定義滑動視窗,並結合增量聚合函式和全視窗函式來得到統計結果,程式碼示例如下:

public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        DataStream<String> text = env.socketTextStream("localhost", 9999);
        DataStream<Tuple2<String, Long>> urlCounts = text
                .flatMap(new Tokenizer())
                .keyBy(value -> value.f0)
                .window(SlidingProcessingTimeWindows.of(Time.seconds(10), Time.seconds(5)))
                .aggregate(new CountAgg(), new WindowResultFunction());
        urlCounts.print();
        env.execute("UrlCount Job");
    }

    public static class CountAgg implements AggregateFunction<Tuple2<String, Integer>, Long, Long> {
        @Override
        public Long createAccumulator() {
            return 0L;
        }

        @Override
        public Long add(Tuple2<String, Integer> value, Long accumulator) {
            return accumulator + value.f1;
        }

        @Override
        public Long getResult(Long accumulator) {
            return accumulator;
        }

        @Override
        public Long merge(Long a, Long b) {
            return a + b;
        }
    }

    public static class WindowResultFunction implements WindowFunction<Long, Tuple2<String, Long>, String, TimeWindow> {
        @Override
        public void apply(String key, TimeWindow window, Iterable<Long> input, Collector<Tuple2<String, Long>> out) {
            Long count = input.iterator().next();
            out.collect(new Tuple2<>(key, count));
        }
    }

    public static final class Tokenizer implements FlatMapFunction<String, Tuple2<String, Integer>> {
        @Override
        public void flatMap(String value, Collector<Tuple2<String, Integer>> out) {
            String[] words = value.toLowerCase().split("\\W+");
            for (String word : words) {
                if (word.length() > 0) {
                    out.collect(new Tuple2<>(word, 1));
                }
            }
        }
    }

在這個示例中,我們首先把資料根據 URL 進行了分組 (keyBy),然後定義了一個滑動視窗,視窗長度是10秒,每5秒滑動一次。接著我們使用增量聚合函式 CountAgg 對每個視窗內的元素進行聚合,最後用全視窗函式 WindowResultFunction 輸出結果。

Window重疊最佳化

視窗重疊是指在使用滑動視窗時,多個視窗之間存在重疊部分。這意味著同一批資料可能會被多個視窗同時處理。

例如,假設我們有一個資料流,它包含了0到9的整數。我們定義了一個大小為5的滑動視窗,滑動距離為2。那麼,我們將會得到以下三個視窗:

  • 視窗1:包含0, 1, 2, 3, 4
  • 視窗2:包含2, 3, 4, 5, 6
  • 視窗3:包含4, 5, 6, 7, 8

在這個例子中,視窗1和視窗2之間存在重疊部分,即2, 3, 4。同樣,視窗2和視窗3之間也存在重疊部分,即4, 5, 6。

enableOptimizeWindowOverlap()方法是用來啟用Flink的視窗重疊最佳化功能的。它可以減少計算重疊視窗時的計算量。

在我之前給出的程式碼示例中,我沒有使用enableOptimizeWindowOverlap()方法來啟用視窗重疊最佳化功能。這意味著Flink不會嘗試最佳化計算重疊視窗時的計算量。

如果你想使用視窗重疊最佳化功能,你可以在你的程式碼中新增以下行:

env.getConfig().enableOptimizeWindowOverlap();

這將啟用視窗重疊最佳化功能,Flink將嘗試最佳化計算重疊視窗時的計算量。

觸發器(Trigger)

觸發器主要是用來控制視窗什麼時候觸發計算。所謂的“觸發計算”,本質上就是執行視窗函式,所以可以認為是計算得到結果並輸出的過程。

基於 WindowedStream 呼叫trigger()方法,就可以傳入一個自定義的視窗觸發器(Trigger)。

public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        DataStream<String> dataStream = env.socketTextStream("localhost", 9999);
        dataStream.map(new MapFunction<String, Tuple2<String, Integer>>() {
                    @Override
                    public Tuple2<String, Integer> map(String value) {
                        return new Tuple2<>(value, 1);
                    }
                })
                .keyBy(value -> value.f0)
                .window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
                .trigger(new MyTrigger())
                .sum(1)
                .print();
        env.execute("Flink Trigger Example");
    }

    public static class MyTrigger extends Trigger<Tuple2<String, Integer>, TimeWindow> {
        @Override
        public TriggerResult onElement(Tuple2<String, Integer> stringIntegerTuple2, long l, TimeWindow timeWindow, TriggerContext triggerContext) throws Exception {
            return TriggerResult.FIRE_AND_PURGE;
        }
        @Override
        public TriggerResult onProcessingTime(long time, TimeWindow window, TriggerContext ctx) {
            return TriggerResult.CONTINUE;
        }
        @Override
        public TriggerResult onEventTime(long time, TimeWindow window, TriggerContext ctx) {
            return TriggerResult.CONTINUE;
        }
        @Override
        public void clear(TimeWindow window, TriggerContext ctx) {
        }
    }

這段程式碼主要從localhost的9999埠讀取資料流,每條資料對映為一個包含該資料和整數1的元組。然後按照元組的第一個元素進行分組,並在每5秒的滾動視窗中對元組的第二個元素求和。最後使用使用者自定義觸發器,當新元素到達時立即觸發計算並清空視窗,但在處理時間或事件時間上不做任何操作。

Trigger 是視窗運算元的內部屬性,每個視窗分配器(WindowAssigner)都會對應一個預設的觸發器。

對於 Flink 內建的視窗型別,它們的觸發器都已經做了實現。例如,所有事件時間視窗,預設的觸發器都是EventTimeTrigger,類似還有 ProcessingTimeTrigger 和 CountTrigger。所以一般情況下是不需要自定義觸發器的,這塊瞭解一下即可。

移除器(Evictor)

移除器(Evictor)是用於在滾動視窗或會話視窗中控制資料保留和清理的元件。它可以根據特定的策略從視窗中刪除一些資料,以確保視窗中保留的資料量不超過指定的限制。

移除器通常與視窗分配器一起使用,視窗分配器負責確定資料屬於哪個視窗,而移除器則負責清理視窗中的資料。

以下是一個使用移除器的程式碼示例,演示如何在滾動視窗中使用基於計數的移除器:

public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        // 建立一個包含整數和時間戳的流
        DataStream<Tuple2<Integer, Long>> dataStream = env.fromElements(
                Tuple2.of(1, System.currentTimeMillis()),
                Tuple2.of(2, System.currentTimeMillis() + 1000),
                Tuple2.of(3, System.currentTimeMillis() + 2000),
                Tuple2.of(4, System.currentTimeMillis() + 3000),
                Tuple2.of(5, System.currentTimeMillis() + 4000),
                Tuple2.of(6, System.currentTimeMillis() + 5000)
        );
        // 新增以下程式碼設定水印和事件時間戳
        dataStream = dataStream.assignTimestampsAndWatermarks(WatermarkStrategy.<Tuple2<Integer, Long>>forBoundedOutOfOrderness(Duration.ofSeconds(1))
                .withTimestampAssigner((event, timestamp) -> event.f1));
        // 在滾動視窗中使用基於計數的移除器,保留最近3個元素
        dataStream
                .keyBy(value -> value.f0)
                .window(TumblingEventTimeWindows.of(Time.seconds(5)))
                .trigger(CountTrigger.of(3))
                .evictor(CountEvictor.of(3))
                .aggregate(new MyAggregateFunction(), new MyProcessWindowFunction())
                .print();

        env.execute("Flink Evictor Example");
    }

    // 自定義聚合函式
    private static class MyAggregateFunction implements AggregateFunction<Tuple2<Integer, Long>, Integer, Integer> {
        @Override
        public Integer createAccumulator() {
            return 0;
        }

        @Override
        public Integer add(Tuple2<Integer, Long> value, Integer accumulator) {
            return accumulator + 1;
        }

        @Override
        public Integer getResult(Integer accumulator) {
            return accumulator;
        }

        @Override
        public Integer merge(Integer a, Integer b) {
            return a + b;
        }
    }

    // 自定義處理視窗函式
    private static class MyProcessWindowFunction extends ProcessWindowFunction<Integer, String, Integer, TimeWindow> {
        private transient ListState<Integer> countState;

        @Override
        public void open(Configuration parameters) throws Exception {
            super.open(parameters);
            ListStateDescriptor<Integer> descriptor = new ListStateDescriptor<>("countState", Integer.class);
            countState = getRuntimeContext().getListState(descriptor);
        }

        @Override
        public void process(Integer key, Context context, Iterable<Integer> elements, Collector<String> out) throws Exception {
            int count = elements.iterator().next();
            countState.add(count);
            long windowStart = context.window().getStart();
            long windowEnd = context.window().getEnd();
            String result = "Window: " + windowStart + " to " + windowEnd + ", Count: " + countState.get().iterator().next();
            out.collect(result);
        }
    }

這段程式碼主要用於對一串包含整數和時間戳的元素進行處理。首先,它建立了一個流並賦予了水印和時間戳。然後在滾動視窗中使用基於計數的觸發器和驅逐器,只保留最近的三個元素。之後,透過自定義聚合和視窗函式,來處理視窗內的資料,聚合函式計算每個視窗內元素的數量,視窗函式將結果與視窗的開始和結束時間一起輸出。

Flink定義了三類時間

  • 事件時間(Event Time):資料在資料來源產生的時間,一般由事件中的時間戳描述,比如使用者日誌中的TimeStamp。
  • 攝取時間(Ingestion Time):資料進入Flink的時間,記錄被Source節點觀察到的系統時間。
  • 處理時間(Process Time):資料進入Flink被處理的系統時間(Operator處理資料的系統時間)。

Flink 流式計算的時候需要顯示定義時間語義,根據不同的時間語義來處理資料,比如指定的時間語義是事件時間,那麼我們就要切換到事件時間的世界觀中,視窗的起始與終止時間都是以事件時間為依據。

在Flink中預設使用的是Process Time,如果要使用其他的時間語義,在執行環境中可以進行設定。

//設定時間語義為Ingestion Time
env.setStreamTimeCharacteristic(TimeCharacteristic.IngestionTime);
//設定時間語義為Event Time 我們還需要指定一下資料中哪個欄位是事件時間(下文會講)
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);

Watermark(水印)

Watermark的本質實質上是時間戳,簡單而言,它是用來處理遲到資料的

在使用Flink處理資料的時候,資料通常都是按照事件產生的時間(事件時間)的順序進入到Flink,但是在遇到特殊情況下,比如遇到網路延遲或者使用Kafka(多分割槽) 很難保證資料都是按照事件時間的順序進入Flink,很有可能是亂序進入。

如果資料一旦是亂序進入,那麼在使用Window處理資料的時候,就會出現延遲資料不會被計算的問題。

  • 舉例: 滾動視窗長度10s。

    2020-04-25 10:00:01

    2020-04-25 10:00:02

    2020-04-25 10:00:03

    2020-04-25 10:00:11 視窗觸發執行

    2020-04-25 10:00:05 延遲資料,不會被上個視窗所計算,導致計算結果不正確

如果有延遲資料,那麼視窗需要等待全部的資料到來之後,再觸發視窗執行。

需要等待多久?不可能無限期等待,我們使用者可以自己來設定延遲時間,這樣就可以儘可能保證延遲資料被處理。

使用Watermark就可以很好的解決延遲資料的問題。

根據使用者指定的延遲時間生成水印(Watermak = 最大事件時間-指定延遲時間),當 Watermak 大於等於視窗的停止時間,這個視窗就會被觸發執行。

  • 舉例:滾動視窗長度10s,指定延遲時間3s

    2020-04-25 10:00:01 wm:2020-04-25 09:59:58

    2020-04-25 10:00:02 wm:2020-04-25 09:59:59

    2020-04-25 10:00:03 wm:2020-04-25 10:00:00

    2020-04-25 10:00:09 wm:2020-04-25 10:00:06

    2020-04-25 10:00:12 wm:2020-04-25 10:00:09

    2020-04-25 10:00:08 wm:2020-04-25 10:00:05 延遲資料

    2020-04-25 10:00:13 wm:2020-04-25 10:00:10

如果沒有 Watermark ,那麼在倒數第三條資料來的時候,就會觸發執行,倒數第二條的延遲資料就不會被計算,有了水印之後就可以處理延遲3s內的資料

生成水印策略

  • 週期性水印(Periodic Watermark):根據事件或者處理時間週期性的觸發水印生成器(Assigner),預設100ms,每隔100毫秒自動向流裡注入一個Watermark。

    final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
            env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
            env.getConfig().setAutoWatermarkInterval(100);
            DataStream<String> stream = env.socketTextStream("node01", 8888)
                    .assignTimestampsAndWatermarks(WatermarkStrategy
                            .<String>forBoundedOutOfOrderness(Duration.ofSeconds(3))
                            .withTimestampAssigner((event, timestamp) -> {
                                return Long.parseLong(event.split(" ")[0]);
                            }));
    
  • 間歇性水印:間歇性水印(Punctuated Watermark)在觀察到事件後,會依據使用者指定的條件來決定是否發射水印。

    public class PunctuatedAssigner implements AssignerWithPunctuatedWatermarks<Tuple2<String, Long>> {
        @Override
        public long extractTimestamp(Tuple2<String, Long> element, long previousElementTimestamp) {
            return element.f1;
        }
        @Override
        public Watermark checkAndGetNextWatermark(Tuple2<String, Long> lastElement, long extractedTimestamp) {
            return lastElement.f0.equals("watermark") ? new Watermark(extractedTimestamp) : null;
        }
        public static void main(String[] args) throws Exception {
            final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
            env.addSource(new SourceFunction<Tuple2<String, Long>>() {
                        private boolean running = true;
                        @Overrid
                        public void run(SourceContext<Tuple2<String, Long>> ctx) throws Exception {
                            while (running) {
                                long currentTimestamp = System.currentTimeMillis();
                                ctx.collect(new Tuple2<>("key", currentTimestamp));
                                if (currentTimestamp % 10 == 0) {
                                    // 每隔一段時間發出一個含有"watermark"的特殊事件
                                    ctx.collect(new Tuple2<>("watermark", currentTimestamp));
                                }
                                Thread.sleep(1000);
                            }
                        }
                        @Override
                        public void cancel() {
                            running = false;
                        }
                    }).assignTimestampsAndWatermarks(new PunctuatedAssigner())
                    .print();
            env.execute("Punctuated Watermark Example");
        }
    }
    

這段程式碼定義了一個名為PunctuatedAssigner的時間戳和watermark分配器類,用於從接收到的元素中提取出時間戳,並根據特定條件(在本例中,元素的key是否為"watermark")生成併傳送watermark。

在main方法中,建立了一個源函式,此函式每秒生成一個新的事件,並且每隔10毫秒就發出一個包含"watermark"的特殊事件。這些事件被收集,分配時間戳和watermark,然後列印出來。

允許延遲(Allowed Lateness)

Flink 還提供了另外一種方式處理遲到資料。我們可以將未收入視窗的遲到資料,放入“側輸出流”(side output)進行另外的處理。所謂的側輸出流,相當於是資料流的一個“分支”,這個流中單獨放置那些錯過了、本該被丟棄的資料

此方法需要傳入一個“輸出標籤”(OutputTag),用來標記分支的遲到資料流。因為儲存的就是流中的原始資料,所以 OutputTag 的型別與流中資料型別相同:

public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        // 定義 OutputTag 來標識側輸出流
        final OutputTag<String> lateDataTag = new OutputTag<String>("late-data"){};
        DataStream<String> dataStream = env.socketTextStream("localhost", 9000);
        SingleOutputStreamOperator<Tuple2<String, Integer>> resultStream = dataStream
                .map(new MapFunction<String, Tuple2<String, Integer>>() {
                    @Override
                    public Tuple2<String, Integer> map(String value) throws Exception {
                        return new Tuple2<>(value, 1);
                    }
                })
                .keyBy(value -> value.f0)
                .process(new ProcessFunction<Tuple2<String, Integer>, Tuple2<String, Integer>>() {
                    @Override
                    public void processElement(Tuple2<String, Integer> value,
                                               Context ctx,
                                               Collector<Tuple2<String, Integer>> out) throws Exception {
                        if (value.f1 == 1) {
                            out.collect(value);
                        } else {
                            // 將遲到的資料傳送到側輸出流
                            ctx.output(lateDataTag, "Late data detected: " + value);
                        }
                    }
                });
        // 獲取側輸出流
        DataStream<String> lateDataStream = resultStream.getSideOutput(lateDataTag);
        resultStream.print();
        lateDataStream.print();
        env.execute("SideOutput Example");
    }

這段程式碼首先建立一個從本地 9000 埠讀取資料的流,然後將每一行資料對映為一個二元組 (value, 1)。接著按照第一個欄位進行分組,並進行處理:如果二元組的第二個元素等於 1,則直接輸出;否則,該條資料會被視為“遲到資料”並輸出至側輸出流。最後,主流和側輸出流的結果都會列印出來。

Flink關聯維度表

在Flink實際開發過程中,可能會遇到 source 進來的資料,需要連線資料庫裡面的欄位,再做後面的處理,比如,想要透過id獲取對應的地區名字,這時候需要透過id查詢地區維度表,獲取具體的地區名。

對於不同的應用場景,關聯維度表的方式不同

  • 場景1:維度表資訊基本不發生改變,或者發生改變的頻率很低。

    實現方案:採用Flink提供的CachedFile。

    Flink提供了一個分散式快取(CachedFile),可以使使用者在並行函式中很方便的讀取本地檔案,並把它放在TaskManager節點中,防止Task重複拉取。

    此快取的工作機制如下:程式註冊一個檔案或者目錄(本地或者遠端檔案系統,例如hdfs或者s3),透過ExecutionEnvironment註冊快取檔案併為它起一個名稱。

    當程式執行,Flink自動將檔案或者目錄複製到所有TaskManager節點的本地檔案系統,僅會執行一次。使用者可以透過這個指定的名稱查詢檔案或者目錄,然後從TaskManager節點的本地檔案系統訪問它。

    public static void main(String[] args) throws Exception {
            final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
            env.registerCachedFile("/root/id2city", "id2city");
            DataStream<String> socketStream = env.socketTextStream("node01", 8888);
            DataStream<Integer> stream = socketStream.map(Integer::valueOf);
            DataStream<String> result = stream.map(new RichMapFunction<Integer, String>() {
                private Map<Integer, String> id2CityMap;
                @Override
                public void open(Configuration parameters) throws Exception {
                    super.open(parameters);
                    id2CityMap = new HashMap<>();
                    BufferedReader reader = new BufferedReader(new FileReader(getRuntimeContext().getDistributedCache().getFile("id2city")));
                    String line;
                    while ((line = reader.readLine()) != null) {
                        String[] splits = line.split(" ");
                        Integer id = Integer.parseInt(splits[0]);
                        String city = splits[1];
                        id2CityMap.put(id, city);
                    }
                    reader.close();
                }
                @Override
                public String map(Integer value) throws IOException {
                    return id2CityMap.getOrDefault(value, "not found city");
                }
            });
            result.print();
            env.execute();
        }
    

    這段程式首先從"node01"主機的8888埠讀取資料,然後將其轉換為整數流。接著,它用一個富對映函式(RichMapFunction)將每個整數ID對映到城市名。這個對映是從在"/root/id2city"路徑下注冊的快取檔案中讀取的。如果無法找到某個ID對應的城市,就會返回"not found city"。

    在叢集中檢視對應TaskManager的log日誌,發現註冊的file會被拉取到各個TaskManager的工作目錄區。

  • 場景2:對於維度表更新頻率比較高並且對於查詢維度表的實時性要求比較高。

    實現方案:使用定時器,定時載入外部配置檔案或者資料庫

    public static void main(String[] args) throws Exception {
            final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
            env.setParallelism(1);
            DataStream<String> stream = env.socketTextStream("node01", 8888);
            stream.map(new RichMapFunction<String, String>() {
                private HashMap<String,String> map = new HashMap<>();
                @Override
                public void open(Configuration parameters) throws Exception {
                    System.out.println("init data ...");
                    query();
                    Timer timer = new Timer(true);
                    timer.schedule(new TimerTask() {
                        @Override
                        public void run() {
                            try {
                                query();
                            } catch (IOException e) {
                                e.printStackTrace();
                            }
                        }
                    },1000,2000);
                }
                void query() throws IOException {
                    Path path = Paths.get("D:\\code\\StudyFlink\\data\\id2city");
                    Stream<String> lines = Files.lines(path);
                    lines.forEach(line -> {
                        String[] parts = line.split(" ");
                        map.put(parts[0], parts[1]);
                    });
    
                    lines.close();
                }
                @Override
                public String map(String key) throws Exception {
                    return map.getOrDefault(key, "not found city");
                }
            }).print();
            env.execute();
        }
    

    這段程式碼從名為"node01"的伺服器的8888埠讀取資料流,然後透過對映函式將每個接收到的資料鍵值(假設是城市ID)轉換為對應的城市名稱。此對映來自一個定期更新的檔案"D:\code\StudyFlink\data\id2city",如果沒有找到匹配的城市ID,則返回"not found city"。

  • 場景3:對於維度表更新頻率高並且對於查詢維度表的實時性要求較高。

    實現方案:將更改的資訊同步至Kafka配置Topic中,然後將kafka的配置流資訊變成廣播流,廣播到業務流的各個執行緒中。

public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        Properties props = new Properties();
        props.setProperty("bootstrap.servers", "node01:9092,node02:9092,node03:9092");
        props.setProperty("group.id", "flink-kafka-001");
        props.setProperty("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        props.setProperty("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        FlinkKafkaConsumer<String> consumer = new FlinkKafkaConsumer<>(
                "configure",
                new SimpleStringSchema(),
                props
        );
        consumer.setStartFromLatest();
        DataStream<String> configureStream = env.addSource(consumer);
        DataStream<String> busStream = env.socketTextStream("node01", 8888);
        MapStateDescriptor<String, String> descriptor = new MapStateDescriptor<>(
                "dynamicConfig",
                BasicTypeInfo.STRING_TYPE_INFO,
                BasicTypeInfo.STRING_TYPE_INFO
        );
        BroadcastStream<String> broadcastStream = configureStream.broadcast(descriptor);
        busStream.connect(broadcastStream).process(
                new BroadcastProcessFunction<String, String, String>() {
                    @Override
                    public void processElement(String line, ReadOnlyContext ctx, Collector<String> out) throws Exception {
                        String city = ctx.getBroadcastState(descriptor).get(line);
                        if (city == null) {
                            out.collect("not found city");
                        } else {
                            out.collect(city);
                        }
                    }

                    @Override
                    public void processBroadcastElement(String line, Context ctx, Collector<String> out) throws Exception {
                        String[] elems = line.split(" ");
                        ctx.getBroadcastState(descriptor).put(elems[0], elems[1]);
                    }
                }
        ).print();
        env.execute();
    }

這段程式碼將從Kafka中獲取的資料作為廣播流,然後與從socket中獲取的資料處理。在處理過程中,根據socket中的資料(作為key)查詢廣播狀態中的城市名稱(作為value),如果找到,則輸出城市名,否則輸出"not found city"。其中,Kafka中的資料以空格分隔,第一個元素作為key,第二個元素作為value存入BroadcastState。

在Spark中有DataFrame這樣的關係型程式設計介面,因其強大且靈活的表達能力,能夠讓使用者透過非常豐富的介面對資料進行處理,有效降低了使用者的使用成本。

Flink也提供了關係型程式設計介面Table API以及基於Table API的SQL API,讓使用者能夠透過使用結構化程式設計介面高效地構建Flink應用。同時Table API以及SQL能夠統一處理批次和實時計算業務,無須切換修改任何應用程式碼就能夠基於同一套API編寫流式應用和批次應用,從而達到真正意義的流批統一。

在 Flink 1.8 架構裡,如果使用者需要同時流計算、批處理的場景下,使用者需要維護兩套業務程式碼,開發人員也要維護兩套技術棧,非常不方便。 Flink 社群很早就設想過將批資料看作一個有界流資料,將批處理看作流計算的一個特例,從而實現流批統一。

阿里巴巴的 Blink 團隊在這方面做了大量的工作,已經實現了 Table API & SQL 層的流批統一。阿里巴巴已經將 Blink 開源回饋給 Flink 社群。

開發環境構建

在 Flink 1.9 中,Table 模組迎來了核心架構的升級,引入了阿里巴巴Blink團隊貢獻的諸多功能,取名叫: Blink Planner

在使用Table API和SQL開發Flink應用之前,透過新增Maven的依賴配置到專案中,在本地工程中引入相應的依賴庫,庫中包含了Table API和SQL介面。

<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-table-planner_2.12</artifactId>
    <version>1.13.6</version>
</dependency>
<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-table-api-scala-bridge_2.12</artifactId>
    <version>1.13.6</version>
</dependency>

Table Environment

和DataStream API一樣,Table API和SQL具有相同的基本程式設計模型。首先需要構建對應的 TableEnviroment 建立關係型程式設計環境,才能夠在程式中使用Table API和SQL來編寫應用程式,另外Table API和SQL介面可以在應用中同時使用,Flink SQL基於Apache Calcite框架實現了SQL標準協議,是構建在Table API之上的更高階介面。

首先需要在環境中建立 TableEnvironment 物件,TableEnvironment 中提供了註冊內部表、執行Flink SQL語句、註冊自定義函式等功能。根據應用型別的不同,TableEnvironment 建立方式也有所不同,但是都是透過呼叫create()方法建立。

流計算環境下建立 TableEnviroment :

//建立流式計算的上下文環境
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
//建立Table API的上下文環境
StreamTableEnvironment streamTableEnvironment = StreamTableEnvironment.create(env);

Table API

Table API 顧名思義,就是基於“表”(Table)的一套 API,專門為處理表而設計的

它提供了關係型程式設計模型,可以用來處理結構化資料,支援表和檢視的概念。在此基礎上,Flink 還基於 Apache Calcite 實現了對 SQL 的支援。這樣一來,我們就可以在 Flink 程式中直接寫 SQL 來實現需求了,非常實用。

下面是一個簡單的例子,它使用Java編寫了一個Flink程式,該程式使用 Table API 從CSV檔案中讀取資料,然後執行簡單的查詢並將結果寫入到自定義的Sink中。

首先我們需要匯入maven依賴:

<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-table-api-java-bridge_2.12</artifactId>
    <version>1.13.6</version>
</dependency>

程式碼示例如下:

public static void main(String[] args) throws Exception {
        // 建立流處理環境
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        // 建立表環境
        EnvironmentSettings settings = EnvironmentSettings.newInstance().useBlinkPlanner().inBatchMode().build();
        StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env, settings);
        // 從CSV檔案中讀取資料
        DataStream<Tuple2<String, Integer>> data = env.readTextFile("input.csv")
                .map(line -> {
                    String[] parts = line.split(",");
                    return new Tuple2<>(parts[0], Integer.parseInt(parts[1]));
                })
                .returns(Types.TUPLE(Types.STRING, Types.INT));
        // 使用Table API將資料轉換為表並註冊為檢視
        String name = "people";
        Schema schema = Schema.newBuilder()
                .column("name", DataTypes.STRING())
                .column("age", DataTypes.INT())
                .build();
        tableEnv.createTemporaryView(name, data, schema);
        // 使用SQL查詢年齡大於30的人
        Table result = tableEnv.sqlQuery("SELECT name, age FROM people WHERE age > 30");
        // 將結果轉換為DataStream
        DataStream<Row> output = tableEnv.toDataStream(result);
        output.addSink(new SinkFunction<Row>() {
            @Override
            public void invoke(Row value, Context context) throws Exception {
                // implement the sink here, e.g., write into a file, send to Kafka, etc.
            }
        });
        env.execute();
    }

這段程式碼是在流處理環境中實現的一個簡單的ETL(提取-轉換-載入)過程:它從CSV檔案中讀取資料,對資料進行對映和轉化,然後使用SQL查詢在一個臨時檢視上查詢年齡大於30的人,最後將結果輸出到某個自定義的Sink上。

Virtual Tables(虛擬表)

在環境中註冊之後,我們就可以在 SQL 中直接使用這張表進行查詢轉換了。

Table newTable = tableEnv.sqlQuery("SELECT name, age FROM people WHERE age > 30");

得到的 newTable 是一箇中間轉換結果,如果之後又希望直接使用這個表執行 SQL,又該怎麼做呢?由於 newTable 是一個 Table 物件,並沒有在表環境中註冊,所以我們還需要將這個中間結果表註冊到環境中,才能在 SQL 中使用:

tableEnv.createTemporaryView("NewTable", newTable);

這裡的註冊其實是建立了一個“虛擬表”(Virtual Table)。這個概念與 SQL 語法中的檢視(View)非常類似,所以呼叫的方法也叫作建立“虛擬檢視” (createTemporaryView)。

表流互轉

// 將錶轉換成資料流,並列印
tableEnv.toDataStream(result).print();
// 將資料流轉換成表
// 我們還可以在 fromDataStream()方法中增加引數,用來指定提取哪些屬性作為表中的欄位名,並可以任意指定位置
Table table = tableEnv.fromDataStream(eventStream, $("timestamp").as("ts"),$("url"));

動態表和持續查詢

在Flink中,動態表(Dynamic Tables)是一種特殊的表,它可以隨時間變化。它們通常用於表示無限流資料,例如事件流或伺服器日誌。與靜態表不同,動態表可以在執行時插入、更新和刪除行。

動態表可以像靜態的批處理表一樣進行查詢操作。由於資料在不斷變化,因此基於它定義的 SQL 查詢也不可能執行一次就得到最終結果。這樣一來,我們對動態表的查詢也就永遠不會停止,一直在隨著新資料的到來而繼續執行。這樣的查詢就被稱作持續查詢(Continuous Query)。

下面是一個簡單的例子,它使用Java編寫了一個Flink程式,該程式從名為"input-topic"的Kafka主題中讀取JSON格式的資料(屬性包括"name"和"age"),過濾出所有年齡大於30歲的記錄,並將結果輸出到另一個名為"output-topic"的Kafka主題中。同時,處理的結果也會在控制檯上列印出來。

public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        EnvironmentSettings settings = EnvironmentSettings.newInstance().useBlinkPlanner().inStreamingMode().build();
        StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env, settings);
        tableEnv.executeSql("CREATE TABLE input (" +
                "  name STRING," +
                "  age INT" +
                ") WITH (" +
                "  'connector' = 'kafka'," +
                "  'topic' = 'input-topic'," +
                "  'properties.bootstrap.servers' = 'localhost:9092'," +
                "  'format' = 'json'" +
                ")");

        tableEnv.executeSql("CREATE TABLE output (" +
                "  name STRING," +
                "  age INT" +
                ") WITH (" +
                "  'connector' = 'kafka'," +
                "  'topic' = 'output-topic'," +
                "  'properties.bootstrap.servers' = 'localhost:9092'," +
                "  'format' = 'json'" +
                ")");

        Table result = tableEnv.sqlQuery("SELECT name, age FROM input WHERE age > 30");
        tableEnv.toAppendStream(result, Row.class).print();
        result.executeInsert("output");
        env.execute();
    }

連線到外部系統

在 Table API編寫的 Flink 程式中,可以在建立表的時候用 WITH 子句指定聯結器(connector),這樣就可以連線到外部系統進行資料互動。

其中最簡單的當然就是連線到控制檯列印輸出:

CREATE TABLE ResultTable (
  user STRING,
  cnt BIGINT
WITH (
  'connector' = 'print'
);
Kafka

需要匯入maven依賴:

<dependency>
   <groupId>org.apache.flink</groupId>
   <artifactId>flink-connector-kafka_2.12</artifactId>
   <version>1.13.6</version>
</dependency>

建立一個連線到 Kafka 的表,需要在 CREATE TABLE 的 DDL 中在 WITH 子句裡指定聯結器為 Kafka,並定義必要的配置引數:

CREATE TABLE KafkaTable (
 `user` STRING,
 `url` STRING,
 `ts` TIMESTAMP(3) METADATA FROM 'timestamp'
) WITH (
 'connector' = 'kafka',
 'topic' = 'events',
 'properties.bootstrap.servers' = 'localhost:9092',
 'properties.group.id' = 'testGroup',
 'scan.startup.mode' = 'earliest-offset',
 'format' = 'csv'
)
MySQL
<dependency>
   <groupId>org.apache.flink</groupId>
   <artifactId>flink-connector-jdbc_2.12</artifactId>
   <version>1.13.6</version>
</dependency>

建立 JDBC 表的方法與前面 Kafka 大同小異:

-- 建立一張連線到 MySQL 的 表
CREATE TABLE MyTable (
 id BIGINT,
 name STRING,
 age INT,
 status BOOLEAN,
 PRIMARY KEY (id) NOT ENFORCED
) WITH (
 'connector' = 'jdbc',
 'url' = 'jdbc:mysql://localhost:3306/mydatabase',
 'table-name' = 'users'
);
-- 將另一張表 T 的資料寫入到 MyTable 表中
INSERT INTO MyTable
SELECT id, name, age, status FROM T;

Table API實戰

1.建立Table

Table API中已經提供了TableSource從外部系統獲取資料,例如常見的資料庫、檔案系統和Kafka訊息佇列等外部系統。

  1. 從檔案中建立Table(靜態表)

    Flink允許使用者從本地或者分散式檔案系統中讀取和寫入資料,只需指定相應的引數即可。但是檔案格式必須是CSV格式的。其他檔案格式也支援(在Flink中還有Connector等來支援其他格式或者自定義TableSource)

    public static void main(String[] args) throws Exception {
            // 建立流式計算的上下文環境
            final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
            // 建立Table API的上下文環境
            StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
            // 建立CSV表源
            String sourceDDL = "CREATE TABLE exampleTab (" +
                    "`id` INT, " +
                    "`name` STRING, " +
                    "`score` DOUBLE" +
                    ") WITH (" +
                    "'connector' = 'filesystem'," +
                    "'path' = 'D:\\code\\StudyFlink\\data\\tableexamples'," +
                    "'format' = 'csv'" +
                    ")";
            tableEnv.executeSql(sourceDDL);
            // 列印表結構
            ResolvedSchema schema = tableEnv.from("exampleTab").getResolvedSchema();
            System.out.println(schema.toString());
        }
    
  2. 從DataStream中建立 Table(動態表)

    前面已經知道Table API是構建在DataStream API和DataSet API之上的一層更高階的抽象,因此使用者可以靈活地使用Table API將Table轉換成DataStream或DataSet資料集,也可以將DataSteam或DataSet資料集轉換成Table,這和Spark中的DataFrame和RDD的關係類似。

    public static void main(String[] args) throws Exception {
            // 先建立StreamExecutionEnvironment
           final StreamExecutionEnvironment bsEnv = StreamExecutionEnvironment.getExecutionEnvironment();
            EnvironmentSettings bsSettings = EnvironmentSettings.newInstance().useBlinkPlanner().inStreamingMode().build();
            StreamTableEnvironment bsTableEnv = StreamTableEnvironment.create(bsEnv, bsSettings);
            // 建立一個DataStream
            DataStream<Tuple2<String, Integer>> stream = bsEnv.fromElements(Tuple2.of("Alice", 3), Tuple2.of("Bob", 4));
            // 將DataStream轉化為Table
            Table table1 = bsTableEnv.fromDataStream(stream);
            // 再把Table轉回DataStream
            DataStream<Row> streamAgain = bsTableEnv.toDataStream(table1);
        }
    

2.查詢和過濾

在Table物件上使用select運算子查詢需要獲取的指定欄位,也可以使用filterwhere方法過濾欄位和檢索條件,將需要的資料檢索出來。

public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment streamEnv = StreamExecutionEnvironment.getExecutionEnvironment();
        streamEnv.setParallelism(1);
        // Create the Table API execution environment.
        StreamTableEnvironment tableEnv = StreamTableEnvironment.create(streamEnv);
        SingleOutputStreamOperator<Tuple5<String, String, String, Long, Long>> data = streamEnv.socketTextStream("hadoop101", 8888)
                .map(new MapFunction<String, Tuple5<String, String, String, Long, Long>>() {
                    @Override
                    public Tuple5<String, String, String, Long, Long> map(String line) throws Exception {
                        String[] arr = line.split(",");
                        return new Tuple5<>(arr[0].trim(), arr[1].trim(), arr[2].trim(), Long.parseLong(arr[4].trim()), Long.parseLong(arr[5].trim()));
                    }
                });
        Table table = tableEnv.fromDataStream(data);
        // Query
        tableEnv.toAppendStream(table.select("f0 AS sid, f1 AS type, f3 AS callTime, f4 AS callOut"), Row.class)
                .print();
        // Filter Query
        tableEnv.toAppendStream(table.filter("f1 === 'success'").where("f1 === 'success'"), Row.class)
                .print();
        tableEnv.execute("sql");
    }

這段程式碼從一個指定的socket中讀取文字資料,將每一行資料對映為一個5元組(Tuple5),然後把這個資料流轉換為表,並進行查詢操作。首先,它進行簡單的列選擇查詢並列印結果;然後,它進行篩選查詢,選取第二欄位"成功"的記錄並列印出來。整個過程在一個名為"sql"的任務中執行。

3.UDF自定義函式

使用者可以在Table API中自定義函式類,常見的抽象類和介面是:

  • ScalarFunction
  • TableFunction
  • AggregateFunction
  • TableAggregateFunction
public static void main(String[] args) {
        EnvironmentSettings settings = EnvironmentSettings.newInstance().useBlinkPlanner().inBatchMode().build();
        TableEnvironment tableEnv = TableEnvironment.create(settings);
        // 註冊UDF
        tableEnv.createTemporarySystemFunction("UpperCase", UpperCaseFunction.class);
        // 使用UDF
        tableEnv.executeSql(
                "SELECT UpperCase(myField) FROM myTable"
        );
    }

    public static  class UpperCaseFunction extends ScalarFunction {
        public String eval(String str) {
            return str.toUpperCase();
        }
    }

這段程式碼建立了自定義函式(UDF)並使用它。首先,它設定了 Flink 的環境,並透過 Blink Planner 以批處理模式執行。然後,它註冊了一個名為 "UpperCase" 的 UDF,該函式將輸入字串轉換為大寫。最後,它在 SQL 查詢中使用了這個 UDF,將 "myTable" 中的 "myField" 欄位的值轉換成大寫形式。

4.Window

public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
        // 建立一個具有 Process Time 時間屬性的表
        tableEnv.executeSql(
                "CREATE TABLE Orders (" +
                        "orderId INT, " +
                        "price DOUBLE, " +
                        "buyer STRING, " +
                        "orderTime TIMESTAMP(3)," +
                        "pt AS PROCTIME()" +   // 使用處理時間
                        ") WITH ('connector' = '...', ...)"
        );

        Table orders = tableEnv.from("Orders");
        Table result1 = orders.window(Tumble.over(lit(10).minutes()).on($("pt")).as("w"))
                .groupBy($("w"), $("buyer"))
                .select($("buyer"), $("w").start().as("start"), $("w").end().as("end"), $("price").sum().as("totalPrice"));

        // 建立一個具有 Event Time 時間屬性的表,使用Watermarks
        tableEnv.executeSql(
                "CREATE TABLE OrdersEventTime (" +
                        "orderId INT, " +
                        "price DOUBLE, " +
                        "buyer STRING, " +
                        "orderTime TIMESTAMP(3), " +
                        "WATERMARK FOR orderTime AS orderTime - INTERVAL '5' SECOND" +   // 使用事件時間和水印
                        ") WITH ('connector' = '...', ...)"
        );

        Table ordersEventTime = tableEnv.from("OrdersEventTime");
        Table result2 = ordersEventTime.window(Tumble.over(lit(10).minutes()).on($("orderTime")).as("w"))
                .groupBy($("w"), $("buyer"))
                .select($("buyer"), $("w").start().as("start"), $("w").end().as("end"), $("price").sum().as("totalPrice"));
        // 對於 IngestionTime,Flink 1.12 中已經不推薦使用,因此在 Flink 1.13.6 版本中,你應該使用 ProcessTime 或 EventTime。
    }

這段程式碼建立了兩個表:一個使用處理時間(Process Time),另一個使用事件時間(Event Time)並設定了水印。針對這兩個表,分別在買家(buyer)和10分鐘的時間視窗上進行分組,並計算了每個時間視窗中的總價(totalPrice)。

多型別資料流

在 Flink 中,DataStreamChangelogStreamAppendStreamRetractStream 用於表示不同型別的資料流。簡單來說,它們之間的主要區別和聯絡如下:

  • DataStream:這是 Flink 的基礎抽象,它表示一個無界的資料流,可以包含任何型別的元素。
  • toChangelogStream:這個方法將錶轉換為一個 ChangeLog 模式的 DataStream。每條記錄都代表一個新增、修改或刪除的事件。事件通常由可選的後設資料標記(例如,'+'(新增)或'-'(撤銷))、更新時間以及唯一的鍵和值組成。ChangelogStream 主要用於處理動態表,並且支援插入,更新和刪除操作。
  • toAppendStream:這個方法將錶轉換為一個只包含新增操作的 DataStream。換句話說,結果表只包含插入(append)操作,不能執行更新或刪除操作。如果查詢的結果表支援刪除或更新,則此方法會丟擲異常。
  • toRetractStream:這個方法將錶轉換為一個包含新增和撤銷訊息的 DataStream。每一條新增訊息表示在結果表中插入了一行,而每一條撤銷訊息表示在結果表中刪除了一行。如果撤銷訊息後沒有相應的新增訊息,那麼可能是因為輸入資料發生了變化,導致之前傳送的結果不再正確,需要被撤銷。

企業中Flink SQL比Table API用的多

Flink SQL 是 Apache Flink 提供的一種使用 SQL 查詢和處理資料的方式。它允許使用者透過 SQL 語句對資料流或批處理資料進行查詢、轉換和分析,無需編寫複雜的程式碼。Flink SQL 提供了一種更直觀、易於理解和使用的方式來處理資料,同時也可以與 Flink 的其他功能無縫整合。

Flink SQL 支援 ANSI SQL 標準,並提供了許多擴充套件和最佳化來適應流式處理和批處理場景。它能夠處理無界資料流,具備事件時間和處理時間的語義,支援視窗、聚合、連線等常見的資料操作,還提供了豐富的內建函式和擴充套件外掛機制。

下面是一個簡單的 Flink SQL 程式碼示例,展示瞭如何使用 Flink SQL 對流式資料進行查詢和轉換。

public static void main(String[] args) throws Exception {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);  // 設定並行度為1,方便觀察輸出結果
        // 建立 Kafka 資料來源
        Properties properties = new Properties();
        properties.setProperty("bootstrap.servers", "localhost:9092");
        properties.setProperty("group.id", "flink-consumer");
        FlinkKafkaConsumer<String> kafkaConsumer = new FlinkKafkaConsumer<>("input-topic", new SimpleStringSchema(), properties);
        DataStream<String> sourceStream = env.addSource(kafkaConsumer);
        // 獲取 StreamTableEnvironment
        StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
        // 註冊資料來源表
        tableEnv.createTemporaryView("source_table", sourceStream, "message");
        // 執行 SQL 查詢和轉換
        String query = "SELECT message, COUNT(*) AS count FROM source_table GROUP BY message";
        // 執行 SQL 查詢和轉換
        Table resultTable = tableEnv.sqlQuery(query);
        DataStream<Result> resultStream = tableEnv.toDataStream(resultTable)
                .map(row -> new Result(row.getField(0).toString(), (Long) row.getField(1)));
        // 列印結果
        resultStream.print();
        env.execute("Flink SQL Example");
    }

    // 自定義結果類
    public static class Result {
        public String message;
        public Long count;
        public Result() {
        }
        public Result(String message, Long count) {
            this.message = message;
            this.count = count;
        }
        @Override
        public String toString() {
            return "Result{" +
                    "message='" + message + '\'' +
                    ", count=" + count +
                    '}';
        }
    }

在上述示例中,我們使用 Kafka 作為資料來源,並建立了一個消費者從名為 "input-topic" 的 Kafka 主題中讀取資料。然後,我們將資料流註冊為名為 "source_table" 的臨時表。

接下來,我們使用 Flink SQL 執行 SQL 查詢和轉換。在這個例子中,我們查詢 "source_table" 表,對 "message" 欄位進行分組並計算每個訊息出現的次數。查詢結果會對映到自定義的 Result 類,並最終透過 print() 方法列印到標準輸出。

最後,我們透過呼叫 env.execute() 方法來啟動 Flink 作業的執行。

Flink SQL中使用滾動視窗,滑動視窗和會話視窗程式碼示例如下:

public static void main(String[] args) throws Exception {
        // 初始化流處理執行環境
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        final StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

        // 對於實際應用程式,請替換為你的資料來源
        String sourceDDL =
                "CREATE TABLE MySourceTable (\n" +
                        "  user_id STRING,\n" +
                        "  event_time TIMESTAMP(3),\n" +
                        "  price DOUBLE\n" +
                        ") WITH (\n" +
                        "'connector' = '...',\n" +
                        "...);\n";

        tableEnv.executeSql(sourceDDL);

        // 滾動視窗
        String tumblingWindowQuery =
                "SELECT user_id, SUM(price) as total_price\n" +
                        "FROM MySourceTable\n" +
                        "GROUP BY user_id, TUMBLE(event_time, INTERVAL '1' HOUR)";

        Table tumblingWindowResult = tableEnv.sqlQuery(tumblingWindowQuery);

        // 滑動視窗
        String slidingWindowQuery =
                "SELECT user_id, SUM(price) as total_price\n" +
                        "FROM MySourceTable\n" +
                        "GROUP BY user_id, HOP(event_time, INTERVAL '30' MINUTE, INTERVAL '1' HOUR)";

        Table slidingWindowResult = tableEnv.sqlQuery(slidingWindowQuery);

        // 會話視窗
        String sessionWindowQuery =
                "SELECT user_id, SUM(price) as total_price\n" +
                        "FROM MySourceTable\n" +
                        "GROUP BY user_id, SESSION(event_time, INTERVAL '1' HOUR)";

        Table sessionWindowResult = tableEnv.sqlQuery(sessionWindowQuery);
    }

程式定義了三種不同型別的視窗查詢:滾動視窗(tumbling window),滑動視窗(sliding window),會話視窗(session window)。

  • 滾動視窗:該查詢對"MySourceTable"中的資料應用滾動視窗,視窗大小為1小時,並按user_id進行分組。每個視窗內,會計算每個使用者的總價格(sum(price))。
  • 滑動視窗:與滾動視窗相似, 但是視窗可以重疊. 這個查詢每半小時滑動一次, 並且每次滑動都會建立一個1小時大小的視窗, 再進行與滾動視窗查詢相同的計算.
  • 會話視窗:會話視窗是根據資料活躍度來劃分的,當一個會話內一段時間(這裡設定為1小時)沒有新的資料到達時,就認為會話結束。該查詢按user_id和event_time的會話視窗進行分組,然後在每個視窗中計算總價格。

每個查詢呼叫tableEnv.sqlQuery(query)方法,並將結果儲存在Table物件中。注意這些查詢在呼叫sqlQuery時並沒有立即執行,只有當你對結果做出動作(如print、collect或者寫入外部系統)時,才會觸發執行。

Flink記憶體最佳化

在大資料領域,大多數開源框架(Hadoop、Spark、Flink)都是基於JVM執行,但是JVM的記憶體管理機制往往存在著諸多類似OutOfMemoryError的問題,主要是因為建立過多的物件例項而超過JVM的最大堆記憶體限制,卻沒有被有效回收掉。

這在很大程度上影響了系統的穩定性,尤其對於大資料應用,面對大量的資料物件產生,僅僅靠JVM所提供的各種垃圾回收機制很難解決記憶體溢位的問題。

在開源框架中有很多框架都實現了自己的記憶體管理,例如Apache Spark的Tungsten專案,在一定程度上減輕了框架對JVM垃圾回收機制的依賴,從而更好地使用JVM來處理大規模資料集。

Flink也基於JVM實現了自己的記憶體管理,將JVM根據記憶體區分為Unmanned Heap、Flink Managed Heap、Network Buffers三個區域

在Flink內部對Flink Managed Heap進行管理,在啟動叢集的過程中直接將堆記憶體初始化成Memory Pages Pool,也就是將記憶體全部以二進位制陣列的方式佔用,形成虛擬記憶體使用空間。

新建立的物件都是以序列化成二進位制資料的方式儲存在記憶體頁面池中,當完成計算後資料物件Flink就會將Page置空,而不是透過JVM進行垃圾回收,保證資料物件的建立永遠不會超過JVM堆記憶體大小,也有效地避免了因為頻繁GC導致的系統穩定性問題。

JobManager配置

JobManager在Flink系統中主要承擔管理叢集資源、接收任務、排程Task、收集任務狀態以及管理TaskManager的功能,JobManager本身並不直接參與資料的計算過程,因此JobManager的記憶體配置項不是特別多,只要指定JobManager堆記憶體大小即可。

  • jobmanager.heap.size:設定JobManager堆記憶體大小,預設為1024MB。

TaskManager配置

TaskManager作為Flink叢集中的工作節點,所有任務的計算邏輯均執行在TaskManager之上,因此對TaskManager記憶體配置顯得尤為重要,可以透過以下引數配置對TaskManager進行最佳化和調整。

  • taskmanager.heap.size:設定TaskManager堆記憶體大小,預設值為1024M,如果在Yarn的叢集中,TaskManager取決於Yarn分配給TaskManager Container的記憶體大小,且Yarn環境下一般會減掉一部分記憶體用於Container的容錯。

  • taskmanager.jvm-exit-on-oom:設定TaskManager是否會因為JVM發生記憶體溢位而停止,預設為false,當TaskManager發生記憶體溢位時,也不會導致TaskManager停止。

  • taskmanager.memory.size:設定TaskManager記憶體大小,預設為0,如果不設定該值將會使用taskmanager.memory.fraction作為記憶體分配依據。

  • taskmanager.memory.fraction:設定TaskManager堆中去除Network Buffers記憶體後的記憶體分配比例。該記憶體主要用於TaskManager任務排序、快取中間結果等操作。例如,如果設定為0.8,則代表TaskManager保留80%記憶體用於中間結果資料的快取,剩下20%的記憶體用於建立使用者定義函式中的資料物件儲存。注意,該引數只有在taskmanager.memory.size不設定的情況下才生效。

  • taskmanager.memory.off-heap:設定是否開啟堆外記憶體供Managed Memory或者Network Buffers使用。

  • taskmanager.memory.preallocate:設定是否在啟動TaskManager過程中直接分配TaskManager管理記憶體。

  • taskmanager.numberOfTaskSlots:每個TaskManager分配的slot數量。

Flink的網路快取最佳化

Flink將JVM堆記憶體切分為三個部分,其中一部分為Network Buffers記憶體。Network Buffers記憶體是Flink資料互動層的關鍵記憶體資源,主要目的是快取分散式資料處理過程中的輸入資料。

通常情況下,比較大的Network Buffers意味著更高的吞吐量。如果系統出現“Insufficient number of network buffers”的錯誤,一般是因為Network Buffers配置過低導致,因此,在這種情況下需要適當調整TaskManager上Network Buffers的記憶體大小,以使得系統能夠達到相對較高的吞吐量。

目前Flink能夠調整Network Buffer記憶體大小的方式有兩種:一種是透過直接指定Network Buffers記憶體數量的方式,另外一種是透過配置記憶體比例的方式。

設定Network Buffer記憶體數量(過時)

直接設定Nework Buffer數量需要透過如下公式計算得出:

NetworkBuffersNum = total-degree-of-parallelism \* intra-node-parallelism * n

其中total-degree-of-parallelism表示每個TaskManager的總併發數量,intra-node-parallelism表示每個TaskManager輸入資料來源的併發數量,n表示在預估計算過程中Repar-titioning或Broadcasting操作並行的數量。intra-node-parallelism通常情況下與Task-Manager的所佔有的CPU數一致,且Repartitioning和Broadcating一般下不會超過4個併發。可以將計算公式轉化如下:

NetworkBuffersNum = <slots-per-TM>^2 \* < TMs>* 4

其中slots-per-TM是每個TaskManager上分配的slots數量,TMs是TaskManager的總數量。對於一個含有20個TaskManager,每個TaskManager含有8個Slot的叢集來說,總共需要的Network Buffer數量為8^2*204=5120個,因此叢集中配置Network Buffer記憶體的大小約為160M較為合適。

計算完Network Buffer數量後,可以透過新增如下兩個引數對Network Buffer記憶體進行配置。其中segment-size為每個Network Buffer的記憶體大小,預設為32KB,一般不需要修改,透過設定numberOfBuffers引數以達到計算出的記憶體大小要求。

  • taskmanager.network.numberOfBuffers:指定Network堆疊Buffer記憶體塊的數量。

  • taskmanager.memory.segment-size:記憶體管理器和Network棧使用的記憶體Buffer大小,預設為32KB。

設定Network Buffer記憶體比例(推薦)

從1.3版本開始,Flink就提供了透過指定記憶體比例的方式設定Network Buffer記憶體大小。

  • taskmanager.network.memory.fraction:JVM中用於Network Buffers的記憶體比例。

  • taskmanager.network.memory.min:最小的Network Buffers記憶體大小,預設為64MB。

  • taskmanager.network.memory.max:最大的Network Buffers記憶體大小,預設1GB。

  • taskmanager.memory.segment-size:記憶體管理器和Network棧使用的Buffer大小,預設為32KB。

結語

感謝你耐心地讀到這裡,在我們結束這篇部落格的同時,我鼓勵你繼續探索和實踐Flink的無盡可能性。無論你是初學者還是專業人士,Flink都有許多值得挖掘的深度和廣度。這就像一場資料處理的冒險,充滿了挑戰與機遇。無論你走到哪一步,都記得享受過程,因為每一個問題的解決都代表著新的認知和成長。

再次感謝你的閱讀,希望這篇文章能夠帶給你收穫以及深入的思考,期待你在Flink的學習旅程中取得更大的進步。

相關文章