跳至主要内容

多租戶設計指南

文檔版本: v2.0.0 最後更新: 2025-12-30 適用對象: 開發團隊、AI Agent、使用 app-tenant-server 範本的團隊

本文檔提供多租戶功能的實作指南,包括架構說明、實作方式、使用範例和最佳實踐。

決策理由請參閱:ADR-001: 多租戶數據隔離策略


目錄

  1. 架構概覽
  2. 框架與應用層分工
  3. 核心組件
  4. 實作步驟
  5. 使用範例
  6. 測試指南
  7. 常見問題

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 解析策略介面
JwtClaimTenantIdResolverOAuth2 JWT 解析實作
JwtDetailsTenantIdResolver本地 JWT 解析實作
UserDetailsTenantIdResolverBasic Auth 解析實作
CompositeTenantIdResolver組合多個 resolver
TenantAwareUserDetailsUserDetails 擴展介面
TenantAwareJPA Entity 介面
TenantAwareEntity租戶感知實體基類(@TenantId + @PrePersist 守衛)
TenantContextIdentifierResolverTenantContext 接到 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);
}

預設實作

實作適用場景解析來源
JwtClaimTenantIdResolverOAuth2 JWTauthentication.getPrincipal()Jwt
JwtDetailsTenantIdResolver本地 JWTauthentication.getDetails()Claims
UserDetailsTenantIdResolverBasic Authauthentication.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 手動 enableFilterTenantIdBinder 自動裝、Session 建構時自動啟用;applyToLoadByKey = truefind() 亦受過濾)
@PrePersist 注入或驗證TenantIdGeneration(未設則注入;已設則 root 放行、非 root 須相符,不符拋 PropertyValueException
@PreUpdateSecurityExceptionupdatable = false + 讀取過濾(載入不到即無從更新)

@NotBlank 的坑:Bean Validation 早於 @TenantId 值生成,加了必然先失敗。這不是可繞的 順序問題——TenantIdGenerationBeforeExecutionGenerator / 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-serverTenantFilterAspectOsivIT


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_TENANTisRoot() 使 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。 它之所以「看起來能動」,只是因為 OSIVspring.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.TenantContext
  • io.leandev.appfuse.security.tenant.TenantAwareUserDetails
  • io.leandev.appfuse.security.tenant.resolver.*
  • io.leandev.appfuse.jpa.tenant.TenantAware
  • io.leandev.appfuse.jpa.tenant.TenantAwareEntity
  • io.leandev.appfuse.jpa.tenant.TenantContextIdentifierResolver
  • io.leandev.appfuse.jpa.tenant.TenantFilterSupport(@Deprecated 4.0.0,4.2.0 移除)

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

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

  • io.leandev.app.tenant.TenantConfig
  • io.leandev.app.auth.AccountUserDetails
  • src/main/resources/config/feature-tenant.yml(resolver 接線)
  • io.leandev.app.tenant.TenantFilterAspectOsivIT(測試:隔離不得依賴 OSIV)
  • io.leandev.app.tenant.RootTenantWriteGuardIT(測試:root 寫入權界)

相關文檔

外部資源


文檔維護者: Development Team + AI Assistant 最後審閱: 2025-12-30