Schema 遷移工具完全比較:Flyway、Alembic、Prisma、TypeORM 與零停機遷移策略 | PostgreSQL

2026/07/22
Schema 遷移工具完全比較:Flyway、Alembic、Prisma、TypeORM 與零停機遷移策略 | PostgreSQL

Schema 遷移工具 是管理資料庫結構版本演進的核心基礎設施。本文完整比較 FlywayLiquibaseAlembicDjango MigrationsTypeORMPrisma Migratesqitch 七大工具,深入解析 版本化遷移狀態比對 兩大策略,並以 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_historyalembic_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
特性FlywayLiquibase
格式支援主要 SQLSQL、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   # 狀態

七大工具完整比較

維度FlywayLiquibaseAlembicDjangoTypeORMPrismasqitch
語言生態JVMJVMPythonPython/DjangoNode.js/TSNode.js/TS語言無關
撰寫格式SQLSQL/YAML/XMLPythonPythonTypeScriptSchema 定義+SQLSQL
自動生成
回滾支援付費免費手動手動手動不支援內建
驗證腳本內建
跨資料庫中等優秀中等中等

選型決策流程

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: falseTypeORM 生產環境必須關閉自動同步
使用 migrate deployPrisma 生產環境不要用 migrate dev
hbm2ddl.auto=validateHibernate 只驗證不修改
設定 lock_timeout每個遷移腳本設定鎖等待超時
CREATE INDEX CONCURRENTLY大表建索引必須使用 CONCURRENTLY
先在 staging 測試所有遷移先在 staging 環境驗證
備份再遷移生產遷移前先做資料庫備份
監控遷移時間記錄每次遷移的執行時間以預測生產耗時

總結

選擇 Schema 遷移工具的關鍵在於:

  1. 匹配技術棧——Java 用 Flyway、Python 用 Alembic/Django、Node.js 用 Prisma/TypeORM、語言無關用 sqitch
  2. 版本化遷移 vs 狀態比對——複雜系統建議版本化遷移(完整歷史、精確控制),快速原型開發可用狀態比對
  3. 零停機遷移——使用 Expand-Contract 模式,分批回填、CONCURRENTLY 建索引、NOT VALID + VALIDATE 加約束
  4. 鎖管理——永遠設定 lock_timeout,大表 DDL 使用安全替代方案
  5. 測試遷移——用 Testcontainers 在 CI 中測試 upgrade + downgrade 完整週期

下一篇,我們將探討 ORM 整合實戰——從 SQLAlchemy、Django ORM 到 Prisma,掌握 PostgreSQL 進階型別映射與查詢最佳化的實戰技巧。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →