# 

# 03-4. 資料庫遷移 Alembic

&gt; ⏱️ **閱讀時間：** 20 分鐘
&gt; 🎯 **難度：** ⭐⭐⭐ (進階)

---

## 🤔 一句話解釋

**Alembic 是 SQLAlchemy 的資料庫遷移工具，讓你用版本控制的方式管理資料庫 Schema 變更。**

---

## 🎯 為什麼需要資料庫遷移？

### 沒有遷移工具的問題

```
❌ 直接修改資料庫：
- 無法追蹤變更歷史
- 無法在不同環境同步 Schema
- 回滾困難
- 團隊協作混亂

✅ 使用 Alembic：
- 版本控制 Schema 變更
- 自動生成遷移腳本
- 支援升級和降級
- 團隊協作順暢
```

### 遷移流程

```
修改 Model  →  生成遷移腳本  →  審核腳本  →  執行遷移
    ↓              ↓              ↓           ↓
class User    alembic revision   檢查 SQL   alembic upgrade
  id: int      --autogenerate
  name: str
  &#43; email: str    # 新增欄位
```

---

## 📦 安裝與初始化

### 安裝

```bash
pip install alembic
```

### 初始化

```bash
# 在專案根目錄執行
alembic init alembic

# 會產生以下結構：
# alembic/
# ├── env.py           # 遷移環境設定
# ├── README
# ├── script.py.mako   # 遷移腳本模板
# └── versions/        # 遷移腳本存放處
# alembic.ini          # Alembic 設定檔
```

---

## 🔧 設定 Alembic

### 1. 修改 alembic.ini

```ini
# alembic.ini

[alembic]
script_location = alembic
prepend_sys_path = .

# 資料庫 URL（可以在 env.py 中覆蓋）
sqlalchemy.url = postgresql://user:password@localhost:5432/mydb
```

### 2. 修改 env.py

```python
# alembic/env.py

from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
import os
import sys

# 加入專案路徑
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

# 匯入你的 Model
from app.models import Base  # 根據你的專案結構調整
from app.config import settings  # 從設定檔讀取資料庫 URL

config = context.config

# 從環境變數或設定檔讀取資料庫 URL
config.set_main_option(&#34;sqlalchemy.url&#34;, settings.database_url)

if config.config_file_name is not None:
    fileConfig(config.config_file_name)

# 設定 metadata（重要！）
target_metadata = Base.metadata


def run_migrations_offline() -&gt; None:
    &#34;&#34;&#34;離線模式執行遷移&#34;&#34;&#34;
    url = config.get_main_option(&#34;sqlalchemy.url&#34;)
    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={&#34;paramstyle&#34;: &#34;named&#34;},
    )

    with context.begin_transaction():
        context.run_migrations()


def run_migrations_online() -&gt; None:
    &#34;&#34;&#34;線上模式執行遷移&#34;&#34;&#34;
    connectable = engine_from_config(
        config.get_section(config.config_ini_section, {}),
        prefix=&#34;sqlalchemy.&#34;,
        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()


if context.is_offline_mode():
    run_migrations_offline()
else:
    run_migrations_online()
```

### 3. 非同步版本的 env.py

```python
# alembic/env.py（非同步版本）

import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from app.models import Base
from app.config import settings

config = context.config
config.set_main_option(&#34;sqlalchemy.url&#34;, settings.database_url)

if config.config_file_name is not None:
    fileConfig(config.config_file_name)

target_metadata = Base.metadata


def run_migrations_offline() -&gt; None:
    url = config.get_main_option(&#34;sqlalchemy.url&#34;)
    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={&#34;paramstyle&#34;: &#34;named&#34;},
    )

    with context.begin_transaction():
        context.run_migrations()


def do_run_migrations(connection: Connection) -&gt; None:
    context.configure(
        connection=connection,
        target_metadata=target_metadata,
        compare_type=True,
        compare_server_default=True,
    )

    with context.begin_transaction():
        context.run_migrations()


async def run_async_migrations() -&gt; None:
    &#34;&#34;&#34;非同步執行遷移&#34;&#34;&#34;
    connectable = async_engine_from_config(
        config.get_section(config.config_ini_section, {}),
        prefix=&#34;sqlalchemy.&#34;,
        poolclass=pool.NullPool,
    )

    async with connectable.connect() as connection:
        await connection.run_sync(do_run_migrations)

    await connectable.dispose()


def run_migrations_online() -&gt; None:
    asyncio.run(run_async_migrations())


if context.is_offline_mode():
    run_migrations_offline()
else:
    run_migrations_online()
```

---

## 📝 建立遷移

### 自動生成遷移腳本

```bash
# 根據 Model 變更自動生成遷移
alembic revision --autogenerate -m &#34;add user table&#34;

# 會在 alembic/versions/ 產生類似：
# xxxxxxxxxxxx_add_user_table.py
```

### 手動建立遷移腳本

```bash
# 建立空白遷移腳本
alembic revision -m &#34;custom migration&#34;
```

### 遷移腳本範例

```python
# alembic/versions/xxxx_add_user_table.py

&#34;&#34;&#34;add user table

Revision ID: abc123
Revises:
Create Date: 2025-12-17 10:00:00.000000

&#34;&#34;&#34;
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa

# revision identifiers
revision: str = &#39;abc123&#39;
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -&gt; None:
    &#34;&#34;&#34;升級：執行變更&#34;&#34;&#34;
    op.create_table(
        &#39;users&#39;,
        sa.Column(&#39;id&#39;, sa.Integer(), nullable=False),
        sa.Column(&#39;username&#39;, sa.String(length=50), nullable=False),
        sa.Column(&#39;email&#39;, sa.String(length=100), nullable=False),
        sa.Column(&#39;hashed_password&#39;, sa.String(length=255), nullable=False),
        sa.Column(&#39;is_active&#39;, sa.Boolean(), nullable=False, server_default=&#39;true&#39;),
        sa.Column(&#39;created_at&#39;, sa.DateTime(), nullable=False, server_default=sa.func.now()),
        sa.PrimaryKeyConstraint(&#39;id&#39;)
    )
    op.create_index(&#39;ix_users_username&#39;, &#39;users&#39;, [&#39;username&#39;], unique=True)
    op.create_index(&#39;ix_users_email&#39;, &#39;users&#39;, [&#39;email&#39;], unique=True)


def downgrade() -&gt; None:
    &#34;&#34;&#34;降級：回滾變更&#34;&#34;&#34;
    op.drop_index(&#39;ix_users_email&#39;, table_name=&#39;users&#39;)
    op.drop_index(&#39;ix_users_username&#39;, table_name=&#39;users&#39;)
    op.drop_table(&#39;users&#39;)
```

---

## 🚀 執行遷移

### 常用指令

```bash
# 升級到最新版本
alembic upgrade head

# 升級到特定版本
alembic upgrade abc123

# 升級一個版本
alembic upgrade &#43;1

# 降級一個版本
alembic downgrade -1

# 降級到特定版本
alembic downgrade abc123

# 降級到最初狀態
alembic downgrade base

# 查看當前版本
alembic current

# 查看遷移歷史
alembic history

# 查看詳細歷史
alembic history --verbose
```

### 查看將要執行的 SQL

```bash
# 查看升級 SQL（不實際執行）
alembic upgrade head --sql

# 儲存 SQL 到檔案
alembic upgrade head --sql &gt; migration.sql
```

---

## 🔧 常見遷移操作

### 新增欄位

```python
def upgrade() -&gt; None:
    op.add_column(&#39;users&#39;, sa.Column(&#39;phone&#39;, sa.String(20), nullable=True))

def downgrade() -&gt; None:
    op.drop_column(&#39;users&#39;, &#39;phone&#39;)
```

### 刪除欄位

```python
def upgrade() -&gt; None:
    op.drop_column(&#39;users&#39;, &#39;old_field&#39;)

def downgrade() -&gt; None:
    op.add_column(&#39;users&#39;, sa.Column(&#39;old_field&#39;, sa.String(100), nullable=True))
```

### 修改欄位

```python
def upgrade() -&gt; None:
    # 修改欄位型別
    op.alter_column(
        &#39;users&#39;,
        &#39;phone&#39;,
        existing_type=sa.String(20),
        type_=sa.String(50),
        existing_nullable=True
    )

    # 修改為非空
    op.alter_column(
        &#39;users&#39;,
        &#39;email&#39;,
        existing_type=sa.String(100),
        nullable=False
    )

def downgrade() -&gt; None:
    op.alter_column(
        &#39;users&#39;,
        &#39;phone&#39;,
        existing_type=sa.String(50),
        type_=sa.String(20),
        existing_nullable=True
    )

    op.alter_column(
        &#39;users&#39;,
        &#39;email&#39;,
        existing_type=sa.String(100),
        nullable=True
    )
```

### 重新命名欄位

```python
def upgrade() -&gt; None:
    op.alter_column(&#39;users&#39;, &#39;name&#39;, new_column_name=&#39;full_name&#39;)

def downgrade() -&gt; None:
    op.alter_column(&#39;users&#39;, &#39;full_name&#39;, new_column_name=&#39;name&#39;)
```

### 新增索引

```python
def upgrade() -&gt; None:
    op.create_index(&#39;ix_users_created_at&#39;, &#39;users&#39;, [&#39;created_at&#39;])

def downgrade() -&gt; None:
    op.drop_index(&#39;ix_users_created_at&#39;, table_name=&#39;users&#39;)
```

### 新增外鍵

```python
def upgrade() -&gt; None:
    op.add_column(&#39;posts&#39;, sa.Column(&#39;author_id&#39;, sa.Integer(), nullable=False))
    op.create_foreign_key(
        &#39;fk_posts_author_id&#39;,
        &#39;posts&#39;,
        &#39;users&#39;,
        [&#39;author_id&#39;],
        [&#39;id&#39;],
        ondelete=&#39;CASCADE&#39;
    )

def downgrade() -&gt; None:
    op.drop_constraint(&#39;fk_posts_author_id&#39;, &#39;posts&#39;, type_=&#39;foreignkey&#39;)
    op.drop_column(&#39;posts&#39;, &#39;author_id&#39;)
```

### 建立新表

```python
def upgrade() -&gt; None:
    op.create_table(
        &#39;categories&#39;,
        sa.Column(&#39;id&#39;, sa.Integer(), primary_key=True),
        sa.Column(&#39;name&#39;, sa.String(50), nullable=False),
        sa.Column(&#39;slug&#39;, sa.String(50), nullable=False),
        sa.Column(&#39;created_at&#39;, sa.DateTime(), server_default=sa.func.now()),
        sa.UniqueConstraint(&#39;slug&#39;)
    )

def downgrade() -&gt; None:
    op.drop_table(&#39;categories&#39;)
```

### 資料遷移

```python
from sqlalchemy import table, column, String

def upgrade() -&gt; None:
    # 1. 新增欄位
    op.add_column(&#39;users&#39;, sa.Column(&#39;status&#39;, sa.String(20), nullable=True))

    # 2. 資料遷移
    users = table(&#39;users&#39;, column(&#39;status&#39;, String))
    op.execute(
        users.update().values(status=&#39;active&#39;)
    )

    # 3. 設為非空
    op.alter_column(&#39;users&#39;, &#39;status&#39;, nullable=False)

def downgrade() -&gt; None:
    op.drop_column(&#39;users&#39;, &#39;status&#39;)
```

---

## ⚠️ 常見問題與解決

### 1. 自動生成未偵測到變更

```python
# 在 env.py 中加入
context.configure(
    connection=connection,
    target_metadata=target_metadata,
    compare_type=True,           # 偵測型別變更
    compare_server_default=True, # 偵測預設值變更
)
```

### 2. 處理既有資料

```python
def upgrade() -&gt; None:
    # 新增欄位時，先允許 NULL
    op.add_column(&#39;users&#39;, sa.Column(&#39;phone&#39;, sa.String(20), nullable=True))

    # 填入預設值
    op.execute(&#34;UPDATE users SET phone = &#39;&#39; WHERE phone IS NULL&#34;)

    # 再改為 NOT NULL
    op.alter_column(&#39;users&#39;, &#39;phone&#39;, nullable=False)
```

### 3. 分支合併

```bash
# 查看分支
alembic heads

# 如果有多個 head，需要合併
alembic merge -m &#34;merge heads&#34; abc123 def456
```

### 4. 錯誤回滾

```bash
# 如果遷移執行失敗，手動標記版本
alembic stamp abc123

# 然後修復遷移腳本，重新執行
alembic upgrade head
```

---

## 📝 最佳實踐

### 專案結構

```
project/
├── alembic/
│   ├── env.py
│   ├── script.py.mako
│   └── versions/
│       ├── 001_initial.py
│       ├── 002_add_user_email.py
│       └── 003_add_posts_table.py
├── alembic.ini
├── app/
│   ├── models/
│   │   ├── __init__.py
│   │   ├── base.py
│   │   └── user.py
│   └── config.py
└── ...
```

### 命名規範

```bash
# 遷移訊息格式
alembic revision --autogenerate -m &#34;add_user_table&#34;
alembic revision --autogenerate -m &#34;add_email_to_users&#34;
alembic revision --autogenerate -m &#34;create_posts_table&#34;
alembic revision --autogenerate -m &#34;add_index_on_created_at&#34;
```

### 遷移腳本檢查清單

```python
def upgrade() -&gt; None:
    # ✅ 檢查是否有資料
    # ✅ 考慮大表的效能影響
    # ✅ 使用批次處理大量更新
    # ✅ 先加欄位（nullable），再遷移資料，最後改 nullable

    # 範例：安全地新增非空欄位
    op.add_column(&#39;users&#39;, sa.Column(&#39;role&#39;, sa.String(20), nullable=True))
    op.execute(&#34;UPDATE users SET role = &#39;user&#39; WHERE role IS NULL&#34;)
    op.alter_column(&#39;users&#39;, &#39;role&#39;, nullable=False)

def downgrade() -&gt; None:
    # ✅ 確保可以回滾
    # ✅ 考慮資料損失的影響
    op.drop_column(&#39;users&#39;, &#39;role&#39;)
```

### CI/CD 整合

```yaml
# GitHub Actions 範例
name: Database Migration

on:
  push:
    branches: [main]

jobs:
  migrate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: &#39;3.11&#39;

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run migrations
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
        run: alembic upgrade head
```

---

## ✅ 重點總結

### 常用指令

| 指令 | 說明 |
|------|------|
| `alembic init alembic` | 初始化 |
| `alembic revision --autogenerate -m &#34;msg&#34;` | 自動生成遷移 |
| `alembic upgrade head` | 升級到最新 |
| `alembic downgrade -1` | 降級一版 |
| `alembic current` | 查看當前版本 |
| `alembic history` | 查看歷史 |

### 遷移操作

| 操作 | 函數 |
|------|------|
| 新增表 | `op.create_table()` |
| 刪除表 | `op.drop_table()` |
| 新增欄位 | `op.add_column()` |
| 刪除欄位 | `op.drop_column()` |
| 修改欄位 | `op.alter_column()` |
| 新增索引 | `op.create_index()` |
| 新增外鍵 | `op.create_foreign_key()` |

---

## 🎤 面試這樣答

### Q: 為什麼需要資料庫遷移工具？

**答案：**

&gt; 資料庫遷移工具（如 Alembic）讓我們可以：
&gt;
&gt; 1. **版本控制**：追蹤 Schema 變更歷史
&gt; 2. **環境同步**：開發、測試、生產環境保持一致
&gt; 3. **團隊協作**：多人開發時不會衝突
&gt; 4. **安全回滾**：出問題可以降級
&gt;
&gt; 使用方式：
&gt; ```bash
&gt; # 自動偵測 Model 變更
&gt; alembic revision --autogenerate -m &#34;add user table&#34;
&gt;
&gt; # 執行遷移
&gt; alembic upgrade head
&gt; ```

---

**上一篇：** [03-3. 非同步資料庫操作](./03-3)
**下一篇：** [03-5. Repository 模式](./03-5)

---

最後更新：2025-12-17


---

> 作者: luk  
> URL: https://yoru-karu-blog-lalaluk-52581ac5e0cef170a3c8922c19182ecb6f7bd604.gitlab.io/posts/tutorial/fastapi/03-4/  

