為什麼 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-change或gh-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: NeverCI/CD pipeline 順序:
- Build image
- Run migration Job(等它完成)
- 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 要靠備份。
實務建議:
- Production migration 前,備份(snapshot / pg_dump)
- 優先使用 Expand/Contract,讓「rollback = 只 rollback app code,不 rollback schema」
- 把「刪欄位」的 migration 推遲到確認舊 code 完全下線之後再跑
各框架工具對照
| 框架 | Migration 工具 | 特點 |
|---|---|---|
| Express + Sequelize | sequelize-cli | npx sequelize db:migrate / db:migrate:undo;migration 檔是 JS/TS,up/down 方法 |
| FastAPI + SQLAlchemy | Alembic | auto-generate(alembic revision --autogenerate);支援 online / offline mode |
| NestJS + TypeORM | TypeORM CLI | 和 Sequelize-cli 類似;也支援 auto-generate |
| Spring Boot | Flyway / Liquibase | Flyway:SQL 腳本按版本號跑;Liquibase:XML/YAML 描述變更,可多 DB 支援 |
| Laravel | Artisan Migration | php artisan migrate / rollback;內建 schema builder |
| Rails | ActiveRecord Migration | 最成熟的 migration 生態,rails db:migrate / rollback |
Flyway vs Liquibase(Spring 生態常見選擇):
Flyway 更簡單——你就是寫 SQL 腳本(V1__init.sql、V2__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 前跑,安全
