JSON 工具箱

JSON 契約的版本演進:相容、棄用與資料遷移

結論:替介面加欄位不等於安全,破壞性變更往往藏在型別與語義裡。這篇給出可執行的版本演進路徑:相容邊界、版本欄位與媒體型別、棄用視窗、消費者排查、雙寫遷移與還原、契約測試,以及漸進式發佈的觀察指標。

實戰約 10 分鐘發佈於 2026-10-31更新於 2026-10-31
JSON 契約版本演進、相容與資料遷移封面

結論:版本演進按「先擴欄位、再回填、最後收縮」的順序做,並把版本欄位寫進文件;每次變更先確認消費端對未知欄位與未知列舉有兜底,再決定是否發新版本,棄用則從凍結、宣告、觀測到刪除走完整個視窗。

版本演進的難點不是改程式,而是你無法確認還有誰在讀舊欄位;相容性從來不是單向的承諾,而是發佈方與未知消費者之間持續對齊的過程。

一、相容的邊界:加欄位未必安全

教科書說「新增可選欄位」是向後相容的,這句話只在消費者對未知欄位寬容時成立。以下三種情況,加欄位照樣會出問題:

  • 消費者用 additionalProperties: false 的 schema 做嚴格驗證;
  • 消費者把物件按固定順序走訪,或按位置而不是按鍵取值;
  • 新欄位與舊欄位同名但含義不同,例如把 status 從「訂單狀態」擴成「履約狀態」。

真正安全的加法需要三個前提:欄位可選、語義獨立、消費端忽略未知鍵。發佈前先確認這三個前提,再談版本號。

{
  "schemaVersion": 2,
  "orderId": "ord-8842",
  "status": "paid",
  "refundDeadline": "2026-11-30T00:00:00+08:00",
  "channel": "wechat"
}

這段回應裡,refundDeadline 與 channel 是 v2 新增的可選欄位,orderId、status 保持原語義;schemaVersion 隨文件一起持久化,而不是只存在於 HTTP 標頭裡。

二、不能做的破壞性變更清單

把下面這些變更視為「必須走版本或雙寫」的紅線,不要指望更新文件就能補救:

變更 為什麼破壞相容 可接受的替代做法
刪除欄位 消費端讀取直接得到 undefined 先棄用並觀測,視窗結束後再刪
改欄位型別 "1" 變 1,強型別客戶端直接報錯,弱型別端也可能驗證失敗 新增欄位名,舊欄位雙寫過渡
新增必填欄位 舊呼叫方沒有該欄位,驗證必失敗 給預設值,或只在新版本中設為必填
改列舉值集合 消費端的 switch 沒有預設分支 客戶端先支援未知值,再擴列舉
改欄位語義 型別沒變,行為卻變了,最難排查 視為新欄位,舊欄位凍結語義
改巢狀層級 所有取值路徑都要改 保留舊結構,新增平鋪欄位或 expand

其中「改語義」最危險:schema 驗證全綠,監控也沒有報錯,只有業務指標慢慢偏移。凡是含義變化,一律當新欄位處理。

三、版本欄位放哪:文件內、URL 與媒體型別

三種常見位置各有適用面:

位置 示例 適用場景 局限
文件欄位 "schemaVersion": 2 資料庫、訊息佇列、離線備份 傳輸層無法據此路由
URL 路徑 /v2/orders/8842 對外公開 API 資源位址與版本耦合,遷移成本高
媒體型別 application/vnd.acme.order.v2+json 需要內容協商的內部服務 呼叫方要正確設定 Accept

一條實用分工:傳輸層用媒體型別協商,持久化資料必須寫版本欄位。訊息佇列裡的舊訊息不會因為你改了 URL 就自動升級,備份檔案更不會。schemaVersion 放在文件裡,重放、回填和稽核才有依據。

GET /orders/ord-8842 HTTP/1.1
Host: api.bbdrug.com
Accept: application/vnd.acme.order.v2+json

如果確實要用 URL 版本,至少保證舊路徑在棄用視窗內仍回傳舊結構,而不是悄悄指向新實作。對外 API 的版本策略可參考 JSON API 資料契約設計要點 裡的演進表格。

四、棄用視窗怎麼開

棄用不是發一則公告就結束,而是一段有起點、有觀測、有終點的視窗。一個可執行的節奏:

  1. 凍結:舊欄位停止新增寫入,只保留讀取;
  2. 宣告:文件、變更日誌、回應標頭三處同時標註下線時間;
  3. 觀測:按呼叫方維度統計舊欄位讀取量,直到連續兩個週期歸零;
  4. 刪除:視窗結束、觀測歸零、回歸通過後,才移除欄位與相容程式碼。

Sunset 回應標頭(RFC 8594)可以給出機器可讀的下線時間;Deprecation 目前仍是 IETF 草案,可用但不要假設所有客戶端都認識。兩者搭配 Link 指向遷移文件:

Deprecation: true
Sunset: Wed, 31 Mar 2027 23:59:59 GMT
Link: <https://bbdrug.com/docs/deprecations/order-status>; rel="deprecation"; type="text/html"

視窗長度按團隊約定,常見下限是一個發佈週期或 90 天,取兩者中更長的一個。對外 API 建議更長,內部服務可以短一些,但都不能短於「所有呼叫方完成升級」所需的時間。

五、消費者排查:先找到誰在讀

棄用前必須能回答「誰還在讀」,否則視窗只是自欺欺人。按可信度排序的排查手段:

  • 閘道日誌:按 appId、User-Agent、客戶端版本聚合舊欄位存取量;
  • 鏈路追蹤:在序列化層替舊欄位打 tag,trace 裡能直接看到呼叫方服務名;
  • 訊息消費登記:佇列的消費組名單比程式碼搜尋更可靠;
  • 倉庫搜尋:rg 一把梭只能覆蓋自家程式碼,外部呼叫方仍然要靠日誌。
rg --type ts --type js 'legacyStatus|statusCode' src/ --stats

搜尋結果只能證明「本倉庫有人在讀」,不能證明「沒有別人還在讀」。真正下線前,把閘道日誌按天匯出,確認舊欄位的讀取量連續歸零,再動手。

六、資料遷移:擴展、回填、收縮

資料遷移推薦 Martin Fowler 的 Parallel Change 三步法:先擴展(新舊欄位共存、新舊版本都能讀),再回填(把歷史資料遷到新結構),最後收縮(刪除舊欄位與相容分支)。順序不能反,否則還原無路可走。

// 遷移腳本骨架:按主鍵游標分批,條件寫入保證冪等,失敗可重跑
async function migrateBatch(collection, cursor, batchSize = 500) {
  const records = await collection
    .find({ _id: { $gt: cursor } })
    .limit(batchSize)
    .toArray();

  if (records.length === 0) {
    return { nextCursor: cursor, done: true };
  }

  const operations = records
    .filter((record) => record.schemaVersion < 2)
    .map((record) => ({
      updateOne: {
        filter: { _id: record._id, schemaVersion: record.schemaVersion },
        update: {
          $set: {
            schemaVersion: 2,
            status: record.legacyStatus ?? record.status,
            refundDeadline: record.refundDeadline ?? null,
          },
        },
      },
    }));

  if (operations.length > 0) {
    await collection.bulkWrite(operations);
  }

  return { nextCursor: records.at(-1)._id, done: false };
}

幾個容易被忽略的細節:

  • 冪等:filter 帶上舊版本號,重複執行不會把已遷移記錄再寫一遍;
  • 分批與斷點:按 _id 游標推進,記錄 checkpoint,失敗從斷點續跑;
  • 演練:先在生產快照上跑一遍,比對遷移前後的記錄數與校驗和;
  • 還原:舊欄位至少保留到新欄位驗證通過,切換讀取路徑用開關,不要直接改 schemaVersion 回退;
  • 驗證:遷移後跑業務約束(金額非負、狀態機合法),不要只看 JSON 能否解析。

設定類資料同樣適用這套流程,具體組織方式可參考 配置、夾具與本地資料的 JSON 管理。

七、契約測試:把相容性寫進 CI

相容不能靠 code review 的記憶,要靠測試。兩層就夠:

  1. 結構測試:用 JSON Schema 驗證回應,但不要對消費端使用 additionalProperties: false;
  2. 消費者子集測試:把舊消費者真正依賴的欄位抽成子 schema,斷言新回應仍然滿足。
// 契約測試:新回應仍需滿足舊消費者依賴的欄位與型別
import Ajv from 'ajv';
import assert from 'node:assert/strict';
import { test } from 'node:test';

const ajv = new Ajv({ strict: false });
const validateLegacySubset = ajv.compile(legacyConsumerSubsetSchema);

test('v2 回應仍滿足 v1 消費者子集', () => {
  const response = buildOrderResponse({ schemaVersion: 2 });
  const { schemaVersion, ...legacyFields } = response;
  const isValid = validateLegacySubset(legacyFields);

  assert.equal(isValid, true, JSON.stringify(validateLegacySubset.errors));
});

子 schema 只宣告舊消費者讀取的欄位及其型別,新欄位一律放行。這樣既能抓住「刪欄位、改型別」的破壞性變更,又不會因為加欄位而誤報。用例資料放進夾具目錄,保持鍵有序、可 diff;夾具的合併衝突處理見 JSON 檔案的 Git 衝突:union 會寫出壞 JSON,按 key 合併才對。

八、漸進式發佈:按維度放量,按指標煞車

版本相容問題幾乎不會在單元測試裡暴露,要靠漸進式發佈。放量維度建議從窄到寬:

維度 做法 適合驗證什麼
租戶白名單 先給內部或友好客戶開啟新讀取路徑 真實資料形態差異
客戶端版本 只對已升級到支援新結構的客戶端啟用 新舊客戶端混跑
流量比例 5% → 25% → 100%,每檔觀察一個完整業務週期 長尾呼叫與快取命中

觀察指標至少三個:介面錯誤率、契約驗證失敗率、新舊欄位讀取比例。建議設定自動煞車條件,例如 5xx 超過 1%、或舊欄位讀取量突然回升,就還原開關。漸進式發佈期間保持雙寫,讓還原只需切換讀取路徑,不必回補資料。

九、上線前的相容檢查清單

  • [ ] 新欄位可選,語義獨立,沒有重用舊欄位名;
  • [ ] 消費端對未知欄位與未知列舉都有預設處理;
  • [ ] 版本欄位隨文件持久化,傳輸層版本可協商;
  • [ ] 棄用視窗有公告、有 Sunset、有觀測;
  • [ ] 舊欄位讀取量能從閘道或追蹤裡定位到呼叫方;
  • [ ] 遷移腳本冪等、分批、可斷點續跑,且有還原路徑;
  • [ ] 契約測試覆蓋舊消費者子集,CI 裡跑;
  • [ ] 漸進式發佈有明確放量維度與煞車指標。

常見問題

新增可選欄位一定相容嗎?

不一定。只有消費端忽略未知欄位、不做嚴格 schema 驗證時才相容;如果對方用了 additionalProperties: false 或按位置取值,新增欄位一樣會失敗。發佈前用契約測試確認對方的寬容度。

schemaVersion 放在 URL 還是回應內容?

傳輸層可以用媒體型別或 URL 區分版本,但落庫的資料必須在文件裡寫 schemaVersion。訊息佇列、離線備份和稽核都不經過 URL,只有文件內版本才能支撐重放與回填。

棄用欄位能直接刪嗎?

不能。先停止寫入,宣告下線時間,按呼叫方統計讀取量;視窗結束且讀取量連續歸零後再刪。刪除前跑一次全鏈路回歸,確認沒有隱藏的定時任務或報表在讀取。

遷移腳本跑到一半失敗怎麼辦?

設計成冪等任務:條件更新、按主鍵游標、記錄 checkpoint。失敗後從斷點重跑,重複執行不會二次寫入;還原保留舊欄位與快照,不要只回退 schemaVersion。

如何安排資料遷移的順序?

按 Parallel Change 三步走:先擴展,讓新舊欄位共存、新舊版本都能讀;再回填,把歷史資料遷到新結構;最後收縮,刪除舊欄位與相容分支。順序不能反,否則還原無路可走;遷移腳本要冪等、分批、可斷點續跑。

為什麼改欄位語義要當新欄位處理?

改語義時型別沒變、schema 驗證全綠、監控也不報錯,只有業務指標慢慢偏移,是最難排查的一類破壞性變更。凡是含義變化,一律視為新欄位,舊欄位凍結語義,再用棄用視窗收尾。