01

為什麼資料格式要進化

應用程式從來不會「定版」——新舊版本必然會在系統裡同時活著一段時間,資料格式得撐得住

本章開場,先聽一句兩千四百年前的老話

DDIA 第 4 章一開頭,引了古希臘哲學家赫拉克利特的話——講的是「萬物流轉」,但拿來形容軟體系統,竟然意外貼切。

原文 · DDIA Ch.4 題詞 Everything changes and nothing stands still. —Heraclitus of Ephesus, as quoted by Plato in Cratylus (360 BCE) Applications inevitably change over time. In most cases, a change to an application's features also requires a change to data that it stores. This means that old and new versions of the code, and old and new data formats, may potentially all coexist in the system at the same time.
白話翻譯

「萬物流轉,沒有什麼會靜止不動。」

——以弗所的赫拉克利特,引自柏拉圖《克拉底魯篇》(西元前 360 年)

應用程式注定會隨時間不斷改變。

而大多數時候,功能一變,連它儲存的資料也得跟著變。

這代表:新舊版本的程式碼、新舊版本的資料格式,很可能會同時活在同一個系統裡。

換句話說,「改版」從來不是「啪」一聲整批切換,而是新舊並存一段時間。這一節,我們就來搞懂為什麼會這樣、以及系統要靠什麼撐住這段過渡期。

「變」才是唯一不變的事

手機裡的 App 三不五時推播更新,這不是巧合,是常態。應用程式會一直變,通常逃不出三個原因:

1
新產品與新功能

為了吸引更多用戶、提供更好的服務,團隊總會不斷推出新東西。

2
用戶需求變化

用戶用久了、理解更深了,原本沒想到的需求才慢慢冒出來。

3
商業環境調整

市場競爭、法規變動,逼著產品也得跟著轉彎。

能夠輕鬆應付這些變化的能力,就叫 可演化性(Evolvability) ——而功能一旦要改,幾乎都會牽連到它背後儲存的資料格式跟著變。

📖
換個畫面想像

你經營一個「共享食譜平台」,讓全世界的人分享、搜尋、烹飪食譜。某天用戶要求新增「營養成分標籤」和「過敏原提醒」——這代表你不只要改 App 畫面,連食譜的資料結構(Schema)也要跟著擴充,加上新欄位。

改版不是「啪」一聲全部切換

在一個大型應用程式裡,程式碼和資料的改變不可能瞬間完成。原因要分兩種情境看:

🖥️
伺服器端應用程式

為了不中斷服務,會做 滾動式升級(Rolling Upgrade) ——一次只換一小批機器,確認沒問題再換下一批。換到一半,新舊版程式碼必然同時跑在線上。

📱
客戶端應用程式

App 什麼時候更新,主控權在用戶手上,不是你說了算。所以你的後端伺服器,極可能同時服務著還沒更新的舊版 App,和已經更新的新版 App。

⚠️
結論:新舊必然共存

不管哪一種情境,結果都一樣——新舊版本的程式碼、新舊版本的資料格式,會在系統裡同時存在並互相打交道一段時間。資料格式的設計,就是要撐住這段「混血期」。

看「滾動式升級」實際跑一遍

用共享食譜平台當例子:新版伺服器陸續上線,新版 App 也陸續被用戶安裝。按「下一步」,看新舊兩端怎麼互相讀懂彼此的資料。

📱
舊版 App
📲
新版 App
🖥️
舊版伺服器節點
⚙️
新版伺服器節點
按「下一步」看滾動式升級怎麼跑
💡
兩支箭頭,方向不同

向後相容是「新程式碼讀舊資料」——通常不難,因為寫新程式的你,本來就知道舊格式長什麼樣。向前相容則是「舊程式碼讀新資料」——比較棘手,因為舊程式碼寫的時候,根本不知道未來會多出哪些欄位。

廚房比喻:新廚師 vs. 舊廚師

把「程式碼」想成廚師、「資料」想成食譜,兩種相容性立刻變得很直覺:

新廚師讀舊食譜——向後相容

新廚師(新程式碼)什麼都學過,拿到一份當年剛入行寫的簡單舊食譜,當然能輕鬆做出來,不會出錯。

舊廚師讀新食譜——向前相容

舊廚師(舊程式碼)拿到一份用了「分子料理膠囊」的新潮食譜,他不認識這些新東西。好設計讓他能安全地略過不懂的部分,照樣把他認得的菜做出來,而不是當場崩潰、整道菜毀掉。

🥘
放回食譜平台情境

新版伺服器讀到一份沒有「過敏原提醒」的舊食譜——沒問題,向後相容。舊版伺服器收到一份多了「過敏原提醒」的新食譜——它得學會「禮貌忽略」這個陌生欄位,正常存下其他內容,不能整筆寫入失敗,這就是向前相容在守住的底線。

小試身手

滾動式升級、向後相容、向前相容——這三個詞先搞清楚方向,後面講編碼格式才會跟得上。來兩題:

為什麼伺服器端的滾動式升級,會讓新舊版本的程式碼與資料格式「同時存在」?
情境

新聞 App 的後端先升級成新版,會在文章資料裡多寫入一個舊版前端不認識的 audio_summary 欄位。為了讓還沒升級的舊版前端不崩潰、仍能正常顯示文章,這裡主要依賴的是哪種相容性?

📦
下一站:編碼格式

原則講完了,那實際上資料要編碼成什麼格式,才能撐住這些相容性?下一站我們就去看 JSON、XML,還有 Protocol Buffers、Thrift、Avro 這些格式,怎麼各自接招。

02

編碼方式大亂鬥:JSON 到 Protobuf

資料要離開記憶體出門遠行,得先「打包」——但打包方式決定了它跑得快不快、能不能跨語言、能不能優雅地長大

資料要出門,得先打包成一條「位元組」

程式跑起來的時候,資料是活的——物件、陣列、雜湊表,彼此用指標牽來牽去,CPU 處理起來飛快。但只要你想把它存進檔案,或透過網路送給別人,指標就完全沒意義了,你必須把這團活生生的資料結構,攤平成一條 編碼(Encoding) 過的位元組序列,收到的那一端再 解碼(Decoding) 回物件。

很多程式語言都內建這種打包機,例如 Java 的 Serializable、Python 的 pickle。方便是方便,但問題不小:

語言綁定

Python 打包的資料,Java 通常看不懂。一旦系統要跨語言溝通,或未來可能換語言,你等於先把自己鎖死了。

安全隱患

解碼時程式得「動態生出物件」,萬一有人塞一段精心構造的位元組序列進來,解碼過程可能被誘導執行惡意程式碼。

版本演進差

資料結構改版後,舊程式讀新資料、新程式讀舊資料,往往兩邊都很狼狽,這類編碼天生不太管這件事。

效率不彰

沒有特別針對體積和速度優化,打包出來常常又大又慢。

🧨
專屬塑膠袋的故事

想像你用自家才看得懂的「專屬塑膠袋說明書」把樂高跑車拆裝寄給外國朋友——他看不懂怎麼組,搞不好說明書裡還夾帶了「順便拆光你城堡」的壞指令。這就是語言限定編碼的縮影:對自己人方便,一旦跨出去就處處是坑。

原文這樣說:欄位標籤是什麼

後面我們會看到 Protobuf/Thrift 怎麼用「數字標籤」取代欄位名稱。這段原文剛好把這個概念講得很乾淨,一句對一句讀讀看:

原文 · DDIA Ch.4 The big difference compared to Figure 4-1 is that there are no field names (userName, favoriteNumber, interests). Instead, the encoded data contains field tags, which are numbers (1, 2, and 3). Those are the numbers that appear in the schema definition. Field tags are like aliases for fields — they are a compact way of saying what field we're talking about, without having to spell out the field name.
白話翻譯

跟前面圖 4-1 那種寫法相比,最大的差別是:這裡完全沒有欄位名稱(不會寫 userName、favoriteNumber、interests)。

取而代之的,編碼後的資料裡只有「欄位標籤」——也就是數字(1、2、3)。

這些數字,正是當初寫在 schema 定義裡的那幾個編號。

欄位標籤就像是欄位的「綽號」——用一個簡短的數字,就能說清楚「我在講哪個欄位」,不必把完整名稱拼出來。

💡
關鍵金句

欄位名稱可以改,欄位標籤不能改——因為編碼後的資料從來不靠名字認欄位,只靠那個數字。這正是後面 schema 演進規則的根。

文字派三巨頭:JSON、XML、CSV 各有各的小脾氣

放棄語言限定編碼之後,最常見的選擇是 文字格式 ——人類可讀、跨語言都吃得開。但它們也都有自己的「潛規則」要注意:

🔢
數字型別模糊

XML/CSV 分不出「123」是數字還是字串;JSON 雖然分得出字串和數字,卻不保證大整數的精確度——超過 2 的 53 次方就可能失真,所以 Twitter 的推文 ID 在 JSON 裡會同時給數字版和字串版。

🖼️
不能直接放二進位資料

想塞一張圖片進去?得先用 Base64 把它轉成一串文字才行,但這樣資料會直接膨脹約三分之一。

📏
冗長 vs 隨意,各走極端

XML 常被嫌「囉嗦」,每個欄位都要頭尾標籤;CSV 最精簡,但完全沒有 schema,欄位裡藏個逗號就可能整行解析錯亂。

📜
Schema:選用,不是必填

JSON 跟 XML 都有對應的 schema 語言可以選用,但現實是很多 JSON 使用者根本不用它——資料對不對,最後常常變成「應用程式自己心裡有數」。

瘦身計畫:二進位編碼的兩種流派

文字格式對人類友善,但對電腦來說有點「胖」——每筆資料都要重複寫欄位名稱、空白和換行也佔位置。 二進位編碼 就是衝著「瘦身」和「加速」而生,但裡面其實分兩個流派,瘦身程度差很多。

📦
二進位 JSON 變體(如 MessagePack)

把 JSON 直接轉成更緊湊的二進位形式,但每個欄位的「名稱」還是得照樣存進去,只是換了種更省空間的寫法——所以瘦身效果有限,比純文字 JSON 只小一點點。

🏷️
Schema 驅動的二進位格式(Protobuf/Thrift/Avro)

讀寫雙方都先共用同一份 schema,知道每個欄位的型別與順序,資料裡就完全不必再寫欄位名稱——改用前一畫面說的「欄位標籤」這種極簡數字,瘦身效果才是真的狠。

🧱
樂高比喻:從散裝到說明書組裝

文字格式像是每塊樂高都貼一張完整標籤再裝箱寄出——朋友不用說明書也能慢慢拼,但箱子大半空間都被標籤紙佔走了。Schema 驅動格式則是雙方先共用一本說明書,積木本身完全不用貼標籤,只要附上極小的「零件編號」,包裹立刻瘦一大圈。

拆開看:Protobuf 訊息裡到底裝了什麼

當 Thrift 或 Protocol Buffers 把一筆資料編碼成位元組時,每個欄位其實是這三段東西黏在一起,沒有任何欄位名稱的蹤影。點點看每一段:

🏷️ 欄位標籤
📏 型別+長度
📦 值(Value)
點上面任一段,看它在 Protobuf/Thrift 編碼裡實際扮演什麼角色。
🍕
披薩店點餐單比喻

菜單(schema)上「1 號=披薩名稱、2 號=麵皮種類」事先講好了,服務生點餐單只需要寫「1:海鮮總匯,2:薄脆」——不必每次都寫出完整品項名稱。這正是欄位標籤在做的事。

動手分類:這些編碼格式各自的招牌特徵是?

把下面五種格式,拖到它最貼切的「特徵描述」上。配完按「對答案」。

JSON
XML
CSV
Thrift
Protocol Buffers
人類可讀,但常被嫌「過於冗長」,每個欄位都要頭尾標籤包起來
拖到這裡
人類可讀,最精簡,但完全沒有 schema、型別也模糊,欄位裡藏個逗號就容易解析錯亂
拖到這裡
人類可讀、相對簡潔,是 Web API 的主流選擇,但對超大整數的精確度不友善
拖到這裡
Schema 驅動的二進位格式,用欄位標籤取代欄位名稱來省空間,常見於微服務間的 RPC 通訊
拖到這裡
Google 開發的 schema 驅動二進位格式,同一筆資料用它編碼往往比 Thrift 的 CompactProtocol 還要再小一點
拖到這裡

改版不死機:欄位標籤怎麼撐住相容性

系統一定會改版,新增、移除欄位是家常便飯。Protobuf 跟 Thrift 靠著「欄位標籤」這個機制,把 向後相容向前相容 這兩件麻煩事處理得相對乾淨:

1
新增欄位

給它一個全新的標籤,並設為 optional 或給預設值。舊程式遇到不認識的標籤,直接跳過,不會壞掉。

2
移除欄位

只能移除 optional 欄位,而且那個標籤編號從此「永久退役」,絕不重複使用,免得未來新欄位被舊資料誤解。

3
改欄位名稱可以,改標籤不行

名稱只活在 schema 裡,編碼後的資料根本不存名稱;但標籤是資料辨認欄位的唯一依據,動了它等於讓所有舊資料失效。

🍕
披薩店菜單下架編號,不能再用

就算「6 號:超級辣粉」這道菜下架了,6 號這個編號也不能拿去給新菜用——不然舊點餐單上殘留的 6 號,會被新廚師誤解成完全不同的東西。

小試身手

Thrift 與 Protocol Buffers 用「欄位標籤」取代欄位名稱,最主要是為了什麼?
在 Protocol Buffers/Thrift 中,移除一個 optional 欄位之後,它原本的欄位標籤該怎麼處理?
🚀
下一站

上面這些格式都還是把欄位名稱或標籤直接寫進每一筆資料裡,有沒有更狠的省法?

03

Avro 的讀寫者協商魔法

完全不貼欄位標籤,卻能解碼到一字不差——秘密全藏在一張雙方都看得到的「綱要地圖」裡

同一筆資料,Avro 為什麼能壓到最小?

還記得上一站的便當:JSON 把每道菜都貼上「這是白飯」「這是烤魚」的標籤(81 bytes); Thrift/Protocol Buffers 聰明一點,把標籤換成數字(33~59 bytes)。

Avro 更狠——它乾脆把所有標籤都拿掉,同一筆記錄只要 32 bytes,是這幾種格式裡最小的。資料裡沒有任何東西告訴你「這段是 userName」「那段是整數還是字串」。它就是一串純粹的值,一個接一個黏起來。

🍱
魔法便當盒

媽媽準備便當時,只要照著「設計圖」把白飯放左上角、烤魚放中間,完全不用在每道菜上貼標籤——因為你打開便當盒時,腦中也有同一張設計圖,看到位置就知道是什麼菜。這張設計圖,就是 Avro 的綱要(Schema)。

原文怎麼說「無標籤」這件事

這幾句話是整個第四章 Avro 那一節的關鍵轉折,我們直接讀原文,旁邊配白話。

原文 · DDIA Ch.4 If you examine the byte sequence, you can see that there is nothing to identify fields or their datatypes. The encoding simply consists of values concatenated together. A string is just a length prefix followed by UTF-8 bytes, but there's nothing in the encoded data that tells you that it is a string. It could just as well be an integer, or something else entirely. To parse the binary data, you go through the fields in the order that they appear in the schema and use the schema to tell you the datatype of each field.
白話翻譯

如果你檢視這串位元組,會發現裡面完全沒有東西能告訴你哪段是哪個欄位、又是什麼型別。

這份編碼就只是把一堆「值」直接串接在一起而已。

一個字串,不過是「長度前綴+UTF-8 位元組」,但資料裡沒有任何標記說「這是字串」。

它完全有可能是個整數,或是別的什麼東西——光看資料本身根本分不出來。

要解析這串二進位資料,你得照著綱要裡欄位出現的順序一一走過去,靠綱要告訴你每個欄位該是什麼型別。

🔑
省下的空間,全部藏到綱要裡

Avro 不是「沒有資訊」,而是把本來要重複塞進每一筆資料的識別資訊,集中搬到外部那張綱要地圖上一次說清楚。資料本體只剩下最精簡的「值」。

演給你看:寫入端與讀取端怎麼「協商」

下面這五個角色,演一遍 Avro 從編碼到解碼的完整戲法。重點看:資料本身完全不帶欄位名稱,靠的是兩端各自手上的 寫入者綱要讀取者綱要 做比對。

🖋️
寫入端程式
🗺️
寫入者綱要
📦
32 bytes 無標籤資料
🧭
讀取者綱要
📖
讀取端程式
按「下一步」看寫讀兩端怎麼協商
🍳
兩個主廚交換食譜

寫入者是主廚小新,讀取者是主廚阿美。小新的新食譜多了「起司」,阿美的舊食譜沒有——Avro 翻譯官會說:「阿美,你食譜沒寫的,先放旁邊,照你的食譜繼續煮。」這就是向前相容。反過來,阿美升級食譜後讀到小新用舊食譜做的半成品,少了起司,Avro 就用食譜上寫好的預設值補上——這是向後相容

協商靠的不是順序,是「名字」

寫入端編碼時,是嚴格按照寫入者綱要裡欄位出現的順序,把值一個接一個塞進去——沒有名稱、沒有順序標記。但讀取端在「解讀」這串位元組時,靠的是欄位名稱去配對,不是死板地比較兩份綱要的順序。

1
順序可以不同

就算讀取者綱要把欄位順序排得跟寫入者綱要不一樣,Avro 還是能正確對應——因為它認的是欄位叫什麼名字,不是排在第幾個。

2
多出來的欄位,直接放旁邊

資料裡有、但讀取者綱要沒定義的欄位,會被安全地忽略,讀取端完全不受影響。

3
少掉的欄位,用預設值補

讀取者綱要要求某個欄位、但資料裡沒有,Avro 就自動填入該欄位在綱要中定義的預設值。

📐
Schema 演化的黃金法則

想讓新舊綱要永遠協商得起來,改動綱要時只能做一件「安全」的事:新增欄位,而且一定要給預設值。你不能隨意改變既有欄位的型別(除非是 int→long 這種不掉資料的安全轉換),也不能把一個欄位直接改名而不留下 aliases ——破了這條規則,舊版本跟新版本就可能對不上話。

這套協商魔法,實際用在哪裡?

「無標籤、強依賴綱要」聽起來像限制,但換個場景看,反而是三種實用的超能力。

🗃️
大型檔案只存一次綱要

Avro 的「物件容器檔案」把寫入者綱要寫在檔案最前面一次,後面數百萬筆記錄都不必重複帶綱要,檔案因此小很多,很適合 Hadoop 這類大數據場景。

🔄
資料庫結構說變就變

把資料庫表結構自動轉成 Avro 綱要,表一改,重新生成綱要就能匯出,不必像 Protocol Buffers/Thrift 那樣手動配置欄位數字標籤。

📡
連線一開始就講好綱要

在 Avro RPC 這類雙向通訊裡,雙方連線初期先協商好綱要版本,之後的每個封包就能用最精簡的無標籤格式傳輸,省下大量網路開銷。

小試身手

來檢查一下,這套「寫讀協商」的魔法你抓到核心了嗎?

Avro 能把同一筆記錄壓縮到比 JSON、Thrift、Protocol Buffers 都小,關鍵原因是什麼?
依照 Avro 的 Schema 演化黃金法則,新增一個欄位時最重要的規定是什麼?
🚚
下一站:位元組要怎麼「流動」

編碼格式講完了——這些壓得乾乾淨淨的位元組,實際上是怎麼從一個程式「流動」到另一個程式的?往下捲,我們去看資料流動的幾種路徑。

04

資料流動的三種模式

同一份編碼過的資料,可能穿越時間、跨越空間、或被悄悄轉手——三種旅程,三套相容規則

先問一句:這筆資料,誰寫的?誰讀的?

前面幾個模組我們一直在講「怎麼把資料變成一串位元組」,也就是 編碼解碼。 但編碼只是前半場——資料變成位元組之後,還得「流動」到某個地方,被某個版本的程式讀走。

而「誰寫、誰讀」這件事,決定了你需要哪一種 結構演進 規則。這一節,我們就把資料流動拆成三種最常見的路線:穿越時間(資料庫)、跨越空間(服務呼叫)、非同步轉手(訊息佇列)。

🧭
先給你一個畫面

想像同一封信,可能是「寫給十年後的自己」(資料庫)、「打電話請對方馬上回覆」(服務呼叫),或是「投進郵筒,讓對方有空再拆」(訊息佇列)。信的內容編碼方式可以一樣,但「誰在什麼時候讀」完全不同,這就是為什麼相容性規則也要跟著變。

點點看:三種資料流動情境

下面三個是同一件事的三種變形——「編碼過的資料要去哪裡」。點開每一個,看看它對編碼相容性提出了什麼不一樣的要求。

🗄️ 穿越時間(資料庫)
🔌 跨越空間(RPC/RESTful API)
📮 非同步傳遞(訊息佇列)
點擊上面任一種情境,看看它對編碼相容性的要求有什麼不同。
🔑
共同的關鍵字:讀寫雙方永遠「版本不同步」

不管走哪條路線,你幾乎永遠不能假設「寫的人」和「讀的人」用的是同一版程式碼。差別只在於:不同步的原因是「時間差」、「不同服務各自改版的速度」,還是「發布者跟訂閱者根本互不認識」。

路線一:穿越時間——寫給未來的自己

資料庫是三條路線裡最容易被忽略、卻也最普遍的一種。你今天寫進資料庫的一筆紀錄,可能十年後才被讀出來——讀它的,甚至可能就是「未來版本的你自己」。

原文 · DDIA Ch.4 In a database, the process that writes to the database encodes the data, and the process that reads from the database decodes it. There may just be a single process accessing the database, in which case the reader is simply a later version of the same process — in that case you can think of storing something in the database as sending a message to your future self. Backward compatibility is clearly necessary here; otherwise your future self won't be able to decode what you previously wrote. This means that a value in the database may be written by a newer version of the code, and subsequently read by an older version of the code that is still running. Thus, forward compatibility is also often required for databases.
白話翻譯

在資料庫裡,寫入資料的那個程式負責編碼,讀出資料的那個程式負責解碼。

如果自始至終只有同一個程式在存取資料庫,那讀它的其實就是「同一支程式的未來版本」——存進資料庫,等於寄一封信給未來的自己。

向後相容性在這裡顯然是必要的,不然未來的自己會看不懂自己以前寫下的東西。

這也代表資料庫裡的某個值,可能是新版程式寫的,卻被還在跑的舊版程式讀到。所以資料庫也常常需要向前相容性。

把這個畫面放大到整個系統:資料庫通常不是只有一個程式在存取,而是好幾個服務、或同一服務的好幾個實例同時讀寫。滾動升級的時候,有些機器已經換上新版程式,有些還沒——這一刻,資料庫裡混雜著新舊兩種版本寫下的資料,完全是常態,不是例外。

1
資料比程式碼長壽

你的程式可能每週都在改版,但資料庫裡某些紀錄可能十年沒被動過。新程式一樣得讀得懂那些「古董資料」。

2
未知欄位遺失的陷阱

新版程式加了一個新欄位並存了進去;舊版程式讀到這筆資料、改了自己認識的部分就整筆寫回——那個新欄位很可能就這樣不見了。

3
正確作法:原樣保留,別急著丟

讀到不認識的欄位,先完整保留下來,只改自己該改的部分,再把整包(含不認識的欄位)寫回去。多數編碼格式其實都支援這樣做,只是需要你在應用層特別留意。

⚠️
這不是理論陷阱,是真的會發生

如果你把資料庫的值解碼成程式裡的物件(model object),改完之後再重新編碼寫回去——這個「解碼再編碼」的過程,就是未知欄位最容易在半路消失的地方。解法不難,難的是「記得要注意」。

路線二:跨越空間——服務對服務的呼叫

當系統被拆成一個個獨立的 服務,它們就得透過網路互相溝通。最常見的兩種角色分工是客戶端與伺服端:伺服端把功能包成一組 API 開放出來,客戶端負責呼叫。

常見的兩條路又分成偏「輕量、貼著網頁標準走」的 REST,跟偏「正式、有嚴謹合約」的 SOAP

🧾
REST

基於 HTTP 標準,用 GET/POST/PUT/DELETE 操作「資源」,常搭配 JSON。直觀、易除錯,適合對外公開的 API。

📠
SOAP

以 XML 訊息為核心,靠 WSDL 這份「服務合約」規定得清清楚楚。嚴謹但重量級,需要專門工具,多用在企業內部整合。

☎️
RPC

想讓「呼叫遠端服務」看起來跟「呼叫本地函式」一樣,這個特性叫位置透明性。方便,但也是個容易讓人低估風險的假象。

💡
為什麼位置透明性是「美麗的錯誤」

本地函式呼叫要嘛成功、要嘛失敗,結果很明確。但網路呼叫可能斷線、可能延遲、可能你根本不知道對方收到了沒——重試還可能讓同一個動作被執行兩次。新一代 RPC 框架(例如 gRPC)已經比較誠實地面對這些差異,但「呼叫遠端 ≠ 呼叫本地」這個事實,永遠不會因為介面寫得像函式呼叫就消失。

服務也會不斷改版,所以一樣要顧 向後相容性向前相容性——常見做法是幫 API 做版本控管(URL 加版本號,或用 HTTP Header 標示版本),加新欄位時盡量維持可選,讓新舊客戶端能夠並存,不必所有人同一天升級。

路線三:非同步傳遞——發布者不等回覆

第三條路線介於資料庫跟服務呼叫之間:既不是直接打電話等對方接(RPC),也不是單純寫進一個大家共用的資料庫——而是透過一個中間人,也就是 訊息代理

發布者把訊息丟進去就可以去忙別的事,不必等訂閱者回覆——這就是 非同步通訊 的精神。跟直接用 RPC 呼叫相比,訊息代理多了幾個好處:

當緩衝區

接收者忙碌或暫時掛掉,訊息先在佇列裡排隊,不會憑空消失,也不會卡住發送者。

解耦

發布者只管把訊息送到某個「信箱」,不需要知道訂閱者是誰、在哪台機器、用哪個版本的程式在跑。

一對多廣播

同一則訊息可以送給多個訂閱者,各自按自己的步調處理,彼此互不拖累。

而正因為發布者和訂閱者「互不知道對方版本」,訊息本身的編碼格式就得自己把話說清楚。分散式 Actor 框架 也是同樣道理——Actor 之間唯一的溝通管道就是訊息,這代表訊息格式的向前、向後相容性,直接決定了系統能不能做到 滾動升級 而不整套停機。

📮
三條路線收斂到同一件事

訊息代理通常不會強制規定訊息內容長什麼樣子——那完全是發布者跟訂閱者自己的事。所以不管走哪條路線,最終還是回到本章一直在講的老問題:讀寫雙方版本不同步時,你的編碼格式撐不撐得住?

小試身手

三條路線都看過了,來檢查一下有沒有抓到重點。

新版程式在一筆使用者紀錄裡加了「心情狀態」欄位並存入資料庫。接著舊版程式讀到這筆紀錄,只修改了它認識的欄位後整筆寫回。最可能發生什麼事?
為什麼教材把 RPC 的「位置透明性」稱為一個「迷人的陷阱」?
🧵
下一站

把編碼格式+讀寫者協商+三種流動模式接在一起,看一筆資料真實走過的完整旅程。

05

大局:一筆訂單的相容性之旅

把編碼、RPC、訊息佇列串成一條線——看一筆資料怎麼在新舊版本之間全身而退

整章其實只回答一個問題

這一路我們談了編碼格式、談了 Avro 的 Schema 解析、 也談了服務之間怎麼透過 RESTRPC訊息代理溝通。 聽起來是很多主題,但拉遠來看,整章其實只在回答一件事:

🧭
「當新舊版本必須共存時,資料要怎麼不壞掉?」

不管是磁碟上的一筆記錄、網路上的一次 RPC 呼叫,還是佇列裡的一則訊息——只要牽涉到滾動式升級,就一定會有某個時刻,新版程式碼碰到舊資料、或舊版程式碼碰到新資料。整章講的所有技巧,都是為了讓這兩種相遇都能「全身而退」。

這一站不重新教細節,而是把前面幾個小節(編碼演進、Avro 的雙 schema、服務間的 REST/RPC、訊息佇列)收整成一趟完整的旅程——讓你看見它們其實是同一套邏輯,用在系統的不同層。

原文的總結,其實就是這整章的濃縮版

DDIA 第 4 章結尾的 Summary,用幾句話把整章重新講了一遍。我們直接對照原文看:

原文 · DDIA Ch.4 Summary Many services need to support rolling upgrades, where a new version of a service is gradually deployed to a few nodes at a time, rather than deploying to all nodes simultaneously. During rolling upgrades, or for various other reasons, we must assume that different nodes are running the different versions of our application's code. Thus, it is important that all data flowing around the system is encoded in a way that provides backward compatibility (new code can read old data) and forward compatibility (old code can read new data).
白話翻譯

很多服務都需要支援滾動式升級——新版本一次只換幾個節點,而不是所有節點同時換新。

在滾動升級期間(或其他各種原因下),我們必須假設:不同節點正在跑著不同版本的程式碼。

所以系統裡流動的每一筆資料,都得用能提供向後相容(新程式碼讀得懂舊資料)與向前相容(舊程式碼讀得懂新資料)的方式編碼。

換句話說:向後相容向前相容 不是某一種編碼格式的專利,而是整條資料路徑——從磁碟、到 RPC、到訊息佇列——都必須共同守住的底線。

跑一遍:一筆訂單的相容性之旅

來看一個具體情境:電商系統正在滾動升級,服務 A 已經換成新版,服務 C 還沒輪到。這時一筆訂單資料要怎麼在「新舊並存」的系統裡,一路暢行無阻?按「下一步」跟著它走一遍。

🗄️
資料庫
🆕
服務 A(新版)
🔗
服務 B(新版・RPC)
📨
訊息佇列
🕰️
服務 C(舊版)
按「下一步」開始這趟旅程
🛡️
整趟旅程只靠一件事撐住

不管是服務 A/B 之間的 RPC,還是服務 A/C 之間的訊息佇列,靠的都是同一套底層邏輯:新欄位一定給 欄位標籤 而不是插隊,也一定標成 optional。 讀的人認得就用,不認得就跳過——沒有例外。

選型時,你其實在回答同一組問題

看完這趟旅程,回頭看服務間怎麼選溝通方式,會發現規則出奇一致——都是在問「這裡的相容性風險由誰扛」。

🌐
對外公開 API

選 REST。用 HTTP 與 JSON,第三方開發者用瀏覽器或 curl 就能測,除錯門檻低,相容性問題自己攤在陽光下。

🏢
企業內部微服務

新一代 RPC 框架(gRPC、Thrift)常見,二進位編碼更緊湊,但別忘了:位置透明性只是「感覺」方便,網路的不確定性從沒消失。

📮
不想互相卡住的服務

走訊息佇列。發送者丟完訊息就去做別的事,接收者忙或當機都不會拖垮上游,還天生適合「一對多」廣播。

⚠️
位置透明性是把雙面刃

RPC 想讓「呼叫遠端」感覺跟「呼叫本地函式」一樣,這份「方便」本身就是陷阱——網路會斷線、會延遲不定、會讓你搞不清楚請求究竟送到了沒。新一代框架沒有消滅這些問題,只是更誠實地把它們攤開讓你處理。

整章總複習

這趟旅程串起了編碼、RPC、訊息佇列。來兩題,檢查你是不是真的把散落的知識點接成了一條線。

某服務正在做滾動式升級,新版本引入了一個新欄位。要讓系統在升級過程中不出錯,下列哪一項描述最完整?
情境

電商訂單服務要通知庫存、支付、物流三個服務,但物流服務常常忙碌或短暫離線。要讓下單流程不被物流服務卡住、也不遺失通知,最適合用什麼方式?

🔁
下一站(其他章)

單機上的編碼與相容性穩了,接下來要放大到多台機器——同一份資料要怎麼「複製」到好幾個地方?