多租戶架構生存指南:一個資料庫一個機構的愛與愁
🎯 前言
「為什麼不用 row-level multi-tenancy?每個表加一個 org_id 欄位就好了,幹嘛這麼複雜?」
這是我被問過最多次的問題。
我維護的 AppSystem 採用的是 database-per-tenant 架構:每個機構(OrganizationA、OrganizationB…)都有自己獨立的資料庫實例。這個設計在當初有其理由,但也帶來了一系列的維護挑戰。
這篇文章想分享這個架構的真實維護經驗——不只是優點,更多是實際遇到的坑,以及我們如何應對的。
🏗️ 架構選擇的背景
為什麼選 database-per-tenant?
AppSystem 最初設計時,選擇 database-per-tenant 有幾個主要原因:
1. 資料隔離的強硬需求
系統服務的是多個機構,各機構之間的資料需要完全隔離。
database-per-tenant 提供了最強的隔離保證:
- 一個機構的操作不可能影響另一個機構的資料
- 不需要在每個 SQL 查詢中加
WHERE org_id = ? - 資料庫備份/還原以機構為單位,靈活性高
2. 歷史原因
系統最初只服務單一機構,後來才擴展到多機構。 每個新機構就是複製一份現有資料庫結構,這個模式延續下來了。
3. 各機構的客製化需求
有些機構需要特殊的欄位或報表格式。 database-per-tenant 讓我們可以在特定機構的資料庫中加欄位, 而不影響其他機構。(後來這個「優點」變成了「痛點」,後面會說到)
⚙️ Session 動態切換資料庫
多租戶系統最核心的機制是:同一套程式碼,根據當前登入的機構,切換到對應的資料庫。
資料庫連線架構
AppSystem 使用一個自訂的資料庫連線類別 DBPDO,負責管理連線池:
class DBPDO {
private static $connections = [];
public static function getConnection(string $db_name): PDO {
if (!isset(self::$connections[$db_name])) {
self::$connections[$db_name] = new PDO(
"mysql:host=localhost;dbname={$db_name};charset=utf8mb4",
DB_USER, // dbuser
DB_PASS // dbpass
);
}
return self::$connections[$db_name];
}
}
連線池設計的好處:同一個 request 中,如果需要多次查詢同一個資料庫,不會重複建立連線。
Session 中的機構資訊
用戶登入後,Session 中會儲存:
// 登入成功後設定
$_SESSION['mcareID'] = $user['id'];
$_SESSION['mcareDBno'] = $org['db_number']; // 對應到 demo-org-{id}
$_SESSION['mOrgID'] = $org['id'];
$_SESSION['mOrgName'] = $org['name'];
業務程式的使用方式
在業務邏輯中,取得資料庫連線的方式:
// 業務程式不直接寫死資料庫名稱
$db_name = 'demo-org-' . $_SESSION['mcareDBno'];
$pdo = DBPDO::getConnection($db_name);
// 查詢
$stmt = $pdo->prepare("SELECT * FROM typeB_records WHERE id = ?");
$stmt->execute([$record_id]);
$data = $stmt->fetch(PDO::FETCH_ASSOC);
跨資料庫查詢
有些功能需要同時查詢多個機構的資料(例如全系統的統計報表)。 這時候 PHP 會逐一連接每個機構的資料庫:
$org_list = getAllOrganizations(); // 從主設定資料庫取得機構清單
$all_data = [];
foreach ($org_list as $org) {
$db_name = 'demo-org-' . $org['db_number'];
$pdo = DBPDO::getConnection($db_name);
$records = getRecords($pdo, $date_range);
$all_data[$org['name']] = $records;
}
😤 真正的痛點:Schema Migration 同步
這是 database-per-tenant 最大的維護挑戰。
問題的根源
我們目前有約 20 個機構,每個機構都有獨立的資料庫。 當系統需要變更資料表結構(新增欄位、修改索引、新增資料表), 就需要對 20 個資料庫 執行相同的 migration。
聽起來只是「跑 20 次 SQL」的問題,但實際上遠比這複雜。
挑戰一:各機構版本不同步
由於歷史原因,各機構的資料庫結構不完全相同:
- 有些機構申請了額外功能,資料表有額外欄位
- 有些舊機構從來沒有執行某些歷史 migration
- 有些機構在測試期間手動改過資料表結構
這代表你不能假設「所有機構的資料表結構都一樣」。 執行 migration 前,必須先確認每個機構的當前狀態。
挑戰二:Migration 失敗的處理
如果 20 個機構的 migration,第 15 個失敗了怎麼辦?
前 14 個已經執行了,第 15-20 個還沒執行。 這個中間狀態很難處理,尤其是當 migration 包含了資料轉換(不只是 DDL)的時候。
挑戰三:MySQL 5.7 的限制
AppSystem 使用 MySQL 5.7,有一些 DDL 操作在 5.7 上的行為和 8.x 不同:
ALTER TABLE在 5.7 會鎖表,在大資料量時影響很大JSON欄位的某些操作在 5.7 有限制- 部分欄位類型的預設值規則不同
🛠️ 我們的解決方案
方案一:批次執行腳本
我們有一套 shell 腳本,可以對所有機構批次執行 SQL:
#!/bin/bash
# 對所有機構執行同一個 SQL migration
SQL_FILE="$1"
LOG_FILE="migration_$(date +%Y%m%d_%H%M%S).log"
# 取得所有機構清單
ORG_LIST=$(mysql -u dbuser -pdbpass system_main -e \
"SELECT db_number FROM organizations WHERE status='active'" -N)
for ORG_NO in $ORG_LIST; do
DB_NAME="demo-org-${ORG_NO}"
echo "執行 ${DB_NAME}..." | tee -a "$LOG_FILE"
mysql -u dbuser -pdbpass "$DB_NAME" < "$SQL_FILE" 2>&1 \
| tee -a "$LOG_FILE"
if [ ${PIPESTATUS[0]} -ne 0 ]; then
echo "❌ ${DB_NAME} 失敗!請手動檢查。" | tee -a "$LOG_FILE"
# 不中斷,繼續執行其他機構
else
echo "✅ ${DB_NAME} 成功" | tee -a "$LOG_FILE"
fi
done
echo "完成。結果記錄在 ${LOG_FILE}"
方案二:Idempotent Migration
所有的 migration SQL 都設計成冪等的(可以重複執行、結果相同):
-- ❌ 不好:執行第二次會報錯
ALTER TABLE typeB_records ADD COLUMN new_field VARCHAR(255);
-- ✅ 好:有欄位就跳過,沒有就新增
ALTER TABLE typeB_records
ADD COLUMN IF NOT EXISTS new_field VARCHAR(255) DEFAULT '';
-- ❌ 不好:索引已存在時報錯
CREATE INDEX idx_date ON typeB_records(date);
-- ✅ 好:索引已存在就不動
CREATE INDEX IF NOT EXISTS idx_date ON typeB_records(date);
注意:MySQL 5.7 不支援
ADD COLUMN IF NOT EXISTS,需要用 stored procedure 或 conditional SQL 實作。在 5.7 上,我們通常用這個方式:
-- MySQL 5.7 的 IF NOT EXISTS 替代方案
SET @dbname = DATABASE();
SET @tablename = 'typeB_records';
SET @columnname = 'new_field';
SET @preparedStatement = (
SELECT IF(
(SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @dbname
AND table_name = @tablename
AND column_name = @columnname) = 0,
'ALTER TABLE typeB_records ADD COLUMN new_field VARCHAR(255) DEFAULT ""',
'SELECT "Column already exists"'
)
);
PREPARE stmt FROM @preparedStatement;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
複雜,但可靠。
方案三:Migration 版本追蹤
我們在每個機構的資料庫中都有一個 schema_versions 表:
CREATE TABLE IF NOT EXISTS schema_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
version VARCHAR(20) NOT NULL,
description VARCHAR(255),
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_version (version)
);
執行 migration 前,先檢查這個機構是否已經執行過:
# 檢查 demo-org-1 是否已執行 v2.5.0 的 migration
APPLIED=$(mysql -u dbuser -pdbpass demo-org-1 -e \
"SELECT COUNT(*) FROM schema_versions WHERE version='v2.5.0'" -N)
if [ "$APPLIED" -eq "0" ]; then
# 執行 migration
mysql -u dbuser -pdbpass demo-org-1 < migration_v2.5.0.sql
# 記錄已執行
mysql -u dbuser -pdbpass demo-org-1 -e \
"INSERT INTO schema_versions (version, description) VALUES ('v2.5.0', '新增 new_field 欄位')"
fi
📊 與 Row-Level Tenancy 的比較
回到開頭的問題:「為什麼不用 row-level?」
老實說,在維護了幾年 database-per-tenant 之後,我有時候也會想這個問題。
| 面向 | Database-per-tenant | Row-level Tenancy |
|---|---|---|
| 資料隔離 | 強(OS 層面的隔離) | 弱(應用層面,依賴 WHERE 條件) |
| 跨機構查詢 | 複雜(需要逐一連接) | 簡單(一個查詢搞定) |
| Schema Migration | 複雜(需要跑 N 次) | 簡單(一次搞定) |
| 機構個別客製化 | 容易(直接改該機構的DB) | 複雜(需要 EAV 或 JSONB) |
| 備份/還原 | 靈活(以機構為單位) | 複雜(資料混在一起) |
| 新增機構 | 複製一份 schema | 只需要新增 org 紀錄 |
| 水平擴展 | 容易(不同機構分不同 DB server) | 較複雜(需要分片策略) |
對於 AppSystem 的使用情境,database-per-tenant 的優點(強隔離、備份彈性、個別客製化)是關鍵需求,所以當初的選擇是合理的。
但如果讓我重新設計,我會更謹慎地考慮各機構「客製化」的邊界,避免讓各機構的 schema 差異越來越大。
🚨 遇過的真實事故
事故 1:忘記對某個機構執行 Migration
情境:新增一個欄位,批次腳本執行時,其中一個機構因為資料庫鎖定而失敗,但沒有被注意到。
症狀:那個機構的某個功能出現 Unknown column 'xxx' in field list 錯誤。
教訓:Migration 完成後,要用 SQL 驗證所有機構的 schema 確實一致:
-- 檢查所有機構的 typeB_records 是否都有 new_field 欄位
SELECT
TABLE_SCHEMA as db_name,
COLUMN_NAME,
COLUMN_TYPE
FROM information_schema.COLUMNS
WHERE TABLE_NAME = 'typeB_records'
AND COLUMN_NAME = 'new_field'
ORDER BY TABLE_SCHEMA;
如果這個查詢的結果數量不等於機構總數,代表有機構沒有執行成功。
事故 2:Session 切換資料庫失效
情境:PHP 程式中,在 Session 更新後,某個函式仍然在查詢舊的資料庫。
根本原因:某個舊的函式有一個「本地快取」的資料庫連線,沒有透過 DBPDO::getConnection() 取得,而是直接使用了一個 module 層級的 $db 變數。
教訓:CLAUDE.md 中明確記錄這個問題,告知 Claude Code 不要直接使用本地 $db 變數,一律透過 DBPDO::getConnection() 取得連線。
💡 給正在考慮這個架構的人
如果你正在設計一個多租戶系統,以下幾點值得考慮:
1. 預先規劃 Schema 版本管理
不要等到有 10+ 個機構才開始思考 migration 策略。
從第一個機構開始就建立 schema_versions 追蹤,養成 idempotent SQL 的習慣。
2. 嚴格控制各機構的 Schema 差異
每一個「只在某機構部署的欄位」,未來都是維護成本。 盡量讓所有機構的 schema 保持一致,特殊需求用 JSON 欄位或設定表處理。
3. 建立自動化驗證
# 定期執行:驗證所有機構的 schema 版本
for ORG_NO in $(get_all_org_numbers); do
LATEST_VERSION=$(get_latest_migration_version)
APPLIED_VERSION=$(get_org_schema_version $ORG_NO)
if [ "$LATEST_VERSION" != "$APPLIED_VERSION" ]; then
echo "警告:demo-org-${ORG_NO} 的 schema 版本落後!"
echo " 目前版本:${APPLIED_VERSION}"
echo " 最新版本:${LATEST_VERSION}"
fi
done
4. Migration 一定要先在測試環境驗證
特別是對 MySQL 5.7,某些 DDL 操作的行為和你預期的可能不同。 先在測試環境的所有機構跑一遍,確認沒問題再上正式環境。
🎉 結語
Database-per-tenant 不是一個完美的架構,但在特定情境下(強隔離需求、大量個別客製化),它是合理的選擇。
關鍵在於:接受它帶來的維護成本,並建立對應的工具和流程來管理它。
批次執行腳本、idempotent migration、schema 版本追蹤——這些不是「過度工程」,而是讓 database-per-tenant 可持續運作的必要基礎建設。
如果你也在維護類似的架構,希望這篇文章能讓你少踩一些坑。
📎 相關文章:
- 打造 AI 友善的專案文檔:CLAUDE.md 完整指南(中) — 多租戶架構的文檔化
- 讓 AI 幫你寫 SQL Migration:多租戶資料庫的批次變更 — 即將發布