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 資料契約設計要點 裡的演進表格。
四、棄用視窗怎麼開
棄用不是發一則公告就結束,而是一段有起點、有觀測、有終點的視窗。一個可執行的節奏:
- 凍結:舊欄位停止新增寫入,只保留讀取;
- 宣告:文件、變更日誌、回應標頭三處同時標註下線時間;
- 觀測:按呼叫方維度統計舊欄位讀取量,直到連續兩個週期歸零;
- 刪除:視窗結束、觀測歸零、回歸通過後,才移除欄位與相容程式碼。
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 的記憶,要靠測試。兩層就夠:
- 結構測試:用 JSON Schema 驗證回應,但不要對消費端使用
additionalProperties: false; - 消費者子集測試:把舊消費者真正依賴的欄位抽成子 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 驗證全綠、監控也不報錯,只有業務指標慢慢偏移,是最難排查的一類破壞性變更。凡是含義變化,一律視為新欄位,舊欄位凍結語義,再用棄用視窗收尾。