前言

如果你做過 SaaS 產品,一定會遇到一個經典問題:「多個客戶的資料,要怎麼放?」這就是所謂的 Multi-tenant(多租戶)架構

我在工作中經歷過兩種主要做法——Schema-per-tenant 和 Row-level Security(RLS),各有各的痛點。這篇文章想把我的實戰經驗整理出來,幫助你在下一個 SaaS 專案中做出更適合的選擇。


什麼是 Multi-tenant?

Multi-tenant 架構的核心概念很簡單:一套系統服務多個客戶(租戶),每個租戶的資料彼此隔離、互不干擾。

想像你開了一棟公寓大樓:

  • 每戶有自己的空間(資料隔離)
  • 共用電梯、水電管線(共用基礎設施)
  • 住戶不能闖進別人家(存取控制)

在資料庫層面,常見的做法有三種:

  1. Database-per-tenant:每個租戶一個獨立資料庫
  2. Schema-per-tenant:同一個資料庫,每個租戶一個 Schema
  3. 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。過早的優化和過度的架構設計,都是時間的敵人。

延伸閱讀