為什麼 Migration 比你想的難

加一個有預設值的欄位——聽起來很簡單,但在一個每分鐘有幾百個請求的系統上,ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'user' 這行 SQL 在 PostgreSQL 裡會鎖 table,鎖住的期間所有 INSERT / UPDATE 都被擋住,你的 API 就 timeout 了。

Migration 的難點不是 SQL 寫法,而是:你的 app 要在 migration 執行期間繼續接請求,而 migration 改變了 app 依賴的 schema


Migration 的四個風險類型

類型一:Table lock

DDL 操作(加欄位、改型別、加 index)在大型 table 上會鎖住整個 table。解法是:

  • PostgreSQL:用 CREATE INDEX CONCURRENTLY(不鎖 table)、加欄位時先加 nullable 再補資料再加 NOT NULL constraint
  • MySQL:用 pt-online-schema-changegh-ost,它們會複製 table 而不是直接 ALTER

類型二:舊版 App 遇到新 Schema

Rolling update 期間,舊 Pod 和新 Pod 同時跑。如果你先跑 migration 再部署 app,舊 Pod 就會遇到它不認識的新欄位或被刪掉的欄位。

類型三:新版 App 遇到舊 Schema

如果你先部署 app 再跑 migration,新 Pod 的 code 期待新欄位但 schema 還是舊的,同樣炸掉。

類型四:Migration 執行失敗

Migration 跑到一半失敗,你的 schema 是半完成狀態。能不能 rollback?能不能重跑?


Expand/Contract Pattern:零停機 Migration 的核心策略

解零停機 migration 的標準方法是 Expand/Contract(也叫 Parallel Change),把一個 schema 變更拆成三個階段:

階段一:Expand(擴張)

只加東西,不刪東西,保持向後相容。

例:把 user_name 欄位改名為 full_name

-- Migration 1:只加新欄位
ALTER TABLE users ADD COLUMN full_name VARCHAR(200);
 
-- App code:同時寫兩個欄位,讀優先讀新欄位
UPDATE users SET full_name = user_name;  -- 或在 app 層做

此時舊 Pod 繼續用 user_name,新 Pod 同時寫 full_name + user_name(保持舊 Pod 不炸)。

階段二:Migrate(遷移)

確認所有流量都走新 Pod(舊 Pod 已下線),全部資料都已同步到新欄位,應用層停止寫舊欄位。

階段三:Contract(收縮)

確認新 code 完全上線、資料已遷移完成,再跑最後的 migration 刪掉舊欄位:

-- Migration 2:確認舊欄位沒人用了才刪
ALTER TABLE users DROP COLUMN user_name;

這個 pattern 讓每個 migration 都是向後相容的,rolling update 不會炸。


何時跑 Migration

錯誤做法:app 啟動時自動跑

// ❌ 危險:多個 Pod 同時啟動 → race condition
async function bootstrap() {
  await dataSource.runMigrations(); // 多個 Pod 同時執行
  await app.listen(3000);
}

5 個 Pod 同時啟動,5 個 Pod 同時跑 migration,PostgreSQL 鎖衝突,然後各種奇怪的狀態。

正確做法:Migration 是獨立的部署步驟

# K8s Job:在 app deploy 之前跑
apiVersion: batch/v1
kind: Job
metadata:
  name: db-migration
spec:
  template:
    spec:
      containers:
        - name: migrate
          image: your-app:latest
          command: ["node", "dist/bin/migrate.js"]
      restartPolicy: Never

CI/CD pipeline 順序:

  1. Build image
  2. Run migration Job(等它完成)
  3. Deploy app(rolling update)

Migration Job 結束後才開始 rolling update,不會有 race condition。


Rollback 策略

能 rollback 的 migration:加欄位、加 table、加 index。這些操作都有對應的反向操作(DROP COLUMN / DROP TABLE / DROP INDEX)。

很難 rollback 的 migration:刪欄位、刪 table、改資料型別(VARCHAR → INT 可能有資料丟失)。一旦資料被刪或型別被改,rollback 要靠備份。

實務建議

  1. Production migration 前,備份(snapshot / pg_dump)
  2. 優先使用 Expand/Contract,讓「rollback = 只 rollback app code,不 rollback schema」
  3. 把「刪欄位」的 migration 推遲到確認舊 code 完全下線之後再跑

各框架工具對照

框架Migration 工具特點
Express + Sequelizesequelize-clinpx sequelize db:migrate / db:migrate:undo;migration 檔是 JS/TS,up/down 方法
FastAPI + SQLAlchemyAlembicauto-generate(alembic revision --autogenerate);支援 online / offline mode
NestJS + TypeORMTypeORM CLI和 Sequelize-cli 類似;也支援 auto-generate
Spring BootFlyway / LiquibaseFlyway:SQL 腳本按版本號跑;Liquibase:XML/YAML 描述變更,可多 DB 支援
LaravelArtisan Migrationphp artisan migrate / rollback;內建 schema builder
RailsActiveRecord Migration最成熟的 migration 生態,rails db:migrate / rollback

Flyway vs Liquibase(Spring 生態常見選擇)

Flyway 更簡單——你就是寫 SQL 腳本(V1__init.sqlV2__add_role.sql),Flyway 按版本號跑。Liquibase 更複雜,用 XML/YAML 描述變更,可以針對不同 DB 生成不同 SQL,適合需要支援多個資料庫的系統。大多數場景 Flyway 就夠了。


Migration 時機的決策原則

這個 migration 會鎖 table 嗎?
  → 是,且 table 很大(> 100 萬行)
  → 用 CONCURRENTLY(PostgreSQL)或線上 schema change 工具

這個 migration 刪除或改名欄位/table?
  → 用 Expand/Contract,分多個 deploy 完成

這個 migration 在 rolling update 期間,新舊 Pod 會同時存在嗎?
  → 用 Expand/Contract,保持向後相容

只是加一個 nullable 欄位或新 table?
  → 直接在 deploy 前跑,安全

延伸閱讀