天天看點

關于序列化協定的思考

思考

最近在設計一個RPC架構,需要處理序列化的問題。有很多種序列化協定可以選擇,比如Java原生的序列化協定,Protobuf, Thrift, Hessian, Kryo等等,這裡說的序列化協定專指Java的基于二進制的協定,不是基于XML, JSON這種格式的協定。在實際開發中考慮了很多點,也遇到一些問題,拿出來說說。

抛開這些協定不說,結合實際的需求,一個理想的序列化協定至少考慮4個方面:
  1. 性能
  2. 是否支援被序列化對象新舊版本的相容性問題。這個需求在實際開發中經常遇到,比如釋出了一個服務,有很多用戶端使用。當服務需要修改,新 添加1個參數時,不可能要求所有用戶端都更新,那樣牽扯的面太大,是以要做到新舊版本的相容
  3. 是否可以直接序列化對象,而不需要額外的輔助類,比如用IDL生成輔助的序列化類
  4. 是否可以支援跨語言使用

性能

性能包括兩個方面,時間複雜度和空間複雜度。

1. 空間開銷,序列化需要在原有的資料上加上描述字段,以為反序列化解析之用。如果序列化過程引入的額外開銷過高,可能會導緻過大的網絡,磁盤等各方面的壓力。對于海量分布式存儲系統,資料量往往以TB為機關,巨大的的額外空間開銷意味着高昂的成本。

2. 時間開銷,複雜的序列化協定會導緻較長的解析時間,這可能會使得序列化和反序列化階段成為整個系統的瓶頸。

經過上述,我們可以知道:序列化這件事說白了就是把一個對象變成一個二進制流,然後把二進制流再轉化成對象的過程。前者好說,關鍵是後者,後者其實就是一個如何分幀(Frame)的問題,即從哪個位元組開始讀幾個位元組來還原成資料的問題。常見的分幀方式有:

  1. 加結束符,比如http協定
  2. 定長
  3. 消息頭+消息,消息頭可以包含長度,類型資訊

需要考慮的問題

對于Java序列化來說,肯定是第三種方式,但是如何設計這個分幀方式又有很多實作。下面說說上述具體有哪些考慮和問題。

  1. 第一是序列化後的位元組數大小。最優的序列化後的位元組數大小肯定是隻有資料的二進制流,這樣沒有任何多餘的分幀資訊。如果要做到在二進制流裡不加任何分幀資訊來反序列化二進制流,有兩個關鍵點:
    1. 确定具體的分幀方式
    2. 肯定要有個地方存放這個分幀方式,并且是序列化方和反序列化方都能拿到。

    我把這個雙方約定分幀方式叫做契約。實際操作的時候隻需要序列化方按照契約把對象的資料轉成二進制流,反序列化方按照契約把二進制流轉成對象資料。

    如果二進制流裡面不加任何的分幀資訊,那麼反序列化方隻能按照字段的順序來依次分幀。了解一下這句話,如果單純拿到一個隻有純資料的二進制流,那麼隻能按照約定的順序依次來讀取,并且還得知道每個字段的長度,這樣才能知道讀取幾個位元組來還原資料。在這裡把順序本身作為一個隐形的契約,雙方按照順序來讀寫。一旦順序錯了,就有可能發生反序列化的錯誤。

  2. 如果我們要位元組數大小盡量小,那麼我們第一想到的是把分幀資訊不放在二進制流中,我們很自然而然想到被序列化對象的Class對象是最自然的選擇,而且它還包含了字段的資訊,Class.getDeclaredFields()可以傳回類的所有執行個體字段。如果getDeclaredFields()方法傳回的字段在任意JVM上都是同樣的順序,那麼我們豈不就是可以指依靠序列化反序列化雙方拿到被序列化的Class對象,然後利用反射機制拿到字段資訊就可以實作最優的序列化後位元組數大小嗎?

    但是經過我的調研發現,利用反射技術Class.getDeclared()方法傳回的字段數組是沒有排序也沒有特定順序的,比如按照聲明的順序。

@CallerSensitive  
    public Field[] getDeclaredFields() throws SecurityException {  
        // be very careful not to change the stack depth of this  
        // checkMemberAccess call for security reasons  
        // see java.lang.SecurityManager.checkMemberAccess  
        checkMemberAccess(Member.DECLARED, Reflection.getCallerClass(), true);  
        return copyFields(privateGetDeclaredFields(false));  
    }  
           

那不能利用反射技術獲得字段順序,能不能利用位元組碼技術來獲得這個類聲明時存放的字段順序呢?比如用ASM來直接讀Class檔案。但是我查閱了Java虛拟機規範,虛拟機規範隻規定了Class檔案中的元素,并沒有要求實際存儲的Filed[]按照聲明順序存儲。這也是對的,實際的虛拟機實作可以按照各自的算法來優化。

關于序列化協定的思考

事實上目前沒有哪個協定做到最優的序列化後位元組數,間接證明了隻使用Class中繼資料來分幀是不能滿足所有平台的,是不可靠的。

解決方案

既然順序這種弱契約關系不可靠,那麼需要一種強契約關系,需要把一些分幀資訊加入到二進制流,然後通過某種方式來擷取這些分幀資訊。加入哪些分幀資訊和如何共享這些分幀資訊有幾種做法:
  1. Java原生的序列化協定把字段類型資訊用字元串格式寫到了二進制流裡面,這樣反序列化方就可以根據字段資訊來反序列化。但是Java原生的序列化協定最大的問題就是生成的位元組流太大
  2. Hessian, Kryo這些協定不需要借助中間檔案,直接把分幀資訊寫入了二進制流,并且沒有使用字元串來存放,而是定義了特定的格式來表示這些類型資訊。Hessian, Kryo生成的位元組流就優化了很多,尤其是Kryo,生成的位元組流大小甚至可以優于Protobuf.
  3. Protobuf和Thrift利用IDL來生成中間檔案,這些中間檔案包含了如何分幀的資訊,比如Thrift給每個字段生成了中繼資料,包含了順序資訊(加了id資訊),和類型資訊,實際寫的二進制流裡面包含了每個字段id, 類型,長度等分幀資訊。序列化方和反序列化方共享這些中間檔案來進行序列化操作。

常見的應用是這樣的

關于序列化協定的思考

存在的問題

Hessian, Kryo, Protobuf, Thrift在生成的位元組數都有了優化,并且可以隻發送部分設定了值的字段資訊來完成序列化,這樣節省的位元組數就更多了。但是還有些問題:

1. Hessian, Kryo不滿足第三個方面,支援被序列化對象的新舊版本相容,隻依靠Class資訊沒有辦法知道新舊Class的差別

2. Protobuf和Thrift已經很優化了,但是需要用IDL來生成靜态的中間檔案。

3. 版本問題,比如服務方給方法的參數新增加了一個字段,要能做到老的用戶端還可以使用這個新服務。這就要求序列化協定讀取到不能識别的字段後能夠處理異常。比如Thrift可以通過字段的id資訊來知道是否支援這個字段,如果不支援讀取,就跳過,進而做到新舊版本的相容。而Kryo這種不依賴中間檔案的協定很難做到這點,因為單純的Class資訊在不同的平台下字段順序是不确定的,并且同一個Java檔案在不同平台下編譯後的Class檔案中,字段資訊也是不确定的。

常見序列化性能和開銷對比

  1. 解析性能
    關于序列化協定的思考
  2. 空間開銷
    關于序列化協定的思考

總結

不依賴中間檔案來序列化并同時滿足前3點,從上面的分析來看很難做到。Protobuf和Thrift這種使用IDL來生産中間檔案的協定,除了從跨平台調用的角度的需要,也包含了序列化的需要。畢竟又要考慮跨語言,又想得到效率,明顯是不可能的。隻有通過犧牲我們自己的時間去建立IDL檔案來達到我們的目的。

參考文章

- http://blog.csdn.net/iter_zc/article/details/40794845

- http://www.infoq.com/cn/articles/serialization-and-deserialization