Một catalogue SaaS phục vụ nhiều khách hàng từ cùng một bản deploy. Mỗi khách hàng, gọi là một tenant, chỉ thấy sản phẩm của mình và không bao giờ thấy của tenant khác, dù cùng một code, cùng một connection pool và thường là cùng các bảng phục vụ tất cả. Sự cô lập đó phải đúng với mọi query ứng dụng gửi đi: các method của repository, JPQL viết tay, các bulk update, và cả native SQL mà ai đó thêm vào sau này. Soft delete là cùng một loại vấn đề. Dòng bị xóa vẫn nằm trong bảng kèm một cờ, và mọi lần đọc đều phải bỏ qua nó, nên đó là thêm một predicate nữa phải có mặt trong mọi statement.
Bài này dựng cả hai trên một product API. Bài xác định tenant từ request, tách tenant theo ba cách (discriminator column với @TenantId của Hibernate, schema per tenant và database per tenant), thêm row-level security của PostgreSQL bên dưới, rồi so sánh @SoftDelete của Hibernate với cặp @SQLDelete và @SQLRestriction cũ hơn. Các ví dụ dùng Spring Boot 4.1.1 và Java 21, kết nối PostgreSQL 18.
![]()
Hai phần đầu dựng các chiến lược và lab; phần còn lại đi theo một request từ header đến câu SQL, lần lượt từng chiến lược, rồi chuyển sang soft delete, và kết thúc bằng cách chọn.
Các chiến lược multi-tenancy: discriminator column, schema per tenant, database per tenant
Ba chiến lược khác nhau ở chỗ ranh giới giữa các tenant nằm ở đâu.
- Discriminator column. Mọi tenant dùng chung các bảng, và mỗi dòng mang một
tenant_id. Ranh giới là một predicate: statement nào cũng phải cówhere tenant_id = ?.@TenantIdcủa Hibernate tự thêm nó vào. - Schema per tenant. Mỗi tenant có bản sao các bảng của riêng mình trong một PostgreSQL schema riêng,
acme.productsvàglobex.products, trong cùng một database. Ranh giới làsearch_pathcủa connection: cùng một câu SQL đọc các bảng khác nhau tùy connection đang trỏ vào schema nào. - Database per tenant. Mỗi tenant có database riêng, nên có connection riêng. Ranh giới là connection pool mà request mượn connection.

Row-level security không phải chiến lược thứ tư. Đó là PostgreSQL tự áp predicate của discriminator, bên dưới ứng dụng, để một query quên predicate vẫn chỉ thấy một tenant.
Lab: catalogue multi-tenant và cách tạo ra các output
Project được tạo từ Spring Initializr với web, data-jpa, postgresql, flyway và validation:
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,data-jpa,postgresql,flyway,validation" -o demo.zipPostgreSQL 18 chạy trong Docker ở host port 5512, còn ứng dụng ở port 8212:
docker run -d --name sba-a12-pg --memory 512m -e POSTGRES_USER=demo -e POSTGRES_PASSWORD=demo -e POSTGRES_DB=demo -p 5512:5432 postgres:18Phiên bản discriminator của catalogue có bảng tenants và cột tenant_id trên categories và products. SKU là duy nhất trong từng tenant, không phải trên toàn hệ thống, nên cả hai tenant đều có thể bán một KB-01:
create table tenants (
id varchar(30) primary key,
name varchar(100) not null
);
create table categories (
id bigint generated by default as identity primary key,
tenant_id varchar(30) not null references tenants (id),
name varchar(60) not null,
unique (tenant_id, name)
);
create table products (
id bigint generated by default as identity primary key,
tenant_id varchar(30) not null references tenants (id),
name varchar(120) not null,
sku varchar(40) not null,
price numeric(10, 2) not null,
category_id bigint not null references categories (id),
unique (tenant_id, sku)
);
create index products_category_id_idx on products (category_id);V2__seed_tenants.sql thêm hai tenant, acme và globex. Acme có các category Keyboards (id 1) và Mice (2) cùng bốn sản phẩm, id 1 đến 4: KB-01 89.00, KB-02 59.00, MS-01 24.50 và MS-02 49.90. Globex có Keyboards (3) và Monitors (4) cùng ba sản phẩm, id 5 đến 7: KB-01 của riêng nó giá 35.00, MN-01 229.00 và MN-02 159.00. Tổng cộng bảy dòng, bốn dòng là của Acme.
spring.application.name=demo
server.port=8212
spring.datasource.url=jdbc:postgresql://localhost:5512/demo
spring.datasource.username=demo
spring.datasource.password=demo
spring.jpa.open-in-view=false
spring.jpa.hibernate.ddl-auto=validate
logging.level.org.hibernate.SQL=DEBUG
spring.mvc.problemdetails.enabled=trueMỗi chiến lược cần một cách map khác nhau cho cùng một entity, nên lab giữ mỗi biến thể trong một Gradle subproject, tất cả dùng chung code tenant, repository và web từ một thư mục source. Một ứng dụng thật chỉ cần một trong số đó:
demo/
├── shared/src/main/java/com/example/demo/ tenant filter and resolver, repository, service, controller
├── discriminator/ @TenantId, row-level security
├── softdelete/ @TenantId + @SoftDelete
├── sqldelete/ @TenantId + @SQLDelete and @SQLRestriction
├── schema/ a schema per tenant
└── database/ a database per tenantCác lời gọi repository chạy trong một ApplicationRunner sau profile lab, profile này tắt web server, đặt logging.pattern.console=%m%n để mỗi dòng log chỉ còn message, và bật org.hibernate.orm.jdbc.bind=TRACE để thấy giá trị được bind. Mỗi khối bắt đầu bằng lời gọi sau >>>, tiếp theo là các statement của Hibernate, rồi đến kết quả: một sản phẩm in ra dạng id:tenant:SKU price. Một exception in ra thành các dòng !!!, mỗi dòng một cause. Profile reset dọn database bằng Flyway trước mỗi lần chạy, nên lần chạy nào cũng bắt đầu từ các dòng seed ở trên.
Xác định tenant từ request header
Trước khi áp được chiến lược nào, ứng dụng phải biết request hiện tại thuộc tenant nào, và chuyển thông tin đó cho Hibernate. Lab đọc nó từ header X-Tenant-Id.
TenantContext holder và servlet filter
Holder là một ThreadLocal, vì một servlet request chạy trên một thread duy nhất từ filter đến repository:
package com.example.demo.tenant;
public final class TenantContext {
private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
private TenantContext() {
}
public static void set(String tenantId) {
CURRENT.set(tenantId);
}
public static String get() {
return CURRENT.get();
}
public static void clear() {
CURRENT.remove();
}
}Filter từ chối request thiếu header hoặc có tenant không tồn tại, gán holder, và clear nó trong finally. Lỗi ném ra từ filter không bao giờ tới được @RestControllerAdvice, nên filter tự ghi ProblemDetail bằng JsonMapper Jackson 3 của ứng dụng:
package com.example.demo.tenant;
import java.io.IOException;
import java.net.URI;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import tools.jackson.databind.json.JsonMapper;
@Component
public class TenantFilter extends OncePerRequestFilter {
public static final String HEADER = "X-Tenant-Id";
private final TenantRegistry tenants;
private final JsonMapper jsonMapper;
public TenantFilter(TenantRegistry tenants, JsonMapper jsonMapper) {
this.tenants = tenants;
this.jsonMapper = jsonMapper;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/api/")
|| request.getRequestURI().startsWith("/api/admin/");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String tenantId = request.getHeader(HEADER);
if (tenantId == null || tenantId.isBlank()) {
reject(request, response, "Missing " + HEADER + " header.");
return;
}
if (!tenants.exists(tenantId)) {
reject(request, response, "Unknown tenant '" + tenantId + "'.");
return;
}
TenantContext.set(tenantId);
try {
chain.doFilter(request, response);
} finally {
TenantContext.clear();
}
}
private void reject(HttpServletRequest request, HttpServletResponse response, String detail)
throws IOException {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, detail);
problem.setTitle("Invalid tenant");
problem.setInstance(URI.create(request.getRequestURI()));
response.setStatus(HttpStatus.BAD_REQUEST.value());
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
jsonMapper.writeValue(response.getOutputStream(), problem);
}
}/api/admin/** được bỏ qua có chủ đích: endpoint cấp platform tạo tenant, ở phần sau của bài, không có tenant nào. Danh sách tenant hợp lệ lấy từ bảng tenants, được nạp khi ứng dụng đã khởi động xong:
@Component
public class TenantRegistry {
private final JdbcClient jdbc;
private final Set<String> tenantIds = ConcurrentHashMap.newKeySet();
public TenantRegistry(JdbcClient jdbc) {
this.jdbc = jdbc;
}
public boolean exists(String tenantId) {
return tenantIds.contains(tenantId);
}
public Set<String> all() {
return Set.copyOf(tenantIds);
}
@EventListener(ApplicationStartedEvent.class)
public void refresh() {
tenantIds.addAll(jdbc.sql("select id from public.tenants").query(String.class).list());
}
}⚠️ Header tenant chỉ dành cho lab này. Client nào cũng có thể gửi
X-Tenant-Id: globexvà đọc dữ liệu của Globex. Trong production, tenant đến từ principal đã xác thực, thường là một claim trong access token, và filter đọc nó từSecurityContextsau bước xác thực; bài 13 dựng resource server cung cấp token đó. Mọi thứ phía sau filter giữ nguyên.
Với phần discriminator mà section tiếp theo dựng, bốn trường hợp qua HTTP:
curl -i -s -H 'X-Tenant-Id: acme' http://localhost:8212/api/products
curl -s -H 'X-Tenant-Id: globex' http://localhost:8212/api/products
curl -i -s http://localhost:8212/api/products
curl -i -s -H 'X-Tenant-Id: initech' http://localhost:8212/api/productsHTTP/1.1 200
Content-Type: application/json
Content-Length: 332
Date: Fri, 18 Sep 2026 07:18:17 GMT
[{"id":1,"sku":"KB-01","name":"Mechanical keyboard","price":89.00,"category":"Keyboards"},{"id":2,"sku":"KB-02","name":"Compact keyboard","price":59.00,"category":"Keyboards"},{"id":3,"sku":"MS-01","name":"Wireless mouse","price":24.50,"category":"Mice"},{"id":4,"sku":"MS-02","name":"Gaming mouse","price":49.90,"category":"Mice"}][{"id":5,"sku":"KB-01","name":"Office keyboard","price":35.00,"category":"Keyboards"},{"id":6,"sku":"MN-01","name":"27-inch monitor","price":229.00,"category":"Monitors"},{"id":7,"sku":"MN-02","name":"24-inch monitor","price":159.00,"category":"Monitors"}]HTTP/1.1 400
Content-Type: application/problem+json
Content-Length: 105
Date: Fri, 18 Sep 2026 07:18:17 GMT
Connection: close
{"detail":"Missing X-Tenant-Id header.","instance":"/api/products","status":400,"title":"Invalid tenant"}HTTP/1.1 400
Content-Type: application/problem+json
Content-Length: 103
Date: Fri, 18 Sep 2026 07:18:17 GMT
Connection: close
{"detail":"Unknown tenant 'initech'.","instance":"/api/products","status":400,"title":"Invalid tenant"}Cùng một URL trả về bốn sản phẩm cho Acme và ba cho Globex. Tomcat tự thêm Connection: close vào cả hai response 400.
Chuyện gì xảy ra khi ThreadLocal không được clear?
Một phiên bản đầu tay thường gặp gán tenant khi có header và trông chờ một chỗ nào đó phía sau sẽ lỗi khi không có. Nó không bao giờ clear holder:
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String tenantId = request.getHeader("X-Tenant-Id");
if (tenantId != null) {
TenantContext.set(tenantId);
}
chain.doFilter(request, response);
}Tomcat tái sử dụng các thread xử lý request. Để chắc chắn việc tái sử dụng xảy ra, lần chạy giới hạn Tomcat còn một thread bằng --server.tomcat.threads.max=1, và ProductService.findAll log tenant mà nó thấy. Một request của Globex, sau đó là một request không có header nào:
curl -s -H 'X-Tenant-Id: globex' http://localhost:8212/api/products
curl -i -s http://localhost:8212/api/productsResponse của request thứ hai, request không có header:
HTTP/1.1 200
Content-Type: application/json
Content-Length: 256
Date: Fri, 18 Sep 2026 07:18:44 GMT
[{"id":5,"sku":"KB-01","name":"Office keyboard","price":35.00,"category":"Keyboards"},{"id":6,"sku":"MN-01","name":"27-inch monitor","price":229.00,"category":"Monitors"},{"id":7,"sku":"MN-02","name":"24-inch monitor","price":159.00,"category":"Monitors"}]Log của server cho cả hai request, bỏ đi các lần lookup hai category của chúng:
2026-09-18T14:18:44.074+07:00 INFO 12703 --- [demo] [nio-8212-exec-1] c.example.demo.product.ProductService : findAll for tenant globex
2026-09-18T14:18:44.097+07:00 DEBUG 12703 --- [demo] [nio-8212-exec-1] org.hibernate.SQL : select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.tenant_id = ?
2026-09-18T14:18:44.098+07:00 TRACE 12703 --- [demo] [nio-8212-exec-1] org.hibernate.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [globex]
2026-09-18T14:18:44.134+07:00 INFO 12703 --- [demo] [nio-8212-exec-1] c.example.demo.product.ProductService : findAll for tenant globex
2026-09-18T14:18:44.135+07:00 DEBUG 12703 --- [demo] [nio-8212-exec-1] org.hibernate.SQL : select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.tenant_id = ?
2026-09-18T14:18:44.135+07:00 TRACE 12703 --- [demo] [nio-8212-exec-1] org.hibernate.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [globex]Request ẩn danh nhận catalogue của Globex với status 200. Cả hai request chạy trên http-nio-8212-exec-1, và request thứ hai thấy giá trị của request đầu vẫn còn trong ThreadLocal. Với 200 thread thay vì một, chuyện tương tự xảy ra mỗi khi request kế tiếp trên thread đó bỏ qua set, nên lỗi trở thành lúc có lúc không chứ không biến mất. Filter ở trên chặn nó bằng hai việc: từ chối request thiếu header trước khi chain chạy, và clear holder trong finally, để không giá trị nào sống lâu hơn request của nó. Cùng hai request đó với filter này, vẫn trên một thread, cho 200 rồi 400 với "Missing X-Tenant-Id header.".
Tenant có đi theo sang thread @Async không?
Không. @Async chạy method trên một thread của task executor của Spring, thread đó có ThreadLocal riêng và rỗng:
@Async
public CompletableFuture<Long> countAsync() {
log.info("countAsync for tenant {}", TenantContext.get());
return CompletableFuture.completedFuture(products.count());
}curl -s -H 'X-Tenant-Id: acme' http://localhost:8212/api/products/count-async02026-09-18T14:19:00.266+07:00 INFO 13379 --- [demo] [ task-1] c.example.demo.product.ProductService : countAsync for tenant null
2026-09-18T14:19:00.278+07:00 DEBUG 13379 --- [demo] [ task-1] org.hibernate.SQL : select count(*) from products p1_0 where p1_0.tenant_id = ?
2026-09-18T14:19:00.278+07:00 TRACE 13379 --- [demo] [ task-1] org.hibernate.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [_none_]Acme có bốn sản phẩm; câu trả lời là 0, kèm status 200 và không có lỗi nào. Trên task-1 holder rỗng, và resolver ở section tiếp theo biến điều đó thành một tenant id không khớp dòng nào. Việc copy giá trị sang các thread của executor bằng TaskDecorator thuộc về bài 17 về @Async và executor; trước đó, hãy đọc tenant trên thread của request và truyền nó như một argument.
Multi-tenancy bằng discriminator column với @TenantId
Hibernate 6.0 thêm @TenantId, và trong Hibernate 7.4.5 nó là toàn bộ chiến lược discriminator: một attribute được đánh annotation trên mỗi entity và một resolver cho biết tenant hiện tại là ai.
Map @TenantId và CurrentTenantIdentifierResolver
Entity có thêm ba dòng. Category cũng thêm đúng ba dòng đó:
package com.example.demo.product;
import java.math.BigDecimal;
import org.hibernate.annotations.TenantId;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@TenantId
@Column(name = "tenant_id", nullable = false, length = 30)
private String tenantId;
@Column(nullable = false, length = 120)
private String name;
@Column(nullable = false, length = 40)
private String sku;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "category_id", nullable = false)
private Category category;
protected Product() {
}
public Product(String name, String sku, BigDecimal price, Category category) {
this.name = name;
this.sku = sku;
this.price = price;
this.category = category;
}
// getters and setPrice; no setter for tenantId
}Resolver trả lời đúng một câu hỏi, tenant hiện tại là ai, và Hibernate hỏi nó mỗi khi mở một session:
package com.example.demo.tenant;
import java.util.Map;
import org.hibernate.cfg.MultiTenancySettings;
import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
import org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer;
import org.springframework.stereotype.Component;
@Component
public class TenantIdentifierResolver
implements CurrentTenantIdentifierResolver<String>, HibernatePropertiesCustomizer {
public static final String NO_TENANT = "_none_";
@Override
public String resolveCurrentTenantIdentifier() {
String tenantId = TenantContext.get();
return tenantId != null ? tenantId : NO_TENANT;
}
@Override
public boolean validateExistingCurrentSessions() {
return false;
}
@Override
public void customize(Map<String, Object> hibernateProperties) {
hibernateProperties.put(MultiTenancySettings.MULTI_TENANT_IDENTIFIER_RESOLVER, this);
}
}NO_TENANTlà thứ Hibernate nhận được khi chưa request nào gán tenant, vì lý do bên dưới.validateExistingCurrentSessionsliên quan đếngetCurrentSession()của chính Hibernate, thứ mà ứng dụng Spring không dùng, nên đểfalse.customizelà cách wiring tường minh: các beanHibernatePropertiesCustomizersửa các property mà Boot truyền cho Hibernate, vàhibernate.tenant_identifier_resolvernhận một instance. Trong stack này nó cũng thừa.HibernateJpaVendorAdaptercủa Spring Framework 7 trao cho Hibernate mộtSpringBeanContainer, và Hibernate 7.4.5 hỏi container đó một beanCurrentTenantIdentifierResolverkhi property vắng mặt; khi làm rỗngcustomize, lần chạy bên dưới vẫn bindacmey hệt. Customizer được giữ lại vì nó nhìn thấy được.
Spring Data chuẩn bị các derived query trong lúc tạo repository, việc đó mở một Hibernate session trên main thread, nơi chưa request nào gán tenant. Phiên bản đầu của resolver trả về TenantContext.get() nguyên trạng, tức null, và ứng dụng không khởi động được:
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'productRepository' defined in com.example.demo.product.ProductRepository defined in @EnableJpaRepositories declared on DataJpaRepositoriesRegistrar.EnableJpaRepositoriesConfiguration: SessionFactory configured for multi-tenancy, but no tenant identifier specified
Caused by: org.springframework.data.repository.query.QueryCreationException: Cannot create query for method [ProductRepository.findByPriceLessThan(java.math.BigDecimal)]; SessionFactory configured for multi-tenancy, but no tenant identifier specified
Caused by: org.hibernate.HibernateException: SessionFactory configured for multi-tenancy, but no tenant identifier specifiedMột giá trị không phải tenant giữ cho startup chạy được và fail closed về sau: nó không khớp tenant_id nào, và một insert với nó lỗi ở products_tenant_id_fkey với Key (tenant_id)=(_none_) is not present in table "tenants". Lần chạy @Async ở trên là hình dạng thực tế của nó: một kết quả rỗng thay vì exception.
Các ví dụ viết cho Hibernate 5 đặt hibernate.multiTenancy=SCHEMA và implement các interface thô, không generic. Hibernate 7.4.5 không còn cả property đó lẫn enum MultiTenancyStrategy mà nó trỏ tới: chiến lược suy ra từ mapping. @TenantId trên một attribute nghĩa là discriminator, còn một MultiTenantConnectionProvider được đăng ký nghĩa là schema hoặc database.

SQL cho find, insert, update và delete
Lab runner gán TenantContext là acme rồi gọi repository:
>>> findById(1) as acme
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.id=? and p1_0.tenant_id = ?
binding parameter (1:BIGINT) <- [1]
binding parameter (2:VARCHAR) <- [acme]
Optional[1:acme:KB-01 89.00]
>>> findById(5) as acme, 5 belongs to globex
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.id=? and p1_0.tenant_id = ?
binding parameter (1:BIGINT) <- [5]
binding parameter (2:VARCHAR) <- [acme]
Optional.empty
>>> findAll() as acme
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.tenant_id = ? order by p1_0.id
binding parameter (1:VARCHAR) <- [acme]
[1:acme:KB-01 89.00, 2:acme:KB-02 59.00, 3:acme:MS-01 24.50, 4:acme:MS-02 49.90] (4 rows)
>>> save(new Product("Vertical mouse", "MS-03", 39.00, Mice)) as acme
insert into products (category_id,name,price,sku,tenant_id) values (?,?,?,?,?)
binding parameter (1:BIGINT) <- [2]
binding parameter (2:VARCHAR) <- [Vertical mouse]
binding parameter (3:NUMERIC) <- [39.00]
binding parameter (4:VARCHAR) <- [MS-03]
binding parameter (5:VARCHAR) <- [acme]
8:acme:MS-03 39.00
>>> update price of 1 to 95.00 as acme
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.id=? and p1_0.tenant_id = ?
binding parameter (1:BIGINT) <- [1]
binding parameter (2:VARCHAR) <- [acme]
update products set category_id=?,name=?,price=?,sku=? where id=?
binding parameter (1:BIGINT) <- [1]
binding parameter (2:VARCHAR) <- [Mechanical keyboard]
binding parameter (3:NUMERIC) <- [95.00]
binding parameter (4:VARCHAR) <- [KB-01]
binding parameter (5:BIGINT) <- [1]
1:acme:KB-01 95.00
>>> deleteById(8) as acme
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.id=? and p1_0.tenant_id = ?
binding parameter (1:BIGINT) <- [8]
binding parameter (2:VARCHAR) <- [acme]
delete from products where id=?
binding parameter (1:BIGINT) <- [8]findByIdcóand p1_0.tenant_id = ?. Hibernate hiện thực@TenantIdbằng một filter tên_tenantId, được định nghĩa vớiapplyToLoadByKey, nên nó áp cho cả việc load theo primary key chứ không chỉ query. Sản phẩm 5 của Globex trả vềOptional.empty, thứ mà controller biến thành 404, giống hệt câu trả lời cho một id không tồn tại.- Câu insert bind
acmechotenant_iddù code không hề gán nó. Giá trị đến từ resolver lúc dòng được insert. - Câu update và delete chỉ có
where id=?, vàtenant_idkhông có trong danh sáchset. Hibernate đánh dấu attribute này là không updatable, nên tenant không thể bị đổi, và nó gửi update, delete theo primary key. Điều đó an toàn chỉ vì entity đã vào session qua câuselectcó filter trước đó.
Gán tay tenant thành giá trị khác bị từ chối trước khi có câu SQL nào:
>>> save a product whose tenantId is set to globex, as acme
!!! org.springframework.dao.DataIntegrityViolationException: assigned tenant id differs from current tenant id [globex != acme] for entity com.example.demo.product.Product.tenantId
!!! org.hibernate.PropertyValueException: assigned tenant id differs from current tenant id [globex != acme] for entity com.example.demo.product.Product.tenantIdJPQL, derived query, Specification, phân trang và bulk update
Repository được dùng chung cho mọi biến thể trong lab. Hai method của nó, deleteInBulkBySku và findAllNative, sẽ quay lại ở các phần soft delete:
public interface ProductRepository extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {
List<Product> findByPriceLessThan(BigDecimal price);
@Query("select p from Product p where p.category.name = :category order by p.price")
List<Product> findInCategory(String category);
@Modifying
@Query("update Product p set p.price = p.price * :factor where p.sku like :skuPattern")
int reprice(String skuPattern, BigDecimal factor);
@Modifying
@Query("delete from Product p where p.sku = :sku")
int deleteInBulkBySku(String sku);
@Query(value = "select * from products where price >= :min order by id", nativeQuery = true)
List<Product> findPricedAtLeastNative(BigDecimal min);
@Query(value = "select * from products where tenant_id = :tenantId order by id", nativeQuery = true)
List<Product> findAllNative(String tenantId);
}Mọi loại query, với tenant acme. Specification là price >= 50, và bulk update tăng 10 phần trăm giá mọi SKU bắt đầu bằng KB-, một pattern khớp cả một sản phẩm của Globex:
>>> findInCategory("Keyboards") as acme (JPQL)
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id and c1_0.tenant_id = ? where p1_0.tenant_id = ? and c1_0.name=? order by p1_0.price
binding parameter (1:VARCHAR) <- [acme]
binding parameter (2:VARCHAR) <- [acme]
binding parameter (3:VARCHAR) <- [Keyboards]
[2:acme:KB-02 59.00, 1:acme:KB-01 89.00] (2 rows)
>>> findByPriceLessThan(50) as acme (derived)
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.tenant_id = ? and p1_0.price<?
binding parameter (1:VARCHAR) <- [acme]
binding parameter (2:NUMERIC) <- [50]
[3:acme:MS-01 24.50, 4:acme:MS-02 49.90] (2 rows)
>>> findAll(priceAtLeast(50)) as acme (Specification)
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.tenant_id = ? and p1_0.price>=?
binding parameter (1:VARCHAR) <- [acme]
binding parameter (2:NUMERIC) <- [50]
[1:acme:KB-01 89.00, 2:acme:KB-02 59.00] (2 rows)
>>> findAll(PageRequest.of(0, 2)) as acme
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.tenant_id = ? order by p1_0.id offset ? rows fetch first ? rows only
binding parameter (1:VARCHAR) <- [acme]
binding parameter (2:INTEGER) <- [0]
binding parameter (3:INTEGER) <- [2]
select count(p1_0.id) from products p1_0 where p1_0.tenant_id = ?
binding parameter (1:VARCHAR) <- [acme]
[1:acme:KB-01 89.00, 2:acme:KB-02 59.00] totalElements=4
>>> count() as acme
select count(*) from products p1_0 where p1_0.tenant_id = ?
binding parameter (1:VARCHAR) <- [acme]
4
>>> reprice("KB-%", 1.10) as acme (bulk @Modifying)
update products p1_0 set price=(p1_0.price*?) where p1_0.sku like ? escape '' and p1_0.tenant_id = ?
binding parameter (1:NUMERIC) <- [1.10]
binding parameter (2:VARCHAR) <- [KB-%]
binding parameter (3:VARCHAR) <- [acme]
2
>>> prices of KB-01 per tenant, read with JdbcClient
[acme:KB-01 97.90, globex:KB-01 35.00] (2 rows)- Join trong JPQL nhận predicate hai lần:
c1_0.tenant_id = ?trong mệnh đềonchoCategoryđược join, vàp1_0.tenant_id = ?trongwhere. Mọi entity có@TenantIdxuất hiện trong query đều bị lọc, dù có được join hay không. - Derived query, Specification, page và count query của nó đều mở đầu
wherebằng predicate tenant. Code dựng chúng không thay đổi gì. - Bulk update là thứ đáng tự kiểm tra, vì các bulk statement bỏ qua persistence context và, như Basics 32 đã cho thấy với auditing, bỏ qua cả entity callback. Hibernate 7.4.5 vẫn thêm
and p1_0.tenant_id = ?: nó update hai dòng,KB-01của Acme từ 89.00 lên 97.90, cònKB-01của Globex giữ nguyên 35.00.
Thứ gì vượt qua @TenantId: native query và JdbcClient
Hibernate không phân tích native SQL, nên không thể thêm predicate vào đó, còn JdbcClient thì không hề đi qua Hibernate:
>>> findPricedAtLeastNative(0) as acme (native @Query)
select * from products where price >= ? order by id
binding parameter (1:NUMERIC) <- [0]
[1:acme:KB-01 89.00, 2:acme:KB-02 59.00, 3:acme:MS-01 24.50, 4:acme:MS-02 49.90, 5:globex:KB-01 35.00, 6:globex:MN-01 229.00, 7:globex:MN-02 159.00] (7 rows)
>>> JdbcClient select as acme
[1:acme:KB-01 89.00, 2:acme:KB-02 59.00, 3:acme:MS-01 24.50, 4:acme:MS-02 49.90, 5:globex:KB-01 35.00, 6:globex:MN-01 229.00, 7:globex:MN-02 159.00] (7 rows)Bảy dòng cho một request lẽ ra chỉ thấy bốn, và native query còn biến các dòng của Globex thành entity Product được quản lý trong session của Acme. Mọi native query trong biến thể discriminator cần where tenant_id = :tenantId của riêng nó, như findAllNative, và không có gì kiểm tra rằng nó có. Đó là lỗ hổng mà row-level security lấp ở phần sau của bài.
Foreign key không biết gì về tenant
getReferenceById trả về một proxy mà không chạy select, nên tenant filter không bao giờ thấy nó. Lưu một sản phẩm của Acme với category 3 của Globex:
>>> save with category 3, which belongs to globex, as acme
insert into products (category_id,name,price,sku,tenant_id) values (?,?,?,?,?)
binding parameter (1:BIGINT) <- [3]
binding parameter (2:VARCHAR) <- [Cross-tenant keyboard]
binding parameter (3:NUMERIC) <- [1.00]
binding parameter (4:VARCHAR) <- [KB-98]
binding parameter (5:VARCHAR) <- [acme]
8:acme:KB-98 1.00Foreign key chỉ kiểm tra rằng category 3 tồn tại. Đưa tenant vào trong khóa sẽ chặn được việc này:
alter table categories add constraint categories_id_tenant_uk unique (id, tenant_id);
alter table products drop constraint products_category_id_fkey;
alter table products add constraint products_category_tenant_fk
foreign key (category_id, tenant_id) references categories (id, tenant_id);Mapping vẫn giữ @JoinColumn(name = "category_id"); giờ database so sánh cả hai cột. Cùng lệnh lưu đó sau V3:
>>> save with category 3, which belongs to globex, as acme
insert into products (category_id,name,price,sku,tenant_id) values (?,?,?,?,?)
binding parameter (1:BIGINT) <- [3]
binding parameter (2:VARCHAR) <- [Cross-tenant keyboard]
binding parameter (3:NUMERIC) <- [1.00]
binding parameter (4:VARCHAR) <- [KB-98]
binding parameter (5:VARCHAR) <- [acme]
HHH000247: ErrorCode: 0, SQLState: 23503
ERROR: insert or update on table "products" violates foreign key constraint "products_category_tenant_fk"
Detail: Key (category_id, tenant_id)=(3, acme) is not present in table "categories".
!!! org.springframework.dao.DataIntegrityViolationException: could not execute statement [ERROR: insert or update on table "products" violates foreign key constraint "products_category_tenant_fk" ...
!!! org.hibernate.exception.ConstraintViolationException: could not execute statement [ERROR: insert or update on table "products" violates foreign key constraint "products_category_tenant_fk" ...
!!! org.postgresql.util.PSQLException: ERROR: insert or update on table "products" violates foreign key constraint "products_category_tenant_fk" ...Schema per tenant với MultiTenantConnectionProvider
Trong biến thể schema, ranh giới rời khỏi câu SQL. public chỉ giữ bảng tenants, còn mỗi tenant có một schema với categories và products riêng, không có cột tenant_id:
@TenantId
@Column(name = "tenant_id", nullable = false, length = 30)
private String tenantId; create table categories (
id bigint generated by default as identity primary key,
name varchar(60) not null unique
);
create table products (
id bigint generated by default as identity primary key,
name varchar(120) not null,
sku varchar(40) not null unique,
price numeric(10, 2) not null,
category_id bigint not null references categories (id)
);
create index products_category_id_idx on products (category_id);Migration nằm trong db/tenant chứ không phải db/migration, vì nó chạy một lần cho mỗi tenant schema; phần Flyway bên dưới nối việc đó lại. public có V1__create_tenants.sql với bảng tenants và hai dòng acme, globex, còn lab nạp đúng các sản phẩm như trước vào acme.products và globex.products.
MultiTenantConnectionProvider chuyển schema
Hibernate xin MultiTenantConnectionProvider một connection cho tenant X và trả lại khi session xong việc. Provider này mượn connection từ HikariCP pool của ứng dụng và trỏ connection vào schema của tenant:
package com.example.demo.tenant;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Map;
import javax.sql.DataSource;
import org.hibernate.cfg.MultiTenancySettings;
import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider;
import org.hibernate.service.UnknownUnwrapTypeException;
import org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer;
import org.springframework.stereotype.Component;
@Component
public class SchemaPerTenantConnectionProvider
implements MultiTenantConnectionProvider<String>, HibernatePropertiesCustomizer {
private final DataSource dataSource;
public SchemaPerTenantConnectionProvider(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public Connection getAnyConnection() throws SQLException {
return dataSource.getConnection();
}
@Override
public void releaseAnyConnection(Connection connection) throws SQLException {
connection.close();
}
@Override
public Connection getConnection(String tenantId) throws SQLException {
Connection connection = getAnyConnection();
connection.setSchema(tenantId);
return connection;
}
@Override
public void releaseConnection(String tenantId, Connection connection) throws SQLException {
connection.setSchema("public");
releaseAnyConnection(connection);
}
@Override
public boolean supportsAggressiveRelease() {
return false;
}
@Override
public boolean isUnwrappableAs(Class<?> unwrapType) {
return false;
}
@Override
public <T> T unwrap(Class<T> unwrapType) {
throw new UnknownUnwrapTypeException(unwrapType);
}
@Override
public void customize(Map<String, Object> hibernateProperties) {
hibernateProperties.put(MultiTenancySettings.MULTI_TENANT_CONNECTION_PROVIDER, this);
}
}Dùng connection.setSchema thay vì tự viết set search_path, vì hai lý do. Server log của PostgreSQL, với log_statement = 'all', cho thấy pgjdbc 42.7.13 gửi gì cho nó:
2026-09-18 07:25:02.274 UTC [383] LOG: execute <unnamed>: SET SESSION search_path TO 'globex'
2026-09-18 07:25:02.275 UTC [383] LOG: execute <unnamed>: select current_schema()Câu đầu là bản dịch của driver, tenant id được gửi dưới dạng một chuỗi literal có dấu nháy thay vì dán thẳng vào SQL như một identifier. Câu thứ hai là HikariCP đọc lại schema: proxy connection của nó chặn setSchema, ghi nhận giá trị mới và đánh dấu connection là dirty, điều mà section tiếp theo hóa ra dựa vào. Một câu set search_path chạy như statement cũng tới database theo cách y hệt, nhưng pool không bao giờ biết. Cách nào thì path cũng chỉ còn schema của tenant, nên một tên không có schema phía trước không còn tìm thấy gì trong public; đó là lý do TenantRegistry viết public.tenants.
Series giữ spring.jpa.hibernate.ddl-auto=validate, và ở đây nó làm ứng dụng dừng lại:
Caused by: org.hibernate.tool.schema.spi.SchemaManagementException: Schema validation: missing table [categories]Hibernate validate qua getAnyConnection(), connection này trỏ vào public, và public không có categories. Trong biến thể này, Flyway mới là thứ giữ cho các tenant schema giống hệt nhau, nên validation bị tắt:
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.hibernate.ddl-auto=none Cùng repository đó, chạy với từng tenant:
>>> findAll() as acme
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 order by p1_0.id
[1:KB-01 89.00, 2:KB-02 59.00, 3:MS-01 24.50, 4:MS-02 49.90] (4 rows)
>>> findAll() as globex
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 order by p1_0.id
[1:KB-01 35.00, 2:MN-01 229.00, 3:MN-02 159.00] (3 rows)
>>> findById(1) as acme
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.id=?
binding parameter (1:BIGINT) <- [1]
Optional[1:KB-01 89.00]
>>> findById(1) as globex
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.id=?
binding parameter (1:BIGINT) <- [1]
Optional[1:KB-01 35.00]
>>> findPricedAtLeastNative(0) inside a transaction as acme (native @Query)
select * from products where price >= ? order by id
binding parameter (1:NUMERIC) <- [0]
[1:KB-01 89.00, 2:KB-02 59.00, 3:MS-01 24.50, 4:MS-02 49.90] (4 rows)
>>> JdbcClient select inside a transaction as acme
acme: 4
>>> count() with no tenant
select count(*) from products p1_0
HHH000247: ErrorCode: 0, SQLState: 42P01
ERROR: relation "products" does not exist
Position: 22
!!! org.springframework.dao.InvalidDataAccessResourceUsageException: JDBC exception executing SQL [ERROR: relation "products" does not exist ...
!!! org.hibernate.exception.SQLGrammarException: JDBC exception executing SQL [ERROR: relation "products" does not exist ...
!!! org.postgresql.util.PSQLException: ERROR: relation "products" does not exist ...Câu SQL hoàn toàn không có predicate tenant, và cùng một statement với cùng id trả về dòng khác nhau cho mỗi tenant. Native query, thứ rò rỉ bảy dòng ở biến thể discriminator, chỉ trả về bốn dòng của Acme, và JdbcClient bên trong transaction cũng vậy: dưới JpaTransactionManager nó chạy trên connection của session, connection mà provider đã trỏ vào acme. Khi không có tenant, sentinel _none_ trở thành một schema không tồn tại, và query lỗi thay vì trả về thứ gì đó.
Bẫy tái sử dụng connection: connection trong pool giữ schema của tenant trước
releaseConnection đặt lại schema trước khi connection về pool. Phiên bản thiếu dòng đó trông như đã xong, vì request nào của tenant cũng tự gán schema:
@Override
public void releaseConnection(String tenantId, Connection connection) throws SQLException {
connection.setSchema("public");
releaseAnyConnection(connection);
}Các request của tenant không sao. Vấn đề là mọi thứ khác mượn connection từ cùng pool mà không đi qua provider: JdbcClient ngoài transaction, một scheduled job, Flyway lúc runtime, một thư viện có query riêng. Lab hỏi schema và process id của backend PostgreSQL, để thấy được connection vật lý, trước và sau một request của Globex:
>>> JdbcClient, before any tenant: select current_schema()
backend 353, schema public
>>> findAll() as globex, then the backend it ran on
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 order by p1_0.id
[1:KB-01 35.00, 2:MN-01 229.00, 3:MN-02 159.00] on backend 353, schema globex
>>> JdbcClient, no tenant: select current_schema()
backend 353, schema globex
>>> JdbcClient, no tenant: select count(*) from tenants
!!! org.springframework.jdbc.BadSqlGrammarException: PreparedStatementCallback; bad SQL grammar [select count(*) from tenants]
!!! org.postgresql.util.PSQLException: ERROR: relation "tenants" does not exist ...Backend 353 phục vụ Globex rồi được trao, vẫn trỏ vào globex, cho đoạn code không biết gì về tenant. Ở đây kết quả là một lỗi, vì tenants chỉ có trong public. Một tên bảng tồn tại trong mọi tenant schema, như products, thì lại trả về các dòng của Globex. HikariCP giữ cho mỗi thread một danh sách các connection mà thread đó dùng gần nhất, đó là lý do cùng một backend quay lại trên cùng thread; với 200 request thread, lỗi trở thành lúc có lúc không.
HikariCP có thể tự đặt lại schema, nhưng chỉ trong một tổ hợp. Cùng lần chạy đó với từng biến thể, biến thể set search_path được viết là statement.execute("set search_path to " + tenantId):
Cách getConnection chuyển schema | Reset trong releaseConnection | spring.datasource.hikari.schema | Người mượn kế tiếp của backend N thấy |
|---|---|---|---|
connection.setSchema(tenantId) | không | không đặt | globex; tenants không tồn tại |
connection.setSchema(tenantId) | không | public | public; count(*) = 2 |
set search_path to … dạng statement | không | public | globex; tenants không tồn tại |
connection.setSchema(tenantId) | connection.setSchema("public") | không đặt | public; count(*) = 2 |
HikariCP 7.0.2 chỉ khôi phục schema khi connection trả về nếu schema đã được đổi qua setSchema, thứ bật dirty bit, và có cấu hình một schema mặc định; Boot để spring.datasource.hikari.schema trống. Một statement đổi search_path thì pool không bao giờ thấy. Vậy hãy dùng setSchema, reset nó trong releaseConnection, và coi hikari.schema=public là lưới an toàn thứ hai chứ không phải cách sửa.
Flyway migration cho từng tenant schema
Flyway của Boot migrate public trước khi JPA khởi động. Mỗi tenant schema cũng cần các migration trong db/tenant, trước khi request nào tới được nó, nên lab migrate chúng từ một FlywayMigrationStrategy, thứ Boot gọi thay cho migrate() của chính nó và trước khi EntityManagerFactory được dựng:
@Component
public class TenantSchemaMigrator {
private static final Logger log = LoggerFactory.getLogger(TenantSchemaMigrator.class);
private final DataSource dataSource;
private final JdbcClient jdbc;
public TenantSchemaMigrator(DataSource dataSource) {
this.dataSource = dataSource;
// not the JdbcClient bean: that one waits for Flyway, and this class runs inside Flyway's migration
this.jdbc = JdbcClient.create(dataSource);
}
public void migrateAll() {
jdbc.sql("select id from public.tenants order by id").query(String.class).list()
.forEach(this::migrate);
}
public void migrate(String tenantId) {
MigrateResult result = Flyway.configure()
.dataSource(dataSource)
.schemas(tenantId)
.locations("classpath:db/tenant")
.load()
.migrate();
log.info("Tenant {}: {} migration(s) applied", tenantId, result.migrationsExecuted);
}
}@Configuration
class TenantFlywayConfig {
@Bean
FlywayMigrationStrategy migratePublicThenEveryTenant(TenantSchemaMigrator tenants) {
return flyway -> {
flyway.migrate();
tenants.migrateAll();
};
}
}Comment trong constructor đến từ một lần khởi động thất bại: khi inject bean JdbcClient, startup dừng với Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?, vì Boot bắt bean JdbcClient chờ Flyway trong khi Flyway đang chờ class này. .schemas(tenantId) cho mỗi tenant một flyway_schema_history riêng bên trong schema của nó và để Flyway tạo schema khi chưa có. Khởi động với một database rỗng, các dòng log chỉ giữ phần message:
Migrating schema "public" to version "1 - create tenants"
Successfully applied 1 migration to schema "public", now at version v1 (execution time 00:00.003s)
Creating schema "acme" ...
Creating Schema History table "acme"."flyway_schema_history" ...
Current version of schema "acme": null
Migrating schema "acme" to version "1 - create catalog"
Successfully applied 1 migration to schema "acme", now at version v1 (execution time 00:00.003s)
Tenant acme: 1 migration(s) applied
Creating schema "globex" ...
Creating Schema History table "globex"."flyway_schema_history" ...
Current version of schema "globex": null
Migrating schema "globex" to version "1 - create catalog"
Successfully applied 1 migration to schema "globex", now at version v1 (execution time 00:00.003s)
Tenant globex: 1 migration(s) appliedLần khởi động thứ hai in ra Schema "acme" is up to date. No migration necessary. cho từng tenant. Vòng lặp chạy bên trong startup, lần lượt từng schema, nên thời gian startup tăng theo số tenant, và một exception từ migration của bất kỳ schema nào sẽ thoát khỏi strategy và dừng ứng dụng; với hàng trăm tenant, vòng lặp này thường được tách thành một bước deploy riêng.
Thêm tenant lúc runtime
Một khách hàng mới là một dòng trong public.tenants, một schema đã migrate và một registry được làm mới. Endpoint nằm dưới /api/admin/, nơi tenant filter bỏ qua:
@RestController
@RequestMapping("/api/admin/tenants")
class TenantAdminController {
record CreateTenantRequest(@NotBlank @Pattern(regexp = "[a-z][a-z0-9_]{1,29}") String id,
@NotBlank String name) {
}
private final JdbcClient jdbc;
private final TenantSchemaMigrator migrator;
private final TenantRegistry registry;
TenantAdminController(JdbcClient jdbc, TenantSchemaMigrator migrator, TenantRegistry registry) {
this.jdbc = jdbc;
this.migrator = migrator;
this.registry = registry;
}
@PostMapping
ResponseEntity<CreateTenantRequest> create(@Valid @RequestBody CreateTenantRequest request) {
jdbc.sql("insert into public.tenants (id, name) values (?, ?)")
.params(request.id(), request.name())
.update();
migrator.migrate(request.id());
registry.refresh();
return ResponseEntity.created(URI.create("/api/admin/tenants/" + request.id())).body(request);
}
}@Pattern giữ tenant id là một identifier chữ thường đơn giản, vì nó sẽ trở thành tên schema. Giống header tenant, endpoint này không có xác thực trong lab. Trước, trong và sau:
curl -s -H 'X-Tenant-Id: initech' http://localhost:8212/api/products
curl -i -s -H 'Content-Type: application/json' -d '{"id":"initech","name":"Initech"}' http://localhost:8212/api/admin/tenants
curl -i -s -H 'X-Tenant-Id: initech' http://localhost:8212/api/products{"detail":"Unknown tenant 'initech'.","instance":"/api/products","status":400,"title":"Invalid tenant"}HTTP/1.1 201
Location: /api/admin/tenants/initech
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 18 Sep 2026 07:26:00 GMT
{"id":"initech","name":"Initech"}HTTP/1.1 200
Content-Type: application/json
Content-Length: 2
Date: Fri, 18 Sep 2026 07:26:00 GMT
[]Migration trong log của server, ba dòng trong số đó:
2026-09-18T14:26:00.043+07:00 INFO 19683 --- [demo] [nio-8212-exec-2] o.f.core.internal.database.base.Schema : Creating schema "initech" ...
2026-09-18T14:26:00.065+07:00 INFO 19683 --- [demo] [nio-8212-exec-2] o.f.core.internal.command.DbMigrate : Migrating schema "initech" to version "1 - create catalog"
2026-09-18T14:26:00.073+07:00 INFO 19683 --- [demo] [nio-8212-exec-2] c.e.demo.tenant.TenantSchemaMigrator : Tenant initech: 1 migration(s) appliedKhông cần restart: thread của request đã tạo schema, và request kế tiếp cho initech nhận một catalogue rỗng thay vì 400.
TenantSchemaMapper của Hibernate 7.1: chuyển schema không cần provider
Hibernate 7.1 thêm một lối tắt, vẫn đang incubating, cho đúng loại provider này: hibernate.multi_tenant.schema_mapper. Với một TenantSchemaMapper, Hibernate vẫn dùng connection provider thông thường, gọi setSchema trên mỗi connection nó lấy, và khôi phục schema ban đầu của connection trước khi trả lại:
@Component
public class TenantSchemas implements TenantSchemaMapper<String>, HibernatePropertiesCustomizer {
@Override
public String schemaName(String tenantId) {
return tenantId;
}
@Override
public void customize(Map<String, Object> hibernateProperties) {
hibernateProperties.put(MultiTenancySettings.MULTI_TENANT_SCHEMA_MAPPER, this);
}
}Với bean này thay cho SchemaPerTenantConnectionProvider, cùng lần chạy kiểm tra tái sử dụng:
>>> JdbcClient, before any tenant: select current_schema()
backend 426, schema public
>>> findAll() as globex, then the backend it ran on
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 order by p1_0.id
[1:KB-01 35.00, 2:MN-01 229.00, 3:MN-02 159.00] on backend 426, schema globex
>>> JdbcClient, no tenant: select current_schema()
backend 426, schema public
>>> JdbcClient, no tenant: select count(*) from tenants
2Các query theo tenant trả về đúng các dòng như khi dùng provider, và trường hợp thiếu tenant lỗi với cùng relation "products" does not exist. Nó được đánh dấu @Incubating, nên contract có thể còn thay đổi; provider mới là SPI ổn định.
Database per tenant: mỗi tenant một connection pool
Cơ chế routing, tức chọn DataSource cho từng lời gọi bằng AbstractRoutingDataSource và tách đọc với ghi, là chủ đề của bài 10. Hibernate có hook riêng cho trường hợp tenant: AbstractDataSourceBasedMultiTenantConnectionProviderImpl xin một DataSource cho mỗi tenant id. Provider này giữ một HikariCP pool cho mỗi tenant, tạo ở lần dùng đầu tiên, kèm chạy Flyway trên nó:
@Component
public class DatabasePerTenantConnectionProvider
extends AbstractDataSourceBasedMultiTenantConnectionProviderImpl<String>
implements HibernatePropertiesCustomizer, DisposableBean {
private final DataSource controlDataSource;
private final TenantRegistry registry;
private final String urlTemplate;
private final String username;
private final String password;
private final Map<String, HikariDataSource> pools = new ConcurrentHashMap<>();
public DatabasePerTenantConnectionProvider(DataSource controlDataSource, TenantRegistry registry,
@Value("${app.tenants.url-template}") String urlTemplate,
@Value("${spring.datasource.username}") String username,
@Value("${spring.datasource.password}") String password) {
this.controlDataSource = controlDataSource;
this.registry = registry;
this.urlTemplate = urlTemplate;
this.username = username;
this.password = password;
}
@Override
protected DataSource selectAnyDataSource() {
return controlDataSource;
}
@Override
protected DataSource selectDataSource(String tenantId) {
if (!registry.exists(tenantId)) {
throw new IllegalStateException("No database for tenant '" + tenantId + "'");
}
return pools.computeIfAbsent(tenantId, this::createPool);
}
private HikariDataSource createPool(String tenantId) {
HikariDataSource pool = new HikariDataSource();
pool.setPoolName("tenant-" + tenantId);
pool.setJdbcUrl(urlTemplate.formatted(tenantId));
pool.setUsername(username);
pool.setPassword(password);
Flyway.configure().dataSource(pool).locations("classpath:db/tenant").load().migrate();
return pool;
}
@Override
public void customize(Map<String, Object> hibernateProperties) {
hibernateProperties.put(MultiTenancySettings.MULTI_TENANT_CONNECTION_PROVIDER, this);
}
@Override
public void destroy() {
pools.values().forEach(HikariDataSource::close);
}
}spring.datasource.url trỏ vào một database điều khiển, dbcontrol, nơi chứa tenants, còn app.tenants.url-template=jdbc:postgresql://localhost:5512/tenant_%s đặt tên database của từng tenant. Lab chạy findAll() một lần cho mỗi tenant, chờ năm giây, rồi đếm connection của server theo từng database trong pg_stat_activity. Với Acme và Globex:
>>> findAll() as acme
tenant-acme - Starting...
tenant-acme - Added connection org.postgresql.jdbc.PgConnection@6eb06667
tenant-acme - Start completed.
Database: jdbc:postgresql://localhost:5512/tenant_acme (PostgreSQL 18.6)
Successfully validated 1 migration (execution time 00:00.002s)
Current version of schema "public": 1
Schema "public" is up to date. No migration necessary.
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 order by p1_0.id
[1:KB-01 89.00, 2:KB-02 59.00, 3:MS-01 24.50, 4:MS-02 49.90] (4 rows)
>>> connections per database, from pg_stat_activity
[dbcontrol=10, tenant_acme=10, tenant_globex=10] (3 rows)
>>> total client connections
30Native findPricedAtLeastNative(0) ở đây cũng chỉ trả về bốn dòng của Acme, từ tenant_acme. Ba mươi connection cho hai tenant và một database điều khiển. HikariCP pool mặc định có maximumPoolSize là 10 và minimumIdle bằng chính nó, nên pool nào cũng lấp đầy mười connection idle dù tenant đó có bận hay không. Thêm tám database tenant nữa, tổng cộng mười tenant:
>>> connections per database, from pg_stat_activity
[dbcontrol=10, tenant_acme=10, tenant_globex=10, tenant_t01=10, tenant_t02=10, tenant_t03=10, tenant_t04=10, tenant_t05=10, tenant_t06=8, tenant_t07=7, tenant_t08=5] (11 rows)
>>> total client connections
100Server dừng ở max_connections bằng 100, và ba pool cuối không bao giờ đầy. PostgreSQL ghi lại các lần từ chối, 27 lần trong lần chạy đó:
2026-09-18 07:27:56.408 UTC [697] FATAL: sorry, too many clients alreadyLog của ứng dụng không có gì: HikariCP 7.0.2 log việc lấp pool nền thất bại ở mức DEBUG. Mọi request vẫn chạy, vì pool nào cũng có ít nhất năm connection, nhưng không thứ gì khác kết nối được vào server đó trong lúc ứng dụng chạy. Một pool cho mỗi tenant nhân số connection lên theo số tenant. Với con số thực tế, các pool cần maximumPoolSize 2 hoặc 3 và minimumIdle bằng 0, một connection pooler như PgBouncer đứng trước server, và các database tenant được rải ra nhiều server.
PostgreSQL row-level security làm tuyến phòng thủ thứ hai
Quay lại biến thể discriminator và native query bảy dòng của nó. Row-level security chuyển predicate tenant vào database: một policy trên bảng lọc mọi statement, kể cả native SQL và JdbcClient, theo một giá trị mà ứng dụng đặt trên connection.
Policy dựa trên current_setting('app.tenant_id')
Ứng dụng cần một database role không sở hữu các bảng; section tiếp theo cho thấy vì sao. Role được tạo một lần, bên ngoài Flyway:
docker exec sba-a12-pg psql -U demo -d demo -c "create role catalog_app login password 'catalog_app'"Migration cấp cho role quyền trên dữ liệu và bật các policy:
grant select, insert, update, delete on tenants, categories, products to catalog_app;
alter table categories enable row level security;
alter table products enable row level security;
create policy tenant_isolation on categories
using (tenant_id = current_setting('app.tenant_id', true));
create policy tenant_isolation on products
using (tenant_id = current_setting('app.tenant_id', true));app.tenant_id là một setting tùy biến, và argument thứ hai của current_setting, missing_ok, khiến nó trả về null thay vì báo lỗi khi setting chưa từng được định nghĩa. Một policy chỉ có using áp cùng điều kiện đó cho các dòng được ghi: với catalog_app và app.tenant_id là acme, câu update products set tenant_id = 'globex' where id = 2 lỗi với new row violates row-level security policy for table "products".
Đặt tenant cho từng transaction bằng set_config
set_config(name, value, is_local) với is_local = true chỉ tồn tại đến hết transaction hiện tại. Trong psql, trên cùng một connection:
before any set: NULL
BEGIN
inside, is_local=true: acme
COMMIT
after commit: ''
is_local=false: acme
next statement: acmeGiá trị cục bộ theo transaction đã biến mất sau commit: một chuỗi rỗng, không phải null, một khi setting đã từng được định nghĩa trong session đó, và chuỗi rỗng cũng không khớp tenant nào. Giá trị cấp session thì còn nguyên cho statement kế tiếp, mà trên một connection trong pool nghĩa là người mượn kế tiếp: cùng cái bẫy như với schema. Vậy nên giá trị được đặt bên trong mọi transaction, ngay sau khi nó bắt đầu. JpaTransactionManager có sẵn hook cho việc đó:
package com.example.demo.tenant;
import java.sql.PreparedStatement;
import java.util.Objects;
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.Session;
import org.springframework.orm.jpa.EntityManagerHolder;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionSynchronizationManager;
public class TenantAwareTransactionManager extends JpaTransactionManager {
public TenantAwareTransactionManager(EntityManagerFactory emf) {
super(emf);
}
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
super.doBegin(transaction, definition);
String tenantId = Objects.requireNonNullElse(TenantContext.get(), "");
EntityManagerHolder holder =
(EntityManagerHolder) TransactionSynchronizationManager.getResource(obtainEntityManagerFactory());
holder.getEntityManager().unwrap(Session.class).doWork(connection -> {
try (PreparedStatement statement =
connection.prepareStatement("select set_config('app.tenant_id', ?, true)")) {
statement.setString(1, tenantId);
statement.execute();
}
});
}
}@Configuration
@Profile("rls")
class RowLevelSecurityConfig {
@Bean
JpaTransactionManager transactionManager(EntityManagerFactory emf) {
return new TenantAwareTransactionManager(emf);
}
}super.doBegin mở transaction và gắn EntityManager vào thread, nên statement chạy trên đúng connection mà transaction sẽ dùng. Bean transactionManager của Boot tự lùi lại khi đã có một TransactionManager khác. Profile rls chuyển ứng dụng sang role mới và để Flyway dùng owner:
spring.datasource.username=catalog_app
spring.datasource.password=catalog_app
spring.flyway.user=demo
spring.flyway.password=demoNative query từng rò rỉ, giờ chạy bằng role không sở hữu bảng
Cùng các lời gọi trong lab với tenant acme, kết nối bằng catalog_app, với pool chỉ một connection để mọi bước dùng lại cùng session:
>>> current_user
catalog_app
>>> findPricedAtLeastNative(0) as acme (native @Query)
select * from products where price >= ? order by id
binding parameter (1:NUMERIC) <- [0]
[] (0 rows)
>>> findPricedAtLeastNative(0) inside a transaction as acme
select * from products where price >= ? order by id
binding parameter (1:NUMERIC) <- [0]
[1:acme:KB-01 89.00, 2:acme:KB-02 59.00, 3:acme:MS-01 24.50, 4:acme:MS-02 49.90] (4 rows)
>>> findByPriceLessThan(50) as acme (derived)
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.tenant_id = ? and p1_0.price<?
binding parameter (1:VARCHAR) <- [acme]
binding parameter (2:NUMERIC) <- [50]
[] (0 rows)
>>> findAll() as acme
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.tenant_id = ? order by p1_0.id
binding parameter (1:VARCHAR) <- [acme]
[1:acme:KB-01 89.00, 2:acme:KB-02 59.00, 3:acme:MS-01 24.50, 4:acme:MS-02 49.90] (4 rows)
>>> JdbcClient select inside a transaction as acme
[1:acme:KB-01, 2:acme:KB-02, 3:acme:MS-01, 4:acme:MS-02] (4 rows)
>>> JdbcClient select outside a transaction as acme
[] (0 rows)
>>> current_setting('app.tenant_id', true) outside a transaction
''
>>> JdbcClient insert of a globex row inside a transaction as acme
!!! org.springframework.jdbc.BadSqlGrammarException: PreparedStatementCallback; bad SQL grammar [insert into products (tenant_id, name, sku, price, category_id) values ('globex', 'Planted keyboard', 'KB-97', 1.00, 3)]
!!! org.postgresql.util.PSQLException: ERROR: new row violates row-level security policy for table "products"- Bên trong transaction, native query từng trả bảy dòng giờ trả về bốn dòng của Acme, và
JdbcClientcũng vậy. Predicate@TenantIdcủa Hibernate và policy giờ cùng đứng vững, độc lập với nhau. - Bên ngoài transaction, policy thấy
app.tenant_idrỗng và không trả về gì. Điều đó bắt được hai method: native query vàfindByPriceLessThan, một derived query có predicatetenant_idđúng nhưng vẫn trả về không dòng nào. Spring Data áp@Transactional(readOnly = true)cho các methodCrudRepositorymà nó hiện thực, nhưfindAll, nhưng các query method khai báo trên interface chạy không có transaction trừ khi có thứ gì đó bên ngoài mở nó. Trong ứng dụng, method@Transactionalcủa service làm việc đó; một query method gọi ngoài transaction sẽ fail closed với kết quả rỗng. - Một insert cho tenant khác bị database từ chối, dù câu SQL ghi rõ
globex.
Vì sao row-level security không lọc gì với table owner?
Cùng lần chạy đó nhưng kết nối bằng demo, user mà Docker image tạo ra và là owner của các bảng, ba bước trong số đó:
>>> current_user
demo
>>> findPricedAtLeastNative(0) inside a transaction as acme
[1:acme:KB-01 89.00, 2:acme:KB-02 59.00, 3:acme:MS-01 24.50, 4:acme:MS-02 49.90, 5:globex:KB-01 35.00, 6:globex:MN-01 229.00, 7:globex:MN-02 159.00] (7 rows)
>>> JdbcClient select outside a transaction as acme
[1:acme:KB-01, 2:acme:KB-02, 3:acme:MS-01, 4:acme:MS-02, 5:globex:KB-01, 6:globex:MN-01, 7:globex:MN-02] (7 rows)
>>> JdbcClient insert of a globex row inside a transaction as acme
1Policy đã bật, set_config đã chạy, và chúng không thay đổi gì. Có hai trường hợp được miễn riêng biệt, và một session psql bên trong một transaction bị rollback cho thấy cả hai, kể cả FORCE ROW LEVEL SECURITY:
select rolname, rolsuper, rolbypassrls from pg_roles where rolname in ('demo', 'catalog_app') order by rolname;
begin;
select set_config('app.tenant_id', 'acme', true);
select current_user, count(*) from products;
alter table products force row level security;
select current_user, count(*) from products;
create role catalog_owner;
alter table products owner to catalog_owner;
set local role catalog_owner;
select current_user, count(*) from products;
reset role;
alter table products no force row level security;
set local role catalog_owner;
select current_user, count(*) from products;
rollback;Các bảng kết quả, bỏ đi command tag và số dòng của psql:
rolname | rolsuper | rolbypassrls
-------------+----------+--------------
catalog_app | f | f
demo | t | t
current_user | count
--------------+-------
demo | 7
current_user | count
--------------+-------
demo | 7
current_user | count
---------------+-------
catalog_owner | 4
current_user | count
---------------+-------
catalog_owner | 7- Superuser, hoặc role có
BYPASSRLS, không bao giờ bị lọc.POSTGRES_USERtrong image chính thức là superuser, nêndemothấy bảy dòng cả trước và sauFORCE. Một lab test row-level security bằng user mặc định của image sẽ thấy nó không làm gì cả. - Table owner không bị lọc trừ khi bảng có
FORCE ROW LEVEL SECURITY.catalog_owner, một role thường sở hữuproductsbên trong transaction, thấy bốn dòng khi cóFORCEvà bảy dòng sauNO FORCE. - Một role không sở hữu bảng và không có
BYPASSRLSbị lọc chỉ vớiENABLE. Đó làcatalog_app, và đó là lý do ứng dụng kết nối bằng nó còn Flyway giữ owner.
Soft delete với @SoftDelete của Hibernate
Soft delete giữ lại dòng và đánh dấu nó, để một order line vẫn hiển thị được sản phẩm đã bán và admin có thể hoàn tác một thao tác nhầm. Hibernate 6.4 thêm @SoftDelete cho việc này, trong 7.4.5 vẫn được đánh dấu @Incubating: delete của entity được đánh annotation trở thành một update, và mọi lần đọc có thêm một predicate, giống cách @TenantId hoạt động.
@SoftDelete trên Product và Category
Biến thể softdelete giữ @TenantId và thêm @SoftDelete cho cả hai entity. Migration của nó cho cả hai bảng cột deleted boolean not null default false, cột mà strategy mặc định cần:
import org.hibernate.annotations.SoftDelete;
import org.hibernate.annotations.TenantId;
@Entity
@Table(name = "products")
@SoftDelete
public class Product {
// id, tenantId, name, sku and price unchanged
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@ManyToOne(optional = false)
@JoinColumn(name = "category_id", nullable = false)
private Category category;@Entity
@Table(name = "categories")
@SoftDelete
public class Category {
// id, tenantId and name unchanged
@OneToMany(mappedBy = "category")
@OrderBy("id")
private List<Product> products = new ArrayList<>(); LAZY phải bỏ đi. Còn giữ nó, ứng dụng không khởi động được:
Caused by: org.hibernate.metamodel.UnsupportedMappingException: To-one attribute (com.example.demo.product.Product.category) cannot be mapped as LAZY as its associated entity is defined with @SoftDeleteHibernate từ chối lazy proxy cho một target có thể đã bị soft delete, vì proxy hứa hẹn một dòng mà nó chưa kiểm tra. Mọi quan hệ to-one trỏ tới một entity @SoftDelete đều được load eager, như câu SQL bên dưới cho thấy, và đó là chi phí thật với một entity được nhiều entity khác trỏ tới.
SQL cho delete, find, JPQL và collection
Với tenant acme, xóa sản phẩm 3 và đọc xung quanh nó:
>>> findById(3) as acme
select p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,c1_0.tenant_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id and c1_0.tenant_id = ? and c1_0.deleted=false where p1_0.deleted=false and p1_0.id=? and p1_0.tenant_id = ?
binding parameter (1:VARCHAR) <- [acme]
binding parameter (2:BIGINT) <- [3]
binding parameter (3:VARCHAR) <- [acme]
Optional[3:acme:MS-01 24.50]
>>> delete product 3 (MS-01) as acme
select p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,c1_0.tenant_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id and c1_0.tenant_id = ? and c1_0.deleted=false where p1_0.deleted=false and p1_0.id=? and p1_0.tenant_id = ?
binding parameter (1:VARCHAR) <- [acme]
binding parameter (2:BIGINT) <- [3]
binding parameter (3:VARCHAR) <- [acme]
update products set deleted=true where id=? and deleted=false
binding parameter (1:BIGINT) <- [3]
deleted
>>> findById(3) as acme
select p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,c1_0.tenant_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id and c1_0.tenant_id = ? and c1_0.deleted=false where p1_0.deleted=false and p1_0.id=? and p1_0.tenant_id = ?
binding parameter (1:VARCHAR) <- [acme]
binding parameter (2:BIGINT) <- [3]
binding parameter (3:VARCHAR) <- [acme]
Optional.empty
>>> findAll() as acme
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.tenant_id = ? and p1_0.deleted=false order by p1_0.id
binding parameter (1:VARCHAR) <- [acme]
select c1_0.id,c1_0.name,c1_0.tenant_id from categories c1_0 where c1_0.deleted=false and c1_0.id=? and c1_0.tenant_id = ?
binding parameter (1:BIGINT) <- [1]
binding parameter (2:VARCHAR) <- [acme]
select c1_0.id,c1_0.name,c1_0.tenant_id from categories c1_0 where c1_0.deleted=false and c1_0.id=? and c1_0.tenant_id = ?
binding parameter (1:BIGINT) <- [2]
binding parameter (2:VARCHAR) <- [acme]
[1:acme:KB-01 89.00, 2:acme:KB-02 59.00, 4:acme:MS-02 49.90] (3 rows)
>>> findInCategory("Mice") as acme (JPQL)
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id and c1_0.tenant_id = ? and c1_0.deleted=false where p1_0.tenant_id = ? and c1_0.name=? and p1_0.deleted=false order by p1_0.price
binding parameter (1:VARCHAR) <- [acme]
binding parameter (2:VARCHAR) <- [acme]
binding parameter (3:VARCHAR) <- [Mice]
select c1_0.id,c1_0.name,c1_0.tenant_id from categories c1_0 where c1_0.deleted=false and c1_0.id=? and c1_0.tenant_id = ?
binding parameter (1:BIGINT) <- [2]
binding parameter (2:VARCHAR) <- [acme]
[4:acme:MS-02 49.90] (1 rows)
>>> category 2 and its products collection as acme
select c1_0.id,c1_0.name,c1_0.tenant_id from categories c1_0 where c1_0.deleted=false and c1_0.id=? and c1_0.tenant_id = ?
binding parameter (1:BIGINT) <- [2]
binding parameter (2:VARCHAR) <- [acme]
select p1_0.category_id,p1_0.id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.category_id=? and p1_0.deleted=false order by p1_0.id
binding parameter (1:BIGINT) <- [2]
2:acme:Mice -> [4:acme:MS-02 49.90]
>>> deleteInBulkBySku("MS-02") as acme (bulk JPQL delete)
update products p1_0 set deleted=true where p1_0.sku=? and p1_0.tenant_id = ? and p1_0.deleted=false
binding parameter (1:VARCHAR) <- [MS-02]
binding parameter (2:VARCHAR) <- [acme]
1deletetrở thànhupdate products set deleted=true where id=? and deleted=false. Phầnand deleted=falsethêm vào khiến lần xóa thứ hai cùng dòng không update gì.findByIdjoincategoriesbằng innerjoin, vìoptional = falsevà target có thể soft delete, và sản phẩm đã soft delete trả vềOptional.empty, giống một id chưa từng tồn tại.findAllkhông có join;categoryeager được load bằng một câuselectriêng cho mỗi category khác nhau, câu nào cũng có cả hai predicate. Đó là pattern N+1 của Basics 28, bị mapping ép buộc.- Collection
Category.productsbỏ qua sản phẩm đã xóa bằngp1_0.deleted=false.wherecủa nó không cótenant_id: Hibernate load collection theo khóa của owner, và owner đã được load qua filter. - Một câu
deleteJPQL, một bulk statement bỏ qua persistence context, vẫn trở thànhupdate ... set deleted=true. Không có dòng nào bị xóa vật lý.
Soft delete và tenancy trong cùng một statement
Query JPQL ở trên mang bốn predicate từ hai annotation, không cái nào nằm trong chuỗi query select p from Product p where p.category.name = :category:
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id
from products p1_0
join categories c1_0 on c1_0.id=p1_0.category_id and c1_0.tenant_id = ? and c1_0.deleted=false
where p1_0.tenant_id = ? and c1_0.name=? and p1_0.deleted=false
order by p1_0.priceEntity được join nhận cả hai predicate của nó trong mệnh đề on; vị trí của chúng trong where thì thay đổi, vì findById ở trên đặt deleted=false đầu tiên và tenant cuối cùng. Hai cơ chế kết hợp với nhau mà không cần code, điều đó cũng có nghĩa là cả hai đều vô hình khi đọc repository. Sự kết hợp này quan trọng với index: mọi query trên products giờ đều mang tenant_id = ? và deleted=false, nên các index bắt đầu bằng tenant_id, và các partial index where not deleted, khớp với thứ mà mọi statement cần.
Chuyện gì xảy ra với product có category bị soft delete?
Soft delete category 1, Keyboards, vẫn còn hai sản phẩm đang sống, rồi đọc một trong số đó:
>>> delete category 1 (Keyboards) as acme
select c1_0.id,c1_0.name,c1_0.tenant_id from categories c1_0 where c1_0.deleted=false and c1_0.id=? and c1_0.tenant_id = ?
binding parameter (1:BIGINT) <- [1]
binding parameter (2:VARCHAR) <- [acme]
update categories set deleted=true where id=? and deleted=false
binding parameter (1:BIGINT) <- [1]
deleted
>>> findById(1) as acme, a product in the deleted category
select p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,c1_0.tenant_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id and c1_0.tenant_id = ? and c1_0.deleted=false where p1_0.deleted=false and p1_0.id=? and p1_0.tenant_id = ?
binding parameter (1:VARCHAR) <- [acme]
binding parameter (2:BIGINT) <- [1]
binding parameter (3:VARCHAR) <- [acme]
Optional.empty
>>> findAll() as acme
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.tenant_id = ? and p1_0.deleted=false order by p1_0.id
binding parameter (1:VARCHAR) <- [acme]
select c1_0.id,c1_0.name,c1_0.tenant_id from categories c1_0 where c1_0.deleted=false and c1_0.id=? and c1_0.tenant_id = ?
binding parameter (1:BIGINT) <- [1]
binding parameter (2:VARCHAR) <- [acme]
!!! org.springframework.orm.ObjectRetrievalFailureException: No row with the given identifier exists for entity [com.example.demo.product.Category with id '1']
!!! org.hibernate.ObjectNotFoundException: No row with the given identifier exists for entity [com.example.demo.product.Category with id '1']
>>> count() as acme
select count(*) from products p1_0 where p1_0.tenant_id = ? and p1_0.deleted=false
binding parameter (1:VARCHAR) <- [acme]
4Ba câu trả lời cho cùng một sản phẩm đang sống. findById giấu nó đi, vì inner join không tìm thấy category còn sống. findAll lỗi cho cả danh sách, vì câu select eager riêng cho category 1 không tìm thấy gì ở chỗ một quan hệ non-optional hứa sẽ có một dòng. count() không join gì và vẫn đếm nó. Soft delete một parent trong khi các child còn sống vẫn trỏ tới nó để lại dữ liệu ở trạng thái mà mapping không biểu diễn được, nên service phải từ chối việc đó, hoặc soft delete các child trong cùng transaction.
Các strategy DELETED, ACTIVE và TIMESTAMP
SoftDeleteType trong Hibernate 7.4.5 có ba giá trị. Lab map một entity nhỏ cho mỗi strategy, và các đoạn dưới đây lấy từ những lần chạy đó:
strategy | Cột cần có | Insert ghi | delete gửi | Lần đọc thêm |
|---|---|---|---|---|
DELETED (mặc định) | deleted boolean | deleted = false | set deleted=true where id=? and deleted=false | deleted=false |
ACTIVE | active boolean | active = true | set active=false where id=? and active=true | active=true |
TIMESTAMP | deleted timestamp(6) | deleted = null | set deleted=localtimestamp where id=? and deleted is null | deleted is null |
Các attribute columnName và converter của annotation đổi tên cột và giá trị được lưu. localtimestamp được tính theo time zone của session, thứ pgjdbc đặt theo JVM: trên một máy ở UTC+7, dòng bị xóa ghi 2026-09-18 14:31:50.689779, lấy lúc 07:31 UTC, còn cùng lần chạy mười hai phút sau với -Duser.timezone=UTC ghi 2026-09-18 07:43:44.578029. Strategy TIMESTAMP ghi lại thời điểm xóa, trong một timestamp không có time zone mà ý nghĩa tùy vào JVM đã xóa dòng đó.
Thứ gì vượt qua @SoftDelete: native SQL và JdbcClient
Cùng quy tắc như với tenant: Hibernate không thêm gì vào SQL mà nó không sinh ra. Sau khi xóa sản phẩm 3 và 4:
>>> findAllNative("acme") as acme (native @Query)
select * from products where tenant_id = ? order by id
binding parameter (1:VARCHAR) <- [acme]
select c1_0.id,c1_0.name,c1_0.tenant_id from categories c1_0 where c1_0.deleted=false and c1_0.id=? and c1_0.tenant_id = ?
binding parameter (1:BIGINT) <- [1]
binding parameter (2:VARCHAR) <- [acme]
select c1_0.id,c1_0.name,c1_0.tenant_id from categories c1_0 where c1_0.deleted=false and c1_0.id=? and c1_0.tenant_id = ?
binding parameter (1:BIGINT) <- [2]
binding parameter (2:VARCHAR) <- [acme]
[1:acme:KB-01 89.00, 2:acme:KB-02 59.00, 3:acme:MS-01 24.50, 4:acme:MS-02 49.90] (4 rows)
>>> findPricedAtLeastNative(0) as acme (native @Query, every tenant)
select * from products where price >= ? order by id
binding parameter (1:NUMERIC) <- [0]
select c1_0.id,c1_0.name,c1_0.tenant_id from categories c1_0 where c1_0.deleted=false and c1_0.id=? and c1_0.tenant_id = ?
binding parameter (1:BIGINT) <- [1]
binding parameter (2:VARCHAR) <- [acme]
select c1_0.id,c1_0.name,c1_0.tenant_id from categories c1_0 where c1_0.deleted=false and c1_0.id=? and c1_0.tenant_id = ?
binding parameter (1:BIGINT) <- [2]
binding parameter (2:VARCHAR) <- [acme]
select c1_0.id,c1_0.name,c1_0.tenant_id from categories c1_0 where c1_0.deleted=false and c1_0.id=? and c1_0.tenant_id = ?
binding parameter (1:BIGINT) <- [3]
binding parameter (2:VARCHAR) <- [acme]
!!! org.springframework.orm.ObjectRetrievalFailureException: No row with the given identifier exists for entity [com.example.demo.product.Category with id '3']
!!! org.hibernate.ObjectNotFoundException: No row with the given identifier exists for entity [com.example.demo.product.Category with id '3']
>>> JdbcClient: acme rows with their deleted flag
[1:KB-01 deleted=false, 2:KB-02 deleted=false, 3:MS-01 deleted=true, 4:MS-02 deleted=true] (4 rows)Native query có giới hạn tenant trả về hai sản phẩm đã xóa như entity bình thường. Query không giới hạn tenant là chỗ rò rỉ của discriminator gặp việc load eager bắt buộc: nó trả về các dòng của Globex, Hibernate sau đó load category 3 của chúng qua tenant filter của Acme, không tìm thấy gì, và cả query lỗi. JdbcClient thấy cột đúng như nó đang là.
So sánh @SQLDelete và @SQLRestriction với @SoftDelete
Trước 6.4, soft delete được viết tay: một câu delete tùy biến và một đoạn nối thêm vào mọi lần đọc. Trong Hibernate 7.4.5, cặp đó là @SQLDelete và @SQLRestriction. Annotation restriction xuất hiện từ 6.3, còn @Where, thứ các ví dụ cũ dùng cho cùng việc này, đã biến mất: hibernate-core 7.4.5 không có class org.hibernate.annotations.Where, nên ví dụ như vậy không compile. Biến thể sqldelete map cùng các bảng:
@Entity
@Table(name = "products")
@SQLDelete(sql = "update products set deleted = true where id = ?")
@SQLRestriction("deleted = false")
public class Product {
// id, tenantId, name, sku and price as before
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "category_id", nullable = false)
private Category category;
private boolean deleted;Category có @SQLDelete(sql = "update categories set deleted = true where id = ?") và cùng restriction. Cùng các lời gọi như ở lần chạy @SoftDelete:
>>> findById(3) as acme
select p1_0.id,p1_0.category_id,p1_0.deleted,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.id=? and p1_0.tenant_id = ? and (p1_0.deleted = false)
binding parameter (1:BIGINT) <- [3]
binding parameter (2:VARCHAR) <- [acme]
Optional[3:acme:MS-01 24.50]
>>> delete product 3 (MS-01) as acme
select p1_0.id,p1_0.category_id,p1_0.deleted,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.id=? and p1_0.tenant_id = ? and (p1_0.deleted = false)
binding parameter (1:BIGINT) <- [3]
binding parameter (2:VARCHAR) <- [acme]
update products set deleted = true where id = ?
binding parameter (1:BIGINT) <- [3]
deleted
>>> findAll() as acme
select p1_0.id,p1_0.category_id,p1_0.deleted,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.tenant_id = ? and (p1_0.deleted = false) order by p1_0.id
binding parameter (1:VARCHAR) <- [acme]
[1:acme:KB-01 89.00, 2:acme:KB-02 59.00, 4:acme:MS-02 49.90] (3 rows)
>>> findInCategory("Mice") as acme (JPQL)
select p1_0.id,p1_0.category_id,p1_0.deleted,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id and c1_0.tenant_id = ? and (c1_0.deleted = false) and (c1_0.deleted = false) where p1_0.tenant_id = ? and (p1_0.deleted = false) and c1_0.name=? order by p1_0.price
binding parameter (1:VARCHAR) <- [acme]
binding parameter (2:VARCHAR) <- [acme]
binding parameter (3:VARCHAR) <- [Mice]
[4:acme:MS-02 49.90] (1 rows)
>>> category 2 and its products collection as acme
select c1_0.id,c1_0.name,c1_0.tenant_id from categories c1_0 where c1_0.id=? and c1_0.tenant_id = ? and (c1_0.deleted = false)
binding parameter (1:BIGINT) <- [2]
binding parameter (2:VARCHAR) <- [acme]
select p1_0.category_id,p1_0.id,p1_0.deleted,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.category_id=? and (p1_0.deleted = false) order by p1_0.id
binding parameter (1:BIGINT) <- [2]
2:acme:Mice -> [4:acme:MS-02 49.90]
>>> JPQL: select p from Product p where p.deleted = true
select p1_0.id,p1_0.category_id,p1_0.deleted,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.tenant_id = ? and (p1_0.deleted = false) and p1_0.deleted=true
binding parameter (1:VARCHAR) <- [acme]
[] (0 rows)
>>> deleteInBulkBySku("MS-02") as acme (bulk JPQL delete)
delete from products p1_0 where p1_0.sku=? and p1_0.tenant_id = ? and (p1_0.deleted = false)
binding parameter (1:VARCHAR) <- [MS-02]
binding parameter (2:VARCHAR) <- [acme]
1
>>> JdbcClient: acme rows with their deleted flag
[1:KB-01 deleted=false, 2:KB-02 deleted=false, 3:MS-01 deleted=true] (3 rows)Và bài test category, với quan hệ vẫn là LAZY:
>>> findById(1) as acme, then its category name
select p1_0.id,p1_0.category_id,p1_0.deleted,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.id=? and p1_0.tenant_id = ? and (p1_0.deleted = false)
binding parameter (1:BIGINT) <- [1]
binding parameter (2:VARCHAR) <- [acme]
select c1_0.id,c1_0.name,c1_0.tenant_id from categories c1_0 where c1_0.id=? and c1_0.tenant_id = ? and (c1_0.deleted = false)
binding parameter (1:BIGINT) <- [1]
binding parameter (2:VARCHAR) <- [acme]
!!! jakarta.persistence.EntityNotFoundException: No row with the given identifier exists for entity [com.example.demo.product.Category with id '1']
!!! org.hibernate.ObjectNotFoundException: No row with the given identifier exists for entity [com.example.demo.product.Category with id '1']
>>> findAll() as acme
select p1_0.id,p1_0.category_id,p1_0.deleted,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 where p1_0.tenant_id = ? and (p1_0.deleted = false) order by p1_0.id
binding parameter (1:VARCHAR) <- [acme]
[1:acme:KB-01 89.00, 2:acme:KB-02 59.00, 3:acme:MS-01 24.50, 4:acme:MS-02 49.90] (4 rows)
- Câu delete là của bạn, nguyên văn:
update products set deleted = true where id = ?, không có điều kiệnand deleted=falsemà@SoftDeletethêm vào. Các placeholder của nó phải khớp với thứ Hibernate bind, ở đây chỉ có id. - Restriction là một đoạn SQL, được dán vào trong ngoặc. Trong join của JPQL, Hibernate 7.4.5 dán nó hai lần,
and (c1_0.deleted = false) and (c1_0.deleted = false): vô hại, và là lời nhắc rằng Hibernate coi nó là text. - Bulk
deleteJPQL là delete thật.@SQLDeletechỉ thay statement cho một entity.deleteInBulkBySku("MS-02")gửidelete from products, và dòng đó biến mất khỏi bảng; với@SoftDelete, cùng method đó gửi mộtupdate. - Cờ có thể được map, điều
@SoftDeletekhông cho phép, nhưng restriction vẫn áp cho mọi query, nênwhere p.deleted = truekhông trả về gì. LAZYvẫn dùng được. Cái giá là cùng một reference hỏng như trước, chỉ bị phát hiện muộn hơn:findAllthành công và proxy lỗi vớiEntityNotFoundExceptionkhi code chạm vào category.
Native SQL và JdbcClient vượt qua cả hai cách theo cùng một cách.
Soft delete và unique constraint: partial unique index
products có unique (tenant_id, sku). Một dòng đã soft delete vẫn giữ SKU của nó, nên SKU đó không dùng lại được. Trong biến thể softdelete, xóa KB-02 rồi tạo một KB-02 mới:
>>> delete product 2 (KB-02) as acme
update products set deleted=true where id=? and deleted=false
deleted
>>> save(new Product("Compact keyboard v2", "KB-02", 64.00, Keyboards)) as acme
insert into products (category_id,name,price,sku,tenant_id,deleted) values (?,?,?,?,?,false)
HHH000247: ErrorCode: 0, SQLState: 23505
ERROR: duplicate key value violates unique constraint "products_tenant_sku_uk"
Detail: Key (tenant_id, sku)=(acme, KB-02) already exists.
!!! org.springframework.dao.DataIntegrityViolationException: could not execute statement [ERROR: duplicate key value violates unique constraint "products_tenant_sku_uk" ...
!!! org.hibernate.exception.ConstraintViolationException: could not execute statement [ERROR: duplicate key value violates unique constraint "products_tenant_sku_uk" ...
!!! org.postgresql.util.PSQLException: ERROR: duplicate key value violates unique constraint "products_tenant_sku_uk" ...
>>> JdbcClient: acme rows with sku KB-02
[2:KB-02 59.00 deleted=true] (1 rows)Sản phẩm người dùng đã xóa vẫn chặn sản phẩm họ đang tạo. Một unique index chỉ bao phủ các dòng còn sống diễn đạt đúng quy tắc thật:
alter table products drop constraint products_tenant_sku_uk;
create unique index products_tenant_sku_live_uk on products (tenant_id, sku) where not deleted;Cùng các bước đó sau V3:
>>> save(new Product("Compact keyboard v2", "KB-02", 64.00, Keyboards)) as acme
insert into products (category_id,name,price,sku,tenant_id,deleted) values (?,?,?,?,?,false)
binding parameter (1:BIGINT) <- [1]
binding parameter (2:VARCHAR) <- [Compact keyboard v2]
binding parameter (3:NUMERIC) <- [64.00]
binding parameter (4:VARCHAR) <- [KB-02]
binding parameter (5:VARCHAR) <- [acme]
8:acme:KB-02 64.00
>>> JdbcClient: acme rows with sku KB-02
[2:KB-02 59.00 deleted=true, 8:KB-02 64.00 deleted=false] (2 rows)Hai dòng dùng chung SKU, một đã xóa, một còn sống, và một KB-02 sống thứ hai vẫn sẽ bị từ chối. Câu insert cho thấy cách @SoftDelete ghi cờ: deleted nằm trong danh sách cột với literal false, không phải một parameter được bind.
Với strategy TIMESTAMP, cột là null với các dòng còn sống, và một unique (tenant_id, sku, deleted) thông thường không giúp được gì, vì PostgreSQL coi các giá trị null là khác nhau. Ba bảng tạm trong psql, mỗi bảng một loại ràng buộc:
create temp table skus_plain (tenant_id text, sku text, deleted timestamp(6), unique (tenant_id, sku, deleted));
insert into skus_plain values ('acme', 'KB-02', null);
insert into skus_plain values ('acme', 'KB-02', null);
select count(*) as live_kb02_rows from skus_plain where deleted is null;
create temp table skus_nnd (tenant_id text, sku text, deleted timestamp(6), unique nulls not distinct (tenant_id, sku, deleted));
insert into skus_nnd values ('acme', 'KB-02', localtimestamp - interval '1 day');
insert into skus_nnd values ('acme', 'KB-02', localtimestamp);
insert into skus_nnd values ('acme', 'KB-02', null);
insert into skus_nnd values ('acme', 'KB-02', null);
create temp table skus_partial (tenant_id text, sku text, deleted timestamp(6));
create unique index skus_partial_live_uk on skus_partial (tenant_id, sku) where deleted is null;
insert into skus_partial values ('acme', 'KB-02', localtimestamp - interval '1 day');
insert into skus_partial values ('acme', 'KB-02', localtimestamp);
insert into skus_partial values ('acme', 'KB-02', null);
insert into skus_partial values ('acme', 'KB-02', null);CREATE TABLE
INSERT 0 1
INSERT 0 1
live_kb02_rows
----------------
2
(1 row)
CREATE TABLE
INSERT 0 1
INSERT 0 1
INSERT 0 1
ERROR: duplicate key value violates unique constraint "skus_nnd_tenant_id_sku_deleted_key"
DETAIL: Key (tenant_id, sku, deleted)=(acme, KB-02, null) already exists.
CREATE TABLE
CREATE INDEX
INSERT 0 1
INSERT 0 1
INSERT 0 1
ERROR: duplicate key value violates unique constraint "skus_partial_live_uk"
DETAIL: Key (tenant_id, sku)=(acme, KB-02) already exists.Ràng buộc thông thường nhận hai dòng KB-02 còn sống. unique nulls not distinct, có từ PostgreSQL 15, nhận hai phiên bản đã xóa với timestamp khác nhau và từ chối dòng sống thứ hai. Partial index where deleted is null làm được điều tương tự mà không cần đụng tới timestamp: hai phiên bản đã xóa được nhận, dòng sống thứ hai bị loại.
Liệt kê và khôi phục các dòng đã soft delete
Màn hình admin cần những dòng mà @SoftDelete giấu đi. Cờ không phải là attribute của entity, nên JPQL không gọi tên được nó:
>>> JPQL: select p from Product p where p.deleted = true
!!! java.lang.IllegalArgumentException: org.hibernate.query.sqm.UnknownPathException: Could not resolve attribute 'deleted' of 'com.example.demo.product.Product' [select p from Product p where p.deleted = true]
!!! org.hibernate.query.sqm.UnknownPathException: Could not resolve attribute 'deleted' of 'com.example.demo.product.Product'
!!! org.hibernate.query.sqm.PathElementException: Could not resolve attribute 'deleted' of 'com.example.demo.product.Product'Hibernate 7.4.5 không có công tắc nào để tắt restriction cho một query. Native query thì dùng được và vượt qua cả @TenantId, nên nó phải tự ghi tenant. Lab dùng một entity thứ hai thay vào đó: một view chỉ đọc trên cùng bảng, có @TenantId và không có @SoftDelete, nơi deleted là một attribute bình thường:
// read-only view of every row, deleted or not, still scoped to the current tenant
@Entity
@Immutable
@Table(name = "products")
public class ProductRecord {
@Id
private Long id;
@TenantId
@Column(name = "tenant_id")
private String tenantId;
private String sku;
private BigDecimal price;
private boolean deleted;
// getters
}public interface ProductRecordRepository extends JpaRepository<ProductRecord, Long> {
List<ProductRecord> findByDeletedTrueOrderById();
@Modifying
@Query(value = "update products set deleted = false where id = :id and tenant_id = :tenantId", nativeQuery = true)
int restore(Long id, String tenantId);
}Sau khi Acme xóa sản phẩm 3 và Globex xóa sản phẩm 7:
>>> findByDeletedTrueOrderById() on ProductRecord as acme
select pr1_0.id,pr1_0.deleted,pr1_0.price,pr1_0.sku,pr1_0.tenant_id from products pr1_0 where pr1_0.tenant_id = ? and pr1_0.deleted=true order by pr1_0.id
binding parameter (1:VARCHAR) <- [acme]
[3:acme:MS-01 (deleted)] (1 rows)
>>> restore(3, "acme") as acme (native update)
update products set deleted = false where id = ? and tenant_id = ?
binding parameter (1:BIGINT) <- [3]
binding parameter (2:VARCHAR) <- [acme]
1
>>> findById(3) as acme
select p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,c1_0.tenant_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.tenant_id from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id and c1_0.tenant_id = ? and c1_0.deleted=false where p1_0.deleted=false and p1_0.id=? and p1_0.tenant_id = ?
binding parameter (1:VARCHAR) <- [acme]
binding parameter (2:BIGINT) <- [3]
binding parameter (3:VARCHAR) <- [acme]
Optional[3:acme:MS-01 24.50]Việc liệt kê giữ nguyên predicate tenant và chỉ trả về dòng đã xóa của Acme, không có sản phẩm 7 của Globex. @Immutable ngăn view ghi dữ liệu, nên việc khôi phục là một native update, và nó ghi rõ tenant vì không có gì khác làm hộ; khi bật row-level security, policy cũng sẽ ép điều đó. Khôi phục sẽ đụng phải partial index của phần trước khi một dòng sống đã lấy SKU đó trong lúc chờ:
>>> restore(2, "acme") while a live KB-02 exists
update products set deleted = false where id = ? and tenant_id = ?
binding parameter (1:BIGINT) <- [2]
binding parameter (2:VARCHAR) <- [acme]
HHH000247: ErrorCode: 0, SQLState: 23505
ERROR: duplicate key value violates unique constraint "products_tenant_sku_live_uk"
Detail: Key (tenant_id, sku)=(acme, KB-02) already exists.Đó là index đang làm đúng việc của nó; một endpoint admin sẽ trả về 409, status mà series dùng cho xung đột, và để người dùng chọn sản phẩm nào giữ SKU.
Chọn chiến lược multi-tenancy và cách soft delete
Với tenancy, kết quả các lần chạy trong lab theo từng chiến lược:
| Chiến lược | Thứ ép sự cô lập | Chi phí mỗi tenant | Migration | Noisy neighbour, backup và restore một tenant | Lỗi đã gặp trong lab |
|---|---|---|---|---|---|
Discriminator, @TenantId | Một predicate trong mọi statement Hibernate sinh ra | Vài dòng, không thêm connection | Một schema, một lịch sử Flyway | Dùng chung bảng và index; lấy dòng của một tenant ra bằng copy (select … where tenant_id = 'acme') | Native SQL và JdbcClient trả về cả 7 dòng; một foreign key nhận category của tenant khác; tenant null làm dừng startup |
| Schema per tenant | search_path của connection | Một schema và các bảng của nó; pool dùng chung | Flyway một lần cho mỗi schema, lúc startup và lúc tạo tenant | Dùng chung server và pool; pg_dump -n acme dump một tenant kèm lịch sử Flyway của nó | Một connection trong pool giữ globex khi thiếu reset; ddl-auto=validate lỗi |
| Database per tenant | Database và pool riêng | Một database và 10 connection theo mặc định của HikariCP | Flyway một lần cho mỗi database | Database có thể chuyển sang server riêng và restore riêng | Mười tenant chạm max_connections 100, chỉ PostgreSQL ghi log |
| Row-level security trên discriminator | PostgreSQL, cho mọi statement kể cả native SQL | Một policy cho mỗi bảng | Policy và grant nằm trong migration | Như discriminator | Không lọc gì với owner hay superuser; kết quả rỗng ngoài transaction |
Discriminator rẻ nhất cho mỗi tenant và hợp với nhiều tenant nhỏ; hãy thêm row-level security ngay khi có native SQL. Schema đổi một vòng migration theo từng schema lấy sự cô lập mà native SQL không ghi tên schema không thoát ra được; một câu lệnh ghi rõ schema khác, globex.products, thì vẫn thoát được. Database per tenant dành cho ít tenant lớn sẵn sàng trả tiền cho backup riêng, vị trí đặt riêng hoặc cách ly khỏi noisy neighbour, và nó cần các pool được định cỡ cho điều đó.
Với soft delete:
| Cách làm | delete gửi | Bulk delete JPQL | To-one tới dòng đã xóa | Cờ trong JPQL | SKU duy nhất giữa các dòng sống |
|---|---|---|---|---|---|
@SoftDelete, DELETED hoặc ACTIVE | update … set deleted=true where id=? and deleted=false | Chuyển thành update | Phải eager; findById giấu child, findAll ném lỗi | Không phải attribute | Partial index where not deleted |
@SoftDelete(strategy = TIMESTAMP) | update … set deleted=localtimestamp where id=? and deleted is null | Chuyển thành update | Như trên | Không phải attribute | Partial index where deleted is null, hoặc unique nulls not distinct |
@SQLDelete + @SQLRestriction | SQL của bạn, nguyên văn | delete vật lý | Cho phép lazy; EntityNotFoundException khi truy cập | Map được, nhưng vẫn bị restriction | Partial index where not deleted |
Cả ba đều để native SQL và JdbcClient không bị lọc. @SoftDelete là cách duy nhất phủ luôn cả bulk delete, nên nó là mặc định an toàn hơn trên Hibernate 7.4.5; @SQLDelete với @SQLRestriction hợp khi câu delete phải làm nhiều hơn là lật một cột, vì SQL của nó hoàn toàn do bạn viết.
FAQ
Làm sao triển khai multi-tenancy trong Spring Boot với Hibernate 7?
Đặt tenant hiện tại vào một ThreadLocal từ một filter, clear trong finally, và cho Hibernate một bean CurrentTenantIdentifierResolver trả về nó, dùng một sentinel thay cho null. Với schema dùng chung, đánh @TenantId lên một cột; với schema hoặc database per tenant, đăng ký một MultiTenantConnectionProvider qua HibernatePropertiesCustomizer, hoặc từ Hibernate 7.1 trở đi là một TenantSchemaMapper. Boot 4.1.1 không cần property nào khác: Hibernate suy ra chiến lược từ mapping.
@TenantId có lọc native query và JdbcClient không?
Không. Hibernate thêm predicate tenant vào find, JPQL, derived query, Specification, count query và bulk update @Modifying, nhưng một native @Query và JdbcClient trả về cả bảy dòng của hai tenant. Hãy tự giới hạn tenant cho từng native query, hoặc bật row-level security của PostgreSQL với ứng dụng kết nối bằng một role không sở hữu bảng.
Vì sao Spring Boot lỗi "SessionFactory configured for multi-tenancy, but no tenant identifier specified"?
Spring Data chuẩn bị derived query lúc startup, việc đó mở một Hibernate session, và resolver trả về null vì chưa request nào gán tenant. Hãy trả về một giá trị không phải tenant, như _none_: nó không khớp dòng nào và làm foreign key lỗi khi insert.
Provider schema-per-tenant nên dùng setSchema hay SET search_path?
connection.setSchema. pgjdbc chuyển nó thành SET SESSION search_path TO 'tenant' với tên được đặt trong dấu nháy, và HikariCP ghi nhận thay đổi; một statement set search_path thì pool không thấy. Dù cách nào, hãy reset schema trong releaseConnection, nếu không người mượn kế tiếp sẽ nhận schema của tenant trước; HikariCP chỉ tự reset khi spring.datasource.hikari.schema được đặt và thay đổi đi qua setSchema.
Vì sao row-level security của PostgreSQL trả về mọi dòng?
Vì role của connection là superuser, có BYPASSRLS, hoặc sở hữu bảng. POSTGRES_USER của Docker image là superuser và thấy cả bảy dòng kể cả với FORCE ROW LEVEL SECURITY; một owner bình thường chỉ bị lọc khi có FORCE. Hãy cho ứng dụng kết nối bằng một role riêng không sở hữu gì.
Làm sao tìm các dòng đã soft delete khi @SoftDelete ẩn chúng đi?
JPQL không gọi tên được cờ, và Hibernate 7.4.5 không có công tắc để bỏ restriction cho một query. Hãy dùng native query có ghi rõ tenant, hoặc map một entity thứ hai, @Immutable, lên cùng bảng với @TenantId và một attribute deleted bình thường, cách này giữ predicate tenant cho màn hình admin.
Kết luận
Multi-tenancy là một câu hỏi đặt ra cho mọi statement, và ba chiến lược trả lời nó ở những chỗ khác nhau. Với @TenantId, Hibernate 7.4.5 thêm tenant_id = ? vào find, JPQL, derived query, Specification, phân trang và cả bulk update, trong khi native SQL và JdbcClient trả về dòng của cả hai tenant và một foreign key nhận category của tenant khác cho tới khi tenant trở thành một phần của khóa. Schema per tenant chuyển ranh giới vào connection, nơi bẫy tái sử dụng connection nằm chờ: thiếu reset, một connection trong pool trao globex cho đoạn code không biết gì về tenant, và HikariCP chỉ giúp được khi thay đổi đi qua setSchema và có cấu hình schema mặc định. Flyway migrate từng schema lúc startup và lúc tạo tenant, còn TenantSchemaMapper của Hibernate 7.1 làm cả việc chuyển lẫn reset schema từ một class chỉ có một method đáng kể. Database per tenant cô lập tốt nhất và tốn mười connection idle cho mỗi tenant, cho tới khi mười tenant lấp đầy một trăm connection của PostgreSQL. Row-level security chặn được native query rò rỉ, nhưng chỉ với role không sở hữu bảng và không phải superuser, và chỉ bên trong transaction đã chạy set_config. Phía request có bẫy riêng: một ThreadLocal không được clear sẽ phục vụ dữ liệu của tenant trước, và thread @Async hoàn toàn không có tenant.
Soft delete chạy trên cùng cơ chế đó. @SoftDelete biến delete, kể cả bulk delete JPQL, thành update và thêm deleted=false bên cạnh predicate tenant, với cái giá là các quan hệ to-one phải eager và một parent đã soft delete làm child của nó bị giấu đi hoặc làm hỏng query. @SQLDelete với @SQLRestriction giữ quan hệ lazy nhưng để bulk delete xóa dòng thật. Dù cách nào, SKU duy nhất cũng cần một partial index, và các dòng đã xóa được lấy ra qua native SQL hoặc một entity thứ hai chỉ đọc.
Bài này khép lại Chương 2 về truy cập dữ liệu nâng cao. Chương 3 chuyển sang bảo mật, và bài đầu tiên của nó, bài 13, nói về OAuth2 và OpenID Connect: OAuth2 Login với Google và GitHub, và Resource Server kiểm tra JWT, đúng loại token mà tenant ở production nên đến từ đó thay cho header.