深入理解Flink中的狀態

一護發表於2019-01-10

本文是整理自幾個月前的內部flink state分享,flink狀態所包含的東西很多,在下面列舉了一些,還有一些在本文沒有體現,後續會單獨的挑出來再進行講解

  • state的層次結構
  • keyedState => windowState
  • OperatorState => kafkaOffset
  • stateBackend
  • snapshot/restore
  • internalTimerService
  • RocksDB操作的初探
  • state ttL
  • state local recovery
  • QueryableState
  • increamental checkpoint
  • state redistribution
  • broadcasting state
  • CheckpointStreamFactory

內部和外部狀態

flink狀態分為了內部和外部使用介面,但是兩個層級都是一一對應,內部介面都實現了外部介面,主要是有兩個目的

  • 內部介面提供了更多的方法,包括獲取state中的serialize之後的byte,以及Namespace的操作方法。內部狀態主要用於內部runtime實現時所需要用到的一些狀態比如window中的windowState,CEP中的sharedBuffer,kafkaConsumer中offset管理的ListState,而外部State介面主要是使用者自定義使用的一些狀態
  • 考慮到各個版本的相容性,外部介面要保障跨版本之間的相容問題,而內部介面就很少受到這個限制,因此也就比較靈活

狀態的使用

瞭解了flink 狀態的層次結構,那麼程式設計中和flink內部是如何使用這些狀態呢?

flink中使用狀態主要是兩部分,一部分是函式中使用狀態,另一部分是在operator中使用狀態

方式:

  • CheckpointedFunction
  • ListCheckpointed
  • RuntimeContext (DefaultKeyedStateStore)
  • StateContext

StateContext

StateInitializationContext

Iterable<StatePartitionStreamProvider> getRawOperatorStateInputs();

Iterable<KeyGroupStatePartitionStreamProvider> getRawKeyedStateInputs();
複製程式碼

ManagedInitializationContext

OperatorStateStore getOperatorStateStore();
KeyedStateStore getKeyedStateStore();

複製程式碼

舉例:

  1. AbstractStreamOperator封裝了這個方法initializeState(StateInitializationContext context)用以在operator中進行raw和managed的狀態管理

  2. CheckpointedFunction的用法其實也是藉助於StateContext進行相關實現

CheckpointedFunction#initializeState方法在transformation function的各個併發例項初始化的時候被呼叫這個方法提供了FunctionInitializationContext的物件,可以通過這個context來獲取OperatorStateStore或者KeyedStateStore,也就是說通過這個介面可以註冊這兩種型別的State,這也是和ListCheckpointed介面不一樣的地方,只是說KeyedStateStore只能在keyedstream上才能註冊,否則就會報錯而已,以下是一個使用這兩種型別狀態的樣例。 可以參見FlinkKafkaConsumerBase通過這個介面來實現offset的管理。

public class MyFunction<T> implements MapFunction<T, T>, CheckpointedFunction {

     private ReducingState<Long> countPerKey;
     private ListState<Long> countPerPartition;

     private long localCount;

     public void initializeState(FunctionInitializationContext context) throws Exception {
         // get the state data structure for the per-key state
         countPerKey = context.getKeyedStateStore().getReducingState(
                 new ReducingStateDescriptor<>("perKeyCount", new AddFunction<>(), Long.class));

         // get the state data structure for the per-partition state
         countPerPartition = context.getOperatorStateStore().getOperatorState(
                 new ListStateDescriptor<>("perPartitionCount", Long.class));

         // initialize the "local count variable" based on the operator state
         for (Long l : countPerPartition.get()) {
             localCount += l;
         }
     }

     public void snapshotState(FunctionSnapshotContext context) throws Exception {
         // the keyed state is always up to date anyways
         // just bring the per-partition state in shape
         countPerPartition.clear();
         countPerPartition.add(localCount);
     }

     public T map(T value) throws Exception {
         // update the states
         countPerKey.add(1L);
         localCount++;

         return value;
     }
 }
 }
複製程式碼

這個Context的繼承介面StateSnapshotContext的方法則提供了raw state的儲存方法,但是其實沒有對使用者函式提供相應的介面,只是在引擎中有相關的使用,相比較而言這個介面提供的方法,context比較多,也有一些簡單的方法去註冊使用operatorstate 和 keyedState。如通過RuntimeContext註冊keyedState:

因此使用簡易化程度為:

RuntimeContext > FunctionInitializationContext > StateSnapshotContext

keyedStream.map(new RichFlatMapFunction<MyType, List<MyType>>() {

     private ListState<MyType> state;

     public void open(Configuration cfg) {
         state = getRuntimeContext().getListState(
                 new ListStateDescriptor<>("myState", MyType.class));
     }

     public void flatMap(MyType value, Collector<MyType> out) {
         if (value.isDivider()) {
             for (MyType t : state.get()) {
                 out.collect(t);
             }
         } else {
             state.add(value);
         }
     }
 });
複製程式碼

通過實現ListCheckpointed來註冊OperatorState,但是這個有限制: 一個function只能註冊一個state,因為並不能像其他介面一樣指定state的名字.

example:

public class CountingFunction<T> implements MapFunction<T, Tuple2<T, Long>>, ListCheckpointed<Long> {

     // this count is the number of elements in the parallel subtask
     private long count;

     {@literal @}Override
     public List<Long> snapshotState(long checkpointId, long timestamp) {
         // return a single element - our count
         return Collections.singletonList(count);
     }

     {@literal @}Override
     public void restoreState(List<Long> state) throws Exception {
         // in case of scale in, this adds up counters from different original subtasks
         // in case of scale out, list this may be empty
         for (Long l : state) {
             count += l;
         }
     }

     {@literal @}Override
     public Tuple2<T, Long> map(T value) {
         count++;
         return new Tuple2<>(value, count);
     }
 }
 }
複製程式碼

下面比較一下里面的兩種stateStore

  • KeyedStateStore
  • OperatorStateStore

檢視OperatorStateStore介面可以看到OperatorState只提供了ListState一種形式的狀態介面,OperatorState和KeyedState主要有以下幾個區別:

  • keyedState只能應用於KeyedStream,而operatorState都可以
  • keyedState可以理解成一個運算元為每個subtask的每個key維護了一個狀態namespace,而OperatorState是每個subtask共享一個狀態
  • operatorState只提供了ListState,而keyedState提供了ValueState,ListState,ReducingState,MapState
  • operatorStateStore的預設實現只有DefaultOperatorStateBackend可以看到他的狀態都是儲存在堆記憶體之中,而keyedState根據backend配置的不同,線上都是儲存在rocksdb之中

snapshot

這個讓我們著眼於兩個Operator的snapshot,AbstractStreamOperatorAbstractUdfStreamOperator,這兩個基類幾乎涵蓋了所有相關operator和function在做snapshot的時候會做的處理。

if (null != operatorStateBackend) {
				snapshotInProgress.setOperatorStateManagedFuture(
					operatorStateBackend.snapshot(checkpointId, timestamp, factory, checkpointOptions));
			}

			if (null != keyedStateBackend) {
				snapshotInProgress.setKeyedStateManagedFuture(
					keyedStateBackend.snapshot(checkpointId, timestamp, factory, checkpointOptions));
}
複製程式碼
  1. 按keyGroup去snapshot各個timerService的狀態,包括processingTimer和eventTimer(RawKeyedOperatorState)
  2. 將operatorStateBackend和keyedStateBackend中的狀態做snapshot
  3. 如果Operator還包含了userFunction,即是一個UdfStreamOperator,那麼可以注意到udfStreamOperator覆寫了父類的snapshotState(StateSnapshotContext context)方法,其主要目的就是為了將Function中的狀態及時的register到相應的backend中,在第二步的時候統一由CheckpointStreamFactory去做快照

StreamingFunctionUtils#snapshotFunctionState

if (userFunction instanceof CheckpointedFunction) {
			((CheckpointedFunction) userFunction).snapshotState(context);

			return true;
		}

		if (userFunction instanceof ListCheckpointed) {
			@SuppressWarnings("unchecked")
			List<Serializable> partitionableState = ((ListCheckpointed<Serializable>) userFunction).
				snapshotState(context.getCheckpointId(), context.getCheckpointTimestamp());

			ListState<Serializable> listState = backend.
				getSerializableListState(DefaultOperatorStateBackend.DEFAULT_OPERATOR_STATE_NAME);

			listState.clear();

			if (null != partitionableState) {
				try {
					for (Serializable statePartition : partitionableState) {
						listState.add(statePartition);
					}
				} catch (Exception e) {
					listState.clear();

					throw new Exception("Could not write partitionable state to operator " +
						"state backend.", e);
				}
			}
複製程式碼

可以看到這裡就只有以上分析的兩種型別的checkpoined介面,CheckpointedFunction,只需要執行相應的snapshot方法,相應的函式就已經將要做snapshot的資料打入了相應的state中,而ListCheckpointed介面由於返回的是個List,所以需要手動的通過getSerializableListState註冊一個ListState(這也是ListCheckpointed只能註冊一個state的原因),然後將List資料挨個存入ListState中。

operatorStateBackend#snapshot

  1. 針對所有註冊的state作deepCopy,為了防止在checkpoint的時候資料結構又被修改,deepcopy其實是通過序列化和反序列化的過程(參見aitozi.com/java-serial…
  2. 非同步將state以及metainfo的資料寫入到hdfs中,使用的是flink的asyncIO(這個也可以後續深入瞭解下),並返回相應的statehandle用作restore的過程
  3. 在StreamTask觸發checkpoint的時候會將一個Task中所有的operator觸發一次snapshot,觸發部分就是上面1,2兩個步驟,其中第二步是會返回一個RunnableFuture,在觸發之後會提交一個AsyncCheckpointRunnable非同步任務,會阻塞一直等到checkpoint的Future,其實就是去呼叫這個方法AbstractAsyncIOCallable, 直到完成之後OperatorState會返回一個OperatorStateHandle,這個地方和後文的keyedState返回的handle不一樣。
@Override
	public V call() throws Exception {

		synchronized (this) {
			if (isStopped()) {
				throw new IOException("Task was already stopped. No I/O handle opened.");
			}

			ioHandle = openIOHandle();
		}

		try {

			return performOperation();

		} finally {
			closeIOHandle();
		}
複製程式碼

在managed keyedState、managed operatorState、raw keyedState、和raw operatorState都完成返回相應的Handle之後,會生成一個SubTaskState來ack jobmanager,這個主要是用在restore的過程中

SubtaskState subtaskState = createSubtaskStateFromSnapshotStateHandles(
					chainedNonPartitionedOperatorsState,
					chainedOperatorStateBackend,
					chainedOperatorStateStream,
					keyedStateHandleBackend,
					keyedStateHandleStream);
					
owner.getEnvironment().acknowledgeCheckpoint(
	checkpointMetaData.getCheckpointId(),
	checkpointMetrics,
	subtaskState);
複製程式碼

在jm端,ack的時候又將各個handle封裝在pendingCheckpoint => operatorStates => operatorState => operatorSubtaskState中,最後無論是savepoint或者是externalCheckpoint都會將相應的handle序列化儲存到hdfs,這也就是所謂的checkpoint後設資料。這個可以起個任務觀察下zk和hdfs上的檔案,補充一下相關的驗證。

至此完成operator state的snapshot/checkpoint階段

KeyedStateBackend#snapshot

和operatorStateBackend一樣,snapshot也分為了同步和非同步兩個部分。

  1. rocksDB的keyedStateBackend的snapshot提供了增量和全量兩種方式
  2. 利用rocksdb自身的snapshot進行this.snapshot = stateBackend.db.getSnapshot(); 這個過程是同步的,rocksdb這塊是怎麼snapshot還不是很瞭解,待後續學習
  3. 之後也是一樣非同步將資料寫入hdfs,返回相應的keyGroupsStateHandle snapshotOperation.closeCheckpointStream();

不同的地方在於增量返回的是IncrementalKeyedStateHandle,而全量返回的是KeyGroupsStateHandle

restore / redistribution

OperatorState的rescale

void setInitialState(TaskStateHandles taskStateHandles) throws Exception;
複製程式碼

一個task在真正的執行任務之前所需要做的事情是把狀態inject到task中,如果一個任務是失敗之後從上次的checkpoint點恢復的話,他的狀態就是非空的。streamTask也就靠是否有這樣的一個恢復狀態來確認運算元是不是在restore來branch他的啟動邏輯

if (null != taskStateHandles) {
		if (invokable instanceof StatefulTask) {
			StatefulTask op = (StatefulTask) invokable;
			op.setInitialState(taskStateHandles);
		} else {
			throw new IllegalStateException("Found operator state for a non-stateful task invokable");
		}
		// be memory and GC friendly - since the code stays in invoke() for a potentially long time,
		// we clear the reference to the state handle
		//noinspection UnusedAssignment
		taskStateHandles = null;
}
複製程式碼

那麼追根究底一下這個Handle是怎麼帶入的呢?

FixedDelayRestartStrategy => triggerFullRecovery => Execution#restart => Execution#scheduleForExecution => Execution#deployToSlot => ExecutionVertex => TaskDeploymentDescriptor => taskmanger => task

當然還有另一個途徑就是通過向jobmanager submitJob的時候帶入restore的checkpoint path, 這兩種方式最終都會通過checkpointCoordinator#restoreLatestCheckpointedState來恢復hdfs中的狀態來獲取到snapshot時候存入的StateHandle。

恢復的過程如何進行redistribution呢? 也就是大家關心的併發度變了我的狀態的行為是怎麼樣的。

// re-assign the task states
final Map<OperatorID, OperatorState> operatorStates = latest.getOperatorStates();

StateAssignmentOperation stateAssignmentOperation =
		new StateAssignmentOperation(tasks, operatorStates, allowNonRestoredState);

stateAssignmentOperation.assignStates();
複製程式碼
  1. 如果併發度沒變那麼不做重新的assign,除非state的模式是broadcast,會將一個task的state廣播給所有的task
  2. 對於operator state會針對每一個name的state計算出每個subtask中的element個數之和(這就要求每個element之間相互獨立)進行roundrobin分配
  3. keyedState的重新分配相對簡單,就是根據新的併發度和最大併發度計算新的keygroupRange,然後根據subtaskIndex獲取keyGroupRange,然後獲取到相應的keyStateHandle完成狀態的切分。

這裡補充關於raw state和managed state在rescale上的差別,由於operator state在reassign的時候是根據metaInfo來計算出所有的List來重新分配,operatorbackend中註冊的狀態是會儲存相應的metainfo,最終也會在snapshot的時候存入OperatorHandle,那raw state的metainfo是在哪裡呢?

其實會在寫入hdfs返回相應的handle的時候構建一個預設的,OperatorStateCheckpointOutputStream#closeAndGetHandle,其中狀態各個partition的構建來自startNewPartition方法,引擎中我所看到的rawstate僅有timerservice的raw keyedState

OperatorStateHandle closeAndGetHandle() throws IOException {
		StreamStateHandle streamStateHandle = delegate.closeAndGetHandle();

		if (null == streamStateHandle) {
			return null;
		}

		if (partitionOffsets.isEmpty() && delegate.getPos() > initialPosition) {
			startNewPartition();
		}

		Map<String, OperatorStateHandle.StateMetaInfo> offsetsMap = new HashMap<>(1);

		OperatorStateHandle.StateMetaInfo metaInfo =
				new OperatorStateHandle.StateMetaInfo(
						partitionOffsets.toArray(),
						OperatorStateHandle.Mode.SPLIT_DISTRIBUTE);

		offsetsMap.put(DefaultOperatorStateBackend.DEFAULT_OPERATOR_STATE_NAME, metaInfo);

		return new OperatorStateHandle(offsetsMap, streamStateHandle);
	}
複製程式碼

KeyedState的keyGroup

keyedState重新分配裡引入了一個keyGroup的概念,那麼這裡為什麼要引入keygroup這個概念呢?

  1. hash(key) = key(identity)
  2. key_group(key) = hash(key) % number_of_key_groups (等於最大併發),預設flink任務會設定一個max parallel
  3. subtask(key) = key_greoup(key) * parallel / number_of_key_groups
  • 避免在恢復的時候帶來隨機IO
  • 避免每個subtask需要將所有的狀態資料讀取出來pick和自己subtask相關的浪費了很多io資源
  • 減少後設資料的量,不再需要儲存每次的key,每一個keygroup組只需保留一個range
int start = operatorIndex == 0 ? 0 : ((operatorIndex * maxParallelism - 1) / parallelism) + 1;
		int end = ((operatorIndex + 1) * maxParallelism - 1) / parallelism;
		return new KeyGroupRange(start, end);
複製程式碼
  • 每一個backend(subtask)上只有一個keygroup range
  • 每一個subtask在restore的時候就接收到了已經分配好的和重啟後當前這個併發相繫結的keyStateHandle
subManagedKeyedState = getManagedKeyedStateHandles(operatorState, keyGroupPartitions.get(subTaskIndex));
			subRawKeyedState = getRawKeyedStateHandles(operatorState, keyGroupPartitions.get(subTaskIndex));
複製程式碼

這裡面關鍵的一步在於,根據新的subtask上的keyGroupRange,從原來的operator的keyGroupsStateHandle中求取本subtask所關心的一部分Handle,可以看到每個KeyGroupsStateHandle都維護了KeyGroupRangeOffsets這樣一個變數,來標記這個handle所覆蓋的keygrouprange,以及keygrouprange在stream中offset的位置,可以看下再snapshot的時候會記錄offset到這個物件中來

keyGroupRangeOffsets.setKeyGroupOffset(mergeIterator.keyGroup(), outStream.getPos());
複製程式碼
public KeyGroupRangeOffsets getIntersection(KeyGroupRange keyGroupRange) {
		Preconditions.checkNotNull(keyGroupRange);
		KeyGroupRange intersection = this.keyGroupRange.getIntersection(keyGroupRange);
		long[] subOffsets = new long[intersection.getNumberOfKeyGroups()];
		if(subOffsets.length > 0) {
			System.arraycopy(
					offsets,
					computeKeyGroupIndex(intersection.getStartKeyGroup()),
					subOffsets,
					0,
					subOffsets.length);
		}
		return new KeyGroupRangeOffsets(intersection, subOffsets);
	}
複製程式碼

KeyGroupsStateHandle是一個subtask的所有state的一個handle KeyGroupsStateHandle維護一個KeyGroupRangeOffsets, KeyGroupRangeOffsets維護一個KeyGroupRange和offsets KeyGroupRange維護多個KeyGroup KeyGroup維護多個key

KeyGroupsStateHandle和operatorStateHandle還有一個不同點,operatorStateHandle維護了metainfo中的offset資訊用在restore時的reassign,原因在於KeyGroupsStateHandle的reassign不依賴這些資訊,當然在restore的時候也需要keygroupOffset中的offset資訊來重新構建keyGroupsStateHandle來進行各個task的狀態分配。

參考:

flink.apache.org/features/20…

chenyuzhao.me/2017/12/24/…

相關文章