多租戶(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)——無需任何人啟用 |
| 自動注入 | TenantIdGeneration 於 INSERT 設定 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,實際只有 OSIV(spring.jpa.open-in-view,Boot 預設true)綁定時才生效; 取不到時enableFilter的例外被自身 catch 吞掉。結果:設成false即租戶 filter 從未啟用、 跨租戶資料全可見且完全無聲。詳見 ADR-016。為何用類名字串而非 Spring
@Bean:一旦有實體掛@TenantId,每個 Session 建構都需要租戶 識別,包含不載入應用@Configuration的@DataJpaTestslice——只註冊為@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_TENANT,
isRoot() 使 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 欄位與外部契約一起
調整。
相關資源
- API 參考: API 參考: Tenant
- Javadoc: Javadoc