結論先講
資料庫遷移不是技術問題,是風險管理問題。 技術上 rename 一個欄位只要一行 SQL,但在有流量的生產環境裡,這一行 SQL 可以讓整個服務掛 10 分鐘。這篇講的不是「怎麼寫 migration」,而是「怎麼讓 migration 不出事」。
核心原則:每次 migration 都要可以 rollback,每個步驟都要能獨立部署,永遠不要在一個 migration 裡做太多事。
Schema Migration 工具比較
主流工具一覽
| 工具 | 語言/框架 | 特色 | 學習曲線 |
|---|---|---|---|
| Alembic | Python / SQLAlchemy | 自動偵測 model 變更產生 migration | 中 |
| Django Migrations | Python / Django | 完全整合框架,自動產生 | 低 |
| TypeORM Migrations | Node.js / TypeORM | 支援自動產生,TypeScript 撰寫 | 中 |
| Prisma Migrate | Node.js / Prisma | 宣告式 schema,自動 diff | 低 |
| Flyway | Java / 多語言 | SQL 為主,版本號命名 | 低 |
| Liquibase | Java / 多語言 | XML/YAML/JSON 定義,支援回滾 | 高 |
Alembic 範例
# alembic/versions/001_add_phone_to_users.py
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('users', sa.Column('phone', sa.String(20), nullable=True))
op.create_index('idx_users_phone', 'users', ['phone'])
def downgrade():
op.drop_index('idx_users_phone', 'users')
op.drop_column('users', 'phone')Flyway 範例
-- V001__add_phone_to_users.sql
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
CREATE INDEX idx_users_phone ON users (phone);
-- U001__add_phone_to_users.sql (undo)
DROP INDEX idx_users_phone;
ALTER TABLE users DROP COLUMN phone;我的建議: 如果你用框架(Django、Rails),用框架內建的 migration。如果是多語言微服務,用 Flyway——純 SQL 最透明,任何人都看得懂。
Migration 的黃金原則
1. 永遠可逆(Always Reversible)
每個 migration 都要有 downgrade / undo。不是說你一定會用到,但它是你的安全網。
# ❌ 不可逆的 migration
def upgrade():
op.drop_column('users', 'legacy_field')
def downgrade():
raise NotImplementedError("Cannot undo") # 資料已經沒了
# ✅ 可逆的做法:先標記廢棄,過一段時間再刪
def upgrade():
op.alter_column('users', 'legacy_field', new_column_name='_deprecated_legacy_field')
def downgrade():
op.alter_column('users', '_deprecated_legacy_field', new_column_name='legacy_field')2. 永遠不破壞(Never Destructive)
在 migration 中避免這些操作:
| 危險操作 | 為什麼危險 | 安全替代方案 |
|---|---|---|
DROP COLUMN | 舊版程式碼還在讀這個欄位 | 先部署不讀的程式碼 → 再刪欄位 |
RENAME COLUMN | 等於同時 DROP + ADD | 新增欄位 → 雙寫 → 切換 → 刪舊欄位 |
ALTER TYPE | 可能鎖表很久 | 新增欄位 → 背景轉換 → 切換 |
ADD NOT NULL | 既有資料可能有 NULL | 先 SET DEFAULT + 背景填值 → 再加 NOT NULL |
3. 小步快跑(Small Increments)
# ❌ 一個大 migration
V010__refactor_entire_order_system.sql (300 行)
# ✅ 拆成多個小步驟
V010__add_new_orders_table.sql
V011__backfill_new_orders.sql
V012__add_triggers_for_dual_write.sql
V013__switch_reads_to_new_table.sql
V014__remove_dual_write_trigger.sql
V015__drop_old_orders_table.sql
零停機遷移模式
Expand-Contract Pattern(擴展-收縮模式)
這是最重要的模式,尤其適合 column rename 和 type change。
場景:把 users.name 拆成 first_name 和 last_name
Phase 1 - Expand(擴展)
├── Migration: 新增 first_name, last_name 欄位(nullable)
├── 部署: 程式碼同時寫入 name + first_name/last_name
└── 背景任務: 把既有資料拆分填入新欄位
Phase 2 - Migrate(遷移)
├── 驗證: 確認所有資料都已填入新欄位
├── 部署: 程式碼改為只讀新欄位(還是雙寫)
└── 測試: 觀察一段時間確認沒問題
Phase 3 - Contract(收縮)
├── 部署: 程式碼停止寫入 name
├── Migration: 加 NOT NULL constraint 到新欄位
└── Migration: 刪除 name 欄位(可以再等一個 sprint)
# Phase 1: Expand
def upgrade():
op.add_column('users', sa.Column('first_name', sa.String(100), nullable=True))
op.add_column('users', sa.Column('last_name', sa.String(100), nullable=True))
# Phase 1: 背景填充腳本
async def backfill_names():
batch_size = 1000
while True:
result = await db.execute("""
UPDATE users SET
first_name = split_part(name, ' ', 1),
last_name = split_part(name, ' ', 2)
WHERE first_name IS NULL
LIMIT 1000
""")
if result.rowcount == 0:
break
await asyncio.sleep(0.1) # 避免打爆 DBDual-Write Pattern(雙寫模式)
適合整張表結構大改或跨資料庫遷移:
// Node.js 雙寫範例
async function createOrder(data) {
// 寫入舊表
await oldDb.orders.create(data);
// 同時寫入新表
try {
await newDb.orders.create(transformData(data));
} catch (err) {
// 新表寫入失敗不影響主流程,但要記錄
logger.error('Dual-write failed for new orders table', err);
metrics.increment('dual_write.failure');
}
}MySQL → PostgreSQL 遷移
這是最常見的跨資料庫遷移場景。
工具選擇
| 工具 | 適用場景 | 特色 |
|---|---|---|
| pgloader | 中小型資料庫(< 100GB) | 開源、一個指令搞定 |
| AWS DMS | 大型、需要持續同步 | 支援 CDC(持續資料捕捉) |
| 手動 dump/restore | 小型資料庫 | 最可控但最麻煩 |
pgloader 範例
-- pgloader 設定檔
LOAD DATABASE
FROM mysql://root:pass@localhost/myapp
INTO postgresql://postgres:pass@localhost/myapp
WITH include drop, create tables, create indexes,
reset sequences, downcase identifiers
SET maintenance_work_mem to '512MB',
work_mem to '48MB'
CAST type int with extra auto_increment to serial,
type bigint with extra auto_increment to bigserial,
type tinyint to boolean using tinyint-to-boolean,
type datetime to timestamptz;常見地雷
| MySQL | PostgreSQL | 注意事項 |
|---|---|---|
AUTO_INCREMENT | SERIAL / GENERATED ALWAYS AS IDENTITY | pgloader 自動處理 |
TINYINT(1) | BOOLEAN | MySQL 沒有真 boolean |
DATETIME | TIMESTAMPTZ | 記得處理時區 |
ENUM('a','b','c') | CREATE TYPE ... AS ENUM | 要先建 type |
GROUP BY 可以不列所有欄位 | 必須列所有非聚合欄位 | 查詢要改 |
utf8 (3-byte) | UTF8 (真的 4-byte) | 資料不會有問題 |
ON UPDATE CURRENT_TIMESTAMP | 需要用 trigger | 沒有自動更新時間戳 |
IFNULL() | COALESCE() | 函數名不同 |
LIMIT x, y | LIMIT y OFFSET x | 語法順序不同 |
-- MySQL 語法
SELECT * FROM orders GROUP BY user_id; -- MySQL 允許(但結果不確定)
SELECT IFNULL(name, 'N/A') FROM users;
SELECT * FROM users LIMIT 10, 20;
-- PostgreSQL 語法
SELECT user_id, MAX(id) FROM orders GROUP BY user_id; -- 必須明確
SELECT COALESCE(name, 'N/A') FROM users;
SELECT * FROM users LIMIT 20 OFFSET 10;Schema Migration vs Data Migration
這是很多人搞混的概念:
| Schema Migration | Data Migration | |
|---|---|---|
| 改什麼 | 表結構、索引、約束 | 資料內容 |
| 速度 | 通常很快(DDL) | 可能很慢(大量 UPDATE) |
| 鎖表 | 部分操作會鎖(ALTER TABLE) | 不鎖表但佔用 I/O |
| 工具 | Alembic、Flyway | 自己寫腳本 |
| 可逆 | 通常可以 | 很難完全可逆 |
Data Migration 最佳實踐:
# ❌ 一次更新所有資料(鎖表 + 可能 OOM)
UPDATE users SET full_name = first_name || ' ' || last_name;
# ✅ 分批處理
import time
BATCH_SIZE = 5000
while True:
result = db.execute("""
UPDATE users SET full_name = first_name || ' ' || last_name
WHERE full_name IS NULL
AND id IN (
SELECT id FROM users WHERE full_name IS NULL LIMIT %s
)
""", [BATCH_SIZE])
if result.rowcount == 0:
break
db.commit()
time.sleep(0.5) # 給 DB 喘口氣
print(f"Updated {result.rowcount} rows")測試 Migration
Shadow Database
在 CI 裡對一個空的測試資料庫跑所有 migration,確保能完整執行:
# GitHub Actions
- name: Test migrations
run: |
# 建立測試資料庫
createdb test_migrations
# 跑所有 migration
alembic upgrade head
# 跑所有 downgrade
alembic downgrade base
# 再跑一次 upgrade 確保可重複
alembic upgrade headProduction 前的檢查清單
- [ ] 在 staging 環境跑過
- [ ] migration 可以 rollback
- [ ] 估算 migration 執行時間(對大表)
- [ ] 確認不會鎖表超過 5 秒
- [ ] 確認舊版程式碼相容新 schema
- [ ] 準備好 rollback 腳本
- [ ] 通知 on-call 工程師Rollback 策略
如果 migration 在 production 失敗了:
- 有 downgrade 腳本 → 直接跑 rollback
- 沒有 downgrade 腳本 → 手動修復(這就是為什麼要永遠寫 downgrade)
- 資料已經被改壞 → 從備份恢復(希望你有做備份)
# Alembic rollback
alembic downgrade -1 # 回退一步
# Flyway(需要 Teams 版本才有 undo)
flyway undo
# Django
python manage.py migrate myapp 0009 # 回退到指定版本Blue-Green Database 部署:
對於風險極高的遷移,可以準備兩套資料庫:
1. Green DB(新版):跑完 migration
2. Blue DB(舊版):維持原樣
3. 應用程式指向 Green DB
4. 觀察 30 分鐘
5. 沒問題 → 關閉 Blue DB
6. 有問題 → 切回 Blue DB
成本高,但對關鍵系統(金流、醫療)值得。
FAQ
Q1: Migration 檔案要不要進 git?
要。Migration 檔案就是你的 schema 變更歷史,跟程式碼一樣重要。永遠不要手動修改已經跑過的 migration 檔案。
Q2: 多人開發時 migration 衝突怎麼辦?
最常見的問題。解法:(1)用 migration 工具的 merge 功能(Alembic 的 alembic merge);(2)約定一次只有一個人在改同一張表;(3)把 migration 的產生和合併納入 code review 流程。
Q3: 大表加索引會鎖表嗎?
在 PostgreSQL 裡,CREATE INDEX 會鎖寫入。用 CREATE INDEX CONCURRENTLY 可以避免鎖表,但耗時更長且不能在 transaction 裡跑。MySQL 8.0 的 ALTER TABLE ... ADD INDEX 預設已經是線上操作(Instant DDL),影響很小。
Q4: 什麼時候該做跨資料庫遷移?
當你的需求已經超出現有資料庫的能力範圍:需要 JSONB 但在用 MySQL、需要地理查詢但沒有 PostGIS、效能瓶頸無法透過優化解決。但記住:遷移成本很高,通常要 2-6 個月,確定值得才做。
Q5: 能不能直接改 production 資料庫的 schema?
在開發環境可以。在 production 絕對不行。所有 schema 變更都要透過 migration 工具,有版本控制、可追蹤、可回滾。手動改 schema 是生產事故的第一步。