跳至主要内容

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)

我們需要在單一資料庫中實現租戶間的數據隔離,確保:

  1. 安全性:租戶 A 無法存取租戶 B 的數據
  2. 性能:查詢效能不能因為多租戶機制而大幅下降
  3. 可擴展性:支援未來的水平擴展(sharding by tenant)
  4. 開發效率:開發者不需要在每個查詢都手動加上 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 欄位,搭配:

  1. TenantAwareEntity 基類自動注入/驗證
  2. TenantFilterInterceptor 自動啟用 Hibernate Filter
  3. 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 + 應用層維護

核心理由:

  1. 性能優先: 避免每次查詢 JOIN,單表查詢效能最佳
  2. 自動化: 透過 Hibernate Filter 自動過濾,開發者無需手動處理
  3. 可擴展性: 支援未來按租戶分片(sharding by tenant)
  4. 開發效率: 繼承 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 - 組合 resolver
  • TenantAwareUserDetails - UserDetails 擴展介面
  • TenantAware - JPA Entity 介面
  • TenantFilterSupport - Hibernate Filter 工具

應用層實作(app-server)

  • TenantConfig - 配置 TenantContext
  • TenantAwareEntity - 實作 TenantAware 的基類
  • AccountUserDetails - 實作 TenantAwareUserDetails
  • TenantFilterAspect - 使用 TenantFilterSupport 的 AOP

必須遵守的規則

  1. 配置 TenantContext

    @Configuration
    public class TenantConfig {
    @PostConstruct
    public void configure() {
    TenantContext.configureWithDefaults();
    }
    }
  2. 所有租戶感知 Entity 必須繼承 TenantAwareEntity

    @Entity
    public class Customer extends TenantAwareEntity { }
  3. 必須加上 @Filter 註解

    @Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
  4. @Table.indexes 加上 tenant_id 索引

    @Index(name = "idx_customer_tenant", columnList = "tenant_id")
  5. 不要建立 @ManyToOneTenant 的關聯

    // ❌ 錯誤
    @ManyToOne private Tenant tenant;

    // ✅ 正確(繼承自 TenantAwareEntity)
    // private String tenantId; (已繼承)
  6. 原生 SQL 必須手動加上 WHERE tenant_id = ?

    @Query(nativeQuery = true, value =
    "SELECT * FROM customer WHERE tenant_id = :tenantId AND status = 'ACTIVE'")

建議的最佳實踐

  1. 使用 TenantContext.getCurrentTenantId() 獲取當前租戶 ID
  2. 使用 TenantContext.runAs() 在排程任務中設定租戶上下文
  3. 在整合測試中驗證跨租戶隔離
  4. 避免使用原生 SQL,優先使用 JPQL 或 Specification
  5. 在 Service 層記錄審計日誌時包含 tenantId

檢查清單

  • 建立 TenantConfig 配置類
  • Entity 繼承 TenantAwareEntity
  • 加上 @Filter 註解
  • 加上 tenant_id 索引
  • 不建立 @ManyToOne Tenant 關聯
  • 整合測試驗證跨租戶隔離
  • 原生 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❌ 不套用
跨租戶查詢✅ 支援(登入認證必須)

設計理由

  1. 登入認證需要跨租戶查詢

    • 使用者登入時只提供 username
    • 系統需要在所有租戶中尋找該帳號
    • 如果套用 Hibernate Filter,會找不到其他租戶的帳號
  2. 支援系統層級資料

    • Account.tenantId = null 表示系統管理員(不屬於任何租戶)
    • Role.tenantId = null 表示系統角色(如 SUPER_ADMIN),可跨租戶使用
  3. 查詢邏輯較複雜

    // Role 需要同時查詢系統角色和租戶自訂角色
    WHERE tenantId IS NULL OR tenantId = :currentTenantId

判斷準則

問題是 → 業務層否 → 認證層
數據是否完全隔離於單一租戶?Customer、OrderAccount、Role
tenantId 是否必填?ProductAccount(系統管理員為 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.TenantContext
  • io.leandev.appfuse.security.tenant.TenantContextCleanupFilter
  • io.leandev.appfuse.security.tenant.TenantAwareUserDetails
  • io.leandev.appfuse.security.tenant.resolver.TenantIdResolver
  • io.leandev.appfuse.security.tenant.resolver.CompositeTenantIdResolver
  • io.leandev.appfuse.jpa.tenant.TenantAware
  • io.leandev.appfuse.jpa.tenant.TenantFilterSupport

應用層原始碼(參考實作 app-server)

以下是參考實作中的相關檔案,供開發者參考:

  • io.leandev.app.config.TenantConfig
  • io.leandev.app.entity.base.TenantAwareEntity
  • io.leandev.app.security.AccountUserDetails
  • io.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