跳至主要内容

多租戶(Multi-Tenant)使用指南

實現 SaaS 應用的多租戶數據隔離


簡介

AppFuse Server 提供完整的多租戶支援,包括:

  • TenantAwareEntity - 多租戶實體基類(Hibernate 原生 @TenantId
  • TenantContext - 租戶上下文管理,從認證資訊解析租戶
  • TenantContextIdentifierResolver - 把 TenantContext 接到 Hibernate 原生多租戶;無租戶時回 ROOT_TENANT 並經 isRoot() 宣告可存取所有分區
  • TenantAware - 租戶感知介面
  • TenantFilterSupport - @Deprecated(4.0.0,4.2.0 移除),見 ADR-016

核心特色

特色說明
查詢過濾Session 建構時自動套用,entityManager.find()applyToLoadByKey = true)——無需任何人啟用
自動注入TenantIdGenerationINSERT 設定 tenantId(已顯式設值者:root 放行、非 root 須相符)
安全驗證防線在(載入不到即無從更新)+ updatable = false(歸屬不可改寫)
跨租戶root session(無 TenantContext)——系統管理員、背景執行緒
彈性擴展可搭配審計功能使用

快速開始

1. 繼承 TenantAwareEntity

import io.leandev.appfuse.jpa.tenant.TenantAwareEntity;

@Entity
@Table(name = "products")
public class Product extends TenantAwareEntity {

@Id
@GeneratedValue(strategy = GenerationType.UUID)
@JdbcTypeCode(SqlTypes.VARCHAR)
@Column(length = 36, nullable = false, updatable = false)
private UUID id;

private String name;
private BigDecimal price;

// getters and setters
}

2. 配置 TenantContext

在應用程式啟動時配置租戶 ID 解析器:

@Configuration
public class TenantConfig {

@PostConstruct
public void configureTenantContext() {
// 使用預設配置(支援 JWT、OAuth2、Basic Auth)
TenantContext.configureWithDefaults();

// 或指定 JWT claim 名稱
// TenantContext.configureWithDefaults("tenant_id");
}
}

3. 接線 resolver

# config/feature-tenant.yml(記得加入 spring.config.import 清單)
spring:
jpa:
properties:
hibernate:
tenant_identifier_resolver: io.leandev.appfuse.jpa.tenant.TenantContextIdentifierResolver

就這樣——租戶 filter 由 Hibernate 於 Session 建構時自動套用,不需要任何 AOP。

⚠️ 本節原本示範一個帶 @Order(1)TenantFilterAspect 手動 enableFilter,那個 pattern 有 靜默的資安缺陷(下游若已照抄,請一併移除):@Order(1) 使 aspect 跑在交易攔截器之前、 無法自行綁定 Session,實際只有 OSIVspring.jpa.open-in-view,Boot 預設 true)綁定時才生效; 取不到時 enableFilter 的例外被自身 catch 吞掉。結果:設成 false 即租戶 filter 從未啟用、 跨租戶資料全可見且完全無聲。詳見 ADR-016

為何用類名字串而非 Spring @Bean:一旦有實體掛 @TenantId每個 Session 建構都需要租戶 識別,包含不載入應用 @Configuration@DataJpaTest slice——只註冊為 @Bean 會使那些 slice 的 context 起不來。


常見場景

場景 1:多租戶 + 審計功能

大多數業務實體需要同時具備多租戶和審計功能:

// 建立應用層基類
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableTenantEntity extends TenantAwareEntity {

@CreatedBy
@Column(name = "created_by", updatable = false)
private String createdBy;

@CreatedDate
@Column(name = "created_date", updatable = false)
private Instant createdDate;

@LastModifiedBy
@Column(name = "last_modified_by")
private String lastModifiedBy;

@LastModifiedDate
@Column(name = "last_modified_date")
private Instant lastModifiedDate;

// getters and setters
}

// 業務實體繼承應用層基類
@Entity
public class Customer extends AuditableTenantEntity {
// ...
}

場景 2:排程任務中設定租戶

排程任務沒有 HTTP 請求上下文,需手動設定租戶:

@Scheduled(cron = "0 0 2 * * ?")
public void dailyReport() {
List<Tenant> tenants = tenantRepository.findAllActive();

for (Tenant tenant : tenants) {
TenantContext.runAs(tenant.getId(), () -> {
// 這裡的所有操作都在指定租戶上下文中執行
reportService.generateDailyReport();
});
}
}

場景 3:管理員跨租戶查詢

root session——即在TenantContext 時開交易。resolver 回報 ROOT_TENANTisRoot() 使 Hibernate 跳過租戶 filter,該 Session 遂看得到所有分區。

/// 呼叫前確保無 TenantContext(系統管理員本就無租戶;背景執行緒天生無 context)
@Transactional(readOnly = true)
public List<Order> findAllOrdersAcrossTenants() {
return orderRepository.findAll(); // root session → 無 filter → 跨租戶
}

不要用 TenantFilterSupport.disableFilter()(已 @Deprecated)——租戶綁在 Session 建構時, 關閉 filter 是繞過設計意圖;root session 才是 Hibernate 明文支援的模型 (isRoot 的定義即「a root tenant with access to all partitions」)。

root 的權界:讀=放行;改他人租戶既有列=放行(刻意;歸屬不受影響);建立租戶資料須 指明租戶(runAs 或顯式 setTenantId),否則被 TenantAwareEntity@PrePersist 守衛擋下 ——否則 ROOT_TENANT 哨兵會被寫成歸屬,該列對所有租戶皆不可見。

場景 4:自訂租戶 ID 解析

如果預設的解析器不符合需求,可自訂:

TenantContext.configure(
CompositeTenantIdResolver.builder()
.withJwtClaim("organization_id") // 從 JWT 的 organization_id claim 取得
.withUserDetails() // 或從 UserDetails 取得
.build()
);

運作機制

整體架構

┌─────────────────────────────────────────────────────────────┐
│ HTTP Request (JWT 包含 tenantId claim) │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ TenantContext │
│ - 從 JWT / Authentication 解析 tenantId │
│ - ThreadLocal 儲存當前租戶 │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Session 建構 (Hibernate 自動,無需應用層介入) │
│ - TenantContextIdentifierResolver 讀 TenantContext │
│ - 有租戶 → 自動啟用租戶 filter │
│ - 無租戶 → ROOT_TENANT + isRoot() → 跳過 filter │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ Hibernate 租戶 filter (_tenantId) │
│ - 所有查詢自動加 WHERE tenant_id = :tenantId │
│ - applyToLoadByKey = true → entityManager.find() 亦受過濾 │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ TenantAwareEntity (@TenantId) │
│ - INSERT: TenantIdGeneration 注入 tenantId │
│ - UPDATE: updatable = false,歸屬不可改寫 │
│ - @PrePersist 守衛: 擋「租戶無處可得」的持久化 │
└─────────────────────────────────────────────────────────────┘

@TenantId 配置

TenantAwareEntity 使用 Hibernate 原生 discriminator:

@MappedSuperclass
public abstract class TenantAwareEntity implements TenantAware {

@TenantId
@Column(name = "tenant_id", length = 36,
nullable = false, updatable = false)
private String tenantId;
}
註解說明
@TenantId宣告 Hibernate discriminator;Session 建立時自動套用隔離
updatable = false禁止以 UPDATE 改寫資料的租戶歸屬

安全性保護

操作保護機制
新增(INSERT)TenantIdGeneration 注入;已顯式設值者 root 放行、非 root 不符則拋 PropertyValueException@PrePersist 守衛另擋「租戶無處可得」
查詢(SELECT)Session 建構時自動套用 filter,find()
更新(UPDATE)防線在(載入不到即無從更新)+ updatable = false(歸屬不可改寫)。JPQL bulk update 亦受過濾(實測跨租戶影響 0 列)
刪除(DELETE)同上——載入不到即無從刪除;JPQL bulk delete 受過濾
原生 SQL不受保護,需手動加 WHERE

進階配置

自訂 Filter 名稱

不再適用。原生機制的 filter 名稱由 Hibernate 固定為 TenantIdBinder.FILTER_NAME_tenantId)、 自動啟用,不由應用層命名或管理。原本示範的 TenantFilterSupport.enableFilter(em, id, filterName, paramName) 已隨 TenantFilterSupport @Deprecated(4.0.0,4.2.0 移除)。

需要額外的非租戶維度過濾(如部門),仍可自行宣告獨立的 @FilterDef/@Filter——見下節。

複合租戶策略

對於需要多層租戶隔離的場景(如:組織 > 部門 > 團隊):

// 可定義多個 Filter
@FilterDef(name = "orgFilter", parameters = @ParamDef(name = "orgId", type = String.class))
@FilterDef(name = "deptFilter", parameters = @ParamDef(name = "deptId", type = String.class))
@Filter(name = "orgFilter", condition = "organization_id = :orgId")
@Filter(name = "deptFilter", condition = "department_id = :deptId")
public abstract class HierarchicalTenantEntity {
// ...
}

常見問題

Q: Filter 只影響查詢嗎?

不完全是。租戶 filter 主要影響 SELECT(含 entityManager.find()——applyToLoadByKey = true), JPQL bulk update / delete 亦受過濾(實測跨租戶影響 0 列)。INSERT 由 TenantIdGeneration 注入、 UPDATE 由 updatable = false 加上「載入不到即無從更新」保護。

原生 SQL 不受任何保護,需手動加 WHERE 條件。

Q: 為什麼不再需要 AOP 啟用 Filter?

Hibernate 原生 discriminator 多租戶在 Session 建構時就套用了,沒有「該由誰、在何時啟用」的問題。

歷史教訓:本問原本回答「AOP 在事務開始後啟用 filter,確保使用正確的 Session」——該說法從未 成立@Order(1) 使 aspect 成為外層、跑在事務之前,根本拿不到交易的 Session;它「看起來能動」 只因 OSIV 已先綁定一個。這份指南把錯誤的機轉寫成設計理由,是該缺陷得以長期潛伏、並經由複製散佈 到下游的原因。見 ADR-016

Q: 如何測試多租戶功能?

@BeforeEach
void setUp() {
TenantContext.setCurrentTenantId("test-tenant-001");
}

@AfterEach
void tearDown() {
TenantContext.clear();
}

@Test
void shouldIsolateDataByTenant() {
// 測試代碼
}

Q: tenantId 欄位長度為什麼是 36?

36 可容納含連字符的 UUID,也作為目前 tenant protocol key 的共同上限; tenant ID 本身仍是 String,可使用 default-tenant 等非 UUID 值。如果 需要更長的格式,必須連同 Tenant、所有 tenant_id 欄位與外部契約一起 調整。


相關資源