这周 Spring 官方的《This Week in Spring》专门提到了一个企业 SaaS 项目越来越绕不开的话题:
Multitenancy,多租户。
Spring 官方这次重点展示的是 Spring Security、OAuth 和多租户结合的方案。(spring.io)
这个功能特别适合现在大量企业 SaaS 系统。
比如你做一个:
门店管理系统CRMERPAI工作台电商SaaS预约系统
刚开始可能只有一家客户。
后来变成:
商户A商户B商户C...1000家商户
所有人使用:
同一套Spring Boot同一套接口甚至同一套MySQL表
但必须保证一件事:
商户A永远不能看到商户B的数据。
最危险的实现是什么?
每条 SQL 都靠开发人员自己记着写:
WHERE tenant_id = ?
只要某一天有人漏了一次:
orderRepository.findAll();
可能直接变成一次严重的数据越权。
后来我换成 Hibernate 自带的多租户能力:
@TenantId
让 Hibernate 自动给当前 Session 做租户隔离。
这套方案对于大量中小 SaaS 项目,真的非常实用。
一、多租户不是“一个数据库建1000套表”
先明确概念。
Hibernate 当前文档把多租户主要分成三种方案:每个租户独立数据库、每个租户独立 Schema,以及多个租户共享表但使用 tenant id 区分数据。(docs.hibernate.org)
第一种:
tenant A↓database_Atenant B↓database_B
隔离最强。
但 1000 个客户就意味着可能要维护大量:
数据库连接池迁移脚本监控备份
成本很高。
第二种:
一个数据库schema_Aschema_Bschema_C
隔离也不错,但 Schema 数量多以后运维同样会越来越复杂。
我这次使用的是第三种:
ordersidtenant_idorder_noamountstatus
所有商户共用:
orders
但每一行都带:
tenant_id
例如:
id tenant_id order_no1 shop_001 SO100012 shop_002 SO100023 shop_001 SO10003
这种模式特别适合:
租户数量很多,但单个租户数据规模没有大到必须独占数据库的 SaaS。
二、千万别直接相信前端传来的tenantId
这是多租户系统最容易犯的错误。
比如前端请求:
GET /api/ordersX-Tenant-Id: shop_001
Java:
String tenantId =request.getHeader(”X-Tenant-Id”);
然后:
SELECT *FROM ordersWHERE tenant_id = ?
看起来没问题。
攻击者只需要把请求改成:
X-Tenant-Id: shop_002
如果后端没有额外检查:
数据直接串租户。
所以:
tenantId
不能由客户端“声明自己是谁”。
我更推荐它来自:
已经通过认证并签名的身份信息。
例如 JWT:
{”sub”: ”10086”,”tenant_id”: ”shop_001”,”roles”: [”ADMIN”]}
这个 JWT 必须经过 Spring Security 正常验签。
然后 Java 才从认证上下文里获取:
shop_001
而不是相信请求里的:
?tenantId=shop_001
或者:
X-Tenant-Id
真正的原则其实非常简单:
租户身份必须来自认证系统,而不是业务参数。
三、用CurrentTenantIdentifierResolver告诉Hibernate:现在是谁
Hibernate 的多租户模型里有一个非常关键的接口:
CurrentTenantIdentifierResolver
它的作用就是:
当前这个 Hibernate Session 属于哪个租户?
Hibernate 官方文档也建议,在 Spring 这类无法手动控制每次 Session 创建的环境中,通常通过 CurrentTenantIdentifierResolver 提供当前租户。(docs.hibernate.org)
例如:
public class SecurityTenantResolverimplementsCurrentTenantIdentifierResolver {public StringresolveCurrentTenantIdentifier() {Authentication authentication =SecurityContextHolder.getContext().getAuthentication();if (authentication == null|| !authentication.isAuthenticated()) {throw new IllegalStateException(”Tenant not authenticated”);}Jwt jwt =(Jwt)authentication.getPrincipal();String tenantId =jwt.getClaimAsString(”tenant_id”);if (tenantId == null|| tenantId.isBlank()) {throw new IllegalStateException(”Tenant id missing”);}return tenantId;}public booleanvalidateExistingCurrentSessions() {return true;}}
现在用户携带:
tenant_id = shop_001
访问系统。
Hibernate 创建 Session 时就会知道:
当前租户=shop_001
接下来最有意思的地方来了。
四、实体只需要增加一个@TenantId
以前我们可能这样写:
public class Order {private Long id;private String tenantId;private String orderNo;private BigDecimal amount;}
然后 Repository:
findByTenantIdAndOrderNo(tenantId,orderNo);
每个查询方法都不断出现:
tenantIdtenantIdtenantId
现在可以改成:
(name = ”orders”)public class Order {private Long id;(name = ”tenant_id”,nullable = false,updatable = false)private String tenantId;private String orderNo;private BigDecimal amount;}
最关键就是:
@TenantId
Hibernate 文档明确说明:被 @TenantId 标记的字段在实体首次持久化时,会自动使用当前 Session 的 tenant id;应用程序不应该自己给这个字段随意赋值。(docs.hibernate.org)
于是保存订单:
Order order =new Order();order.setOrderNo(”SO10001”);order.setAmount(new BigDecimal(”299”));orderRepository.save(order);
你甚至不需要:
order.setTenantId(currentTenant);
Hibernate 会根据当前租户自动设置:
tenant_id = shop_001
这个感觉已经很好了。
真正更爽的是查询。
五、findAll()也只能看到自己的数据
假设数据库现在有:
shop_001100条订单shop_002300条订单
以前最危险的代码:
orderRepository.findAll();
很可能返回:
400条
开发人员必须自己记得:
findAllByTenantId(...)
但是使用 Hibernate discriminator 多租户以后,在当前 Session:
tenant = shop_001
的情况下:
orderRepository.findAll();
Hibernate 会自动限定当前租户。
Hibernate 7.2 文档明确说明,@TenantId 会让当前 Session 的实体查询自动过滤,只让与当前 tenant id 匹配的数据可见。(docs.hibernate.org)
也就是说开发人员写:
findAll();
业务效果相当于:
SELECT *FROM ordersWHERE tenant_id = 'shop_001';
用户:
shop_002
访问同一个接口。
同一份 Java 代码:
findAll();
看到的却是:
shop_002的数据。
这就是我认为 Hibernate 多租户真正有价值的地方:
租户隔离不再依赖每一个开发人员记得写WHERE。
六、但有一个大坑:Native SQL不会自动帮你隔离
做到这里千万不要以为:
加了
@TenantId,整个系统永远不会串数据了。
不是。
Hibernate 官方专门警告:
Native SQL 不会自动添加 tenant id 过滤条件。 (docs.hibernate.org)
比如:
@Query(value = ”””SELECT *FROM ordersWHERE status = 'UNPAID'”””,nativeQuery = true)List findUnpaidOrders();
这条 SQL 是你自己写的。
Hibernate不会偷偷给你变成:
AND tenant_id = ?
所以多租户项目里,我会专门制定一条代码规范:
业务表尽量使用 Hibernate/JPA 查询;必须写 Native SQL 时,tenant_id 属于强制审查项。
例如:
SELECT *FROM ordersWHERE tenant_id = :tenantIdAND status = 'UNPAID';
而且:
tenantId
仍然不能从用户请求参数直接拿。
必须来自认证上下文。
这可能是整套方案里最需要团队注意的一点。
七、数据库索引也必须把tenant_id算进去
加上:
tenant_id
只是第一步。
如果数据库设计不变,数据量上来以后查询性能可能很难看。
例如原来:
CREATE UNIQUE INDEXuk_order_noON orders(order_no);
在多租户系统里可能就不合理。
因为:
shop_001
完全可能存在:
SO10001
同时:
shop_002
也存在:
SO10001
真正唯一的应该是:
tenant_id+order_no
所以:
CREATE UNIQUE INDEXuk_tenant_orderON orders(tenant_id,order_no);
查询经常使用:
WHERE tenant_id = ?AND status = ?
那么可以考虑:
CREATE INDEXidx_tenant_statusON orders(tenant_id,status);
这也是多租户系统数据库设计很容易忽略的变化:
以前很多索引是:
businessColumn
现在往往要变成:
tenant_id+businessColumn
否则随着租户数量增加,查询范围越来越大。
八、异步任务和MQ是最容易偷偷串租户的地方
HTTP 请求阶段还比较简单。
因为:
JWT↓Spring Security↓CurrentTenantIdentifierResolver
整条链路都在。
真正危险的是:
@Async
或者:
KafkaRocketMQJobRunr定时任务
因为这些代码执行时,可能已经没有原来的:
SecurityContext
了。
比如:
public void generateReport() {orderRepository.findAll();}
问题来了:
这里到底属于哪个tenant?
所以我会要求所有跨线程、跨进程任务都显式携带:
tenantId
例如 MQ:
{”eventId”: ”EV10001”,”tenantId”: ”shop_001”,”orderId”: 10086}
消费者收到以后:
先恢复Tenant Context↓再打开Hibernate Session↓再执行数据库操作
绝不能假设:
ThreadLocal 会自己穿过 MQ。
更不能假设:
Spring Security 的登录状态会自动跟着异步任务走。
一个多租户系统最容易发生数据事故的地方,往往不是 Controller。
而是:
异步任务批处理MQ消费者后台管理员Native SQL
这些“正常请求之外”的路径。
九、最后
Spring 这周专门把 Multitenancy 放到开发者视野里,我觉得这个方向很值得 Java 团队关注,因为越来越多项目正在从:
给一家客户部署一套系统
变成:
一套SaaS服务几百甚至几千家企业
而多租户系统真正难的,从来不是:
表里加一个tenant_id。
而是保证:
所有访问路径都不可能忘记tenant_id。
我现在更推荐的核心链路是:
用户登录↓JWT携带tenant_id↓Spring Security验签↓CurrentTenantIdentifierResolver↓Hibernate Session获得当前租户↓↓普通ORM查询自动隔离↓数据库联合索引↓MQ / 异步任务显式传播tenantId
对于绝大多数中小 SaaS:
共享数据库+共享表+tenant_id+Hibernate @TenantId
已经是一种非常实用的起步方案。
但是一定要记住两个红线:
Native SQL不会自动隔离。tenantId永远不能相信客户端自己声明。
如果这两个地方控制住,再把异步任务的 Tenant Context 做好,系统的安全性会比:
findByTenantIdAndXXX(...)
在几百个 Repository 里复制粘贴,可靠得多。
所以以后再做:
CRM SaaSERP SaaS商家后台AI企业工作台连锁门店系统
别等业务上线以后才想到:
“对了,我们还得加tenant_id。”
多租户不是一个字段。
它应该从认证、Hibernate、数据库索引到异步消息,成为整个 Java 系统的一条:
扫码领红包数据隔离边界。
微信赞赏
支付宝扫码领红包

