前言
如果你做過 SaaS 產品,一定會遇到一個經典問題:「多個客戶的資料,要怎麼放?」這就是所謂的 Multi-tenant(多租戶)架構。
我在工作中經歷過兩種主要做法——Schema-per-tenant 和 Row-level Security(RLS),各有各的痛點。這篇文章想把我的實戰經驗整理出來,幫助你在下一個 SaaS 專案中做出更適合的選擇。
什麼是 Multi-tenant?
Multi-tenant 架構的核心概念很簡單:一套系統服務多個客戶(租戶),每個租戶的資料彼此隔離、互不干擾。
想像你開了一棟公寓大樓:
- 每戶有自己的空間(資料隔離)
- 共用電梯、水電管線(共用基礎設施)
- 住戶不能闖進別人家(存取控制)
在資料庫層面,常見的做法有三種:
- Database-per-tenant:每個租戶一個獨立資料庫
- Schema-per-tenant:同一個資料庫,每個租戶一個 Schema
- Row-level isolation:同一張表,用欄位區分租戶
第一種太重,通常只有企業級大客戶才值得這樣搞。所以今天聚焦在第二和第三種。
Schema-per-tenant:每個租戶一個 Schema
概念
在 PostgreSQL 中,Schema 是 Database 底下的命名空間。你可以把它想成「資料夾」——同一個資料庫裡,每個租戶有自己的一組資料表。
-- 建立租戶專屬 Schema
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_globex;
-- 在各自的 Schema 下建表
CREATE TABLE tenant_acme.users (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
email VARCHAR(255)
);
CREATE TABLE tenant_globex.users (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
email VARCHAR(255)
);
切換租戶
應用程式在處理請求時,根據租戶資訊切換 search_path:
-- 設定當前連線的 Schema 搜尋路徑
SET search_path TO tenant_acme, public;
-- 之後的查詢自動指向 tenant_acme
SELECT FROM users; -- 等同於 SELECT FROM tenant_acme.users
在後端程式中,通常會寫成 middleware:
# Django middleware 範例
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
tenant = self.resolve_tenant(request)
connection = connections['default']
with connection.cursor() as cursor:
cursor.execute(f"SET search_path TO {tenant.schema_name}, public")
response = self.get_response(request)
return response
def resolve_tenant(self, request):
hostname = request.get_host().split(':')[0]
subdomain = hostname.split('.')[0]
return Tenant.objects.get(subdomain=subdomain)
優點
- 強隔離:租戶之間完全隔離,不可能因為忘記加 WHERE 條件而洩漏資料
- 獨立遷移:可以針對特定租戶做資料庫變更,灰度發布變得容易
- 備份還原:可以單獨備份、還原某個租戶的資料
- 效能調優:可以針對大客戶的 Schema 做獨立的索引優化
缺點
- Schema 爆炸:租戶多了之後,幾百個 Schema 的管理會很頭痛
- 遷移成本高:每次改表結構,要對所有 Schema 執行遷移
- 連線池問題:每次切換 Schema 要重設
search_path,跟連線池不太搭 - 跨租戶查詢困難:管理後台要看全部租戶的統計數據很麻煩
Row-level Isolation:同表不同列
概念
所有租戶共用同一張表,用 tenant_id 欄位來區分資料歸屬。
CREATE TABLE users (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id),
name VARCHAR(100),
email VARCHAR(255)
);
-- 一定要加索引
CREATE INDEX idx_users_tenant ON users(tenant_id);
-- 查詢時永遠帶上 tenant_id
SELECT * FROM users WHERE tenant_id = 42;
用 PostgreSQL RLS 強制隔離
Row-level isolation 最大的風險是「有人忘記加 WHERE tenant_id = ?」。PostgreSQL 的 Row-Level Security(RLS) 可以在資料庫層面強制過濾:
-- 啟用 RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- 建立策略:只能看到自己租戶的資料
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.current_tenant')::INTEGER);
-- 強制表的擁有者也受 RLS 約束(預設不會)
ALTER TABLE users FORCE ROW LEVEL SECURITY;
應用程式在每次請求開始時設定當前租戶:
-- 在 transaction 開始時設定
SET LOCAL app.current_tenant = '42';
-- 之後的查詢自動被 RLS 過濾
SELECT * FROM users;
-- 實際執行的是:SELECT * FROM users WHERE tenant_id = 42
後端整合範例(Node.js + Knex):
// middleware: 設定當前租戶
app.use(async (req, res, next) => {
const tenantId = extractTenantId(req);
// 在請求上下文中保存 tenant
req.tenantId = tenantId;
// 使用 knex 的 transaction 包裝
req.db = await knex.transaction(async (trx) => {
await trx.raw(SET LOCAL app.current_tenant = '${tenantId}');
return trx;
});
next();
});
// 路由中直接查詢,RLS 自動過濾
app.get('/api/users', async (req, res) => {
const users = await req.db('users').select('*');
// 不需要手動加 WHERE tenant_id = ?
res.json(users);
});
優點
- 簡單統一:只有一套表結構,遷移只需執行一次
- 跨租戶查詢容易:管理後台可以直接聚合統計
- 連線池友好:不需要切換 Schema
- 擴展性好:配合分區表(partitioning by tenant_id),可以處理大量資料
缺點
- 隔離較弱:依賴 RLS 或應用層邏輯,出錯的風險較高
- 資料量集中:單表資料量可能非常大,需要考慮分區
- 備份還原困難:很難單獨備份某個租戶的資料
- 噪音鄰居問題:一個大租戶的查詢可能影響其他租戶的效能
實際比較:什麼情況用哪種?
| 考量面向 | Schema-per-tenant | Row-level (RLS) |
|———|——————-|—————–|
| 租戶數量 | 適合 < 100 個 | 適合 100+ 個 |
| 資料隔離要求 | 高(金融、醫療) | 中等 |
| 維護成本 | 較高 | 較低 |
| 開發複雜度 | 中等 | 較低 |
| 效能 | 各租戶獨立 | 需注意大表優化 |
| 客製化需求 | 容易做差異化 | 較難 |
我的經驗法則
選 Schema-per-tenant 的情境:
- 企業客戶少、但每個客戶很重要(B2B enterprise)
- 客戶有嚴格的資料隔離合規要求
- 不同客戶需要不同版本的資料結構
選 Row-level (RLS) 的情境:
- 租戶數量多(幾百甚至上萬)
- 標準化的 SaaS 產品,所有租戶功能一致
- 團隊規模不大,希望降低維護成本
進階:混合策略
實務上,有些團隊會採用混合策略:
一般租戶 → Row-level isolation(共享表)
VIP 大客戶 → Schema-per-tenant(獨立 Schema)
超大型客戶 → Database-per-tenant(獨立資料庫)
這需要更複雜的路由邏輯,但能兼顧成本和隔離需求。
class HybridTenantRouter:
def route_for_tenant(self, tenant):
if tenant.tier == 'enterprise':
return SchemaConnection(tenant.schema_name)
elif tenant.tier == 'dedicated':
return DatabaseConnection(tenant.db_config)
else:
return SharedConnection(tenant.id)
PostgreSQL RLS 實作注意事項
如果你選擇了 RLS,有幾個陷阱要注意:
1. 別忘了 FORCE
-- 沒有 FORCE,表的 owner 不受 RLS 約束
ALTER TABLE users FORCE ROW LEVEL SECURITY;
2. Superuser 不受 RLS 影響
資料庫的 superuser 會繞過所有 RLS 策略。應用程式連線絕對不要用 superuser。
3. 注意 JOIN 的陷阱
-- 如果 orders 有 RLS 但 order_items 沒有...
SELECT oi.* FROM order_items oi
JOIN orders o ON o.id = oi.order_id;
-- order_items 可能洩漏其他租戶的資料!
所有相關的表都要設定 RLS。
4. 測試 RLS 策略
-- 用指定的角色測試
SET ROLE app_user;
SET app.current_tenant = '1';
SELECT * FROM users; -- 應該只看到 tenant_id = 1 的資料
SET app.current_tenant = '2';
SELECT * FROM users; -- 應該只看到 tenant_id = 2 的資料
RESET ROLE;
小結
Multi-tenant 資料庫設計沒有銀彈。Schema-per-tenant 提供強隔離但維護成本高;Row-level isolation 靈活輕量但要小心安全漏洞。
我的建議是:先從 Row-level + RLS 開始,等到真的有大客戶需要獨立環境時,再把他們遷移到獨立 Schema 或 Database。過早的優化和過度的架構設計,都是時間的敵人。
延伸閱讀
- PostgreSQL Row Level Security 官方文件
- Citus Data Multi-tenant SaaS Tutorial
- Django Tenants — 基於 Schema-per-tenant 的 Django 套件
- Apartment gem — Ruby on Rails 的多租戶方案