多租戶設計指南
文檔版本: v2.0.0 最後更新: 2025-12-30 適用對象: 開發團隊、AI Agent、使用 app-tenant-server 範本的團隊
本文檔提供多租戶功能的實作指南,包括架構說明、實作方式、使用範例和最佳實踐。
決策理由請參閱:ADR-001: 多租戶數據隔離策略
目錄
1. 架構概覽
設計原則
核心理念:應用層提供租戶 context,Hibernate 原生 @TenantId
discriminator 在 Session 邊界自動隔離資料;資料庫不建立指向 Tenant 的外鍵。
框架化設計:核心工具由 appfuse-server 框架提供,應用程式可選擇是否啟用多租戶功能。
多租戶流程
┌─────────────────────────────────────────────────────────────────┐
│ 1. 認證階段 (AuthController) │
│ - 用戶登入 │
│ - 從 Account 獲取 tenantId │
│ - 注入到 JWT token claims │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 2. 請求處理階段 (TenantContext + TenantIdResolver) │
│ - TenantIdResolver 從認證資訊解析 tenantId │
│ - 支援多種認證方式:OAuth2 JWT、本地 JWT、Basic Auth │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 3. Session 建構階段 (Hibernate 原生 discriminator 多租戶) │
│ - TenantContextIdentifierResolver 讀 TenantContext │
│ - 有租戶 → Hibernate 自動套用租戶 filter(無需任何人啟用) │
│ - 無租戶 → ROOT_TENANT 哨兵 + isRoot() → 跳過 filter │
│ (=系統管理員 / 背景執行緒的跨租戶視野) │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 4. Entity 操作階段 (@TenantId) │
│ - 查詢:filter 自動加 WHERE tenant_id = ? │
│ (含 entityManager.find():applyToLoadByKey = true) │
│ - 新增:TenantIdGeneration 於 INSERT 注入 tenantId │
│ 已顯式設值者:root 放行、非 root 須相符 │
│ - 更新:tenant_id 為 updatable = false,歸屬不可改寫 │
└─────────────────────────────────────────────────────────────────┘
關鍵:租戶在 Session 建構時綁定(第 3 階段),非在 persist 時讀取。故業務碼必須 先設
TenantContext、再開交易。詳見 ADR-016。
2. 框架與應用層分工
多租戶功能採用框架提供工具、應用層選擇啟用的設計:
appfuse-server 框架提供
| 組件 | 說明 |
|---|---|
TenantContext | 租戶上下文工具類 |
TenantContextCleanupFilter | 請求結束後清理 ThreadLocal |
TenantIdResolver | 租戶 ID 解析策略介面 |
JwtClaimTenantIdResolver | OAuth2 JWT 解析實作 |
JwtDetailsTenantIdResolver | 本地 JWT 解析實作 |
UserDetailsTenantIdResolver | Basic Auth 解析實作 |
CompositeTenantIdResolver | 組合多個 resolver |
TenantAwareUserDetails | UserDetails 擴展介面 |
TenantAware | JPA Entity 介面 |
TenantAwareEntity | 租戶感知實體基類(@TenantId + @PrePersist 守衛) |
TenantContextIdentifierResolver | 把 TenantContext 接到 Hibernate 原生多租戶;無租戶時回 ROOT_TENANT 並經 isRoot() 宣告可存取所有分區 |
TenantFilterSupport | @Deprecated(4.0.0,4.2.0 移除)——租戶 filter 已由 Hibernate 於 Session 建構時自動套用,不再需要手動 enable/disable |
app-tenant-server 應用層實作
| 組件 | 說明 |
|---|---|
TenantConfig | 配置 TenantContext、註冊 ThreadLocal 清理 Filter |
config/feature-tenant.yml | 以類名字串把 resolver 交給 Hibernate(見 3.7) |
AccountUserDetails | 實作 TenantAwareUserDetails |
TenantFilterAspect已刪除(ADR-016)。舊設計以應用層 AOP 手動enableFilter,該 aspect 因@Order(1)跑在交易攔截器之前、無法自行綁定 Session,實際只有 OSIV 綁定時才生效——spring.jpa.open-in-view=false即靜默跨租戶外洩。原生機制無此失效模式。
3. 核心組件
3.1 TenantContext(框架)
套件: io.leandev.appfuse.security.tenant
從認證資訊中獲取當前租戶 ID,支援多種認證方式:
租戶 ID 是認證 claim、TenantContext 與 Hibernate discriminator 共用的
assigned String protocol identity,不要求 UUID 格式;例如
default-tenant。其 VARCHAR(36) 長度是跨資料庫儲存上限,不代表 Java
型別或值格式是 UUID。
// 配置(應用程式啟動時)
TenantContext.configureWithDefaults();
// 或自訂 claim 名稱
TenantContext.configureWithDefaults("tenant_id");
// 或完全自訂
TenantContext.configure(CompositeTenantIdResolver.builder()
.withJwtClaim("org_id")
.withUserDetails()
.build());
使用方式:
// 取得租戶 ID
String tenantId = TenantContext.getCurrentTenantId();
// 嘗試取得(不拋出異常)
String tenantId = TenantContext.getTenantIdOrNull();
// 檢查是否有租戶上下文
if (TenantContext.hasTenantContext()) {
// ...
}
// 在指定租戶上下文中執行(適用於排程任務)
TenantContext.runAs("tenant-123", () -> {
// 這裡的 TenantContext.getCurrentTenantId() 會返回 "tenant-123"
customerService.processAll();
});
3.2 TenantContextCleanupFilter(框架)
套件: io.leandev.appfuse.security.tenant
確保每個 HTTP 請求結束後清理 TenantContext 的 ThreadLocal,防止在使用 Thread Pool 時發生跨請求的租戶 ID 洩漏。
為什麼需要這個 Filter?
- Servlet 容器(如 Tomcat)使用 Thread Pool 處理請求
- 如果某個請求設定了 ThreadLocal 但未清理,下一個請求可能會讀取到錯誤的租戶 ID
- 這是一個潛在的安全風險,可能導致跨租戶數據洩漏
配置方式(在應用層):
@Configuration
public class TenantConfig {
@Bean
public FilterRegistrationBean<TenantContextCleanupFilter> tenantContextCleanupFilter() {
FilterRegistrationBean<TenantContextCleanupFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new TenantContextCleanupFilter());
registration.addUrlPatterns("/*");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
return registration;
}
}
3.3 TenantIdResolver(框架)
套件: io.leandev.appfuse.security.tenant.resolver
策略介面,定義從 Authentication 解析租戶 ID 的邏輯:
@FunctionalInterface
public interface TenantIdResolver {
String resolve(Authentication authentication);
}
預設實作:
| 實作 | 適用場景 | 解析來源 |
|---|---|---|
JwtClaimTenantIdResolver | OAuth2 JWT | authentication.getPrincipal() → Jwt |
JwtDetailsTenantIdResolver | 本地 JWT | authentication.getDetails() → Claims |
UserDetailsTenantIdResolver | Basic Auth | authentication.getPrincipal() → TenantAwareUserDetails |
組合使用:
// 使用預設組合(推薦)
TenantIdResolver resolver = CompositeTenantIdResolver.withDefaults();
// 使用 Builder 自訂
TenantIdResolver resolver = CompositeTenantIdResolver.builder()
.withJwtClaim("tenantId")
.withJwtDetails("tenantId")
.withUserDetails()
.add(customResolver) // 加入自訂 resolver
.build();
3.4 TenantConfig(應用層)
檔案: src/main/java/io/leandev/app/config/TenantConfig.java
配置 TenantContext 使用的 resolver:
@Configuration
public class TenantConfig {
@PostConstruct
public void configureTenantContext() {
// 使用預設配置,支援 OAuth2 JWT、本地 JWT、Basic Auth
TenantContext.configureWithDefaults();
}
}
3.5 TenantAwareEntity(框架)
檔案: io/leandev/appfuse/jpa/tenant/TenantAwareEntity.java
所有需要租戶隔離的 Entity 繼承此類(或應用層的 audit 組合子類):
@MappedSuperclass
public abstract class TenantAwareEntity implements TenantAware {
// 不得加 @NotBlank —— Bean Validation 於 pre-persist 執行,早於 @TenantId 值生成,
// 必然先失敗(ConstraintViolationException: propertyPath=tenantId)。
// 非空由 DB 的 nullable = false 把關。
@TenantId
@Column(name = "tenant_id", length = 36, nullable = false, updatable = false)
@Size(max = 36)
private String tenantId;
// 唯一超出 Hibernate 的守衛:擋「租戶無處可得」的持久化,
// 否則 ROOT_TENANT 哨兵會被寫成租戶歸屬 → 該列對所有租戶皆不可見(靜默污染)。
@PrePersist
void rejectRootTenantOnPersist() {
if (tenantId == null && TenantContext.getTenantIdOrNull() == null) {
throw new IllegalStateException("Cannot persist tenant-scoped entity [...] "
+ "without a tenant context. Root sessions are for cross-tenant reads only; "
+ "wrap writes in TenantContext.runAs(tenantId, ...) to target a specific tenant.");
}
}
}
@TenantId 一個註解即取代了舊設計手寫的三件事:
| 舊(手寫) | 新(Hibernate 原生) |
|---|---|
@FilterDef / @Filter + 應用層 AOP 手動 enableFilter | TenantIdBinder 自動裝、Session 建構時自動啟用;且 applyToLoadByKey = true(find() 亦受過濾) |
@PrePersist 注入或驗證 | TenantIdGeneration(未設則注入;已設則 root 放行、非 root 須相符,不符拋 PropertyValueException) |
@PreUpdate 拋 SecurityException | updatable = false + 讀取過濾(載入不到即無從更新) |
@NotBlank的坑:Bean Validation 早於@TenantId值生成,加了必然先失敗。這不是可繞的 順序問題——TenantIdGeneration是BeforeExecutionGenerator/INSERT_ONLY,值在 INSERT 才產生。 同理,flush 前讀getTenantId()會得到null。
3.6 AccountUserDetails(應用層)
檔案: src/main/java/io/leandev/app/security/AccountUserDetails.java
擴展 Spring Security 的 User,實作 TenantAwareUserDetails:
@Getter
public class AccountUserDetails extends User implements TenantAwareUserDetails {
private final String tenantId;
private final String accountId;
public AccountUserDetails(String username, String password,
Collection<? extends GrantedAuthority> authorities,
String tenantId, String accountId) {
super(username, password, authorities);
this.tenantId = tenantId;
this.accountId = accountId;
}
}
3.7 TenantContextIdentifierResolver(框架)+ 接線(應用層)
檔案: io/leandev/appfuse/jpa/tenant/TenantContextIdentifierResolver.java
把 TenantContext 接到 Hibernate 原生多租戶。Hibernate 於每個 Session 建構時呼叫它:
public class TenantContextIdentifierResolver implements CurrentTenantIdentifierResolver<String> {
/// 無租戶 context 時回報的 root 租戶識別(可存取所有分區)
public static final String ROOT_TENANT = "__root__";
@Override
public String resolveCurrentTenantIdentifier() {
String tenantId = TenantContext.getTenantIdOrNull();
return tenantId != null ? tenantId : ROOT_TENANT; // Hibernate 不接受 null
}
@Override
public boolean validateExistingCurrentSessions() { return false; }
@Override
public boolean isRoot(String tenantId) { return ROOT_TENANT.equals(tenantId); }
}
Hibernate 的 AbstractSharedSessionContract#setUpMultitenancy 據此決定是否套用 filter:
if ( resolver == null || !resolver.isRoot( tenantIdentifier ) ) {
// turn on the filter, unless this is the "root" tenant with access to all partitions
loadQueryInfluencers.enableFilter( TenantIdBinder.FILTER_NAME )...
}
ROOT_TENANT 哨兵是必要而非選配:resolver 回 null 會直接丟
HibernateException: SessionFactory configured for multi-tenancy, but no tenant identifier specified。
接線(應用層):以設定,不以 @Bean
檔案: src/main/resources/config/feature-tenant.yml
spring:
jpa:
properties:
hibernate:
tenant_identifier_resolver: io.leandev.appfuse.jpa.tenant.TenantContextIdentifierResolver
為何是類名字串而非 Spring @Bean:一旦任何實體掛 @TenantId,_tenantId filter 即在
SessionFactory 層定義,於是每一個 Session 建構都需要租戶識別。若 resolver 只是應用層
@Configuration 裡的 @Bean,不載入該 config 的 @DataJpaTest slice 會整個 context 起不來。
hibernate.tenant_identifier_resolver 接受類名字串並由 Hibernate 自行實例化(故該類刻意無依賴、
可無參建構),既解決 slice 問題,也不需 autoconfiguration(符合
ADR-014)。
記得把片段加入
spring.config.import清單。
4. 實作步驟
Step 1: 配置 TenantContext
建立 TenantConfig 配置類:
@Configuration
public class TenantConfig {
@PostConstruct
public void configureTenantContext() {
TenantContext.configureWithDefaults();
}
}
Step 2: UserDetails 實作 TenantAwareUserDetails
public class AccountUserDetails extends User implements TenantAwareUserDetails {
private final String tenantId;
@Override
public String getTenantId() {
return tenantId;
}
}
Step 3: Entity 繼承 TenantAwareEntity
@Entity
@Table(name = "customer", indexes = {
@Index(name = "idx_customer_tenant", columnList = "tenant_id"),
@Index(name = "idx_customer_phone", columnList = "phone")
})
public class Customer extends TenantAwareEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@JdbcTypeCode(SqlTypes.VARCHAR)
@Column(length = 36, nullable = false, updatable = false)
private UUID id;
@Column(length = 100, nullable = false)
private String name;
// ❌ 不要這樣做
// @ManyToOne private Tenant tenant;
// ✅ tenantId 繼承自 TenantAwareEntity
}
Step 4: 接線 resolver
新增 src/main/resources/config/feature-tenant.yml 並加入 spring.config.import 清單——見 3.7 節。
不需要建立任何 AOP:租戶 filter 由 Hibernate 於 Session 建構時自動套用。
Step 5: 驗證不依賴 OSIV
以 spring.jpa.open-in-view=false 跑一次隔離測試。舊機制在此設定下會靜默失效;原生機制不受影響。
參考實作見 app-tenant-server 的 TenantFilterAspectOsivIT。
5. 使用範例
5.1 Service 層
@Service
@RequiredArgsConstructor
@Transactional
public class CustomerService {
private final CustomerRepository customerRepository;
public Customer create(CreateCustomerRequest request) {
Customer customer = new Customer();
customer.setName(request.getName());
customer.setPhone(request.getPhone());
// 不需要:customer.setTenantId(...);
return customerRepository.save(customer);
// Hibernate @TenantId 依目前 Session 的租戶注入 tenantId
}
public List<Customer> findAll() {
return customerRepository.findAll();
// Hibernate @TenantId 自動加上 tenant_id discriminator
}
}
5.2 排程任務中使用
@Component
public class DailyReportJob {
@Autowired
private TenantRepository tenantRepository;
@Autowired
private ReportService reportService;
@Scheduled(cron = "0 0 2 * * ?")
public void generateDailyReports() {
List<Tenant> tenants = tenantRepository.findAll();
for (Tenant tenant : tenants) {
// 在指定租戶上下文中執行
TenantContext.runAs(tenant.getId(), () -> {
reportService.generateDailyReport();
});
}
}
}
6. 測試指南
6.1 單元測試
@ExtendWith(MockitoExtension.class)
class CustomerServiceTest {
@Mock
private CustomerRepository customerRepository;
@InjectMocks
private CustomerService customerService;
private static final String TENANT_A = "tenant-a";
@BeforeEach
void setUp() {
// 配置 TenantContext(測試環境)
TenantContext.configureWithDefaults();
TenantContext.setCurrentTenantId(TENANT_A);
}
@AfterEach
void tearDown() {
TenantContext.clear();
}
@Test
void testCreate_shouldAutoInjectTenantId() {
// ...
}
}
6.2 使用 runAs 簡化測試
@Test
void testCrossTenantAccessBlocked() {
// 使用 runAs 切換租戶上下文
TenantContext.runAs("tenant-a", () -> {
Customer customerA = customerService.create(new CreateCustomerRequest("Alice", "0912345678"));
TenantContext.runAs("tenant-b", () -> {
// 應該找不到 tenant-A 的客戶
Optional<Customer> found = customerRepository.findById(customerA.getId());
assertFalse(found.isPresent());
});
});
}
7. 常見問題
Q1: 如何自訂租戶 ID 的 claim 名稱?
A: 在配置時指定:
TenantContext.configureWithDefaults("tenant_id"); // 使用 "tenant_id" 而非 "tenantId"
或使用 Builder:
TenantContext.configure(CompositeTenantIdResolver.builder()
.withJwtClaim("org_id")
.withJwtDetails("org_id")
.withUserDetails()
.build());
Q2: 如何查詢所有租戶的數據(管理員功能)?
A: 走 root session——即在無 TenantContext 的情況下開啟交易。TenantContextIdentifierResolver
會回報 ROOT_TENANT,isRoot() 使 Hibernate 跳過租戶 filter,該 Session 遂看得到所有分區。
@Service
public class AdminService {
@Autowired
private CustomerRepository customerRepository;
/// 呼叫前確保無 TenantContext(系統管理員本就無租戶;背景執行緒天生無 context)
@Transactional(readOnly = true)
public List<Customer> findAllCustomersAcrossTenants() {
return customerRepository.findAll(); // root session → 無 filter → 跨租戶
}
}
不要用 TenantFilterSupport.disableFilter()(已 @Deprecated)——租戶綁在 Session 建構時,
關閉 filter 是繞過設計意圖的操作;root session 才是 Hibernate 明文支援的模型
(isRoot 的定義即「a root tenant with access to all partitions」)。
root 的權界:讀=放行;改他人租戶的既有列=放行(刻意,租戶歸屬不受影響); 建立租戶資料須指明租戶(
runAs或顯式setTenantId),否則被@PrePersist守衛擋下。 見 ADR-016。
Q3: 如何在排程任務中設定租戶上下文?
A: 使用 TenantContext.runAs() 或 callAs():
// 無返回值
TenantContext.runAs("tenant-123", () -> {
customerService.processAll();
});
// 有返回值
List<Customer> customers = TenantContext.callAs("tenant-123", () -> {
return customerService.findAll();
});
Q4: 為什麼不再需要 AOP 來啟用 filter?
A: 因為 Hibernate 原生 discriminator 多租戶在 Session 建構時就套用了——沒有「該由誰、在何時 啟用」這個問題(見 3.7)。
歷史(值得記住的教訓):本節原本回答的是「為什麼使用 AOP 而非 HandlerInterceptor」,理由寫著 「AOP 在事務開始後、方法執行前啟用 filter,確保使用正確的 Session」。
那個說法從未成立。
TenantFilterAspect標@Order(1),而 Spring 的交易攔截器預設是Ordered.LOWEST_PRECEDENCE——aspect 因此是外層、跑在事務之前,根本拿不到交易的 Session。 它之所以「看起來能動」,只是因為 OSIV(spring.jpa.open-in-view,Boot 預設true)已先綁定了 一個 Session。設成false即靜默失效、跨租戶全可見,而enableFilter的例外被自身catch吞掉。這份指南把一個錯誤的機轉寫成設計理由,是該缺陷能長期潛伏的原因之一——當時的測試也全都手動
enableFilter,從未驗證過接線。詳見 ADR-016。
Q5: 原生 SQL 查詢會自動過濾嗎?
A: ❌ 原生 SQL 不會——它繞過 Hibernate 的 filter,需手動加 WHERE 條件。
JPQL 會(含 bulk update):實測 update Product p set ... where p.id = :id 打他人租戶的列,
影響 0 列。這一點新機制優於舊機制——舊設計下 @PreUpdate 對 bulk update 完全不觸發、
filter 又未必啟用,跨租戶 bulk update 很可能會成功。
建議優先使用 JPQL 或 Specification。
參考資料
框架原始碼(appfuse-server)
io.leandev.appfuse.security.tenant.TenantContextio.leandev.appfuse.security.tenant.TenantAwareUserDetailsio.leandev.appfuse.security.tenant.resolver.*io.leandev.appfuse.jpa.tenant.TenantAwareio.leandev.appfuse.jpa.tenant.TenantAwareEntityio.leandev.appfuse.jpa.tenant.TenantContextIdentifierResolver(@Deprecated 4.0.0,4.2.0 移除)io.leandev.appfuse.jpa.tenant.TenantFilterSupport
應用層原始碼(參考實作 app-tenant-server)
以下是參考實作中的相關檔案,供開發者參考:
io.leandev.app.tenant.TenantConfigio.leandev.app.auth.AccountUserDetailssrc/main/resources/config/feature-tenant.yml(resolver 接線)io.leandev.app.tenant.TenantFilterAspectOsivIT(測試:隔離不得依賴 OSIV)io.leandev.app.tenant.RootTenantWriteGuardIT(測試:root 寫入權界)
相關文檔
- 決策記錄: ADR-016: 租戶隔離改採 Hibernate 原生 @TenantId(現行機制)
- 決策記錄: ADR-001: 多租戶數據隔離策略(欄位策略;機制部分已由 ADR-016 取代)
- 資料層設計: database-design.md - 第 1 章
外部資源
文檔維護者: Development Team + AI Assistant 最後審閱: 2025-12-30