資料庫遷移模式 是做什麼的?
安全、可逆的資料庫綱要變更,專為生產系統設計。
啟用時機
- 建立或修改資料庫資料表
- 新增/移除欄位或索引
- 執行資料遷移(回溯填補、轉換)
- 規劃零停機的綱要變更
- 為新專案設定遷移工具
核心原則
- 每個變更都是一次遷移 — 絕不直接手動修改生產資料庫
- 遷移在生產環境中僅向前推進 — 回滾使用新的向前遷移
- 綱要遷移與資料遷移應分開 — 絕不在一個遷移中混合 DDL 和 DML
- 在與生產規模相當的資料上測試遷移 — 可在 100 行上運作的遷移,可能在 1000 萬行上造成鎖定
- 一旦部署,遷移即不可變 — 絕不編輯已在生產環境中執行過的遷移
遷移安全檢查清單
在套用任何遷移之前:
- 遷移同時包含 UP 和 DOWN(或明確標記為不可逆)
- 大型資料表上無全表鎖(使用並行操作)
- 新欄位具有預設值或可為 null(絕不新增 NOT NULL 而無預設值)
- 索引已並行建立(不與現有資料表的 CREATE TABLE 內嵌使用)
- 資料回溯填補是與綱要變更分開的遷移
- 已針對生產資料的副本進行測試
- 回滾計畫已記錄
PostgreSQL 模式
安全新增欄位
-- 良好:可為 null 的欄位,無鎖
ALTER TABLE users ADD COLUMN avatar_url TEXT;
-- 良好:含預設值的欄位(Postgres 11+ 立即完成,無需重寫)
ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT true;
-- 不良:在現有資料表上 NOT NULL 卻無預設值(需要完整重寫)
ALTER TABLE users ADD COLUMN role TEXT NOT NULL;
-- 這會鎖定資料表並重寫每一行
無停機建立索引
-- 不良:在大型資料表上會阻塞寫入
CREATE INDEX idx_users_email ON users (email);
-- 良好:非阻塞,允許並行寫入
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);
-- 注意:CONCURRENTLY 不能在交易區塊內執行
-- 大多數遷移工具需要特殊處理
重新命名欄位(零停機)
絕不在生產環境中直接重新命名。使用擴展-收縮模式:
-- 步驟 1:新增新欄位(遷移 001)
ALTER TABLE users ADD COLUMN display_name TEXT;
-- 步驟 2:回溯填補資料(遷移 002,資料遷移)
UPDATE users SET display_name = username WHERE display_name IS NULL;
-- 步驟 3:更新應用程式程式碼以同時讀寫兩個欄位
-- 部署應用程式變更
-- 步驟 4:停止寫入舊欄位,並刪除它(遷移 003)
ALTER TABLE users DROP COLUMN username;
安全移除欄位
-- 步驟 1:移除所有對該欄位的應用程式參照
-- 步驟 2:部署不包含該欄位參照的應用程式
-- 步驟 3:在下一次遷移中刪除欄位
ALTER TABLE orders DROP COLUMN legacy_status;
-- 對於 Django:使用 SeparateDatabaseAndState 從模型移除
-- 而不產生 DROP COLUMN(然後在下次遷移中刪除)
大型資料遷移
-- 不良:在單一交易中更新所有行(鎖定資料表)
UPDATE users SET normalized_email = LOWER(email);
-- 良好:批次更新並顯示進度
DO $$
DECLARE
batch_size INT := 10000;
rows_updated INT;
BEGIN
LOOP
UPDATE users
SET normalized_email = LOWER(email)
WHERE id IN (
SELECT id FROM users
WHERE normalized_email IS NULL
LIMIT batch_size
FOR UPDATE SKIP LOCKED
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
RAISE NOTICE 'Updated % rows', rows_updated;
EXIT WHEN rows_updated = 0;
COMMIT;
END LOOP;
END $$;
Prisma(TypeScript/Node.js)
工作流程
# 從綱要變更建立遷移
npx prisma migrate dev --name add_user_avatar
# 在生產環境中套用待處理的遷移
npx prisma migrate deploy
# 重設資料庫(僅限開發環境)
npx prisma migrate reset
# 在綱要變更後產生客戶端
npx prisma generate
綱要範例
model User {
id String @id @default(cuid())
email String @unique
name String?
avatarUrl String? @map("avatar_url")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
orders Order[]
@@map("users")
@@index([email])
}
自訂 SQL 遷移
對於 Prisma 無法表達的操作(並行索引、資料回溯填補):
# 建立空的遷移,然後手動編輯 SQL
npx prisma migrate dev --create-only --name add_email_index
-- migrations/20240115_add_email_index/migration.sql
-- Prisma 無法產生 CONCURRENTLY,因此我們手動撰寫
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON users (email);
Drizzle(TypeScript/Node.js)
工作流程
# 從綱要變更產生遷移
npx drizzle-kit generate
# 套用遷移
npx drizzle-kit migrate
# 直接推送綱要(僅限開發環境,無遷移檔案)
npx drizzle-kit push
綱要範例
import { pgTable, text, timestamp, uuid, boolean } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull().unique(),
name: text("name"),
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
Kysely(TypeScript/Node.js)
工作流程(kysely-ctl)
# 初始化設定檔(kysely.config.ts)
kysely init
# 建立新的遷移檔案
kysely migrate make add_user_avatar
# 套用所有待處理的遷移
kysely migrate latest
# 回滾最後一個遷移
kysely migrate down
# 顯示遷移狀態
kysely migrate list
遷移檔案
// migrations/2024_01_15_001_create_user_profile.ts
import { type Kysely, sql } from 'kysely'
// 重要:始終使用 Kysely<any>,而非您型別化的 DB 介面。
// 遷移在時間點上是凍結的,不得依賴當前的綱要型別。
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('user_profile')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('email', 'varchar(255)', (col) => col.notNull().unique())
.addColumn('avatar_url', 'text')
.addColumn('created_at', 'timestamp', (col) =>
col.defaultTo(sql`now()`).notNull()
)
.execute()
await db.schema
.createIndex('idx_user_profile_avatar')
.on('user_profile')
.column('avatar_url')
.execute()
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('user_profile').execute()
}
程式化遷移器
import { Migrator, FileMigrationProvider } from 'kysely'
import { promises as fs } from 'fs'
import * as path from 'path'
// 僅限 ESM — CJS 可直接使用 __dirname
import { fileURLToPath } from 'url'
const migrationFolder = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'./migrations',
)
// `db` 是您的 Kysely<any> 資料庫實例
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder,
}),
// 警告:僅在開發環境中啟用。停用時間戳記排序驗證,
// 可能導致環境間的綱要漂移。
// allowUnorderedMigrations: true,
})
const { error, results } = await migrator.migrateToLatest()
results?.forEach((it) => {
if (it.status === 'Success') {
console.log(`migration "${it.migrationName}" executed successfully`)
} else if (it.status === 'Error') {
console.error(`failed to execute migration "${it.migrationName}"`)
}
})
if (error) {
console.error('migration failed', error)
process.exit(1)
}
Django(Python)
工作流程
# 從模型變更產生遷移
python manage.py makemigrations
# 套用遷移
python manage.py migrate
# 顯示遷移狀態
python manage.py showmigrations
# 為自訂 SQL 產生空遷移
python manage.py makemigrations --empty app_name -n description
資料遷移
from django.db import migrations
def backfill_display_names(apps, schema_editor):
User = apps.get_model("accounts", "User")
batch_size = 5000
users = User.objects.filter(display_name="")
while users.exists():
batch = list(users[:batch_size])
for user in batch:
user.display_name = user.username
User.objects.bulk_update(batch, ["display_name"], batch_size=batch_size)
def reverse_backfill(apps, schema_editor):
pass # 資料遷移,無需反向操作
class Migration(migrations.Migration):
dependencies = [("accounts", "0015_add_display_name")]
operations = [
migrations.RunPython(backfill_display_names, reverse_backfill),
]
SeparateDatabaseAndState
從 Django 模型中移除欄位,而不立即從資料庫中刪除:
class Migration(migrations.Migration):
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.RemoveField(model_name="user", name="legacy_field"),
],
database_operations=[], # 暫時不碰資料庫
),
]
golang-migrate(Go)
工作流程
# 建立遷移配對
migrate create -ext sql -dir migrations -seq add_user_avatar
# 套用所有待處理的遷移
migrate -path migrations -database "$DATABASE_URL" up
# 回滾最後一個遷移
migrate -path migrations -database "$DATABASE_URL" down 1
# 強制版本(修復骯髒狀態)
migrate -path migrations -database "$DATABASE_URL" force VERSION
遷移檔案
-- migrations/000003_add_user_avatar.up.sql
ALTER TABLE users ADD COLUMN avatar_url TEXT;
CREATE INDEX CONCURRENTLY idx_users_avatar ON users (avatar_url) WHERE avatar_url IS NOT NULL;
-- migrations/000003_add_user_avatar.down.sql
DROP INDEX IF EXISTS idx_users_avatar;
ALTER TABLE users DROP COLUMN IF EXISTS avatar_url;
零停機遷移策略
對於關鍵的生產環境變更,請遵循擴展-收縮模式:
階段 1:擴展
- 新增新欄位/資料表(可為 null 或含預設值)
- 部署:應用程式同時寫入舊欄位和新欄位
- 回溯填補既有資料
階段 2:遷移
- 部署:應用程式從新欄位讀取,並同時寫入兩者
- 驗證資料一致性
階段 3:收縮
- 部署:應用程式僅使用新欄位
- 在單獨的遷移中刪除舊欄位/資料表
時間軸範例
第 1 天:遷移新增 new_status 欄位(可為 null)
第 1 天:部署應用程式 v2 — 同時寫入 status 和 new_status
第 2 天:為既有資料列執行回溯填補遷移
第 3 天:部署應用程式 v3 — 僅從 new_status 讀取
第 7 天:遷移刪除舊的 status 欄位
反模式
| 反模式 | 為何失敗 | 更好的方法 |
|---|---|---|
| 在生產環境中手動執行 SQL | 無審計軌跡,不可重複 | 始終使用遷移檔案 |
| 編輯已部署的遷移 | 導致環境間漂移 | 改為建立新的遷移 |
| NOT NULL 無預設值 | 鎖定資料表,重寫所有資料列 | 先設為可為 null,回溯填補,再加入約束 |
| 在大型資料表上內嵌建立索引 | 建置期間阻塞寫入 | CREATE INDEX CONCURRENTLY |
| 綱要與資料混合在一個遷移中 | 難以回滾,交易時間長 | 分開的遷移 |
| 在移除程式碼前刪除欄位 | 應用程式因缺少欄位而報錯 | 先移除程式碼,下次部署再刪除欄位 |