前言
抱歉起這種爛大街的日本輕小說風(fēng)格標(biāo)題來吸引注意力。原本我認(rèn)為這是常識,不需要專門寫一篇文章來講解如此細(xì)碎的點。但是在最近工作巡檢中發(fā)現(xiàn)了越來越多如同ValueState<Map>
的狀態(tài)用法(當(dāng)然大部分是歷史遺留),部分Flink作業(yè)深受性能問題困擾,所以還是抽出點時間快速聊一聊,順便給出不算優(yōu)雅但還算有效的挽救方案。
基于RocksDB的狀態(tài)序列化
我們已經(jīng)知道,RocksDB是基于二進(jìn)制流的內(nèi)嵌K-V存儲,所以Flink任務(wù)使用RocksDB狀態(tài)后端時,寫/讀操作的狀態(tài)數(shù)據(jù)都需要經(jīng)過序列化和反序列化,從而利用TaskManager本地磁盤實現(xiàn)海量的狀態(tài)存儲。
舉個栗子,RocksDBValueState的取值和更新方法如下:
class RocksDBValueState<K, N, V> extends AbstractRocksDBState<K, N, V>
implements InternalValueState<K, N, V> {
@Override
public TypeSerializer<K> getKeySerializer() {
return backend.getKeySerializer();
}
@Override
public TypeSerializer<N> getNamespaceSerializer() {
return namespaceSerializer;
}
@Override
public TypeSerializer<V> getValueSerializer() {
return valueSerializer;
}
@Override
public V value() {
try {
byte[] valueBytes =
backend.db.get(columnFamily, serializeCurrentKeyWithGroupAndNamespace());
if (valueBytes == null) {
return getDefaultValue();
}
dataInputView.setBuffer(valueBytes);
return valueSerializer.deserialize(dataInputView);
} catch (IOException | RocksDBException e) {
throw new FlinkRuntimeException("Error while retrieving data from RocksDB.", e);
}
}
@Override
public void update(V value) {
if (value == null) {
clear();
return;
}
try {
backend.db.put(
columnFamily,
writeOptions,
serializeCurrentKeyWithGroupAndNamespace(),
serializeValue(value));
} catch (Exception e) {
throw new FlinkRuntimeException("Error while adding data to RocksDB", e);
}
}
}
可見key和value都需要經(jīng)過對應(yīng)類型的TypeSerializer
的處理,即如果將狀態(tài)聲明為ValueState<Map<K, V>>
,那么將由MapSerializer<K, V>
負(fù)責(zé)值的正反序列化。特別注意,serializeCurrentKeyWithGroupAndNamespace()
方法中,key需要加上它所對應(yīng)的KeyGroup編號和對應(yīng)的Namespace(Namespace是窗口信息),形成一個復(fù)合key,即:CompositeKey(KG, K, NS)
,RocksDB實際存儲的狀態(tài)數(shù)據(jù)的key都類似如此。具體可參看SerializedCompositeKeyBuilder
類,不再贅述。
接下來再看一下RocksDBMapState的部分實現(xiàn)。
class RocksDBMapState<K, N, UK, UV> extends AbstractRocksDBState<K, N, Map<UK, UV>>
implements InternalMapState<K, N, UK, UV> {
@Override
public TypeSerializer<K> getKeySerializer() {
return backend.getKeySerializer();
}
@Override
public TypeSerializer<N> getNamespaceSerializer() {
return namespaceSerializer;
}
@Override
public TypeSerializer<Map<UK, UV>> getValueSerializer() {
return valueSerializer;
}
@Override
public UV get(UK userKey) throws IOException, RocksDBException {
byte[] rawKeyBytes =
serializeCurrentKeyWithGroupAndNamespacePlusUserKey(userKey, userKeySerializer);
byte[] rawValueBytes = backend.db.get(columnFamily, rawKeyBytes);
return (rawValueBytes == null
? null
: deserializeUserValue(dataInputView, rawValueBytes, userValueSerializer));
}
@Override
public void put(UK userKey, UV userValue) throws IOException, RocksDBException {
byte[] rawKeyBytes =
serializeCurrentKeyWithGroupAndNamespacePlusUserKey(userKey, userKeySerializer);
byte[] rawValueBytes = serializeValueNullSensitive(userValue, userValueSerializer);
backend.db.put(columnFamily, writeOptions, rawKeyBytes, rawValueBytes);
}
@Override
public void putAll(Map<UK, UV> map) throws IOException, RocksDBException {
if (map == null) {
return;
}
try (RocksDBWriteBatchWrapper writeBatchWrapper =
new RocksDBWriteBatchWrapper(
backend.db, writeOptions, backend.getWriteBatchSize())) {
for (Map.Entry<UK, UV> entry : map.entrySet()) {
byte[] rawKeyBytes =
serializeCurrentKeyWithGroupAndNamespacePlusUserKey(
entry.getKey(), userKeySerializer);
byte[] rawValueBytes =
serializeValueNullSensitive(entry.getValue(), userValueSerializer);
writeBatchWrapper.put(columnFamily, rawKeyBytes, rawValueBytes);
}
}
}
@Override
public Iterable<Map.Entry<UK, UV>> entries() {
return this::iterator;
}
@Override
public Iterator<Map.Entry<UK, UV>> iterator() {
final byte[] prefixBytes = serializeCurrentKeyWithGroupAndNamespace();
return new RocksDBMapIterator<Map.Entry<UK, UV>>(
backend.db, prefixBytes, userKeySerializer, userValueSerializer, dataInputView) {
@Override
public Map.Entry<UK, UV> next() {
return nextEntry();
}
};
}
由于MapState的本身有用戶定義的key UK
,所以RocksDB存儲它時,會在上文所述的復(fù)合key后面,再加上UK
的值,即:CompositeKey(KG, K, NS) :: UK
。這樣,同屬于一個KeyContext的所有用戶鍵值對就存在一個連續(xù)的存儲空間內(nèi),可以通過RocksDB WriteBatch機制攢批,實現(xiàn)批量寫(putAll()
方法),也可以通過RocksDB Iterator機制做前綴掃描,實現(xiàn)批量讀(entries()
方法)。
問題的癥結(jié)
代碼讀完了。假設(shè)我們在某個key下有5條數(shù)據(jù)的狀態(tài),若使用ValueState<Map<String, String>>
來存儲,按照MapSerializer
的序列化方式,其存儲可以記為:
(1, k, VoidNamespace) -> [5, k1, false, v1, k2, false, v2, k3, true, k4, false, v4, k5, false, v5]
注意對于無窗口上下文的狀態(tài),NS為VoidNamespace
。且序列化Map時,會加上Map的大小,以及表示每個value是否為NULL的標(biāo)記。
如果使用MapState<String, String>
存儲,可以記為:
(1, k, VoidNamespace) :: k1 -> v1
(1, k, VoidNamespace) :: k2 -> v2
(1, k, VoidNamespace) :: k3 -> NULL
(1, k, VoidNamespace) :: k4 -> v4
(1, k, VoidNamespace) :: k5 -> v5
如果我們獲取或修改一條狀態(tài)數(shù)據(jù),前者需要將所有數(shù)據(jù)做一遍序列化和反序列化,而后者只需要處理一條。在Map比較小的情況下可能沒有明顯的性能差異,但是如果Map有幾十個甚至上百個鍵值對,或者某些value的長度很長(如各類打標(biāo)標(biāo)記串等),ValueState<Map>
的性能退化就會非常嚴(yán)重,造成反壓。
有的同學(xué)可能會問:我對狀態(tài)數(shù)據(jù)的操作基本都是“整存整取”(即讀/寫整個Map),也不建議使用ValueState<Map>
嗎?答案仍然是不建議。除了前面提到的WriteBatch和Iterator為MapState
帶來的優(yōu)化之外,RocksDB更可以利用多線程進(jìn)行讀寫,而單個大value不僅不能享受這個便利,還會擠占Block Cache空間,在出現(xiàn)數(shù)據(jù)傾斜等場景時,磁盤I/O可能會打到瓶頸。所以,我們在開始編寫作業(yè)時就應(yīng)該正確使用MapState
。
平滑遷移
為了消除此類狀態(tài)誤用的影響,常見的重構(gòu)方式是將ValueState<Map>
修改為MapState
,重置位點后消費歷史數(shù)據(jù),積攢狀態(tài),并替換掉舊任務(wù)。但是對于狀態(tài)TTL較長、size較大的場景(例如物流監(jiān)控場景經(jīng)常有30天TTL、十幾TB大的State),這樣顯然非常不方便,下面提供一種簡單的平滑遷移方式。
假設(shè)原本誤用的狀態(tài)為mainState
,我們聲明兩個新的狀態(tài),一個是新的MapState newMainState
,一個是布爾型ValueState isMigratedState
,表示該key對應(yīng)的狀態(tài)是否已經(jīng)遷移成了新的,即:
private transient ValueState<Map<String, String>> mainState;
private transient ValueState<Boolean> isMigratedState;
private transient MapState<String, String> newMainState;
當(dāng)然,它們的TTL等參數(shù)要完全相同。
寫兩個新的方法,負(fù)責(zé)在讀寫mainState
時將其遷移成newMainState
,并做上相應(yīng)的標(biāo)記。不存在歷史狀態(tài)的,直接以新格式存儲。再強調(diào)一遍,newMainState.entries()
和newMainState.putAll()
的性能很不錯,不必過于擔(dān)心。
private Map<String, String> wrapGetMainState() throws Exception {
Boolean isMigrated = isMigratedState.value();
if (isMigrated == null || !isMigrated) {
Map<String, String> oldStateData = mainState.value();
if (oldStateData != null) {
newMainState.putAll(mainState.value());
}
isMigratedState.update(true);
mainState.clear();
}
Map<String, String> result = new HashMap<>();
for (Entry<String, String> e : newMainState.entries()) {
result.put(e.getKey(), e.getValue());
}
return result;
}
private void wrapUpdateMainState(Map<String, String> data) throws Exception {
Boolean isMigrated = isMigratedState.value();
if (isMigrated == null || !isMigrated) {
Map<String, String> oldStateData = mainState.value();
if (oldStateData != null) {
newMainState.putAll(mainState.value());
}
isMigratedState.update(true);
mainState.clear();
}
newMainState.putAll(data);
}
再將歷史代碼中的狀態(tài)訪問全部替換成wrapGetMainState()
和wrapUpdateMainState()
方法的調(diào)用即可。表面上看是由一個狀態(tài)句柄變成了兩個狀態(tài)句柄,但是標(biāo)記狀態(tài)的訪問十分輕量級,且隨著程序的運行,舊狀態(tài)的數(shù)據(jù)漸進(jìn)式地替換完畢之后,就可以安全地刪除mainState
和isMigratedState
了。當(dāng)然,托管內(nèi)存的設(shè)置要科學(xué),并添加一些有利于RocksDB狀態(tài)吞吐量的參數(shù),如:
state.backend.rocksdb.predefined-options SPINNING_DISK_OPTIMIZED_HIGH_MEM
state.backend.rocksdb.memory.partitioned-index-filters true
基于堆的狀態(tài)呢?
與RocksDB相反,基于堆的JobManager和FileSystem狀態(tài)后端無需序列化和反序列化,當(dāng)然狀態(tài)的大小就要受制于TaskManager內(nèi)存。不過,如果我們采用這兩種狀態(tài)后端,ValueState<Map>
和MapState
也就沒有明顯的性能差別了,因為HeapValueState
和HeapMapState
的底層都是相同的,即CopyOnWriteStateTable
,本質(zhì)上是內(nèi)存中的狀態(tài)映射表。讀者有興趣可以自行參考對應(yīng)的Flink源碼,這里不再啰嗦了。
ValueState、ListState、MapState三者在RocksDB狀態(tài)后端和基于堆的狀態(tài)后端中的異同點可以概括成下表。