Flink狀態(一)

单行线的旋律發表於2024-06-20

key狀態和運算元狀態

key狀態

key狀態總是與key有關,只能被用於keyedStream型別的函式與運算元。你可以認為key狀態是一種被分割槽的運算元狀態,每一個key有一個狀態分割槽。每一個key狀態邏輯上由<parellel-operator-instance, key>唯一確定,由於每一個key只分布在key運算元的多個併發例項中的一個例項上,我們可以將<parellel-operator-instance, key>簡化為<operator, key>.

運算元(operator)狀態

運算元狀態也稱為非key狀態。每一個運算元狀態繫結一個併發運算元例項。kafka connector是flink運算元狀態比較好的應用範例。每一個 kafka consumer併發例項都維護一個topic分割槽和分割槽對應的offset的map,並將此map作為運算元狀態。

當併發數改變的時候,運算元狀態支援在併發例項間重新分配狀態。有多種不同的重分配策略。

原始的和被管理的狀態

key狀態和運算元狀態以兩種形式存在:被管理的和原始的。
被管理的狀態由flink runtime管理,以一種資料結構表示,比如內部hash表或者RocksDB,例如: ValueState,ListState等等。flink執行時對狀態進行編碼,然後寫入checkpoints.

原始狀態是運算元儲存到它們自己定義的資料結構中的一種狀態。當checkpoint發生的時候,flink僅僅將二進位制寫入到checkpoint中,它不知道狀態的資料結構,僅僅能看見原始的二進位制位元組資料。

所有的stream資料流function可以使用被管理的狀態,但是當實現運算元介面的時候,僅僅能使用原始狀態介面。推薦使用被管理的狀態而不是原始狀態,因為使用被管理狀態,當併發度變化的時候,flink能夠自動重新分配狀態,而且也能夠更好地管理記憶體。

注意: 如果你需要自定義被管理狀態的序列化邏輯,為了確保特性相容,請看相應的說明。Flink預設的序列化不需要特別的處理。

使用被管理的Key狀態

被管理的Key狀態介面能夠處理不同型別的狀態,包括現有所有輸入元素的key。這意味著這類狀態僅僅能被用於KeyedStream上。KeyedStream能夠透過stream.keyBy(...)建立。

現在,我們首先看一下當前所有的不同型別狀態介面,然後我們看一下如何在程式中使用。狀態介面型別如下:

  • ValueState<T>: 儲存一個值。這個值可以被更新或獲取(涉及到上面提到的輸入元素的key, 每個key中都可能對應一個值)。這個值可以透過update(T)更新,透過T value()獲取。
  • ListState<T>: 儲存元素列表。可以新增元素和獲取當前儲存的所有元素Iterable物件。使用add(T)或addAll(List<T>)方法新增元素。使用Iterable<T> get()方法獲取iterable物件。也可以透過update(List<T>)方法覆蓋現有的列表。
  • ReducingState<T>: 儲存一個值,這個值是新增到狀態中所有值的聚合結果。這個介面與ListState相似,但是透過add(T)方法新增的元素會透過指定的ReduceFunction聚合起來。
  • AggregatingState<IN,OUT>: 儲存一個值,這個值是新增到狀態的所有值的聚合結果。與ReducingState相比,聚合後的資料型別也許與新增進狀態的元素型別不同。這個介面與ListState相同,但是透過add(IN)新增的元素使用指定的AggregateFunction物件聚合。
  • FoldingState<T,ACC>: 儲存一個值,這個值是新增到狀態的所有值的聚合結果。與ReducingState相比,聚合結果的型別可能與新增到狀態中的元素型別不一樣。這個介面與ListState相似,但是透過add(T)新增的元素透過指定的FoldFunction聚合
  • MapState<UK,UV>: 儲存一個map物件。你可以將kv鍵值對放入狀態中,也可以獲取當前儲存的鍵值列表一個Iterable物件。使用put(UK, UK)putAll(Map\<UK,UV\>)方法新增鍵值對。與key對應的value可以透過get(UK)獲取。map中kv關係,key,value資料分別可以透過entries(),keys()values()方法獲取。

所有型別的state狀態介面都有一個clear()方法,能夠清除當有輸入元素key的狀態。

注意: FoldingStateFoldingStateDescriptor已經在flink1.4中廢棄了,將來會完全移除。請用AggregatingStateAggregatingStateDescriptor代替。

我們要記住兩件重要的事,第一件事是上面這些state介面型別僅僅用於與狀態互動。狀態不一定必須要儲存到flink內部,也可以儲存到硬碟或其它地方。第二件事是你獲取到的狀態的值依賴輸入元素的key值,所以在同一個user function中如果兩次輸入流中的key值不一樣的話,value也不一樣。

為了獲得一個狀態處理類,你必須要建立一個StateDescriptor物件。它裡面儲存著狀態的名字(後面我們會看到,你可以建立多個狀態,他們必須有不同的名字,以便你可以根據名字獲取狀態),狀態儲存值的型別和一個使用者自定義的function,例如一個ReduceFunction。根據你想要儲存狀態的型別不同,你可以建立ValueStateDescriptor,ListStateDescrptor,ReducingStateDescriptor,FoldingStateDescriptorMapStateDescriptor物件。

狀態可以透過RuntimeContext獲取,它只能透過富函式(rich function)獲取。請看這裡詳細瞭解。但是我們也看一個簡短的例子。RichFunction中的RuntimeContext物件有如下方法可以獲取狀態。

  • ValueState getState(ValueStateDescriptor)
  • ReducingState getReducingState(ReducingStateDescriptor)
  • ListState getListState(ListStateDescriptor)
  • AggregatingState<IN, OUT> getAggregatingState(AggregatingStateDescriptor<IN, ACC, OUT>)
  • FoldingState<T, ACC> getFoldingState(FoldingStateDescriptor<T, ACC>)
  • MapState<UK, UV> getMapState(MapStateDescriptor<UK, UV>)

下面舉一個FlatMapFunction的例子說明所有部分如何配合的。

 public class CountWindowAverage extends RichFlatMapFunction<Tuple2<Long,Long>, Tuple2<Long,Long>> {
   
   private transient ValueState<Tuple2<Long,Long>> sum;
   
   @Override
   public void flatMap(Tuple2<Long,Long> input, Collector<Tuple2<Long,Long>> out) throws Exception {
     // 獲取狀態值
     Tuple2<Long,Long> currentSum = sum.value();
     
     // 更新數量
     currentSum.f0 += 1;
     
     // 將輸入資料累加到第2個欄位上
     currentSum.f1 += input.f1;
     
     // 更新狀態
     sum.update(currentSum);
     
     // 如果數量達到2個,計算平均值,傳送到下游,並清空狀態
     if (currentSum.f0 >= 2) {
       out.collect(new Tuple2<>(input.f0, currentSum.f1 / currentSum.f0));
       sum.clear();
     }
   }
   
   @Override
   public void open(Configuration config) {
    ValueStateDescriptor<Tuple2<Long,Long>> descriptor = 
                                 new ValueStateDescriptor<>(
                                        "average", // 狀態名稱
                                        TypeInformation.of(new TypeHint<Tuple2<Long,Long>> () {}), //型別資訊
                                        Tuple2.of(0L,0L)); // 狀態預設值
         sum = getRuntimeContext().getState(descriptor);
   }
 }
 
 // 可以在流處理程式中像這樣使用(假設我們有一個StreamExecutionEnvironment env)
 env.fromElements(Tuple2.of(1L, 3L), Tuple2.of(1L, 5L), Tuple2.of(1L, 7L), Tuple2.of(1L, 4L), Tuple2.of(1L, 2L))
   .keyBy(0)
   .flatMap(new CountWindowAverage())
   .print();
   
 // 將列印輸出(1,4)和(1,5)

這個例子實現了一個貧血的計數視窗。我們以tuple的第一個欄位做為key來分類(這個例子中所有key都為1)。CountWindowAverage類成員變數ValueState中儲存著實時計算的數量和累加和。一量數量達到2個,它將計算平均值,傳送到下游並清空狀態,從0開始。需要注意的是,對於不同輸入的key(輸入元素Tuple的第一個元素值不同),ValueState將儲存不同的值。

狀態存活時間(TTL)

TTL可以被分配給任何型別的key狀態。如果key狀態設定了TTL,並且狀態過期了,狀態中儲存的值將被清空。後面將詳細說明。

所有狀態集合的TTL是設定在每個元素上的。這意味著列表和map中每個元素元素過都是過期處理邏輯都是獨立的,互不影響

為了使用TTL,首先要建立一個StateTtlConfig物件。透過給TTL函式傳遞這個狀態配置物件啟用TTL。

import org.apache.flink.api.common.state.StateTtlConfig;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.api.common.time.Time;

StateTtlConfig ttlConfig = StateTtlConfig
    .newBuilder(Time.seconds(1))
    .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
    .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
    .build();
    
ValueStateDescriptor<String> stateDescriptor = new ValueStateDescriptor<>("text state", String.class);
stateDescriptor.enableTimeToLive(ttlConfig);

配置狀態需要考慮以下幾個方面:
newBuilder方法的第一個引數是必須的,它是TTL過期時間。
狀態的TTL時間戳需要被重新整理,還需要配置更新型別,表示在什麼情況下重新整理,預設是OnCreateAndWrite:

  • StateTtlConfig.UpdateType.OnCreateAndWrite - 僅僅當建立和寫入時重新整理TTL

  • StateTtlConfig.UpdateType.OnReadAndWrite - 讀和寫時重新整理TTL
    狀態可見性配置當讀取的時候如果過期的值沒有被清除的話,是否返回,預設是NeverReturnExpired

  • StateTtlConfig.StateVisibility.NeverReturnExpired - 從不返回過期的值

  • StateTtlConfig.StateVisibility.ReturnExpiredIfNotCleanedUp - 如果過期的值仍然可以獲得則返回

如果設定成NeverReturnExpired,過期的狀態資料即使沒有被清除也獲取不到,就好像不存在一樣。這個選項在資料過期後必須不可用的情況下是有用的,例如對隱私資料敏感的應用。

另一個選項是ReturnExpiredIfNotCleanedUp,如果過期的狀態值沒有被清除的話,允許返回。

注意:

  • 狀態儲存器除了儲存狀態值還會儲存資料最後一次被修改的時間戳,意味著如果啟用TTL這個特性會增加狀態儲存的開銷。堆狀態儲存器會在記憶體中儲存一個引用使用者狀態資料的一個java物件,還有一個原始的long型別。RocksDB狀態儲存器會給每一個值,列表或map中的每個元素都增加8個位元組的儲存開銷。
  • 當前僅支援處理時間(processing time)的TTL,不支援事件(event time)的TTL.
  • 沒有配置TTL,卻啟動TTL或反之,都會導致相容失敗和StateMigrationException
  • TTL配置不是checkpoint或savepoint的一部分,而是flink處理當前正執行Job的一種方式
  • 設定TTL的map狀態如果想支援null值,僅當狀態值序列化器能夠處理null值的時候。如果序列化器不支援null值 ,可以使用NullableSerializer包裝類,但這將多消耗一個位元組的儲存空間。

過期狀態資料的清除

預設情況下,過期的狀態資料僅當顯示的讀取的時候才會被清除,例如呼叫ValueState.value()的時間。

注意: 這意味著如果過期狀態資料沒有被讀取,它將不會被清除,可能導致狀態資料的不斷增長。在後來的flink版本中可能會改變。

在完全快照時清除

另外,你可以在執行狀態完全快照時清除過期的狀態值,這將減小快照的大小。在當前flink實現中,本地狀態不會被清除,但是當從上一個快照中恢復的時候,不會包括過期的狀態。可以在StateTtlConfig中配置:

import org.apache.flink.api.common.state.StateTtlConfig;
import org.apache.flink.api.common.time.Time;

StateTtlConfig ttlConfig = StateTtlConfig
    .newBuilder(Time.seconds(1))
    .cleanupFullSnapshot()
    .build();

這個選項不適合於以RocksDB儲存狀態資料的遞增checkpoint.

注意:

  • 對於已經存在的job,清除策略可以在任意時間在StateTtlConfig中啟用或關閉啟用,例如:從savepoint重啟後

狀態儲存後端清除

除了在完全快照中清除,你還可以在後端清除。如果狀態後端儲存支援,下面選項可以啟用預設的後端清除策略。

import org.apache.flink.api.common.state.StateTtlConfig;
StateTtlConfig ttlConfig = StateTtlConfig
   .newBuilder(Time.seconds(1))
   .cleanupInBackground()
   .build();

對於更詳細的控制某些特別的後端清除策略,你可以按照下面描述的單獨配置。當前,堆狀態儲存依賴遞增的清除,RocksDB則使用壓縮過濾器。

遞增的清除

另一個選項是觸發狀態的遞增清除策略。觸發可以是每個狀態的獲取或每條記錄處理時的回撥。如果某些狀態啟用了清除策略,後端的儲存將持有一個全域性的針對所有元素的惰性迭代器。每次遞增清除策略被觸發時,迭代器就向前進,會檢查經過的元素,過期的狀態資料將被清除。

這個特性可以在StateTtlConfig啟用

import org.apache.flink.api.common.state.StateTtlConfig;
 StateTtlConfig ttlConfig = StateTtlConfig
    .newBuilder(Time.seconds(1))
    .cleanupIncrementally()
    .build();

這個策略有兩個引數。第一個引數是每次清除觸發時檢查的元素個數。如果啟用,當獲取每一個狀態資料時都會觸發。第二個引數配置每條記錄處理時,是否也觸發清除。如果你啟用預設的後端清除策略,對於堆儲存來說,這個策略將在每次狀態資料獲取時被觸發並檢查5個元素,並且每條記錄被處理時不會觸發清除。

注意:

  • 如果沒有獲取狀態資料或者沒有處理任何記錄,過期的狀態資料將一直儲存
  • 花費在遞增清除上的時間增加會記錄處理的時間
  • 當前遞增清除僅僅適用於堆儲存,為RocksDB設定遞增清除沒有作用
  • 如果堆儲存使用同步快照,那麼全域性的迭代器在迭代時將儲存所有key的副本。因為flink目前的實現不支援對狀態的併發修改。啟用這個特性將增加記憶體消耗。非同步快照沒有這個問題
  • 對於已經存在的job,清除策略可以在任意時間在StateTtlConfig中啟用或關閉啟用,例如當從savepoint重啟的時候。

在RocksDB壓縮過程中清除

如果使用RocksDB儲存狀態,另一個清除策略是啟用flink壓縮過濾器。RocksDB週期性的執行非同步壓縮來合併狀態更新和減小儲存。flink壓縮過濾器檢查帶TTL元素的時間戳,排除掉過期的狀態資料。

這個特性預設沒有啟用。可以透過flink配置檔案配置,將state.backend.rocksdb.ttl.compaction.filter.enabled設定為true,或者當為某個Job建立自定義RocksDB狀態儲存時呼叫RocksDBStateBackend::enableTtlCompactionFilter設定。這樣設定了TTL的狀態將會使用過濾器。

import org.apache.flink.api.common.state.StateTtlConfig

StateTtlConfig ttlConfig = StateTtlConfig
       .newBuilder(Time.seconds(1))
       .cleanupInRocksdbCompactFilter(1000)
       .build();

當處理一定數量的狀態資料後,RocksDB壓縮過濾器將查詢當前時間戳,檢查有沒有過期。你可以改變它,傳遞一個自定義值給StateTtlConfig.newBuilder(...).cleanupInRocksdbCompactFilter(long queryTimeAfterNumEntries)方法. 更新時間戳越頻繁,清除的速度越快,但卻降低了壓縮效能。因為需要呼叫JNI本地方法. 如果你啟用預設的清除策略(這個策略適用於RocksDB狀態儲存),在每處理1000個元素後都查詢一次當前的時間戳。

如果想為RocksDB過濾器開啟本地方法的debug級別日誌,可以為FlinkCompactionFilter設定日誌級別為Debug.

log4j.logger.org.rocksdb.FlinkCompactionFilter=DEBUG

注意:

  • 在壓縮過程中呼叫TTL過濾器會減慢flink處理速度。TTL過濾器必須在key正在被壓縮的過程中,重新整理key對應的每一個value元素的時間戳,並檢查是否過期。對於集合型別例如list或map,也將檢查其中的每一個元素。
  • 如果這個特性用於列表狀態,列表的長度不固定。 TTL過濾器必須對每一個元素另外透過JNI呼叫Java型別的序列化器,由第一個過期的元素決定下一個沒過期元素的偏移量。
  • 對於已經存在的job,清除策略可以在任意時間在StateTtlConfig中啟用或關閉啟用,例如當從savepoint重啟時。

Scala DataStream API中的狀態

除了上面講到的介面, Scala API對於KeyStream中使用ValueState儲存單個狀態值的map()或flatmap()函式有更簡短的寫法。user function從Option物件中得到ValueState當前狀態值,並返回一個待更新的值。

val stream: DataStream[(String, Int)] = ...

val counts: DataStream[(String, Int)] = stream
  .keyBy(_._1)
  .mapWithState((in: (String, Int), count: Option[Int]) =>
    count match {
      case Some(c) => ( (in._1, c), Some(c + in._2) )
      case None => ( (in._1, 0), Some(in._2) )
    })

使用被管理的運算元狀態

為了使用被管理的運算元狀態,必須實現通用的CheckpointedFunction介面,或者實現ListCheckpointed介面。

CheckpointedFunction

CheckpointedFunction介面可以讓我們儲存不同資料結構的非key物件的狀態。我們如果要使用,必須實現以下兩個方法:

void snapshotState(FunctionSnapshotContext conetxt) throws Exception;
void initializeState(FunctionInitializationContext context) throws Exception;

每當checkpoint執行的時候都會呼叫snapshotState()方法,而另一個方法,initializeState(),則是使用者自定義的功能初始化的時候呼叫,包括首次初始化或者從之前的checkpoint恢復的時候。鑑於此,initializeState()不僅包含不同的需要儲存狀態的資料初始化的邏輯,還需要包含恢復狀態的邏輯。

當前,支援列表型別的被管理的運算元狀態,狀態應該是由互相獨立的可序列化的物件組成的列表,當伸縮的時候需要重新分配。換言之,這些物件是非key狀態分配的最小粒度。根據狀態獲取方式的不同,定義了以下分配策略:

  • 平均分配(Even-split redistribution): 每一個運算元返回一個狀態集合,整個狀態邏輯上是所有列表集合的並集。當恢復或重新分配時,根據併發數平均分配成多個子集,每一個運算元獲取一個子集合,可能是空集合,也可能包含一個或多個元素。例如,併發度1的時候,一個運算元的checkpoint狀態包含元素1和元素2,當併發度增加到2的時候,元素1可能被分配到運算元0,元素2被分配到運算元1.
  • 合併分配(union redistribution): 每一個運算元返回一個狀態的集合,整個狀態邏輯上是所有這些列表集合的並集,當恢復或重新分配時,每一個運算元都將分配到整個狀態的列表集合。

下面是一個帶狀態的SinkFunction示例,使用平均分配策略,功能是在將一些元素髮送給外部系統之前,使用CheckpointedFunction快取這些元素.

 public class BufferingSink 
           implements SinkFunction <Tuple2<String,Integer>>, CheckpointedFunction {
        
        private final int threshold;
        private transient ListState<Tuple2<String, Integer>> checkpoinedState;
        private List<Tuple2<String, Integer>> bufferedElements;
        public BufferingSink(int threshold) {
         this.threshold = threshold;
         this.bufferedElements = new ArrayList<>();
        }
        
        @Override
        public void invoke(Tuple2<String, Integer> value, Context context) throws Exception {
         bufferedElement.add(value);
         if (bufferedElements.size() == threshold) {
           for (Tuple2<String, Integer> element : bufferedElements) {
               // send it to the sink
           }
           bufferedElement.clear();
         }
        }
        
        @Override
        public void snapshotState(FunctionSnapshotContext context) throws Exception {
          checkpointedState.clear();
          for (Tuple<String, Integer> element : bufferedElements) {
            checkpointedState.add(element);
          }
        }
        
        @Override
        public void initializeState(FunctionInitializationContext context) throws Exception {
          ListStateDescriptor<Tuple2<String, Integer>> descriptor = 
              new ListStateDescriptor<>(
                  "buffered-elements",
                  TypeInformation.of(new TypeHint<Tuple2<String, Integer>>(){}));
                  
         checkpointedState = context.getOperatorStateStore().getListState(descriptor);
         
         if(context.isRestored()) {
             for (Tuple2<String, Integer> element : checkpointedStage.get()) {
                bufferedElements.add(element);
             }
          }
     }
 }

initializeState方法接收一個FunctionInitializationContext引數。這個引數用於初始化非key的狀態"容器"。有一個ListState型別的狀態容器,當checkpointing發生的時候,非key的狀態會儲存在ListState物件中。

注意一下狀態容器是如何初始化的,和key狀態相似,都需要一個StateDescriptor來定義狀態名和儲存狀態的資料型別資訊。

ListStateDescriptor<Tuple2<String, Integer>> descriptor =
    new ListStateDescriptor<>(
        "buffered-elements",
        TypeInformation.of(new TypeHint<Tuple2<Long, Long>>() {}));

checkpointedState = context.getOperatorStateStore().getListState(descriptor);

獲取狀態方法名稱的不同代表了不同的分配策略。例如,如果想在恢復的時候使用合併分配策略,獲取狀態時,使用getUnionListState(descriptor)方法。如果方法名不包含分配策略名稱,例如:getListState(descriptor),就預設表示將會使用平均分配策略。

在初始化狀態容器之後,我們使用isRestored()方法來判斷當前是否是失敗後的恢復,如果是,將執行恢復邏輯。

我們再來看看類BufferingSink的程式碼, ListState是成員變數,在initializeState方法初始化,以便在snapshotState方法中使用。在snapshotState方法中, ListState首先清除上一次checkpont的所有物件,然後儲存這一次需要checkpoint的物件。

順便提一下,key狀態也能使用initializeState方法初始化,可以使用FunctionInitializationContext物件實現。

ListCheckpointed

ListCheckpointedCheckpointedFunction的一個變體,有更多的限制條件,僅支援list型別的狀態儲存,並且只能是平均分配。包含以下兩個方法:

List<T> snapshotState(long checkpointId, long timestamp) throws Exception;
void restoreState(List<T> state) throws Exception;

snapshotState方法中,運算元應該返回需要儲存的list物件,當恢復的時候,在 restoreState方法中編寫恢復list資料的邏輯。 如果狀態不需要重新分割槽,可以在snapshotState方法中返回Collections.singletonList(MY_STATE)物件。

Stateful Source函式

相對於其它運算元,Stateful source運算元有一點特別。為了使更新狀態和輸出狀態原子化(失敗/恢復的恰好一次語義要求),必須在source運算元的上下文中使用鎖。

public static class CounterSource
        extends RichParallelSourceFunction<Long>
        implements ListCheckpointed<Long> {

    /**  恰好一次語義使用的偏移量 */
    private Long offset = 0L;

    /**job是否取消的標識*/
    private volatile boolean isRunning = true;

    @Override
    public void run(SourceContext<Long> ctx) {
        final Object lock = ctx.getCheckpointLock();

        while (isRunning) {
            // 輸出和更新狀態是原子化的
            synchronized (lock) {
                ctx.collect(offset);
                offset += 1;
            }
        }
    }

    @Override
    public void cancel() {
        isRunning = false;
    }

    @Override
    public List<Long> snapshotState(long checkpointId, long checkpointTimestamp) {
        return Collections.singletonList(offset);
    }

    @Override
    public void restoreState(List<Long> state) {
        for (Long s : state)
            offset = s;
    }
}

當flink checkpoint完全確認的時候,一些運算元也許需要與外部系統交換一些資訊,這種情況看下org.apache.flink.runtime.state.CheckpointListener介面。

相關文章