Schema 遷移工具完全比較:Flyway、Alembic、Prisma、TypeORM 與零停機遷移策略 | PostgreSQL
Schema 遷移工具 是管理資料庫結構版本演進的核心基礎設施。本文完整比較 Flyway、Liquibase、Alembic、Django Migrations、TypeORM、Prisma Migrate、sqitch 七大工具,深入解析 版本化遷移 與 狀態比對 兩大策略,並以 Expand-Contract 模式實戰 零停機遷移。
為什麼需要 Schema 遷移工具?
手動在資料庫上執行 DDL(Data Definition Language)語句有五大風險:
| 風險 | 說明 |
|---|---|
| 狀態不一致 | 開發機與生產環境的 Schema 悄悄分歧 |
| 無法回滾 | 不知道上一個版本的 Schema 長什麼樣 |
| 無法審計 | 不知道誰、在何時做了哪個變更 |
| 並行衝突 | 多位工程師同時修改 Schema 互相干擾 |
| 環境差異 | 新人搭建環境時需要手動猜測正確順序 |
Schema 遷移工具通過「版本化變更」解決以上問題:每次 Schema 變更被捕捉為一個有序的遷移檔案,工具追蹤哪些遷移已被執行,並能按順序應用或回滾。
兩大遷移策略
| 策略 | 代表工具 | 核心概念 | 優點 | 缺點 |
|---|---|---|---|---|
| 版本化遷移 | Flyway、Liquibase、sqitch | 每次變更是有序的遷移腳本,按版本號依序執行 | 完整歷史、精確控制、可回滾 | 遷移檔案累積、合併衝突需手動解決 |
| 狀態比對 | Prisma Migrate | 比對「目標 Schema 定義」與「目前資料庫狀態」的差異,自動產生 SQL | 開發便捷、Schema 定義即文件 | 複雜變更(如資料遷移)難以自動化 |
| 混合模式 | Alembic、TypeORM、Django | 提供兩種模式或自動產生後允許手動編輯 | 靈活度高 | 學習曲線較陡 |
所有工具都在資料庫中維護一個「遷移歷史表」(如 flyway_schema_history、alembic_version),記錄已執行的遷移版本。
Flyway(Java 生態首選)
Flyway 是 Java 生態最廣泛使用的遷移工具,以 SQL 腳本為核心,版本化命名規則嚴格。
檔案命名規則
V{版本}__{描述}.sql — 版本化遷移(不可修改)
U{版本}__{描述}.sql — 復原腳本(Undo,付費功能)
R__{描述}.sql — 可重複遷移(每次內容改變時重新執行)
範例:
V1__create_users_table.sql
V2__add_email_to_users.sql
V2.1__add_email_index.sql
R__create_reporting_views.sql
典型遷移腳本
-- V1__create_users_table.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users (email);
COMMENT ON TABLE users IS '使用者帳號主表';
-- V2__add_user_profile.sql
ALTER TABLE users
ADD COLUMN display_name VARCHAR(100),
ADD COLUMN avatar_url TEXT,
ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE;
-- V3__create_posts_table.sql
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
author_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
content TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'published', 'archived')),
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_posts_author_id ON posts (author_id);
CREATE INDEX idx_posts_status ON posts (status) WHERE status = 'published';
Spring Boot 整合
# application.yml
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: false
validate-on-migrate: true
out-of-order: false
table: flyway_schema_history
常用命令
flyway migrate # 執行所有待執行的遷移
flyway info # 查看遷移狀態
flyway validate # 驗證已執行遷移的 checksum
flyway repair # 修復失敗的遷移記錄
flyway clean # 清空整個資料庫(危險!僅用於開發)
核心限制: 已執行的遷移腳本不可修改(checksum 驗證會失敗),Undo 腳本屬付費功能。
Liquibase(跨資料庫首選)
Liquibase 支援 SQL、XML、YAML、JSON 四種格式撰寫 Changeset(變更集),對跨資料庫支援更好。
YAML 格式範例
# db/changelog/001-create-users.yaml
databaseChangeLog:
- changeSet:
id: 001
author: alice
comment: "建立使用者主表"
changes:
- createTable:
tableName: users
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: username
type: VARCHAR(50)
constraints:
nullable: false
unique: true
- column:
name: email
type: VARCHAR(255)
constraints:
nullable: false
unique: true
- changeSet:
id: 003
author: bob
comment: "新增個人資料欄位"
changes:
- addColumn:
tableName: users
columns:
- column:
name: display_name
type: VARCHAR(100)
- column:
name: is_active
type: BOOLEAN
defaultValueBoolean: true
constraints:
nullable: false
rollback:
- dropColumn:
tableName: users
columnName: display_name
- dropColumn:
tableName: users
columnName: is_active
| 特性 | Flyway | Liquibase |
|---|---|---|
| 格式支援 | 主要 SQL | SQL、YAML、XML、JSON |
| Rollback | 付費功能 | 免費支援(需手動定義) |
| 跨 DB 支援 | 中等 | 優秀(抽象化 DDL) |
| 學習曲線 | 較低 | 較高 |
| 命名方式 | 版本號排序 | Changeset ID(不需排序) |
Alembic(Python / SQLAlchemy)
Alembic 是 Python SQLAlchemy ORM 官方推薦的遷移工具,提供 autogenerate 自動偵測 Schema 差異。
初始化與配置
pip install alembic sqlalchemy psycopg2-binary
# 初始化
alembic init alembic
# alembic/env.py(關鍵配置)
from myapp.models import Base
target_metadata = Base.metadata
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True, # 偵測欄位型別變更
compare_server_default=True # 偵測預設值變更
)
with context.begin_transaction():
context.run_migrations()
常用命令
# 自動比對模型與資料庫,生成遷移腳本
alembic revision --autogenerate -m "add posts table"
# 執行所有待執行的遷移
alembic upgrade head
# 回滾一個版本
alembic downgrade -1
# 查看當前版本
alembic current
# 查看遷移歷史
alembic history --verbose
遷移腳本範例(含資料遷移)
"""add posts table
Revision ID: ae1027a6acf
Revises: 1234567890ab
"""
from alembic import op
import sqlalchemy as sa
revision = 'ae1027a6acf'
down_revision = '1234567890ab'
def upgrade() -> None:
op.create_table(
'posts',
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column('author_id', sa.BigInteger(), nullable=False),
sa.Column('title', sa.String(500), nullable=False),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('status', sa.String(20), nullable=False, server_default='draft'),
sa.Column('created_at', sa.TIMESTAMP(timezone=True),
server_default=sa.text('NOW()'), nullable=False),
sa.ForeignKeyConstraint(['author_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.CheckConstraint(
"status IN ('draft', 'published', 'archived')",
name='posts_status_check'
)
)
op.create_index('idx_posts_author_id', 'posts', ['author_id'])
def downgrade() -> None:
op.drop_index('idx_posts_author_id')
op.drop_table('posts')
注意:
autogenerate無法自動偵測表名/欄位名重新命名、觸發器、自訂函式、部分索引條件等變更,這些需手動撰寫。
Django Migrations
Django 的內建遷移系統與 ORM 深度整合,提供最佳的 Python Web 開發體驗。
# 根據 Model 變更自動生成遷移
python manage.py makemigrations
# 執行所有待執行的遷移
python manage.py migrate
# 查看遷移生成的 SQL(不執行)
python manage.py sqlmigrate myapp 0003
# 查看遷移狀態
python manage.py showmigrations
RunSQL 與 RunPython
# 在遷移中執行任意 SQL
migrations.RunSQL(
sql="ALTER TABLE posts ADD COLUMN search_vector tsvector",
reverse_sql="ALTER TABLE posts DROP COLUMN search_vector"
),
# 在遷移中執行 Python 邏輯(資料遷移)
def populate_full_name(apps, schema_editor):
User = apps.get_model('myapp', 'User')
for user in User.objects.all():
user.full_name = f"{user.first_name} {user.last_name}".strip()
user.save(update_fields=['full_name'])
migrations.RunPython(
populate_full_name,
reverse_code=migrations.RunPython.noop
),
TypeORM Migrations(Node.js/TypeScript)
// DataSource 配置
export const AppDataSource = new DataSource({
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT ?? '5432'),
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: ['src/entities/**/*.ts'],
migrations: ['src/migrations/**/*.ts'],
synchronize: false, // 生產環境絕對不要設為 true
});
# 比對 Entity 與資料庫,自動生成遷移
npx typeorm migration:generate src/migrations/AddPostsTable -d ormconfig.ts
# 執行遷移
npx typeorm migration:run -d ormconfig.ts
# 回滾最後一個遷移
npx typeorm migration:revert -d ormconfig.ts
重要警告: 生產環境嚴禁使用
synchronize: true——它會在每次應用啟動時自動同步 Schema,可能導致資料遺失。
Prisma Migrate(宣告式 Schema)
Prisma 以宣告式 schema.prisma 為核心,自動產生遷移 SQL。
// prisma/schema.prisma
model User {
id BigInt @id @default(autoincrement())
username String @unique @db.VarChar(50)
email String @unique @db.VarChar(255)
displayName String? @db.VarChar(100) @map("display_name")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
posts Post[]
@@map("users")
}
model Post {
id BigInt @id @default(autoincrement())
authorId BigInt @map("author_id")
title String @db.VarChar(500)
content String?
status String @default("draft") @db.VarChar(20)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
@@index([authorId], name: "idx_posts_author_id")
@@map("posts")
}
# 開發環境:生成遷移並套用
npx prisma migrate dev --name add-posts-table
# 生產環境:只套用已存在的遷移
npx prisma migrate deploy
# 查看遷移狀態
npx prisma migrate status
Prisma 的限制: 無法直接管理 Stored Procedure、Trigger、自訂函式、部分索引、物化視圖——需在產生的 migration.sql 中手動追加。
sqitch(語言無關的變更集模式)
sqitch 以「變更集(Change)」而非版本號為核心,強調變更之間的依賴關係。
# 初始化
sqitch init myproject --engine pg
# 新增一個變更(生成 deploy/revert/verify 三個檔案)
sqitch add create-users-table -n '建立使用者主表'
Deploy / Revert / Verify 三件組
-- deploy/create-users-table.sql
BEGIN;
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMIT;
-- revert/create-users-table.sql
BEGIN;
DROP TABLE IF EXISTS users CASCADE;
COMMIT;
-- verify/create-users-table.sql
SELECT id, username, email, created_at
FROM users
WHERE FALSE; -- 只驗證結構,不查詢資料
sqitch deploy db:pg://user:pass@localhost/mydb # 部署
sqitch revert db:pg://user:pass@localhost/mydb # 回滾
sqitch verify db:pg://user:pass@localhost/mydb # 驗證
sqitch status db:pg://user:pass@localhost/mydb # 狀態
七大工具完整比較
| 維度 | Flyway | Liquibase | Alembic | Django | TypeORM | Prisma | sqitch |
|---|---|---|---|---|---|---|---|
| 語言生態 | JVM | JVM | Python | Python/Django | Node.js/TS | Node.js/TS | 語言無關 |
| 撰寫格式 | SQL | SQL/YAML/XML | Python | Python | TypeScript | Schema 定義+SQL | SQL |
| 自動生成 | 否 | 否 | 是 | 是 | 是 | 是 | 否 |
| 回滾支援 | 付費 | 免費 | 手動 | 手動 | 手動 | 不支援 | 內建 |
| 驗證腳本 | 否 | 否 | 否 | 否 | 否 | 否 | 內建 |
| 跨資料庫 | 中等 | 優秀 | 中等 | 中等 | 好 | 好 | 好 |
選型決策流程
1. 你的主要技術棧是什麼?
Java/Spring Boot → Flyway(首選)或 Liquibase
Python/Django → Django Migrations(內建)
Python/FastAPI → Alembic
Node.js + Prisma ORM → Prisma Migrate
Node.js + TypeORM → TypeORM Migrations
語言無關/多語言 → sqitch 或 Flyway
2. 你需要自動偵測 Schema 差異嗎?
需要 → Alembic、Django、TypeORM、Prisma
不需要 → Flyway、sqitch
3. 你的 Schema 複雜度如何?
簡單 DDL → 所有工具均可
觸發器/函式 → Flyway 或 sqitch(純 SQL 控制)
含資料遷移 → Alembic 或 Django(Python 邏輯)
Zero-Downtime Migration:Expand-Contract 模式
在高可用系統中,Schema 遷移不能導致服務停機。Expand-Contract 模式 分三階段進行:
傳統做法(有停機風險):
1. 停止服務
2. 執行遷移(如重新命名欄位)
3. 部署新版應用程式
4. 啟動服務
→ 步驟 2-3 之間有不一致窗口
Expand-Contract 模式(零停機):
階段 1(Expand/擴張):
- 新增新欄位,舊欄位保留
- 部署「同時寫入新舊欄位」的應用程式版本
- 資料回填
階段 2(驗證期):
- 確認新欄位資料完整性
- 監控錯誤率(持續數天到數週)
階段 3(Contract/收縮):
- 部署「只讀寫新欄位」的應用程式版本
- 刪除舊欄位
實際範例:重新命名欄位
-- ========= 階段 1:Expand =========
-- 步驟 1a:新增新欄位
ALTER TABLE users ADD COLUMN username VARCHAR(50);
-- 步驟 1b:分批回填(避免長時間鎖定大表)
DO $$
DECLARE
batch_size INT := 1000;
last_id BIGINT := 0;
max_id BIGINT;
BEGIN
SELECT MAX(id) INTO max_id FROM users;
WHILE last_id < max_id LOOP
UPDATE users
SET username = user_name
WHERE id > last_id
AND id <= last_id + batch_size
AND username IS NULL;
last_id := last_id + batch_size;
PERFORM pg_sleep(0.01);
END LOOP;
END;
$$;
-- 步驟 1c:CONCURRENTLY 建索引(不鎖定讀寫)
CREATE UNIQUE INDEX CONCURRENTLY idx_users_username ON users (username);
-- 步驟 1d:部署 v2(同時讀寫 user_name 與 username)
-- ========= 階段 2:驗證期 =========
SELECT COUNT(*) FROM users WHERE username IS DISTINCT FROM user_name;
-- 應為 0
-- ========= 階段 3:Contract =========
-- 步驟 3a:部署 v3(只讀寫 username)
-- 步驟 3b:加上 NOT NULL 約束(分兩步驟避免全表鎖)
ALTER TABLE users
ADD CONSTRAINT users_username_not_null
CHECK (username IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_username_not_null;
ALTER TABLE users ALTER COLUMN username SET NOT NULL;
-- 步驟 3c:刪除舊欄位
ALTER TABLE users DROP COLUMN user_name;
ALTER TABLE users DROP CONSTRAINT users_username_not_null;
DDL 鎖管理最佳實踐
Schema 遷移的許多 DDL 操作需要 AccessExclusiveLock(排他鎖),這會阻塞所有讀寫操作:
高風險 DDL(需要長時間鎖)
-- PG<11 全表重寫
ALTER TABLE large_table ADD COLUMN new_col TEXT DEFAULT 'value';
-- 全表重寫
ALTER TABLE large_table ALTER COLUMN col TYPE new_type;
-- 非 CONCURRENTLY 模式
CREATE INDEX ON large_table (col);
低風險 DDL(安全替代方案)
-- PG 11+:ADD COLUMN 有預設值不再需要全表重寫(瞬間完成)
ALTER TABLE large_table ADD COLUMN new_col TEXT DEFAULT 'safe';
-- CONCURRENTLY 建立索引(不鎖定讀寫)
CREATE INDEX CONCURRENTLY idx_name ON large_table (col);
-- NOT VALID + VALIDATE 分離加約束(不全表鎖)
ALTER TABLE large_table
ADD CONSTRAINT col_not_null CHECK (col IS NOT NULL) NOT VALID;
ALTER TABLE large_table VALIDATE CONSTRAINT col_not_null;
-- 外鍵也用 NOT VALID + VALIDATE
ALTER TABLE posts
ADD CONSTRAINT fk_author FOREIGN KEY (author_id) REFERENCES users(id)
NOT VALID;
ALTER TABLE posts VALIDATE CONSTRAINT fk_author;
鎖等待超時設置
-- 在遷移腳本開頭設置,避免遷移長時間等待鎖
SET lock_timeout = '3s';
SET statement_timeout = '60s';
ALTER TABLE users ADD COLUMN new_col TEXT;
RESET lock_timeout;
RESET statement_timeout;
多人協作的版本衝突處理
場景:Alice 和 Bob 同時建立 V5 遷移,合併時衝突
解決策略 1(時間戳記版本號):
使用毫秒時間戳記,衝突機率極低
V20260722103000001__add_column_a.sql
V20260722143000002__add_table_b.sql
解決策略 2(版本號分段):
每位工程師分配版本號段
Alice: V1xxx__...
Bob: V2xxx__...
解決策略 3(sqitch 依賴宣告):
不依賴順序,通過依賴關係解決
sqitch add add-column-a --requires create-users-table
解決策略 4(分支策略):
遷移只在 main 分支合併後確定最終版本號
開發分支使用臨時名稱
遷移測試最佳實踐
# 使用 Testcontainers 進行遷移測試
from testcontainers.postgres import PostgresContainer
from alembic import command
from alembic.config import Config
def test_migrations_up_and_down():
with PostgresContainer("postgres:16") as pg:
alembic_cfg = Config("alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", pg.get_connection_url())
# 測試升級
command.upgrade(alembic_cfg, "head")
# 測試降級(回滾所有)
command.downgrade(alembic_cfg, "base")
# 再次升級,確保冪等性
command.upgrade(alembic_cfg, "head")
生產環境安全檢查清單
| 檢查項目 | 說明 |
|---|---|
synchronize: false | TypeORM 生產環境必須關閉自動同步 |
使用 migrate deploy | Prisma 生產環境不要用 migrate dev |
hbm2ddl.auto=validate | Hibernate 只驗證不修改 |
設定 lock_timeout | 每個遷移腳本設定鎖等待超時 |
CREATE INDEX CONCURRENTLY | 大表建索引必須使用 CONCURRENTLY |
| 先在 staging 測試 | 所有遷移先在 staging 環境驗證 |
| 備份再遷移 | 生產遷移前先做資料庫備份 |
| 監控遷移時間 | 記錄每次遷移的執行時間以預測生產耗時 |
總結
選擇 Schema 遷移工具的關鍵在於:
- 匹配技術棧——Java 用 Flyway、Python 用 Alembic/Django、Node.js 用 Prisma/TypeORM、語言無關用 sqitch
- 版本化遷移 vs 狀態比對——複雜系統建議版本化遷移(完整歷史、精確控制),快速原型開發可用狀態比對
- 零停機遷移——使用 Expand-Contract 模式,分批回填、CONCURRENTLY 建索引、NOT VALID + VALIDATE 加約束
- 鎖管理——永遠設定
lock_timeout,大表 DDL 使用安全替代方案 - 測試遷移——用 Testcontainers 在 CI 中測試 upgrade + downgrade 完整週期
下一篇,我們將探討 ORM 整合實戰——從 SQLAlchemy、Django ORM 到 Prisma,掌握 PostgreSQL 進階型別映射與查詢最佳化的實戰技巧。