編碼與演化:讓新舊程式碼,讀得懂彼此的資料

· tech

#distributed-systems#book-notes#data-engineering

📑 目錄

上一篇講資料怎麼放上磁碟,這篇講一個更容易被輕視的問題:資料寫出去時,是用什麼格式編碼的? 你可能覺得「JSON 就好了啊」——直到 schema 要改的那一天。這章的重量,來自兩個躲不掉的事實:資料比程式碼活得久(程式碼你今天就能全部換新,但五年前寫進資料庫的那筆資料還躺在那),而且新舊版程式碼會同時運作(只要你用滾動更新,這就是常態,不是意外)。這兩件事合起來,把「編碼格式」從小事變成了跨版本、跨時間的相容性工程

為什麼需要「兩種」相容:滾動更新把新舊逼到同一時刻

滾動更新中:新舊版同時在跑,資料雙向流動 新版程式 v2已升級的那幾台 舊版程式 v1還沒輪到的那幾台 同一個資料庫 / topic 兩邊都在寫、也都在讀 向後相容 backward 新程式,讀得懂「舊資料」 大家都記得(migration 思維) 向前相容 forward 舊程式,讀得懂「新資料」 最常被忘,但滾動與回滾窗口天天發生 schema 每一次變更,兩種相容都要顧——少一邊,滾動到一半就開始噴錯
滾動更新期間,新版舊版程式同時在跑、共用同一個資料庫或 topic——於是資料雙向流動。向後相容(新讀舊)大家都記得,因為那是 migration 的日常;向前相容(舊讀新)才是最常被忘的那一半——可它在每次滾動的窗口、以及回滾(退回舊版後,新版已寫入的資料還在!)時天天發生。schema 的每一次變更,兩種相容都要顧

先把 JSON 的問題說完:它人類可讀、到處都通,但沒有 schema 強制力(欄位改名、型別改掉,編譯期沒人攔你,炸在 runtime)、數字含糊(大整數精度、int/float 不分)、又肥。小系統無所謂;一旦資料要跨團隊、跨服務、活很多年,你就需要「有 schema 的二進位格式」——這就輪到 Thrift、Protocol Buffers 和 Avro 上場。

Field tag:讓演化安全的那個小機關

Protobuf / Thrift 的核心巧思,是編碼時不寫欄位名稱,只寫「field tag」(一個數字)。schema 是雙方各自持有的說明書,tag 是資料裡的座標。而正是這個小機關,讓 schema 可以安全地演化:

編碼裡沒有欄位名,只有 tag——演化因此安全 schema v2 1: name 2: email 3: phone(v2 新增,optional) 資料裡只存 tag 編號 + 值,不存名稱 舊程式(只認識 tag 1、2)讀「新資料」 遇到不認識的 tag 3 → 跳過 name / email 照讀 → 向前相容 ✓ 新程式讀「舊資料」(沒有 tag 3) 找不到 tag 3 → 用預設值 不會炸 → 向後相容 ✓ 鐵律:新欄位用新 tag · tag 永不改/重用 · 新欄位必須 optional 或有預設值
因為編碼裡只有 tag 編號、沒有名稱:舊程式讀新資料時,遇到不認識的 tag 直接跳過,其餘照讀(向前相容);新程式讀舊資料時,找不到新 tag 就用預設值(向後相容)。整套演化安全就靠三條鐵律:新欄位一定用新的 tag、tag 永遠不能改或重用(那是舊資料的座標!)、新欄位必須 optional 或有預設值。順帶一提,欄位「改名」因此是安全的——編碼裡本來就沒有名字

Avro 走另一條更極致的路:編碼裡連 tag 都沒有,就是值一個接一個排——所以它最省空間,但讀取時必須拿著 writer’s schema(寫入當時的)和 reader’s schema(你現在期望的)做解析對照,由 Avro 負責把兩個版本的差異調和掉(欄位比對用名稱、缺的補預設)。這個「讀時調和兩份 schema」的設計,讓它特別適合 schema 常變、或動態產生 schema 的場景(例如從資料庫 dump 整批資料)——這也是它成為大資料與 Kafka Schema Registry 生態主流的原因:registry 集中管 schema 版本、檢查每次變更有沒有破壞相容,把這章講的紀律變成了自動把關。

反思

資料比程式碼活得久——相容性是一份「跨時間的 API」

這章最打到我的一句話是 data outlives code。程式碼你今天就能全部換新,但五年前寫進資料庫的那筆資料、三年前落在 log 裡的那則事件,還原封不動躺在那,等著未來某天被讀。所以 schema 相容性的本質,不是「格式的小事」,是你跟過去與未來的自己簽的 API 契約——向後相容是對過去負責,向前相容是對未來謙虛。想通這點後,我看 schema 變更的嚴肅程度,跟看對外 API 的 breaking change 是同一等級:改一個欄位,就是在改一份所有歷史資料都引用中的介面。

向前相容,是最容易被忘、卻天天在發生的那一半

向後相容大家都有 sense(migration 思維);向前相容——舊程式讀得懂新資料——才是實務上最常炸的那半。它發生在兩個你躲不掉的窗口:滾動更新進行中(舊實例讀到新實例剛寫的資料),以及回滾之後(退回舊版了,但新版已經寫進去的資料還在!)。我的教訓是把它變成部署紀律:schema 變更和程式變更分開上、schema 先行——先上「能讀新格式但還不寫」的版本,確認全面鋪開,才開始寫新格式。這跟 滾動更新「小步、可回退」是同一種安全感的兩面:程式可以回滾,資料不能——所以資料格式的每一步,都要讓前後兩個版本都接得住。

沒有 schemaless 這回事,只有「沒寫下來的 schema」

做資料這行越久,我越不相信「schemaless 很自由」這句話。資料只要被讀,就一定有人對它的結構抱著預期——schema 永遠存在,差別只是它被顯式寫下來、有人把關,還是散落在每個讀取方的程式碼裡、靠默契維持上上篇的 schema-on-read 是把檢查延後,不是讓結構消失;而所謂的自由,常常是「寫入方自由了,讀取方在半夜收爛攤」。所以我的立場很清楚:資料要跨團隊或跨時間,契約就要顯式化——Protobuf/Avro 的 schema 檔進版控、Schema Registry 自動擋不相容的變更。這跟我在 宣告式與版控、dashboard as code 一路講的是同一條紀律:重要的約定,不能活在人的腦袋裡。