Avro、Protobuf 和 Thrift 中的模式演變
馬丁 - 克萊普曼於 2012 年 12 月 5 日發表。
你有一些數據,你想存儲在一個文件中或通過網絡發送。你可能會發現自己經歷了幾個階段的演變。
-
使用你的編程語言的內置序列化,例如 Java serialization, Ruby 的 marshal 或 Python 的 pickle. 或者你甚至可以發明你自己的格式。
-
然後你意識到被鎖定在一種編程語言中是很糟糕的,所以你轉而使用一種被廣泛支持的、與語言無關的格式,如 JSON(如果你喜歡像 1999 年那樣狂歡,也可以使用 XML)。
-
然後你決定 JSON 太冗長了,解析起來太慢了,你對它不區分整數和浮點感到惱火,並且認爲你很喜歡二進制字符串和 Unicode 字符串。所以你發明了某種二進制格式,有點像 JSON,但又是二進制 (1, 2, 3, 4, 5, 6).
-
然後你發現人們把各種隨機的字段塞進他們的對象中,使用不一致的類型,而你很想有一個模式和一些文檔,非常感謝。也許你還在使用一種靜態類型的編程語言,並想從模式中生成模型類。你也意識到你的二進制 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” 風格。
來源:
https://www.toutiao.com/article/7078084133943001631/?log_from=f72a76d35dc64_1648180420342
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/FKQ3a0AltowgjEkSG7eRPQ