Avro、Protobuf 和 Thrift 中的模式演變

馬丁 - 克萊普曼於 2012 年 12 月 5 日發表。

你有一些數據,你想存儲在一個文件中或通過網絡發送。你可能會發現自己經歷了幾個階段的演變。

  1. 使用你的編程語言的內置序列化,例如 Java serialization, Ruby 的 marshal 或 Python 的 pickle. 或者你甚至可以發明你自己的格式。

  2. 然後你意識到被鎖定在一種編程語言中是很糟糕的,所以你轉而使用一種被廣泛支持的、與語言無關的格式,如 JSON(如果你喜歡像 1999 年那樣狂歡,也可以使用 XML)。

  3. 然後你決定 JSON 太冗長了,解析起來太慢了,你對它不區分整數和浮點感到惱火,並且認爲你很喜歡二進制字符串和 Unicode 字符串。所以你發明了某種二進制格式,有點像 JSON,但又是二進制 (1, 2, 3, 4, 5, 6).

  4. 然後你發現人們把各種隨機的字段塞進他們的對象中,使用不一致的類型,而你很想有一個模式和一些文檔,非常感謝。也許你還在使用一種靜態類型的編程語言,並想從模式中生成模型類。你也意識到你的二進制 JSON-lookalike 實際上並不那麼緊湊,因爲你仍然在重複存儲字段名;嘿,如果你有一個模式,你可以避免存儲對象的字段名,你可以節省一些字節

一旦你到了第四階段,你的選擇通常是 Thrift, Protocol Buffers 或 Avro。所有這三個都提供了高效的、跨語言的、使用模式的數據序列化,併爲 Java 生成代碼。

已經有很多關於它們的比較文章然而,許多文章忽略了一個乍看起來很平凡的細節,但實際上是至關重要的。如果模式發生變化會怎樣?

在現實生活中,數據總是在不斷變化。當你認爲你已經敲定了一個模式的時候,有人會想出一個沒有預料到的用例,並希望 "只是快速添加一個字段"。幸運的是,Thrift、Protobuf 和 Avro 都支持模式演進:你可以改變模式,你可以讓生產者和消費者同時使用不同版本的模式,而且都能繼續工作。當你處理一個大的生產系統時,這是一個非常有價值的功能,因爲它允許你在不同的時間獨立地更新系統的不同組件,而不用擔心兼容性問題。

這把我們帶到了今天文章的主題。我想探討一下 Protocol Buffers、Avro 和 Thrift 實際上是如何將數據編碼成字節的 -- 這也將有助於解釋它們各自如何處理模式變化。每個框架的設計選擇都很有趣,通過比較,我認爲你可以成爲一個更好的工程師(通過一點點)。

我將使用的例子是一個描述一個人的小對象。在 JSON 中我將這樣寫。

{
    "userName": "Martin",
    "favouriteNumber": 1337,
    "interests": ["daydreaming", "hacking"]
}

這個 JSON 編碼可以作爲我們的基線。如果我去掉所有的空白,它消耗了 82 個字節。

Protobuf

人物對象的 Protobuf 模式可能看起來像這樣。

message Person {
    required string user_name        = 1;
    optional int64  favourite_number = 2;
    repeated string interests        = 3;
}

當我們 encode 上面的數據使用這種模式時,它使用了 33 個字節,如下所示。

準確地看一下二進制表示法的結構,逐個字節地看。這個人的記錄只是其字段的連接。每個字段以一個字節開始,表示它的標籤號(上述模式中的數字 1、2、3),以及字段的類型。如果一個字段的第一個字節表明該字段是一個字符串,那麼它後面是該字符串的字節數,然後是該字符串的 UTF-8 編碼。如果第一個字節表明該字段是一個整數,那麼接下來是一個可變長度的數字編碼。沒有數組類型,但一個標籤號可以出現多次,以代表一個多值字段。

這種編碼對模式的進化有影響。

這種用一個標籤號來代表每個字段的方法簡單而有效。但我們馬上就會看到,這並不是唯一的方法。

Avro

Avro 模式可以用兩種方式編寫,一種是 JSON 格式。

{
    "type": "record",
    "name": "Person",
    "fields": [
        {"name": "userName",        "type": "string"},
        {"name": "favouriteNumber", "type": ["null", "long"]},
        {"name": "interests",       "type": {"type": "array", "items": "string"}}
    ]
}

... 或在一個 IDL 中。

record Person {
    string               userName;
    union { null, long } favouriteNumber;
    array<string>        interests;
}

請注意,在模式中沒有標籤號!在模式中沒有標籤號。那麼,它是如何工作的呢?

下面是同一個例子的數據 encoded 只用了 32 個字節。

字符串只是一個長度前綴,後面是 UTF-8 字節,但字節流中沒有任何東西告訴你它是一個字符串。它也可能是一個變長的整數,或者完全是其他的東西。你能解析這個二進制數據的唯一方法是通過與模式一起閱讀,而模式告訴你接下來應該期待什麼類型。你需要擁有與所用數據的編寫者完全相同的模式版本。如果你有錯誤的模式,解析器將不能對二進制數據進行首尾呼應。

那麼,Avro 是如何支持模式演變的呢?好吧,儘管你需要知道寫入數據的確切模式(寫入者的模式),但這並不一定與消費者所期望的模式(讀者的模式)相同。實際上,你可以給 Avro 分析器提供兩種不同的模式,它用 resolution rules 來將數據從寫模式翻譯成讀模式。

這對模式的進化有一些有趣的影響。

這就給我們留下了一個問題,就是要知道某條記錄是用什麼模式寫的。最好的解決方案取決於你的數據被使用的環境。

一種看法是:在 Protocol Buffers 中,記錄中的每個字段都被標記,而在 Avro 中,整個記錄、文件或網絡連接都被標記爲模式版本。

乍一看,Avro 的方法似乎有更大的複雜性,因爲你需要付出額外的努力來分配模式。然而,我開始認爲 Avro 的方法也有一些明顯的優勢。

Thrift

Thrift 是一個比 Avro 或 Protocol Buffers 更大的項目,因爲它不僅僅是一個數據序列化庫,也是一個完整的 RPC 框架。它也有一些不同的文化:Avro 和 Protobuf 標準化了一個單一的二進制編碼,而 Thrift embraces 有各種不同的序列化格式(它稱之爲 "協議")。

事實上,Thrift 有兩種不同的 JSON 編碼,以及不少於三種不同的二進制編碼。(然而,其中一種二進制編碼,DenseProtocol,是隻支持 C++ 的實現的;由於我們對跨語言的序列化感興趣,我將專注於其他兩種編碼)。

所有的編碼都有相同的模式定義,在 Thrift IDL 中。

struct Person {
  1: string       userName,
  2: optional i64 favouriteNumber,
  3: list<string> interests
}

BinaryProtocol 的編碼非常直接,但也相當浪費(它需要 59 個字節來編碼我們的示例記錄)。

CompactProtocol 編碼在語義上是等同的,但它使用可變長度的整數和比特打包,將大小減少到 34 字節。

正如你所看到的,Thrift 的模式演化方法與 Protobuf 的相同:每個字段在 IDL 中被手動分配一個標籤,標籤和字段類型被存儲在二進制編碼中,這使得解析器可以跳過未知字段。Thrift 定義了一個明確的列表類型,而不是 Protobuf 的重複字段方法,但除此之外,兩者非常相似。

就哲學而言,這些庫是非常不同的。Thrift 傾向於 "一站式服務" 的風格,給你一個完整的 RPC 框架和許多選擇,而 Protocol Buffers 和 Avro 似乎更傾向於遵循一種 “do one thing and do it well” 風格。

來源:

https://www.toutiao.com/article/7078084133943001631/?log_from=f72a76d35dc64_1648180420342

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/FKQ3a0AltowgjEkSG7eRPQ