这周 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 Adatabase_A tenant Bdatabase_B

隔离最强。

但 1000 个客户就意味着可能要维护大量:

数据库连接池迁移脚本监控备份

成本很高。

第二种:

一个数据库 schema_Aschema_Bschema_C

隔离也不错,但 Schema 数量多以后运维同样会越来越复杂。

我这次使用的是第三种:

orders idtenant_idorder_noamountstatus

所有商户共用:

orders

但每一行都带:

tenant_id

例如:

id    tenant_id    order_no 1     shop_001     SO10001 2     shop_002     SO10002 3     shop_001     SO10003

这种模式特别适合:

租户数量很多,但单个租户数据规模没有大到必须独占数据库的 SaaS。


二、千万别直接相信前端传来的tenantId

这是多租户系统最容易犯的错误。

比如前端请求:

GET /api/orders X-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)

例如:

@Componentpublic class SecurityTenantResolver        implements        CurrentTenantIdentifierResolver {     @Override    public String    resolveCurrentTenantIdentifier() {         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;    }     @Override    public boolean    validateExistingCurrentSessions() {         return true;    }}

现在用户携带:

tenant_id = shop_001

访问系统。

Hibernate 创建 Session 时就会知道:

当前租户=shop_001

接下来最有意思的地方来了。


四、实体只需要增加一个@TenantId

以前我们可能这样写:

@Entity@Table(name = ”orders”)public class Order {     @Id    private Long id;     private String tenantId;     private String orderNo;     private BigDecimal amount;}

然后 Repository:

findByTenantIdAndOrderNo(        tenantId,        orderNo);

每个查询方法都不断出现:

tenantIdtenantIdtenantId

现在可以改成:

@Entity@Table(name = ”orders”)public class Order {     @Id    private Long id;     @TenantId    @Column(        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 orders        WHERE status = 'UNPAID'        ”””,    nativeQuery = true)List findUnpaidOrders();

这条 SQL 是你自己写的。

Hibernate不会偷偷给你变成:

AND tenant_id = ?

所以多租户项目里,我会专门制定一条代码规范:

业务表尽量使用 Hibernate/JPA 查询;必须写 Native SQL 时,tenant_id 属于强制审查项。

例如:

SELECT *FROM ordersWHERE tenant_id = :tenantId  AND 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 请求阶段还比较简单。

因为:

JWTSpring SecurityCurrentTenantIdentifierResolver

整条链路都在。

真正危险的是:

@Async

或者:

KafkaRocketMQJobRunr定时任务

因为这些代码执行时,可能已经没有原来的:

SecurityContext

了。

比如:

@Asyncpublic 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获得当前租户    ↓@Entity@TenantId    ↓普通ORM查询自动隔离    ↓数据库联合索引    ↓MQ / 异步任务显式传播tenantId

对于绝大多数中小 SaaS:

共享数据库+共享表+tenant_id+Hibernate @TenantId

已经是一种非常实用的起步方案。

但是一定要记住两个红线:

Native SQL不会自动隔离。 tenantId永远不能相信客户端自己声明。

如果这两个地方控制住,再把异步任务的 Tenant Context 做好,系统的安全性会比:

findByTenantIdAndXXX(...)

在几百个 Repository 里复制粘贴,可靠得多。

所以以后再做:

 

CRM SaaSERP SaaS商家后台AI企业工作台连锁门店系统

别等业务上线以后才想到:

“对了,我们还得加tenant_id。”

多租户不是一个字段。

它应该从认证、Hibernate、数据库索引到异步消息,成为整个 Java 系统的一条:

数据隔离边界。

扫码领红包

微信赞赏支付宝扫码领红包

发表回复

后才能评论