[BEE-19039] 零停機資料庫 Schema 遷移
INFO
零停機 Schema 遷移在不讓應用程式下線的情況下變更生產資料庫的 Schema——透過將每個不向後相容的變更分解為一系列向後相容的步驟,讓舊版和新版程式碼在滾動部署期間可以同時運行。
背景
簡單的 Schema 遷移執行一個 ALTER TABLE 語句,資料庫獲取資料表鎖,語句完成,應用程式重啟。對於小型資料表,這只需要毫秒。對於一個有五億行的資料表,ALTER TABLE 持有鎖的時間可能是幾分鐘或幾小時,阻塞所有讀取和寫入——這就是完全中斷服務。
即使沒有長時間的鎖,滾動部署也會產生時間重疊問題。當 Kubernetes 滾動部署逐一升級 Pod 時,舊 Pod 和新 Pod 同時提供流量。如果新程式碼期望有一個名為 user_email 的欄位,而遷移已經將其從 email 重新命名,舊 Pod 將失敗。如果遷移還沒有運行,新 Pod 將失敗。Schema 和程式碼在過渡期間必須雙向相容。
Martin Fowler 在 2014 年將這個問題形式化為平行變更(Parallel Change)模式(又稱展開-收縮),一個不向後相容的變更被分解為三個獨立的部署——展開(添加新 Schema 元素)、遷移(從舊 Schema 複製資料)和收縮(移除舊 Schema)。這確保在任何時間點,應用程式的一致版本都能針對資料庫運行。這個模式現在是持續交付文獻中的標準。
線上 DDL 工具解決了互補的問題:大型資料表結構變更在機制上是安全的(沒有資料丟失,不需要應用程式變更),但由於鎖持續時間而在物理上造成中斷。GitHub 在 2016 年推出了 gh-ost(GitHub 的線上 Schema 遷移器),專門針對 MySQL 上有數億行的資料表無法執行 ALTER TABLE 的問題。gh-ost 創建一個影子資料表,分批複製行,並追蹤 binary log 來重播並發的寫入——然後在影子完全跟上時執行原子資料表交換,無需應用程式鎖。
Percona 的 pt-online-schema-change(pt-osc)比 gh-ost 更早,使用基於觸發器的方法:創建影子資料表,安裝 INSERT/UPDATE/DELETE 觸發器保持同步,複製現有行,然後重命名資料表。觸發器開銷是其主要限制;gh-ost 的基於日誌的方法避免了這個問題,並增加了暫停和限速遷移的能力。
PostgreSQL 處理 DDL 的方式與 MySQL 不同。現代 PostgreSQL(14+)中的許多 ALTER TABLE 操作是即時的或只需要短暫的鎖。在 PostgreSQL 11 後,添加沒有預設值的可空欄位是即時的——不需要重寫任何行。CREATE INDEX CONCURRENTLY 在不持有寫入鎖的情況下建立索引。危險在於仍然需要在整個持續時間內持有 ACCESS EXCLUSIVE 鎖的操作:在沒有預設值的情況下添加 NOT NULL,在沒有 NOT VALID 的情況下添加檢查約束,以及需要重寫的類型更改。
設計思維
風險的三個軸
每個 Schema 變更都有三個風險軸:
鎖持續時間:DDL 語句是否持有資料表鎖?持多久?在繁忙的資料表上,即使是短暫的
ACCESS EXCLUSIVE鎖也會排在活躍事務之後並阻塞後面的事務(PostgreSQL 的鎖佇列是 FIFO,因此等待在長事務後面的單個 DDL 會阻塞所有後續讀取)。資料量:變更是否需要觸及每一行(重寫)?對於大型資料表,任何重寫都需要幾分鐘,而不是幾秒鐘。
應用程式相容性:變更是否與當前部署的程式碼不向後相容?如果舊版和新版程式碼必須在部署期間共存,Schema 必須同時滿足兩者。
零停機遷移策略必須獨立解決這三個軸。
展開-收縮序列
對於任何不向後相容的變更,步驟如下:
第一階段——展開: 在舊有 Schema 元素旁邊添加新的 Schema 元素(欄位、資料表、索引)。編寫同時寫入舊和新的程式碼,從新 Schema 讀取並回退到舊 Schema。部署此程式碼。資料庫現在同時有舊的和新的;所有正在運行的程式碼都與這個狀態相容。
第二階段——遷移: 將現有資料從舊 Schema 回填到新 Schema。這作為後台任務運行,而不是在部署管道中的遷移腳本中。大型回填必須(MUST)分批進行(見最佳實踐)。
第三階段——收縮: 一旦所有資料都已遷移且沒有程式碼引用舊 Schema 元素,就移除它。先部署不再引用舊欄位的程式碼,然後執行刪除它的遷移。
這需要在大型資料表的情況下,跨越數小時或數天進行三個獨立的部署。展開和收縮之間的窗口是向後相容性窗口。
何時需要線上 DDL 工具
在以下情況需要線上 DDL 工具(MySQL 的 gh-ost、pt-osc;PostgreSQL 的 pg_repack、pgroll):
- 資料表足夠大,DDL 重寫將以不可接受的持續時間持有鎖
- 變更在機制上是安全的(不需要應用程式程式碼更改),但物理上涉及重寫
線上 DDL 工具不能替代展開-收縮。它們解決鎖持續時間;展開-收縮解決應用程式相容性。
最佳實踐
必須(MUST)使用展開-收縮模式分解不向後相容的 Schema 變更。 欄位重命名、類型更改和添加 NOT NULL 約束是不向後相容的。每個都至少需要兩個部署:一個添加新元素並部署相容程式碼,一個在遷移完成後移除舊元素。試圖在滾動部署中進行單步重命名或約束添加將導致舊 Pod 失敗(看不到新 Schema)或新 Pod 失敗(無法寫入舊 Schema)。
必須不(MUST NOT)在單個遷移步驟中添加沒有伺服器端預設值的 NOT NULL 欄位。 這是生產事故最常見的原因之一。正確順序:
- 部署:將欄位添加為可空,添加應用程式程式碼在寫入時填充它。
- 回填:運行後台任務填充現有行。
- 驗證:確認欄位中零個 NULL 行。
- 部署:使用
SET NOT NULL(PostgreSQL 12+:如果沒有空值,這是即時的元數據操作)或ADD CONSTRAINT ... CHECK ... NOT VALID添加 NOT NULL 約束,再在單獨的事務中執行VALIDATE CONSTRAINT。
必須(MUST)對大型資料表使用 CREATE INDEX CONCURRENTLY(PostgreSQL)或線上 DDL 工具(MySQL)進行索引創建。 沒有 CONCURRENTLY 的 CREATE INDEX 獲取一個 SHARE 鎖,在整個建構期間阻塞所有寫入。CONCURRENTLY 允許在整個過程中進行寫入,但大約需要兩倍的時間,並且不能在事務塊中運行。gh-ost 和 pt-osc 為 MySQL 提供了等效功能。
必須(MUST)在繁忙資料表上的 DDL 語句前設置 lock_timeout。 即使是即時的 DDL 也可能在 PostgreSQL 的鎖佇列中被長時間運行的事務阻擋。在 ALTER TABLE 之前設置 SET lock_timeout = '2s' 會讓語句失敗而不是無限期等待。配合重試邏輯:如果語句因鎖逾時失敗,短暫間隔後重試。這總是比靜默阻塞所有後續查詢更好。
必須(MUST)對大型回填進行分批處理。 在單個事務中更新一億行資料表的每一行,會在修改的行上創建長達數分鐘的獨佔鎖,並生成大量的 WAL/binlog 量。相反,以有限的批次(每批 1,000–10,000 行)進行更新,批次之間有短暫睡眠,以允許正常流量繼續進行。以主鍵順序處理行以提高局部性:
-- 範例:分批從 email 回填 new_email
DO $$
DECLARE
last_id BIGINT := 0;
batch_size INT := 5000;
rows_updated INT;
BEGIN
LOOP
UPDATE users
SET new_email = email
WHERE id > last_id
AND id <= last_id + batch_size
AND new_email IS NULL;
GET DIAGNOSTICS rows_updated = ROW_COUNT;
EXIT WHEN rows_updated = 0;
last_id := last_id + batch_size;
PERFORM pg_sleep(0.05); -- 批次之間 50ms 暫停
END LOOP;
END $$;應該(SHOULD)在新程式碼版本部署之前執行遷移腳本,而非之後。 標準滾動部署順序是:執行遷移(必須與當前運行的程式碼向後相容),然後部署新程式碼。這意味著部署管道中的每個遷移腳本必須可以被舊程式碼版本讀取。如果遷移移除了舊程式碼讀取的欄位,舊 Pod 在新 Pod 就緒之前就會失敗。舊程式碼必須能夠忽略或不引用正在移除的欄位。
應該(SHOULD)對大型資料表添加檢查約束時使用 NOT VALID + VALIDATE CONSTRAINT(PostgreSQL)。 ALTER TABLE ... ADD CONSTRAINT ... CHECK (...) 在持有 ACCESS EXCLUSIVE 的情況下掃描整個資料表。ALTER TABLE ... ADD CONSTRAINT ... CHECK (...) NOT VALID 標記約束並立即只驗證新行(短暫鎖),然後 ALTER TABLE ... VALIDATE CONSTRAINT ... 以只需要 SHARE UPDATE EXCLUSIVE 鎖的方式驗證現有行——允許並發讀取和大多數寫入。
視覺說明
常見遷移步驟
重命名欄位(3 次部署)
| 部署 | Schema 變更 | 程式碼變更 |
|---|---|---|
| 1 | 添加 new_name 欄位(可空) | 同時寫入 old_name 和 new_name;讀取 new_name ?? old_name |
| — | 後台任務從 old_name 回填 new_name | — |
| 2 | 對 new_name 添加 NOT NULL 約束(回填完成後) | 僅讀取和寫入 new_name |
| 3 | 刪除 old_name 欄位 | 程式碼已移除引用 |
對現有欄位添加 NOT NULL(2 次部署)
| 部署 | Schema 變更 | 程式碼變更 |
|---|---|---|
| 1 | (無) | 在所有寫入時填充欄位;在應用層拒絕空值 |
| — | 後台任務回填現有空值 | — |
| 2 | SET NOT NULL(PostgreSQL 12+:如果沒有空值,即時元數據操作) | (無) |
將一個欄位分割為兩個(3 次部署)
| 部署 | Schema 變更 | 程式碼變更 |
|---|---|---|
| 1 | 添加 col_a、col_b(可空) | 同時寫入舊 col 和新 col_a/col_b;讀取新欄位並回退 |
| — | 後台任務從 col 回填 col_a、col_b | — |
| 2 | 對 col_a、col_b 設置 NOT NULL | 僅讀取/寫入 col_a/col_b |
| 3 | 刪除 col | 程式碼已移除引用 |
相關 BEE
- BEE-6007 -- 資料庫遷移:涵蓋遷移工具(Flyway、Liquibase、Alembic)和版本控制 Schema 變更的基礎;本文以零停機技術擴展了它
- BEE-7003 -- Schema 演進與向後相容性:定義向後相容和不向後相容的變更類型;這些分類直接決定需要哪些展開-收縮階段
- BEE-16002 -- 部署策略:滾動部署是展開-收縮解決的時間重疊問題的根本原因;藍綠部署簡化了 Schema 遷移但需要資料庫連接切換
- BEE-19028 -- Fencing Token:與應用寫入並發運行的回填任務必須是冪等的;BEE-8005 中的冪等性技術直接適用
參考資料
- Parallel Change -- Martin Fowler (2014)
- gh-ost: GitHub's Online Schema Migration Tool -- GitHub Engineering Blog (2016)
- pt-online-schema-change -- Percona Toolkit Documentation
- Schema Changes and PostgreSQL Lock Queue -- Xata Engineering
- Backward Compatible Database Changes -- PlanetScale
- pgroll: Zero-Downtime PostgreSQL Migrations -- Xata Engineering