Avro、Protobuf和Thrift中的模式演變

聞數起舞 發佈 2022-04-17T12:46:14.490025+00:00

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

馬丁-克萊普曼於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編碼。如果第一個字節表明該欄位是一個整數,那麼接下來是一個可變長度的數字編碼。沒有數組類型,但一個標籤號可以出現多次,以代表一個多值欄位。

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

  • 可選欄位、必填欄位和重複欄位之間的編碼沒有區別(除了標籤號可以出現的次數)。這意味著你可以將一個欄位從可選欄位改為重複欄位,反之亦然(如果解析器期待一個可選欄位,但在一條記錄中多次看到相同的標籤號,它就會丟棄除最後一個值以外的所有欄位)。required有一個額外的驗證檢查,所以如果你改變它,你會有運行時錯誤的風險(如果消息的發送者認為它是可選的,但接收者認為它是必需的)。
  • 一個沒有值的可選欄位,或者一個值為零的重複欄位,根本不會出現在編碼數據中--帶有該標籤號的欄位根本不存在。因此,從模式中刪除這類欄位是安全的。然而,你決不能在將來為另一個欄位重複使用標籤號,因為你可能仍然有存儲的數據,這些數據在你刪除的欄位中使用了該標籤。
  • 你可以向你的記錄添加一個欄位,只要給它一個新的標籤號。如果Protobuf分析器看到一個在其模式版本中沒有定義的標籤號,它就沒有辦法知道這個欄位叫什麼。但是它確實大致知道它是什麼類型,因為該欄位的第一個字節中包含了一個3位類型代碼。這意味著,即使解析器不能準確地解釋這個欄位,它也能算出需要跳過多少個字節,以便找到記錄中的下一個欄位。
  • 你可以重命名欄位,因為欄位名在二進位序列化中並不存在,但你永遠不能改變標籤號。

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

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來將數據從寫模式翻譯成讀模式。

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

  • Avro編碼沒有一個指示器來說明哪個欄位是下一個;它只是按照它們在模式中出現的順序,對一個又一個欄位進行編碼。因為解析器沒有辦法知道一個欄位被跳過,所以在Avro中沒有可選欄位這種東西。相反,如果你想撇開一個值,你可以使用一個聯合類型,比如上面的union { null, long }。這被編碼為一個字節,告訴解析器要使用哪種可能的聯合類型,然後是值本身。通過使用null類型的Union(簡單地編碼為零字節),你可以讓一個欄位變得可有可無。
  • Union類型很強大,但在改變它們時,你必須小心。如果你想給Union添加一個類型,你首先需要用新的模式更新所有的讀者,這樣他們就知道該怎麼做了。只有當所有的讀者都被更新後,寫作者才可以開始把這個新的類型放在他們生成的記錄中。
  • 你可以隨心所欲地重新排列記錄中的欄位。儘管欄位是按照它們被聲明的順序進行編碼的,但解析器是按照名字來匹配讀寫器模式中的欄位的,這就是為什麼在Avro中不需要標籤號。
  • 因為欄位是按名稱匹配的,所以改變欄位的名稱是很棘手的。你需要首先更新數據的所有讀者以使用新的欄位名,同時保留舊的名稱作為別名(因為名稱匹配使用來自讀者模式的別名)。然後,你可以更新寫作者的模式以使用新的欄位名。
  • 你可以在一條記錄中添加一個欄位,只要你給它一個默認值(例如,如果欄位的類型是與null聯合的,則為null)。默認值是必要的,這樣當使用新模式的讀者解析用舊模式寫的記錄時(因此缺少欄位),它就可以填入默認值來代替。
  • 相反,你可以從一條記錄中刪除一個欄位,只要它以前有一個默認值。(這是一個很好的理由,如果可能的話,讓你的所有欄位都有默認值。)這樣,當使用模式的讀者解析用模式寫的記錄時,它就可以返回到默認值。

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

  • 在Hadoop中,你通常會有包含數百萬條記錄的大文件,這些記錄都是用同一個模式編碼的。 Object container files處理這種情況:他們只是在文件的開頭包括一次模式,文件的其餘部分就可以用該模式進行解碼。
  • 在RPC上下文中,在每個請求和響應中發送模式的開銷可能太大。但是,如果你的RPC框架使用長壽命的連接,它可以在連接開始時協商一次模式,並在許多請求中分攤開銷。
  • 如果你在資料庫中逐一存儲記錄,最終可能會出現在不同時間編寫的不同模式版本,因此你必須在每條記錄上注釋其模式版本。如果存儲模式本身的開銷太大,你可以使用一個 hash的模式,或者一個連續的模式版本號。然後你需要一個 schema registry在這裡,你可以為一個給定的版本號查找準確的模式定義。

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

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

  • 對象容器文件是很好的自我描述:文件中嵌入的作者模式包含了所有的欄位名和類型,甚至還有文檔字符串(如果模式的作者費心寫了一些)。這意味著你可以將這些文件直接加載到交互式工具中,如 Pig等交互式工具中,而且無需任何配置就能正常工作。
  • 由於Avro模式是JSON格式,你可以在其中添加你自己的元數據,例如,描述一個欄位的應用級語義。當你分發模式時,這些元數據也會自動分發。
  • 模式註冊表在任何情況下都可能是一件好事,它可以作為 documentation並幫助你找到和重用數據。而且因為沒有模式,你根本無法解析Avro數據,所以模式註冊表可以保證是最新的。當然,你也可以建立一個protobuf模式註冊表,但由於它不是操作所必需的,所以它最終將是在盡力而為的基礎上。

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」風格。

關鍵字: