讓 AI 幫你寫 SQL Migration:多租戶資料庫的批次變更


如果你還不熟悉多租戶架構的概念,建議先閱讀: 📖 多租戶架構生存指南:一個資料庫一個機構的愛與愁


🎯 前言

手動對 20 個資料庫跑 migration,聽起來就讓人頭痛。

在多租戶 database-per-tenant 的架構下,每次需要變更資料表結構,就意味著同樣的 SQL 要在每個機構的資料庫各跑一次。不只數量多,還要面對:某些機構的資料庫結構因歷史原因已有差異、SQL 跑到一半失敗要怎麼處理、如何確認所有資料庫都成功了。

我用 Claude Code 輔助這個流程有一段時間了,最大的體會是:AI 的價值不是讓你省略思考,而是讓草稿品質提升,使 code review 的速度變快。

一個自己手寫可能要 2 小時的 migration SQL,Claude 可以在 5 分鐘內給你一個完整的草稿(包含 up/down 腳本),你花 20 分鐘 review 就好。


🏗️ 情境說明

我們的系統(AppSystem)採用 database-per-tenant 架構:

  • 每個機構一個 MySQL 資料庫(demo-org-1demo-org-2… 目前 20+ 個)
  • MySQL 版本:5.7(注意:這個版本有許多限制)
  • 正式環境不能停機,migration 必須在線執行
  • 各機構資料庫因歷史原因,部分資料表結構不完全相同

這幾個條件加在一起,讓「跑 migration」這件事比想像中複雜得多。


🤝 讓 Claude Code 起草 Migration SQL

準備資料表結構

要讓 Claude 產生精確的 migration SQL,首先要給它足夠的 context。

我:
請幫我產生一個 migration SQL。
我需要在所有機構的 demo_table 資料表新增一個欄位 config_json。

以下是目前的資料表結構(來自 demo-org-1):

[貼上 SHOW CREATE TABLE 的輸出]

請同時輸出:
1. up.sql(新增欄位)
2. down.sql(回滾)
3. 說明這個 migration 做了什麼、可能的風險

注意:MySQL 版本是 5.7,請確認語法相容。

SHOW CREATE TABLE 的完整輸出貼給 Claude,它就能理解目前的結構、現有欄位的命名慣例、engine 和 charset 設定。

Claude 的輸出格式

好的 Claude 輸出應該包含三個部分:

-- ============================================================
-- migration: add_config_json_to_demo_table
-- 說明:新增 config_json 欄位,用於儲存各機構的客製化設定
-- MySQL 版本:5.7
-- 風險評估:低(新增可為空欄位,不影響現有資料)
-- ============================================================

-- up.sql
ALTER TABLE `demo_table`
  ADD COLUMN `config_json` TEXT NULL COMMENT '機構客製化設定(JSON 格式)'
  AFTER `updated_at`;

-- ============================================================
-- down.sql
ALTER TABLE `demo_table`
  DROP COLUMN `config_json`;

注意:如果 Claude 輸出的語法有問題,很可能是 MySQL 5.7 相容性問題,下一節會說明常見錯誤。


❌ AI 常犯的 MySQL 5.7 相容性錯誤

這是我踩過坑整理出來的清單,每次使用 Claude 生成 migration 都要特別留意:

錯誤 1:ADD COLUMN IF NOT EXISTS

-- ❌ MySQL 8.0 才有的語法,5.7 不支援
ALTER TABLE `demo_table`
  ADD COLUMN IF NOT EXISTS `config_json` TEXT NULL;

-- ✅ MySQL 5.7 要用 information_schema 判斷(見下一節)

錯誤 2:Window Functions

-- ❌ MySQL 5.7 不支援 window functions
SELECT id, ROW_NUMBER() OVER (PARTITION BY org_id ORDER BY created_at) AS rn
FROM demo_table;

-- ✅ 改用子查詢或 user-defined variables

錯誤 3:utf8 vs utf8mb4

-- ❌ 新欄位用 utf8 會有 emoji 儲存問題
ADD COLUMN `note` VARCHAR(255) CHARACTER SET utf8;

-- ✅ 應該用 utf8mb4
ADD COLUMN `note` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

錯誤 4:Generated Columns 語法

-- ❌ MySQL 5.7 的 generated column 語法與 8.0 不同
-- 且 5.7 的 virtual generated column 有 index 限制

解法:在 CLAUDE.md 中明確記錄 MySQL 版本限制,讓 Claude 每次都會注意到。


✅ 讓 Migration 冪等的模式

在多租戶環境中,migration 必須是冪等的(idempotent):跑第二次也不會出錯。原因是:

  1. 某個機構的 migration 失敗,修復後重跑
  2. 不確定哪些已跑過,想全部重跑確認

MySQL 5.7 不支援 ADD COLUMN IF NOT EXISTS,但可以用 information_schema 繞過:

-- MySQL 5.7 冪等 ADD COLUMN 的標準模式
SET @dbname = DATABASE();
SET @tablename = 'demo_table';
SET @columnname = 'config_json';

SET @sql = (
  SELECT IF(
    COUNT(*) = 0,
    CONCAT(
      'ALTER TABLE `', @tablename, '` ',
      'ADD COLUMN `', @columnname, '` TEXT NULL ',
      'COMMENT ''機構客製化設定(JSON 格式)'' ',
      'AFTER `updated_at`'
    ),
    'SELECT ''Column already exists, skipping.'''
  )
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA = @dbname
    AND TABLE_NAME = @tablename
    AND COLUMN_NAME = @columnname
);

PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

這個 PREPARE/EXECUTE 模式是 MySQL 5.7 中處理條件式 DDL 的標準方式。把它納入 CLAUDE.md 範例,Claude 就會在產生 migration 時自動套用。


🛠️ 批次執行腳本

有了好的 migration SQL,接下來要建立能安全批次執行的腳本。

#!/bin/bash
# run_migration.sh - 跨所有機構執行 migration SQL
# 使用方式:./run_migration.sh --file up.sql [--dry-run]

set -euo pipefail

MYSQL_HOST="${MYSQL_HOST:-127.0.0.1}"
MYSQL_PORT="${MYSQL_PORT:-3306}"
MYSQL_USER="${MYSQL_USER:-dbuser}"
MYSQL_PASS="${MYSQL_PASS:-dbpass}"
DRY_RUN=false
SQL_FILE=""

# 解析參數
while [[ $# -gt 0 ]]; do
  case $1 in
    --file) SQL_FILE="$2"; shift 2 ;;
    --dry-run) DRY_RUN=true; shift ;;
    *) echo "未知參數:$1"; exit 1 ;;
  esac
done

if [[ -z "$SQL_FILE" ]]; then
  echo "錯誤:請指定 --file 參數"
  exit 1
fi

# 取得所有機構資料庫清單(從系統主庫查詢)
DATABASES=$(mysql -h"$MYSQL_HOST" -P"$MYSQL_PORT" -u"$MYSQL_USER" -p"$MYSQL_PASS" \
  -N -e "SELECT db_name FROM system_main.org_list WHERE is_active = 1 ORDER BY db_name;")

SUCCESS_LIST=()
FAIL_LIST=()

echo "================================================"
echo "Migration 腳本:$SQL_FILE"
echo "模式:$([ "$DRY_RUN" = true ] && echo 'DRY RUN(不實際執行)' || echo '實際執行')"
echo "目標資料庫數量:$(echo "$DATABASES" | wc -l | tr -d ' ')"
echo "================================================"
echo ""

for DB in $DATABASES; do
  if [ "$DRY_RUN" = true ]; then
    echo "[DRY RUN] $DB"
    SUCCESS_LIST+=("$DB")
    continue
  fi

  echo -n "執行中:$DB ... "

  if mysql -h"$MYSQL_HOST" -P"$MYSQL_PORT" -u"$MYSQL_USER" -p"$MYSQL_PASS" \
    --database="$DB" < "$SQL_FILE" 2>/tmp/migration_error_"$DB".log; then
    echo "✅ 成功"
    SUCCESS_LIST+=("$DB")
  else
    echo "❌ 失敗"
    FAIL_LIST+=("$DB")
    # 失敗不中斷,繼續下一個資料庫
  fi
done

# 輸出摘要報告
echo ""
echo "================================================"
echo "執行完成"
echo "成功:${#SUCCESS_LIST[@]} 個"
echo "失敗:${#FAIL_LIST[@]} 個"

if [ ${#FAIL_LIST[@]} -gt 0 ]; then
  echo ""
  echo "失敗的資料庫:"
  for DB in "${FAIL_LIST[@]}"; do
    echo "  - $DB"
    echo "    錯誤訊息:$(cat /tmp/migration_error_"$DB".log)"
  done
  exit 1
fi

執行流程

# 第一步:dry-run,確認腳本沒有語法問題
./run_migration.sh --file up.sql --dry-run

# 第二步:實際執行
./run_migration.sh --file up.sql

# 第三步:執行驗證查詢(見下方)
mysql -u dbuser -pdbpass < verify.sql

🔄 回滾策略

MySQL DDL 操作(ALTER TABLE不支援 transaction 回滾BEGIN / ROLLBACK 對 DDL 無效。

這意味著每個 migration 的回滾必須靠 down.sql 手動執行:

migrations/
├── 20260211_add_config_json/
│   ├── up.sql      ← 正向 migration
│   ├── down.sql    ← 回滾腳本
│   └── verify.sql  ← 驗證腳本

重要原則

  1. 每個 migration 寫完後,立刻驗證 down.sql 是否真的能回滾
  2. ADD COLUMN 的 down 是 DROP COLUMN,但方向要對
  3. 如果是 MODIFY COLUMN(改型別),down.sql 要保留原始型別定義

📊 實戰案例:新增 config_json 欄位

以下完整走一遍,讓 AppSystem 的所有機構資料庫新增 config_json 欄位。

Step 1:取得資料表結構

SHOW CREATE TABLE `demo_table`\G

輸出結果貼給 Claude。

Step 2:Claude 產生 migration

-- migrations/20260211_add_config_json/up.sql
-- 冪等模式:欄位已存在時跳過

SET @dbname = DATABASE();
SET @sql = (
  SELECT IF(
    COUNT(*) = 0,
    'ALTER TABLE `demo_table` ADD COLUMN `config_json` TEXT NULL COMMENT ''機構客製化設定'' AFTER `updated_at`',
    'SELECT ''config_json already exists, skipping'''
  )
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA = @dbname
    AND TABLE_NAME = 'demo_table'
    AND COLUMN_NAME = 'config_json'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- migrations/20260211_add_config_json/down.sql
ALTER TABLE `demo_table` DROP COLUMN `config_json`;
-- migrations/20260211_add_config_json/verify.sql
-- 驗證所有機構都有這個欄位
SELECT
  TABLE_SCHEMA AS '資料庫',
  COLUMN_NAME AS '欄位',
  COLUMN_TYPE AS '型別',
  IS_NULLABLE AS '允許空值'
FROM information_schema.COLUMNS
WHERE TABLE_NAME = 'demo_table'
  AND COLUMN_NAME = 'config_json'
  AND TABLE_SCHEMA LIKE 'demo-org-%'
ORDER BY TABLE_SCHEMA;

Step 3:執行與驗證

# Dry run
./run_migration.sh --file migrations/20260211_add_config_json/up.sql --dry-run

# 實際執行
./run_migration.sh --file migrations/20260211_add_config_json/up.sql

# 驗證(應該顯示 20+ 筆,每個機構各一筆)
mysql -u dbuser -pdbpass < migrations/20260211_add_config_json/verify.sql

💡 提示技巧

在 CLAUDE.md 中記錄 MySQL 版本限制

## MySQL 限制(5.7)

重要:產生 migration SQL 時必須遵守以下限制:
- 不支援 `ADD COLUMN IF NOT EXISTS`,改用 information_schema + PREPARE/EXECUTE
- 不支援 window functions(ROW_NUMBER、RANK 等)
- 不支援 JSON_TABLE
- DDL 不支援 transaction 回滾
- 使用 utf8mb4 而非 utf8(避免 emoji 問題)

冪等 ADD COLUMN 標準模式:[貼上上方的 PREPARE/EXECUTE 範例]

Few-Shot 範例

每次請 Claude 產生 migration 時,附上一個已知正確的範例:

以下是一個符合我們專案規範的 migration 範例:

[貼上之前確認過可用的 migration SQL]

請依照相同模式,幫我產生 [新的需求] 的 migration。

這個 few-shot 範例能大幅降低 Claude 犯相容性錯誤的機率。


🎉 結語

在多租戶架構中跑 migration 從來就不是一件容易的事,但 Claude Code 讓這個流程變得更有紀律:

  1. 起草品質提升:不再從空白開始寫,Claude 的草稿已經考慮到 up/down/說明
  2. Review 更快:有了明確的 few-shot 範例和 CLAUDE.md 限制記錄,Claude 的錯誤率降低
  3. 流程標準化:每次 migration 都有相同的目錄結構和腳本模式

AI 不會消除 migration 的風險,但能讓你把心力集中在真正重要的事:仔細 review 變更、確認回滾策略、在非尖峰時段執行。


📎 相關文章