關(guān)于使用Flink RocksDB狀態(tài)后端時一定要寫MapState而非ValueState<Map>這檔事(以及解決方法)

前言

抱歉起這種爛大街的日本輕小說風(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)式地替換完畢之后,就可以安全地刪除mainStateisMigratedState了。當(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也就沒有明顯的性能差別了,因為HeapValueStateHeapMapState的底層都是相同的,即CopyOnWriteStateTable,本質(zhì)上是內(nèi)存中的狀態(tài)映射表。讀者有興趣可以自行參考對應(yīng)的Flink源碼,這里不再啰嗦了。

ValueState、ListState、MapState三者在RocksDB狀態(tài)后端和基于堆的狀態(tài)后端中的異同點可以概括成下表。

The End

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,983評論 6 537
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,772評論 3 422
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 176,947評論 0 381
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,201評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 71,960評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,350評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,406評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,549評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,104評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,914評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,089評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,647評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,340評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,753評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,007評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,834評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 48,106評論 2 375

推薦閱讀更多精彩內(nèi)容