本文是整理自幾個月前的內部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();
複製程式碼
舉例:
-
AbstractStreamOperator封裝了這個方法
initializeState(StateInitializationContext context)
用以在operator中進行raw和managed的狀態管理 -
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,AbstractStreamOperator
和 AbstractUdfStreamOperator
,這兩個基類幾乎涵蓋了所有相關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));
}
複製程式碼
- 按keyGroup去snapshot各個timerService的狀態,包括processingTimer和eventTimer(RawKeyedOperatorState)
- 將operatorStateBackend和keyedStateBackend中的狀態做snapshot
- 如果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
- 針對所有註冊的state作deepCopy,為了防止在checkpoint的時候資料結構又被修改,deepcopy其實是通過序列化和反序列化的過程(參見aitozi.com/java-serial…)
- 非同步將state以及metainfo的資料寫入到hdfs中,使用的是flink的asyncIO(這個也可以後續深入瞭解下),並返回相應的statehandle用作restore的過程
- 在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也分為了同步和非同步兩個部分。
- rocksDB的keyedStateBackend的snapshot提供了增量和全量兩種方式
- 利用rocksdb自身的snapshot進行
this.snapshot = stateBackend.db.getSnapshot();
這個過程是同步的,rocksdb這塊是怎麼snapshot還不是很瞭解,待後續學習 - 之後也是一樣非同步將資料寫入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();
複製程式碼
- 如果併發度沒變那麼不做重新的assign,除非state的模式是broadcast,會將一個task的state廣播給所有的task
- 對於operator state會針對每一個name的state計算出每個subtask中的element個數之和(這就要求每個element之間相互獨立)進行roundrobin分配
- keyedState的重新分配相對簡單,就是根據新的併發度和最大併發度計算新的keygroupRange,然後根據subtaskIndex獲取keyGroupRange,然後獲取到相應的keyStateHandle完成狀態的切分。
這裡補充關於raw state和managed state在rescale上的差別,由於operator state在reassign的時候是根據metaInfo來計算出所有的List
其實會在寫入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這個概念呢?
- hash(key) = key(identity)
- key_group(key) = hash(key) % number_of_key_groups (等於最大併發),預設flink任務會設定一個max parallel
- 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的狀態分配。
參考: