Apache Flink 進階(五):資料型別和序列化

芊寶寶最可愛發表於2019-11-01

本文根據 Apache Flink 系列直播整理而成,由 Apache Flink Contributor、360 資料開發高階工程師馬慶祥老師分享。文章主要從如何為Flink量身定製的序列化框架、Flink序列化的最佳實踐、Flink通訊層的序列化以及問答環節四部分分享。

為 Flink 量身定製的序列化框架

為什麼要為 Flink 量身定製序列化框架?

大家都知道現在大資料生態非常火,大多數技術元件都是執行在 JVM 上的,Flink 也是執行在 JVM 上,基於 JVM 的資料分析引擎都需要將大量的資料儲存在記憶體中,這就不得不面臨 JVM 的一些問題,比如 Java 物件儲存密度較低等。針對這些問題,最常用的方法就是實現一個顯式的記憶體管理,也就是說用自定義的記憶體池來進行記憶體的分配回收,接著將序列化後的物件儲存到記憶體塊中。

現在 Java 生態圈中已經有許多序列化框架,比如說 Java serialization, Kryo, Apache Avro 等等。但是 Flink 依然是選擇了自己定製的序列化框架,那麼到底有什麼意義呢?若 Flink 選擇自己定製的序列化框架,對型別資訊瞭解越多,可以在早期完成型別檢查,更好的選取序列化方式,進行資料佈局,節省資料的儲存空間,直接操作二進位制資料。

Flink 的資料型別

Flink 在其內部構建了一套自己的型別系統,Flink 現階段支援的型別分類如圖所示,從圖中可以看到 Flink 型別可以分為基礎型別(Basic)、陣列(Arrays)、複合型別(Composite)、輔助型別(Auxiliary)、泛型和其它型別(Generic)。Flink 支援任意的 Java 或是 Scala 型別。不需要像 Hadoop 一樣去實現一個特定的介面(org.apache.hadoop.io.Writable),Flink 能夠自動識別資料型別。

那這麼多的資料型別,在 Flink 內部又是如何表示的呢?圖示中的 Person 類,複合型別的一個 Pojo 在 Flink 中是用 PojoTypeInfo 來表示,它繼承至 TypeInformation,也即在 Flink 中用 TypeInformation 作為型別描述符來表示每一種要表示的資料型別。

TypeInformation

TypeInformation 的思維導圖如圖所示,從圖中可以看出,在 Flink 中每一個具體的型別都對應了一個具體的 TypeInformation 實現類,例如 BasicTypeInformation 中的 IntegerTypeInformation 和 FractionalTypeInformation 都具體的對應了一個 TypeInformation。然後還有 BasicArrayTypeInformation、CompositeType 以及一些其它型別,也都具體對應了一個 TypeInformation。

TypeInformation 是 Flink 型別系統的核心類。對於使用者自定義的 Function 來說,Flink 需要一個型別資訊來作為該函式的輸入輸出型別,即 TypeInfomation。該型別資訊類作為一個工具來生成對應型別的序列化器 TypeSerializer,並用於執行語義檢查,比如當一些欄位在作為 joing 或 grouping 的鍵時,檢查這些欄位是否在該型別中存在。

如何使用 TypeInformation?下面的實踐中會為大家介紹。

Flink 的序列化過程

在 Flink 序列化過程中,進行序列化操作必須要有序列化器,那麼序列化器從何而來?每一個具體的資料型別都對應一個 TypeInformation 的具體實現,每一個 TypeInformation 都會為對應的具體資料型別提供一個專屬的序列化器。透過 Flink 的序列化過程圖可以看到 TypeInformation 會提供一個 createSerialize() 方法,透過這個方法就可以得到該型別進行資料序列化操作與反序化操作的物件 TypeSerializer。

對於大多數資料型別 Flink 可以自動生成對應的序列化器,能非常高效地對資料集進行序列化和反序列化,比如,BasicTypeInfo、WritableTypeIno 等,但針對 GenericTypeInfo 型別,Flink 會使用 Kyro 進行序列化和反序列化。其中,Tuple、Pojo 和 CaseClass 型別是複合型別,它們可能巢狀一個或者多個資料型別。在這種情況下,它們的序列化器同樣是複合的。它們會將內嵌型別的序列化委託給對應型別的序列化器。

簡單的介紹下 Pojo 的型別規則,即在滿足一些條件的情況下,才會選用 Pojo 的序列化進行相應的序列化與反序列化的一個操作。即類必須是 Public 的,且類有一個 public 的無引數建構函式,該類(以及所有超類)中的所有非靜態 no-static、非瞬態 no-transient 欄位都是 public 的(和非最終的 final)或者具有公共 getter 和 setter 方法,該方法遵循 getter 和 setter 的 Java bean 命名約定。當使用者定義的資料型別無法識別為 POJO 型別時,必須將其作為 GenericType 處理並使用 Kryo 進行序列化。

Flink 自帶了很多 TypeSerializer 子類,大多數情況下各種自定義型別都是常用型別的排列組合,因而可以直接複用,如果內建的資料型別和序列化方式不能滿足你的需求,Flink 的型別資訊系統也支援使用者擴充。若使用者有一些特殊的需求,只需要實現 TypeInformation、TypeSerializer 和 TypeComparator 即可定製自己型別的序列化和比較大小方式,來提升資料型別在序列化和比較時的效能。

序列化就是將資料結構或者物件轉換成一個二進位制串的過程,在 Java 裡面可以簡單地理解成一個 byte 陣列。而反序列化恰恰相反,就是將序列化過程中所生成的二進位制串轉換成資料結構或者物件的過程。下面就以內嵌型的 Tuple 3 這個物件為例,簡述一下它的序列化過程。Tuple 3 包含三個層面,一是 int 型別,一是 double 型別,還有一個是 Person。Person 包含兩個欄位,一是 int 型的 ID,另一個是 String 型別的 name,它在序列化操作時,會委託相應具體序列化的序列化器進行相應的序列化操作。從圖中可以看到 Tuple 3 會把 int 型別透過 IntSerializer 進行序列化操作,此時 int 只需要佔用四個位元組就可以了。根據 int 佔用四個位元組,這個能夠體現出 Flink 可序列化過程中的一個優勢,即在知道資料型別的前提下,可以更好的進行相應的序列化與反序列化操作。相反,如果採用 Java 的序列化,雖然能夠儲存更多的屬性資訊,但一次佔據的儲存空間會受到一定的損耗。

Person 類會被當成一個 Pojo 物件來進行處理,PojoSerializer 序列化器會把一些屬性資訊使用一個位元組儲存起來。同樣,其欄位則採取相對應的序列化器進行相應序列化,在序列化完的結果中,可以看到所有的資料都是由 MemorySegment 去支援。MemorySegment 具有什麼作用呢?

MemorySegment 在 Flink 中會將物件序列化到預分配的記憶體塊上,它代表 1 個固定長度的記憶體,預設大小為 32 kb。MemorySegment 代表 Flink 中的一個最小的記憶體分配單元,相當於是 Java 的一個 byte 陣列。 每條記錄都會以序列化的形式儲存在一個或多個 MemorySegment 中。

Flink 序列化的最佳實踐

最常見的場景

Flink 常見的應用場景有四種,即註冊子型別、註冊自定義序列化器、新增型別提示、手動建立 TypeInformation,具體介紹如下:

  • 註冊子型別:如果函式簽名只描述了超型別,但是它們實際上在執行期間使用了超型別的子型別,那麼讓 Flink 瞭解這些子型別會大大提高效能。可以在 StreamExecutionEnvironment 或 ExecutionEnvironment 中呼叫 .registertype (clazz) 註冊子型別資訊。
  • 註冊自定義序列化:對於不適用於自己的序列化框架的資料型別,Flink 會使用 Kryo 來進行序列化,並不是所有的型別都與 Kryo 無縫連線,具體註冊方法在下文介紹。
  • 新增型別提示:有時,當 Flink 用盡各種手段都無法推測出泛型資訊時,使用者需要傳入一個型別提示 TypeHint,這個通常只在 Java API 中需要。
  • 手動建立一個 TypeInformation:在某些 API 呼叫中,這可能是必需的,因為 Java 的泛型型別擦除導致 Flink 無法推斷資料型別。

其實在大多數情況下,使用者不必擔心序列化框架和註冊型別,因為 Flink 已經提供了大量的序列化操作,不需要去定義自己的一些序列化器,但是在一些特殊場景下,需要去做一些相應的處理。

實踐–型別宣告

型別宣告去建立一個型別資訊的物件是透過哪種方式?通常是用 TypeInformation.of() 方法來建立一個型別資訊的物件,具體說明如下:

  • 對於非泛型類,直接傳入 class 物件即可。
    PojoTypeInfo<Person> typeInfo = (PojoTypeInfo<Person>) TypeInformation.of(Person.class);
  • 對於泛型類,需要透過 TypeHint 來儲存泛型型別資訊。
    final TypeInfomation<Tuple2<Integer,Integer>> resultType = TypeInformation.of(new TypeHint<Tuple2<Integer,Integer>>(){});
  • 預定義常量。

如 BasicTypeInfo,這個類定義了一系列常用型別的快捷方式,對於 String、Boolean、Byte、Short、Integer、Long、Float、Double、Char 等基本型別的型別宣告,可以直接使用。而且 Flink 還提供了完全等價的 Types 類(org.apache.flink.api.common.typeinfo.Types)。特別需要注意的是,flink-table 模組也有一個 Types 類(org.apache.flink.table.api.Types),用於 table 模組內部的型別定義資訊,用法稍有不同。使用 IDE 的自動 import 時一定要小心。

  • 自定義 TypeInfo 和 TypeInfoFactory。

透過自定義 TypeInfo 為任意類提供 Flink 原生記憶體管理(而非 Kryo),可令儲存更緊湊,執行時也更高效。需要注意在自定義類上使用 @TypeInfo 註解,隨後建立相應的 TypeInfoFactory 並覆蓋 createTypeInfo() 方法。

實踐–註冊子型別

Flink 認識父類,但不一定認識子類的一些獨特特性,因此需要單獨註冊子型別。

StreamExecutionEnvironment 和 ExecutionEnvironment 提供 registerType() 方法用來向 Flink 註冊子類資訊。

final ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();
Env. registerType(typeClass);

在 registerType() 方法內部,會使用 TypeExtractor 來提取型別資訊,如上圖所示,獲取到的型別資訊屬於 PojoTypeInfo 及其子類,那麼需要將其註冊到一起,否則統一交給 Kryo 去處理,Flink 並不過問(這種情況下效能會變差)。

實踐–Kryo 序列化

對於 Flink 無法序列化的型別(例如使用者自定義型別,沒有 registerType,也沒有自定義 TypeInfo 和 TypeInfoFactory),預設會交給 Kryo 處理,如果 Kryo 仍然無法處理(例如 Guava、Thrift、Protobuf 等第三方庫的一些類),有兩種解決方案:

  • 強制使用 Avro 來代替 Kryo。
    env.getConfig().enableForceAvro();
  • 為 Kryo 增加自定義的 Serializer 以增強 Kryo 的功能。
    env.getConfig().addDefaultKryoSerializer(clazz, serializer);

注:如果希望完全禁用 Kryo(100% 使用 Flink 的序列化機制),可以透過 Kryo-env.getConfig().disableGenericTypes() 的方式完成,但注意一切無法處理的類都將導致異常,這種對於除錯非常有效。

Flink 通訊層的序列化

Flink 的 Task 之間如果需要跨網路傳輸資料記錄, 那麼就需要將資料序列化之後寫入 NetworkBufferPool,然後下層的 Task 讀出之後再進行反序列化操作,最後進行邏輯處理。

為了使得記錄以及事件能夠被寫入 Buffer,隨後在消費時再從 Buffer 中讀出,Flink 提供了資料記錄序列化器(RecordSerializer)與反序列化器(RecordDeserializer)以及事件序列化器(EventSerializer)。

Function 傳送的資料被封裝成 SerializationDelegate,它將任意元素公開為 IOReadableWritable 以進行序列化,透過 setInstance() 來傳入要序列化的資料。

在 Flink 通訊層的序列化中,有幾個問題值得關注,具體如下:

  • 何時確定 Function 的輸入輸出型別?

在構建 StreamTransformation 的時候透過 TypeExtractor 工具確定 Function 的輸入輸出型別。TypeExtractor 類可以根據方法簽名、子類資訊等蛛絲馬跡自動提取或恢復型別資訊。

  • 何時確定 Function 的序列化/反序列化器?

構造 StreamGraph 時,透過 TypeInfomation 的 createSerializer() 方法獲取對應型別的序列化器 TypeSerializer,並在 addOperator() 的過程中執行 setSerializers() 操作,設定 StreamConfig 的 TYPE_SERIALIZER_IN_1 、 TYPE_SERIALIZER_IN_2、 TYPE_SERIALIZER_OUT_1 屬性。

  • 何時進行真正的序列化/反序列化操作?這個過程與 TypeSerializer 又是怎麼聯絡在一起的呢?

大家都應該清楚 Tsk 和 StreamTask 兩個概念,Task 是直接受 TaskManager 管理和排程的,而 Task 又會呼叫 StreamTask,而 StreamTask 中真正封裝了運算元的處理邏輯。在 run() 方法中,首先將反序列化後的資料封裝成 StreamRecord 交給運算元處理;然後將處理結果透過 Collector 發動給下游(在構建 Collector 時已經確定了 SerializtionDelegate),並透過 RecordWriter 寫入器將序列化後的結果寫入 DataOutput;最後序列化的操作交給 SerializerDelegate 處理,實際還是透過 TypeSerializer 的 serialize() 方法完成。

原文連結

本文為雲棲社群原創內容,未經允許不得轉載。

    

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69949601/viewspace-2662289/,如需轉載,請註明出處,否則將追究法律責任。

相關文章