ADR-001: 多租戶數據隔離策略
ADR 編號: 001 狀態: 已接受 (Accepted) 決策日期: 2025-12-23 更新日期: 2026-01-14 決策者: Development Team 取代: 無 被取代: 無
摘要
在單一資料庫中使用應用層維護的 String tenantId 搭配 Hibernate Filter,而非資料庫外鍵約束,實現多租戶數據隔離。
背景 (Context)
問題陳述
app-server 是一個多租戶 SaaS 花店管理系統,每個租戶(花店)需要完全隔離的數據:
- 客戶資料 (Customer)
- 訂單資料 (Order)
- 產品目錄 (Product)
- 用戶帳號 (Account)
我們需要在單一資料庫中實現租戶間的數據隔離,確保:
- 安全性:租戶 A 無法存取租戶 B 的數據
- 性能:查詢效能不能因為多租戶機制而大幅下降
- 可擴展性:支援未來的水平擴展(sharding by tenant)
- 開發效率:開發者不需要在每個查詢都手動加上 tenantId 過濾
限制條件
- 資料庫: 使用單一共享資料庫(開發環境 H2,生產環境 PostgreSQL/MySQL)
- 框架: Spring Boot 3.5 + JPA/Hibernate
- 性能要求: 常見查詢需在 100ms 內完成
- 開發資源: 中小型團隊,需要簡單易維護的方案
假設前提
- 租戶數量預計在 1000 以內(中短期)
- 單一租戶的數據量在合理範圍(< 1M 筆記錄)
- 不考慮跨租戶的數據查詢需求
- 租戶 ID 在用戶登入時即可確定(存於 JWT token)
考量的方案 (Options Considered)
方案 A: @ManyToOne 外鍵關聯
說明:
使用 JPA @ManyToOne 建立 Entity → Tenant 的外鍵關聯,由資料庫層級保證完整性。
@Entity
public class Customer {
@ManyToOne
@JoinColumn(name = "tenant_id", nullable = false)
private Tenant tenant;
}
優點:
- ✅ 資料庫層級保證數據完整性(外鍵約束)
- ✅ 符合傳統關聯式資料庫設計慣例
- ✅ 開發者熟悉的 JPA 模式
缺點:
- ❌ 每次查詢需要 JOIN Tenant 表(性能開銷)
- ❌ 容易觸發 N+1 查詢問題
- ❌ 難以支援未來的水平擴展(跨分片外鍵困難)
- ❌ 需要在每個查詢手動加上
WHERE tenant = ?
評分: 2/5
方案 B: String tenantId + 應用層維護 (✅ 選擇)
說明:
使用 String tenantId 欄位,搭配:
TenantAwareEntity基類自動注入/驗證TenantFilterInterceptor自動啟用 Hibernate Filter- JWT token 攜帶 tenantId
@Entity
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Customer extends TenantAwareEntity {
// tenantId 繼承自 TenantAwareEntity
}
優點:
- ✅ 單表查詢,無 JOIN 開銷
- ✅ 不存在 N+1 問題
- ✅ 支援未來按租戶分片(sharding)
- ✅ 自動過濾(Hibernate Filter),開發者無需關心
- ✅ 三層保護機制(PrePersist + PreUpdate + Filter)
缺點:
- ❌ 放棄資料庫層級的外鍵完整性保證
- ❌ 需要應用層確保隔離機制正確
- ❌ 原生 SQL 查詢需手動加上 WHERE 條件
評分: 5/5
方案 C: Schema per Tenant
說明: 每個租戶使用獨立的資料庫 Schema。
優點:
- ✅ 完全隔離(資料庫層級)
- ✅ 單一租戶的備份/還原簡單
缺點:
- ❌ Schema 變更(migration)需要執行 N 次
- ❌ 管理複雜度高(需要 Schema 路由機制)
- ❌ 難以執行跨租戶的統計分析
- ❌ 資料庫連線池管理複雜
評分: 2/5
方案 D: Database per Tenant
說明: 每個租戶使用獨立的資料庫實例。
優點:
- ✅ 完全隔離(實例層級)
- ✅ 租戶間互不影響
缺點:
- ❌ 成本高(需要大量資料庫實例)
- ❌ 運維複雜度極高
- ❌ 不適合中小型 SaaS
評分: 1/5
決策 (Decision)
選擇方案: B - String tenantId + 應用層維護
核心理由:
- 性能優先: 避免每次查詢 JOIN,單表查詢效能最佳
- 自動化: 透過 Hibernate Filter 自動過濾,開發者無需手動處理
- 可擴展性: 支援未來按租戶分片(sharding by tenant)
- 開發效率: 繼承
TenantAwareEntity即可自動獲得多租戶功能
權衡分析 (Trade-offs)
我們獲得什麼 (Gains)
- ✅ 查詢性能: 每個查詢都是單表查詢,無 JOIN 開銷
- ✅ 自動化: tenantId 自動注入、自動驗證、自動過濾
- ✅ 可擴展性: 支援未來按租戶分片(sharding)
- ✅ 開發效率: 繼承基類即可,不需要每次手動處理
我們放棄什麼 (Losses)
- ❌ 資料庫完整性: 放棄資料庫層級的外鍵約束保證
- ❌ 傳統設計: 不符合傳統 ORM 的關聯設計模式
風險與緩解措施 (Risks & Mitigations)
| 風險 | 嚴重性 | 機率 | 緩解措施 |
|---|---|---|---|
| 應用層隔離機制失效,導致跨租戶數據洩漏 | 高 | 低 | 三層保護機制:@PrePersist、@PreUpdate、Hibernate Filter |
| 開發者忘記啟用 Filter,導致查詢到所有租戶數據 | 高 | 低 | TenantFilterInterceptor 自動啟用,不需手動處理 |
| 原生 SQL 忘記加上 WHERE tenant_id = ? | 中 | 中 | 程式碼審查時檢查、建議避免使用原生 SQL |
| TenantContext 未正確設定 | 中 | 低 | 整合測試覆蓋、JWT 自動注入 tenantId |
影響 (Consequences)
正面影響
- ➕ 性能提升: 查詢速度顯著提升(無 JOIN)
- ➕ 開發體驗: 開發者不需關心租戶過濾邏輯
- ➕ 可維護性: 清晰的繼承架構,易於理解和擴展
- ➕ 未來擴展: 為水平擴展預留空間
負面影響
- ➖ 複雜度轉移: 從資料庫層級轉移到應用層
- ➖ 測試負擔: 需要額外測試跨租戶隔離機制
中性影響
- 🔸 架構決策: 確立了應用層優先於資料庫層的設計哲學
- 🔸 學習曲線: 新成員需要理解 Hibernate Filter 機制
實作指南 (Implementation Guidelines)
框架化設計(2025-12-30 更新)
多租戶核心工具已重構至 appfuse-server 框架,應用程式可選擇是否啟用:
框架提供(appfuse-server):
TenantContext- 租戶上下文工具TenantIdResolver- 租戶 ID 解析策略介面CompositeTenantIdResolver- 組合 resolverTenantAwareUserDetails- UserDetails 擴展介面TenantAware- JPA Entity 介面TenantFilterSupport- Hibernate Filter 工具
應用層實作(app-server):
TenantConfig- 配置 TenantContextTenantAwareEntity- 實作 TenantAware 的基類AccountUserDetails- 實作 TenantAwareUserDetailsTenantFilterAspect- 使用 TenantFilterSupport 的 AOP
必須遵守的規則
-
配置 TenantContext
@Configuration
public class TenantConfig {
@PostConstruct
public void configure() {
TenantContext.configureWithDefaults();
}
} -
所有租戶感知 Entity 必須繼承
TenantAwareEntity@Entity
public class Customer extends TenantAwareEntity { } -
必須加上
@Filter註解@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") -
在
@Table.indexes加上tenant_id索引@Index(name = "idx_customer_tenant", columnList = "tenant_id") -
不要建立
@ManyToOne到Tenant的關聯// ❌ 錯誤
@ManyToOne private Tenant tenant;
// ✅ 正確(繼承自 TenantAwareEntity)
// private String tenantId; (已繼承) -
原生 SQL 必須手動加上 WHERE tenant_id = ?
@Query(nativeQuery = true, value =
"SELECT * FROM customer WHERE tenant_id = :tenantId AND status = 'ACTIVE'")
建議的最佳實踐
- 使用
TenantContext.getCurrentTenantId()獲取當前租戶 ID - 使用
TenantContext.runAs()在排程任務中設定租戶上下文 - 在整合測試中驗證跨租戶隔離
- 避免使用原生 SQL,優先使用 JPQL 或 Specification
- 在 Service 層記錄審計日誌時包含 tenantId
檢查清單
- 建立
TenantConfig配置類 - Entity 繼承
TenantAwareEntity - 加上
@Filter註解 - 加上
tenant_id索引 - 不建立
@ManyToOneTenant 關聯 - 整合測試驗證跨租戶隔離
- 原生 SQL(如有)手動加上 WHERE 條件
認證層 vs 業務層的多租戶差異
並非所有 Entity 都適合使用 TenantAwareEntity。根據 Entity 的用途,分為兩種多租戶處理方式:
業務層 Entity(使用 TenantAwareEntity)
適用對象:Customer、Product、Order 等業務數據
| 特性 | 說明 |
|---|---|
| 基類 | 繼承 TenantAwareEntity |
| tenantId | 必填,由基類定義 |
| 自動注入 | ✅ @PrePersist 自動從 TenantContext 注入 |
| 自動驗證 | ✅ @PreUpdate 驗證 tenantId 防止跨租戶更新 |
| Hibernate Filter | ✅ 自動套用,查詢自動加上 WHERE tenant_id = ? |
| 跨租戶查詢 | ❌ 不支援(被 Filter 隔離) |
設計理由:
- 業務數據完全隔離於每個租戶
- 無需查詢其他租戶的業務數據
- 自動過濾簡化 Service 層邏輯
認證層 Entity(不使用 TenantAwareEntity)
適用對象:Account、Role 等認證授權相關 Entity
| 特性 | 說明 |
|---|---|
| 基類 | 繼承 AuditableBase(不是 TenantAwareEntity) |
| tenantId | 手動定義,可為 null |
| 自動注入 | ❌ 由應用層明確設定 |
| 自動驗證 | ❌ 不驗證(允許系統管理員操作) |
| Hibernate Filter | ❌ 不套用 |
| 跨租戶查詢 | ✅ 支援(登入認證必須) |
設計理由:
-
登入認證需要跨租戶查詢
- 使用者登入時只提供 username
- 系統需要在所有租戶中尋找該帳號
- 如果套用 Hibernate Filter,會找不到其他租戶的帳號
-
支援系統層級資料
Account.tenantId = null表示系統管理員(不屬於任何租戶)Role.tenantId = null表示系統角色(如 SUPER_ADMIN),可跨租戶使用
-
查詢邏輯較複雜
// Role 需要同時查詢系統角色和租戶自訂角色
WHERE tenantId IS NULL OR tenantId = :currentTenantId
判斷準則
| 問題 | 是 → 業務層 | 否 → 認證層 |
|---|---|---|
| 數據是否完全隔離於單一租戶? | Customer、Order | Account、Role |
| tenantId 是否必填? | Product | Account(系統管理員為 null) |
| 是否需要跨租戶查詢? | 不需要 | 登入認證需要 |
實作範例
業務層(Customer):
@Entity
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Customer extends TenantAwareEntity {
// tenantId 由基類管理,自動注入、自動過濾
}
認證層(Account):
@Entity
public class Account extends AuditableBase {
// 手動定義 tenantId,可為 null
@Column(name = "tenant_id", length = 36)
private String tenantId; // null = 系統管理員
// 不套用 @Filter,支援跨租戶查詢
}
相關文檔 (References)
內部文檔
框架原始碼(appfuse-server)
io.leandev.appfuse.security.tenant.TenantContextio.leandev.appfuse.security.tenant.TenantContextCleanupFilterio.leandev.appfuse.security.tenant.TenantAwareUserDetailsio.leandev.appfuse.security.tenant.resolver.TenantIdResolverio.leandev.appfuse.security.tenant.resolver.CompositeTenantIdResolverio.leandev.appfuse.jpa.tenant.TenantAwareio.leandev.appfuse.jpa.tenant.TenantFilterSupport
應用層原始碼(參考實作 app-server)
以下是參考實作中的相關檔案,供開發者參考:
io.leandev.app.config.TenantConfigio.leandev.app.entity.base.TenantAwareEntityio.leandev.app.security.AccountUserDetailsio.leandev.app.config.TenantFilterAspect
外部資源
相關 ADR
變更歷史 (Change Log)
| 日期 | 變更內容 | 變更者 |
|---|---|---|
| 2025-12-23 | 初版 | Development Team |
| 2025-12-30 | 重構至 appfuse-server 框架,新增策略模式 | Development Team |
| 2026-01-14 | 新增「認證層 vs 業務層的多租戶差異」章節 | Development Team |
文檔維護者: Development Team + AI Assistant 最後審閱: 2026-01-14