Avro、Protocol、Buffers以及Thrift中的模式演化

       當你想要將一些數據存儲在文件中或想要通過網絡發送時,你經歷了一下幾個演化階段:
(1)使用編程語言的內置序列化,如Java序列化、Ruby的marshal,或Python的pickle。
(2)然而,你意識到困在一種編程語言中是很糟糕的,所以轉而使用一種廣泛支持的、與語言無關的格式,比如Json。
(3)然而,你發現JSON太過冗餘,解析太慢,不能區分整數和浮點數,並且你認爲自己非常喜歡二進制字符串和Unicode字符串。
(4)然後你會發現人們使用不一致的類型將各種各樣的隨機字段填充到他們的對象中,這時你非常需要一個schema以及一些documentation。也許你還在使用靜態類型的語言,並從schema生成model類。你還會意識到,與JSON相似的二進制文件實際上不是那麼緊湊,因爲你在一遍又一遍地存儲字段名。如果你有一個schema,你可以避免存儲對象的字段名,這樣可以節省更多字節。

       如果你到了第四階段,你的選擇一般會使Thrift,Protocol Buffers或Avro。這三種方法都爲Java人員提供了高效的、跨語言的數據序列化(使用schema)和代碼生成。

       如果schema發生了變化,會發生什麼?
       現實生活中,數據總是不斷變化的。比如添加一個字段。幸運的是,Thrift,Protobuf和Avro都支持模式演化(schema evolution):你可以更改schema,生產者和消費者可以同時使用schema的不同版本,且一切都可以繼續工作。在處理大型生產系統時,這是一個非常有價值的特性,因爲它允許您在不同的時間獨立地更新系統的不同組件,而不用擔心兼容性。
       這就引出了今天的話題。我想探討Protocol Buffers、Avro和Thrift如何將數據編碼爲字節——這有助於解釋它們如何處理schema更改。
       我將使用的示例是一個描述人的對象。JSON格式如下:

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

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

Protocol Buffers

       Protocol Buffers的schema如下所示:

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

       當我們使用這個模式對上面的數據進行編碼時,它使用了33個字節,如下所示:



       看看二進制表示是如何一個字節一個字節地結構化的。person記錄只是其字段的組合。每個字段以一個字節開始,字節指示其標記號(上面schema中的1,2,3)和字段類型。如果一個字段的第一個字節表明該字段是一個字符串,則後面跟着字符串的字節數,然後是字符串的UTF-8編碼。沒有數組類型,但是標記號可以多次出現以表示多值字段。
       這種編碼對模式演化有如下影響:
(1)optional字段、required字段和repeated字段間的編碼沒有區別(除了標籤號可以出現的次數)。這意味着可以將字段從可選更改爲重複,反之亦然(如果解析器希望看到一個可選字段,但是在一條記錄中多次看到相同的標記號,那麼它將丟棄除最後一個值之外的所有標記)。required字段有一個額外的驗證檢查,因此如果更改它,將面臨運行時錯誤的風險(如果消息的發送方認爲它是可選的,但接收方認爲它是必需的)。
(2)沒有值的optional字段或零值的repeated字段根本不會出現在編碼的數據中——帶有該標記號的字段根本不存在。因此,從模式中刪除這類字段是安全的。但是,永遠不能在將來將標記號用於另一個字段,因爲可能仍然存儲了將該標記用於已刪除字段的數據。
(3)可以將字段添加到記錄中,只要它有一個新的標記號。如果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;
}

       注意,schema中沒有標記號!那麼它是如何工作的呢?
       下面是一個簡單的例子,數據編碼後的大小爲32字節:


       字符串只是長度前綴後面跟着UTF-8字節,但是字節流中沒有任何東西告訴你這是一個字符串。它也可以是一個可變長度的整數,或者完全是另一個整數。解析這個二進制數據的惟一方法是在schema旁邊讀取它,模式會告訴您接下來要使用的類型。你需要擁有與數據產生者完全相同的schema版本。如果你使用了錯誤的schema,解析器將無法理解二進制數據。
       Avro如何支持模式演化?雖然需要知道數據所使用的確切模式(生產者的模式),但它不必與使用者期望的模式(讀者的模式)相同。實際上,您可以向Avro解析器提供兩個不同的模式,它使用解析規則將數據從writer模式轉換爲reader模式。
       這對模式演化有一些有趣的影響:
(1)Avro編碼沒有指示符表明下一個字段是哪個;它只是按照字段在模式中出現的順序,對一個字段接着一個字段進行編碼。由於解析器無法知道某個字段已被跳過,所以在Avro中不存在可選字段。相反,如果您想省去一個值,可以使用union類型,如上面的union {null, long}。通過使用null類型(它被簡單地編碼爲零字節)創建union,可以實現可選字段。
(2)Union類型非常強大,但是在更改它們時必須小心。如果希望向union中添加類型,首先需要用新模式更新所有讀取端,以便它們知道將會發生什麼。只有在更新了所有讀取器之後,編寫器纔可能開始將這種新類型放入它們生成的記錄中。
(3)您可以按照自己的意願對記錄中的字段進行重新排序。儘管字段是按照聲明的順序編碼的,但是解析器根據名稱匹配讀寫schema中的字段,這就是爲什麼Avro中不需要標記號。
(4)因爲字段是由名稱匹配的,所以更改字段名稱是很棘手的。首先需要更新數據的所有讀取端,以使用新的字段名,同時將舊字段名保留爲別名。然後可以更新寫入端的schema以使用新字段名。
(5)可以在記錄中添加新字段,但是需要給它一個默認值(如果字段的類型是有null的union,則爲null)。默認值是必要的,這樣當使用新模式的讀取端解析使用舊模式編寫的記錄時,會將其填充爲null。
(6)相反,如果字段以前具有默認值,則可以從記錄中刪除該字段(如果可能的話,給所有字段一個默認值)。這樣,當使用舊schema的讀取端解析用新schema編寫的記錄時,它就可以填充爲默認值。
       這就給我們留下了一個問題,即如何知道給定記錄的特定schema。最好的解決方案取決於數據使用的環境:
(1)在Hadoop中,您通常擁有包含數百萬條記錄的大型文件,這些記錄都使用相同的模式進行編碼。Object container files處理這種情況:它們只在文件的開頭包含模schema一次,文件的其餘部分可以使用該schema進行解碼。
(2)在RPC上下文中,爲每個請求和響應發送schema的開銷可能太大。但是如果你的RPC框架使用長連接,可以在連接開始時協商schema,將該開銷分攤到許多請求上。
(3)如果逐個地在數據庫中存儲記錄,可能會得到在不同時間編寫的不同schema版本,因此必須使用每個記錄的schema版本對其進行註釋。如果存儲模式本身的開銷太大,則可以使用模式的散列或順序模式版本號。然後需要一個schema registry,在該註冊表中可以查找給定版本號的準確模式定義。
       可以這麼看:在Protocol Buffers中,記錄中的每個字段都被標記,而在Avro中,整個記錄,文件或網絡連接都被標記爲schema版本。
       乍一看,Avro的方法似乎更加複雜,因爲您需要進行額外的工作來分發schema。然而,我開始認爲Avro的方法也有一些明顯的優勢:
(1)Object container files有非常好的自描述性:嵌入文件中的writer schema包含所有字段名和類型,甚至文檔字符串。這意味着您可以直接加載這些文件到交互式工具如Pig,不需要任何配置。
(2)由於Avro模式是JSON,您可以向其中添加自己的元數據,當分發schema時,元數據也會自動被分發。
(3)在任何情況下,schema registry都可能是一件好事,它可以作爲文檔,幫助您查找和重用數據。因爲沒有schema就無法解析Avro數據,所以schema registry保證是最新的。

Thrift

       thrift是一個比Avro或Protocol buffer大得多的項目,因爲它不僅是一個數據序列化庫,而且是一整個RPC框架。雖然Avro和Protobuf標準化了單個二進制編碼,但是Thrift包含了各種不同的序列化格式(它稱之爲“協議”)。
       事實上,Thrift有兩個不同的JSON編碼,而且不少於三個不同的二進制編碼。
       所有編碼在Thrift IDL共享相同的模式定義:

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

       BinaryProtocol encoding非常簡單,但也相當浪費(編碼我們的示例記錄需要59字節):



       CompactProtocol編碼在語義上是等效的,但是使用可變長度的整數和位填充來將大小減少到34字節:



如你所見,Thrift對於模式進化的方法和Protobuf的方法是一樣的:每個字段在IDL中手動分配一個標記,標記和字段類型存儲在二進制編碼中,這使得解析器可以跳過未知字段。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章