claude-code/claude-zh/skills/database-migrations/SKILL.md

335 lines
9.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
name: database-migrations
description: 資料庫遷移最佳實踐,涵蓋架構變更、資料遷移、回滾以及 PostgreSQL、MySQL 與常見 ORM (Prisma, Drizzle, Django, TypeORM, golang-migrate) 的零停機部署。
---
# 資料庫遷移模式 (Database Migration Patterns)
針對生產系統提供安全且可逆的資料庫架構變更。
## 何時啟用
- 建立或修改資料庫資料表。
- 新增/移除欄位或索引。
- 執行資料遷移 (回填 Backfill、轉換 Transform)。
- 規劃零停機 (Zero-downtime) 的架構變更。
- 為新專案設置遷移工具。
## 核心原則
1. **所有的變更都是遷移 (Everything is a migration)** — 絕不要手動修改生產環境資料庫。
2. **生產環境中的遷移僅能向前執行 (Forward-only)** — 回滾操作應使用新的「向前」遷移來達成。
3. **架構遷移與資料遷移分離** — 絕不要在同一個遷移任務中混合 DDL (架構定義) 與 DML (資料操作)。
4. **針對與生產環境規模相當的資料進行測試** — 在 100 筆資料上能運行的遷移,在 1000 萬筆資料上可能會導致鎖定。
5. **遷移一旦部署即具備不可變性** — 絕不要編輯已在生產環境執行的遷移檔案。
## 遷移安全檢核清單
在套用任何遷移之前:
- [ ] 遷移具備 UP 與 DOWN 指令(或明確標記為不可逆)。
- [ ] 大型資料表不會發生全表鎖定 (使用併發 Concurrent 操作)。
- [ ] 新欄位具備預設值或允許為 Null (絕不要在沒有預設值的情況下新增 NOT NULL)。
- [ ] 以併發方式建立索引 (針對現有資料表,不要在 CREATE TABLE 內聯建立)。
- [ ] 資料回填路徑與架構變更分離成獨立的遷移任務。
- [ ] 已針對生產環境資料的複本進行測試。
- [ ] 已編寫回滾計畫。
## PostgreSQL 模式
### 安全新增欄位
```sql
-- 推薦 (GOOD):允許 Null 的欄位,不會產生鎖定
ALTER TABLE users ADD COLUMN avatar_url TEXT;
-- 推薦 (GOOD):帶有預設值的欄位 (Postgres 11+ 可即時完成,無需重寫檔案)
ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT true;
-- 錯誤 (BAD):在現有資料表新增 NOT NULL 且無預設值 (需要重寫整張表)
ALTER TABLE users ADD COLUMN role TEXT NOT NULL;
-- 這會鎖定資料表並重寫每一列資料
```
### 零停機新增索引
```sql
-- 錯誤 (BAD):在大型資料表執行時會阻塞寫入操作
CREATE INDEX idx_users_email ON users (email);
-- 推薦 (GOOD):非阻塞式,允許併發寫入
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);
-- 註CONCURRENTLY 無法在交易區塊 (Transaction block) 內執行
-- 大多數遷移工具需要對此進行特殊處理
```
### 欄位重新命名 (零停機)
絕不直接在生產環境重新命名。請使用「擴展-收縮 (Expand-contract)」模式:
```sql
-- 步驟 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;
```
### 安全移除欄位
```sql
-- 步驟 1移除應用程式中對該欄位的所有參照
-- 步驟 2部署不含該欄位參照的應用程式版本
-- 步驟 3在下一次遷移中移除欄位
ALTER TABLE orders DROP COLUMN legacy_status;
-- 針對 Django使用 SeparateDatabaseAndState 從模型中移除
-- 但不生成 DROP COLUMN (隨後在下一個遷移中正式移除)
```
### 大型資料遷移
```sql
-- 錯誤 (BAD):在單一交易中更新所有列 (會鎖定資料表)
UPDATE users SET normalized_email = LOWER(email);
-- 推薦 (GOOD):分批更新並顯示進度
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 '已更新 % 列', rows_updated;
EXIT WHEN rows_updated = 0;
COMMIT;
END LOOP;
END $$;
```
## Prisma (TypeScript/Node.js)
### 工作流
```bash
# 根據架構變更建立遷移
npx prisma migrate dev --name add_user_avatar
# 在生產環境部署掛起的遷移
npx prisma migrate deploy
# 重置資料庫 (僅限開發環境)
npx prisma migrate reset
# 架構變更後生成用戶端
npx prisma generate
```
### Schema 範例
```prisma
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 無法表達的操作 (併發索引、資料回填)
```bash
# 建立僅含核心結構的遷移檔案,隨後手動編輯 SQL
npx prisma migrate dev --create-only --name add_email_index
```
```sql
-- 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)
### 工作流
```bash
# 根據架構變更生成遷移
npx drizzle-kit generate
# 套用遷移
npx drizzle-kit migrate
# 直接推送架構 (僅限開發環境,不生成遷移檔案)
npx drizzle-kit push
```
### Schema 範例
```typescript
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(),
});
```
## Django (Python)
### 工作流
```bash
# 根據模型變更生成遷移任務
python manage.py makemigrations
# 套用遷移
python manage.py migrate
# 顯示遷移狀態
python manage.py showmigrations
# 為自定義 SQL 生成空白遷移檔案
python manage.py makemigrations --empty app_name -n 描述
```
### 資料遷移
```python
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 模型中移除欄位,但不立即從資料庫中刪除:
```python
class Migration(migrations.Migration):
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.RemoveField(model_name="user", name="legacy_field"),
],
database_operations=[], # 先不更動資料庫
),
]
```
## golang-migrate (Go)
### 工作流
```bash
# 建立成對的遷移檔案
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
# 強制更新版本 (修復 dirty 狀態)
migrate -path migrations -database "$DATABASE_URL" force 版本號
```
### 遷移檔案
```sql
-- 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;
```
## 零停機遷移策略
對於關鍵的生產環境變更,請遵循「擴展-收縮 (expand-contract)」模式:
```
階段 1擴展 (EXPAND)
- 新增新欄位/資料表 (設為可 null 或具備預設值)
- 部署:應用程式同時寫入「舊」與「新」欄位
- 回填現有資料
階段 2遷移 (MIGRATE)
- 部署:應用程式從「新」欄位讀取,但維持寫入「兩者」
- 驗證資料一致性
階段 3收縮 (CONTRACT)
- 部署:應用程式僅使用「新」欄位
- 在獨立的遷移任務中移除「舊」欄位/資料表
```
### 時間軸範例
```
第 1 天:遷移任務新增 new_status 欄位 (可為 Null)
第 1 天:部署 app v2 — 同時寫入 status 與 new_status
第 2 天:針對現有列執行回填遷移
第 3 天:部署 app v3 — 僅從 new_status 讀取
第 7 天:遷移任務移除舊的 status 欄位
```
## 反模式
| 反模式 | 失敗原因 | 更好的做法 |
|-------------|-------------|-----------------|
| 在生產環境手動執行 SQL | 無審計追蹤,不可重複執行 | 始終使用遷移檔案 |
| 編輯已部署的遷移 | 導致環境間出現差異 | 建立新的遷移任務 |
| NOT NULL 且無預設值 | 鎖定資料表,重寫所有列 | 先新增可為 Null 的欄位,回填後再加入約束 |
| 在大型資料表使用內聯索引 | 建置期間會阻塞寫入 | 使用 CREATE INDEX CONCURRENTLY |
| 架構 + 資料混在同一個遷移 | 難以回退,且交易時間過長 | 將兩者分離成獨立遷移 |
| 移除程式碼前先刪除欄位 | 應用程式因找不到欄位而報錯 | 先移除程式碼參照,在下次部署才移除欄位 |