多租户系统到底怎么设计?从 tenant_id 到 JWT 租户隔离的真实实践
前言
很多人第一次做多租户系统时,第一反应通常是:
sql
ALTER TABLE patient
ADD tenant_id VARCHAR(64);
然后所有查询手动加:
sql
WHERE tenant_id = ?
从功能角度看,这确实能够实现最基础的数据区分。
但真正把多租户系统做到实际项目里以后,我越来越觉得:
多租户绝对不是"每张表多一个 tenant_id"这么简单。
因为 tenant_id 只是数据最终落到数据库以后的一种表现。
真正的问题其实是一整条链路:
text
用户属于哪个租户?
↓
登录时如何确认租户?
↓
客户端能不能伪造 tenantId?
↓
JWT 要不要保存 tenantId?
↓
后端业务代码从哪里获得当前租户?
↓
SELECT 如何自动过滤?
↓
UPDATE / DELETE 是否也会自动隔离?
↓
INSERT 如何保证 tenant_id 正确?
↓
Redis 怎么避免不同租户缓存冲突?
↓
@Async 换线程以后租户上下文怎么办?
↓
定时任务没有登录用户怎么办?
↓
平台管理员怎么跨租户查询?
↓
怎么验证某个接口绝对读不到别的租户的数据?
我最近开发的一个医院智慧填报系统,本身就是一个典型的多租户场景。
系统中:
text
一家医院 = 一个 Tenant
例如:
text
医院 A
tenant_id = HOSPITAL_10001
医院 B
tenant_id = HOSPITAL_10002
两家医院使用的是:
text
同一套前端
同一套后端
同一套数据库
同一套业务表
但是:
医院 A 绝对不能看到医院 B 的患者、病例、会议等业务数据。
因此我的最终目标并不是实现:
text
tenant_id
而是建立:
text
Identity
↓
JWT
↓
TenantContext
↓
SQL
↓
Redis
↓
Async / Scheduler
↓
Audit / Test
这样一条完整的租户隔离链路。
本文就结合我在真实项目中的设计过程,完整聊聊一个共享数据库、共享表的多租户系统到底应该怎么设计。
本文涉及的医院名称、用户数据和业务编号均为示例数据,不涉及任何真实患者信息。
一、先不要写代码:多租户真正需要隔离的是什么?
Multi-Tenancy,也就是多租户,是 SaaS 系统中非常常见的一种架构方式。
例如:
text
SaaS Platform
│
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
医院 A 医院 B 医院 C
Tenant A Tenant B Tenant C
三家医院共享应用基础设施,但是数据逻辑上相互独立。
医院 A 可以拥有:
text
用户
患者
病例
会议
科室
配置
医院 B 也可以拥有:
text
用户
患者
病例
会议
科室
配置
但是两个租户之间:
text
A ─────X─────> B
B ─────X─────> A
正常情况下不能互相访问。
1.1 常见的三种多租户数据库模式
通常可以把多租户数据库设计分成三类:
| 方案 | Database | Schema / Table | 隔离强度 | 成本 |
|---|---|---|---|---|
| 独立数据库 | 每租户独立 | 独立 | 高 | 高 |
| 共享数据库、独立 Schema | 共享 | Schema 独立 | 较高 | 中 |
| 共享数据库、共享表 | 共享 | 通过 tenant_id 区分 | 逻辑隔离 | 低 |
我目前这个项目选择的是:
text
Shared Database
+
Shared Table
+
tenant_id
也就是:
text
共享数据库
共享业务表
tenant_id 行级隔离
例如:
text
patient
id tenant_id name
----------------------------------
1 HOSPITAL_10001 张三
2 HOSPITAL_10001 李四
3 HOSPITAL_10002 王五
4 HOSPITAL_10003 赵六
医院 A 查询:
sql
SELECT *
FROM patient
WHERE tenant_id = 'HOSPITAL_10001';
只应该得到:
text
张三
李四
这种方式最大的优势是:
- 数据库数量少;
- 表结构统一;
- 数据库升级容易;
- 运维复杂度低;
- 非常适合很多中小型 SaaS 项目;
- 可以结合 MyBatis-Plus 自动完成租户条件注入。
但它最大的风险同样非常明显:
只要有一次查询漏掉 tenant_id,就可能产生跨租户数据泄露。
因此这种架构的核心不是:
text
大家记得写 tenant_id
而应该变成:
text
默认隔离
显式放行
也就是:
没有特别声明的情况下,任何业务 SQL 都应该自动受到租户约束。
1.2 这其实已经是一个安全问题,而不只是数据库问题
例如现在有接口:
http
GET /api/patient/100
医院 A 用户可以正常访问:
text
patient.id = 100
tenant_id = HOSPITAL_10001
但攻击者尝试修改:
http
GET /api/patient/101
而:
text
patient.id = 101
tenant_id = HOSPITAL_10002
如果后端只是:
sql
SELECT *
FROM patient
WHERE id = 101;
那么虽然用户已经完成登录,依然可能读到其他租户的数据。
这类问题本质上和 OWASP API Security Top 10 中的:
text
Broken Object Level Authorization
非常接近。
问题并不是:
text
用户有没有登录?
而是:
text
这个已经登录的用户
有没有权访问"这一条具体对象"?
因此多租户系统必须同时解决两个问题:
text
Authentication
你是谁?
Authorization / Tenant Boundary
你能够访问谁的数据?
JWT 解决的主要是前者以及身份上下文传递。
tenant_id 过滤解决的则是数据边界的一部分。
两者不能混为一谈。
二、数据库怎么设计?tenant_id 不只是多一个字段
我首先建立了一张真正的租户主表。
例如:
sql
CREATE TABLE sys_tenant
(
id BIGINT AUTO_INCREMENT COMMENT '主键ID'
PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL COMMENT '租户编号',
tenant_name VARCHAR(100) NOT NULL COMMENT '租户名称',
tenant_type INT DEFAULT 1 COMMENT '租户类型',
contact_name VARCHAR(50) NULL COMMENT '联系人',
contact_phone VARCHAR(20) NULL COMMENT '联系电话',
license_number VARCHAR(100) NULL COMMENT '许可证/统一社会信用代码',
address VARCHAR(255) NULL COMMENT '地址',
logo VARCHAR(255) NULL COMMENT 'Logo',
expire_time DATETIME NULL COMMENT '租户到期时间',
status TINYINT DEFAULT 1 COMMENT '0停用 1正常',
del_flag TINYINT DEFAULT 0 COMMENT '删除标志',
create_time DATETIME NULL,
update_time DATETIME NULL,
remark VARCHAR(500) NULL,
UNIQUE KEY uk_tenant_id (tenant_id)
)
COMMENT '租户表';
这里我会把:
text
id
和:
text
tenant_id
分开。
例如:
text
id = 17
tenant_id = HOSPITAL_10001
tenant_name = XX医院
其中:
text
id
是数据库内部主键。
而:
text
tenant_id
是系统中的业务租户标识。
JWT、Redis、日志、业务上下文中统一使用:
text
HOSPITAL_10001
而不是把数据库自增:
text
17
作为整个系统的租户标识。
2.1 业务表统一增加 tenant_id
例如患者表:
sql
CREATE TABLE patient
(
id BIGINT AUTO_INCREMENT
PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL COMMENT '租户ID',
patient_no VARCHAR(64) NOT NULL COMMENT '患者编号',
name VARCHAR(50) NOT NULL COMMENT '患者姓名',
gender TINYINT NULL COMMENT '性别',
birthday DATE NULL COMMENT '出生日期',
phone VARCHAR(20) NULL COMMENT '手机号',
create_time DATETIME NULL,
update_time DATETIME NULL,
UNIQUE KEY uk_tenant_patient_no
(tenant_id, patient_no),
KEY idx_tenant_name
(tenant_id, name)
)
COMMENT '患者信息';
病例表:
sql
CREATE TABLE patient_case
(
id BIGINT AUTO_INCREMENT
PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL COMMENT '租户ID',
patient_id BIGINT NOT NULL COMMENT '患者ID',
report_no VARCHAR(64) NOT NULL COMMENT '填报编号',
status INT DEFAULT 0 COMMENT '病例状态',
create_time DATETIME NULL,
update_time DATETIME NULL,
UNIQUE KEY uk_tenant_report_no
(tenant_id, report_no),
KEY idx_tenant_patient
(tenant_id, patient_id)
)
COMMENT '患者病例';
这里很重要的一点是:
tenant_id 会直接影响索引设计。
系统最常执行的查询可能是:
sql
SELECT *
FROM patient_case
WHERE tenant_id = 'HOSPITAL_10001'
AND patient_id = 1001;
那么:
sql
KEY idx_tenant_patient
(
tenant_id,
patient_id
)
通常就比只有:
sql
KEY idx_patient_id
(
patient_id
)
更加符合查询模型。
2.2 唯一约束同样需要加入租户维度
普通单租户系统可能设计:
sql
UNIQUE KEY uk_report_no
(
report_no
)
但是多租户系统中:
text
医院 A -> BG000001
医院 B -> BG000001
完全可能同时存在。
因此真正的业务唯一约束是:
sql
UNIQUE KEY uk_tenant_report_no
(
tenant_id,
report_no
);
也就是说:
text
tenant_id
已经不再是一个普通业务字段。
它开始参与:
text
查询条件
索引
唯一约束
缓存 Key
日志
认证上下文
这也是为什么我认为多租户需要作为基础设施设计,而不能简单理解成:
text
给每张表补一个字段。
2.3 并不是所有表都应该加 tenant_id
例如:
text
sys_tenant
sys_country
sys_region
sys_global_config
如果这些数据本身属于:
text
整个平台
那么它们就不应该被强行套上:
text
tenant_id
因此数据库实际上存在两类表:
text
Database
│
┌─────────────┴─────────────┐
│ │
▼ ▼
平台级数据 租户级数据
│ │
无 tenant_id tenant_id 必填
│ │
sys_tenant patient
sys_region patient_case
global_config meeting
这一步一定要提前明确。
因为后面 MyBatis-Plus:
java
ignoreTable(...)
本质上就是在回答:
哪些表不属于租户隔离范围?
三、登录后 tenantId 从哪里来?不要信任客户端自己声明身份
数据库建立以后,接下来必须解决:
当前请求到底属于哪个租户?
很多项目喜欢使用:
http
X-Tenant-Id: HOSPITAL_10001
后端直接:
java
String tenantId =
request.getHeader("X-Tenant-Id");
这种设计并不是一定错误。
请求头可以用于:
- 登录前识别租户;
- 根据域名选择租户;
- 平台管理员切换租户;
- 内部调用传递上下文。
但是对于已经登录的普通租户用户:
不能简单把客户端传入的 tenantId 当成最终数据权限依据。
否则用户登录的是:
text
HOSPITAL_10001
然后直接修改:
http
X-Tenant-Id: HOSPITAL_10002
如果服务器完全相信客户端,就相当于:
text
客户端自己告诉服务器:
"我现在属于医院 B 了。"
这是明显不合理的。
3.1 我的做法:租户身份跟登录身份绑定
登录:
http
POST /auth/login
后端根据用户获得:
text
userId = 10086
username = admin
tenantId = HOSPITAL_10001
再由服务器生成 JWT。
Payload 可以类似:
json
{
"sub": "10086",
"username": "admin",
"tenantId": "HOSPITAL_10001",
"iat": 1789344000,
"exp": 1789351200
}
RFC 7519 中 JWT Claim 分为:
text
Registered Claim
Public Claim
Private Claim
像:
text
tenantId
这样的业务字段可以作为应用自己约定的 Claim 使用。
需要注意:
tenantId能放进 JWT,不代表"只要 Token 里有 tenantId 就完成授权"。
JWT 在这里更准确的作用是:
text
携带服务器已经确认过的用户身份上下文
真正的数据对象访问仍然需要租户边界、角色权限等后端规则继续控制。
3.2 为什么普通用户不应该每次请求重新上传 tenantId?
我的普通业务请求最终更倾向:
http
GET /api/patient/list
Authorization: Bearer eyJ...
而不是:
http
GET /api/patient/list
Authorization: Bearer eyJ...
X-Tenant-Id: HOSPITAL_10001
服务端自己:
java
Claims claims = jwtTokenService.parse(token);
String tenantId =
claims.get(
"tenantId",
String.class
);
于是:
text
客户端
只负责提交 Token
↓
服务端
验证 Token
↓
获得 tenantId
↓
建立当前请求 TenantContext
也就是说:
tenantId 是服务端签发和验证后的身份上下文,而不是每次请求都让前端重新声明。
四、TenantContext + MyBatis-Plus:把租户边界下沉到基础设施
有了 JWT 之后,又出现一个问题。
难道所有 Service 都这样?
java
Claims claims = jwtUtils.parse(token);
String tenantId =
claims.get("tenantId", String.class);
当然不应该。
JWT 属于认证基础设施。
业务层应该只知道:
java
TenantContext.getTenantId();
而不是关心:
text
JWT 怎么解析
Authorization Header 怎么读取
Token 用什么算法
4.1 TenantContext
最简单的实现可以使用 ThreadLocal:
java
public final class TenantContext {
private static final ThreadLocal<String> CONTEXT =
new ThreadLocal<>();
private TenantContext() {
}
public static void setTenantId(
String tenantId
) {
CONTEXT.set(tenantId);
}
public static String getTenantId() {
String tenantId =
CONTEXT.get();
if (tenantId == null
|| tenantId.isBlank()) {
throw new IllegalStateException(
"Tenant context not initialized"
);
}
return tenantId;
}
public static String getTenantIdNullable() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
}
然后在认证过滤器中建立上下文:
java
@Component
public class JwtAuthenticationFilter
extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
try {
String token =
resolveToken(request);
if (token != null) {
Claims claims =
jwtTokenService.parse(token);
String tenantId =
claims.get(
"tenantId",
String.class
);
TenantContext.setTenantId(
tenantId
);
}
filterChain.doFilter(
request,
response
);
} finally {
TenantContext.clear();
}
}
}
这里我认为最重要的代码反而不是:
java
TenantContext.setTenantId(...)
而是:
java
finally {
TenantContext.clear();
}
因为 Servlet 容器通常复用线程。
如果上下文不清理,理论上可能出现:
text
Thread-1
请求 A
tenant = HOSPITAL_A
↓
请求结束
↓
ThreadLocal 未清理
↓
Thread-1 被线程池复用
↓
请求 B
因此使用 ThreadLocal 保存安全上下文时必须明确它的生命周期:
text
set
↓
业务执行
↓
clear
4.2 业务代码不应该手动追加 tenant_id
如果以后每一个 Mapper 都这样:
java
LambdaQueryWrapper<Patient> wrapper =
Wrappers.lambdaQuery();
wrapper.eq(
Patient::getTenantId,
TenantContext.getTenantId()
);
return patientMapper.selectList(wrapper);
其实系统依然很危险。
因为总会有开发人员某一天写:
java
patientMapper.selectList(null);
于是:
sql
SELECT *
FROM patient;
直接查询全部租户。
所以我更希望实现:
text
开发人员
负责业务 WHERE
框架
负责 tenant_id WHERE
这里就可以使用 MyBatis-Plus:
java
TenantLineInnerInterceptor
4.3 TenantLineHandler
例如:
java
@Component
public class CustomTenantLineHandler
implements TenantLineHandler {
private static final Set<String>
IGNORE_TABLES =
Set.of(
"sys_tenant",
"sys_region",
"sys_global_config"
);
@Override
public Expression getTenantId() {
String tenantId =
TenantContext.getTenantId();
return new StringValue(
tenantId
);
}
@Override
public String getTenantIdColumn() {
return "tenant_id";
}
@Override
public boolean ignoreTable(
String tableName
) {
return IGNORE_TABLES.contains(
tableName.toLowerCase()
);
}
}
注册:
java
@Configuration
public class MyBatisPlusConfiguration {
@Bean
public MybatisPlusInterceptor
mybatisPlusInterceptor(
CustomTenantLineHandler handler
) {
MybatisPlusInterceptor interceptor =
new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(
new TenantLineInnerInterceptor(
handler
)
);
interceptor.addInnerInterceptor(
new PaginationInnerInterceptor(
DbType.MYSQL
)
);
return interceptor;
}
}
MyBatis-Plus 官方给出的插件顺序原则中,也是让:
text
Tenant / Dynamic Table
这种会改变 SQL 的插件优先,然后再进行:
text
Pagination
Optimistic Lock
等处理。
4.4 最终 SQL 会发生什么?
业务代码:
java
patientMapper.selectList(null);
开发者逻辑上写的是:
sql
SELECT *
FROM patient;
最终应该被转换成:
sql
SELECT *
FROM patient
WHERE tenant_id = 'HOSPITAL_10001';
更加关键的是:
java
patientMapper.selectById(101L);
也必须受到租户限制。
预期 SQL:
sql
SELECT *
FROM patient
WHERE id = 101
AND tenant_id = 'HOSPITAL_10001';
那么即使:
text
id = 101
真实存在,但是:
text
tenant_id = HOSPITAL_10002
当前用户依然不能查询到。
这才真正建立:
text
Object ID
+
Tenant Boundary
两个条件。
4.5 不要只测试 SELECT
这是多租户实现里我认为特别需要提醒的一点。
很多教程只演示:
sql
SELECT
但租户隔离至少应该覆盖:
text
SELECT
INSERT
UPDATE
DELETE
例如:
SELECT
sql
SELECT *
FROM patient
WHERE id = 100
AND tenant_id = ?;
UPDATE
危险版本:
sql
UPDATE patient
SET name = ?
WHERE id = ?;
正确情况下应该受到:
sql
UPDATE patient
SET name = ?
WHERE id = ?
AND tenant_id = ?;
约束。
DELETE
同样不能只有:
sql
DELETE
FROM patient
WHERE id = ?;
而应该在租户环境下限制目标数据。
INSERT
INSERT 又是另外一个问题:
sql
INSERT INTO patient(...)
VALUES (...);
必须保证写进去的:
text
tenant_id
来自可信的 TenantContext。
MyBatis-Plus 官方文档也特别提醒,多租户场景下 INSERT 要正确处理租户字段,通常需要结合自动填充。
所以:
不要验证了 SELECT 就认为多租户完成了。
五、INSERT、Redis、异步和定时任务,才是最容易遗漏 tenantId 的地方
5.1 INSERT:客户端不能决定数据写入哪个租户
假设前端提交:
json
{
"name": "张三",
"tenantId": "HOSPITAL_10002"
}
当前登录用户却属于:
text
HOSPITAL_10001
后端不能直接:
java
BeanUtils.copyProperties(
request,
patient
);
然后把:
text
HOSPITAL_10002
写进去。
否则攻击者可以主动制造:
text
跨租户数据写入
更加合理的是:
text
客户端请求
│
├── name
├── gender
└── birthday
tenant_id
↑
│
服务端 TenantContext
即:
java
patient.setTenantId(
TenantContext.getTenantId()
);
或者使用统一填充:
java
@Data
public abstract class TenantEntity {
@TableField(
fill = FieldFill.INSERT
)
private String tenantId;
}
然后:
java
@Component
public class MyMetaObjectHandler
implements MetaObjectHandler {
@Override
public void insertFill(
MetaObject metaObject
) {
String tenantId =
TenantContext
.getTenantIdNullable();
if (tenantId != null) {
strictInsertFill(
metaObject,
"tenantId",
String.class,
tenantId
);
}
}
}
最后贯彻的还是那条原则:
业务代码提交业务数据,租户身份由基础设施写入。
5.2 Redis 也属于租户隔离范围
数据库安全了,不代表系统已经安全。
比如:
text
医院 A
userId = 100
医院 B
userId = 100
如果缓存设计:
text
user:100
两个租户就发生冲突。
因此我更倾向:
text
tenant:HOSPITAL_10001:user:100
tenant:HOSPITAL_10002:user:100
患者缓存:
text
tenant:HOSPITAL_10001:patient:1001
病例缓存:
text
tenant:HOSPITAL_10001:case:2001
可以封装:
java
public final class TenantRedisKey {
private TenantRedisKey() {
}
public static String build(
String key
) {
return "tenant:"
+ TenantContext.getTenantId()
+ ":"
+ key;
}
}
使用:
java
String redisKey =
TenantRedisKey.build(
"patient:" + patientId
);
这样系统才形成:
text
TenantContext
│
├── SQL
│ └── tenant_id
│
├── Redis
│ └── tenant:{id}
│
└── Log
└── tenantId
成熟多租户框架同样不会只处理数据库。
例如 RuoYi-Plus-UniApp 的多租户模块不仅进行了 SQL 租户过滤,也包含 Redis / Cache 等租户隔离设计。
5.3 @Async:ThreadLocal 不会自动穿越线程
比如:
java
@Async
public void generateReport() {
String tenantId =
TenantContext.getTenantId();
}
这段代码不能想当然地认为:
text
tenantId 一定存在。
因为:
text
HTTP Thread
│
│ @Async
▼
Async Thread
已经不是同一个线程。
普通 ThreadLocal:
text
http-nio-8080-exec-1
tenantId = HOSPITAL_10001
│
│ @Async
▼
task-1
tenantId = null
因此最简单可靠的方法之一就是显式传递:
java
String tenantId =
TenantContext.getTenantId();
reportService.generateReport(
tenantId,
caseId
);
异步方法:
java
@Async
public void generateReport(
String tenantId,
Long caseId
) {
try {
TenantContext.setTenantId(
tenantId
);
// 执行业务
} finally {
TenantContext.clear();
}
}
如果项目异步场景很多,可以进一步设计:
text
TaskDecorator
TransmittableThreadLocal
统一上下文传播组件
但是无论使用什么方案,都应该明确:
租户上下文必须传播,而不是假设它会自动存在。
5.4 @Scheduled:根本没有"当前登录租户"
定时任务更明显。
java
@Scheduled(cron = "0 */5 * * * ?")
public void scanExpiredCase() {
caseMapper.selectList(null);
}
Scheduler 没有:
text
HTTP Request
Authorization
JWT
Login User
所以根本没有:
text
当前租户
一种更安全的处理模式是:
text
查询有效租户
↓
遍历租户
↓
建立 TenantContext
↓
执行当前租户任务
↓
clear
例如:
java
@Scheduled(cron = "0 */5 * * * ?")
public void execute() {
List<String> tenantIds =
tenantService
.listEnabledTenantIds();
for (
String tenantId :
tenantIds
) {
try {
TenantContext.setTenantId(
tenantId
);
caseService
.scanExpiredCases();
} finally {
TenantContext.clear();
}
}
}
最终:
text
Scheduler
│
├── Hospital A
│ └── scan
│
├── Hospital B
│ └── scan
│
└── Hospital C
└── scan
每个租户在明确的上下文中执行。
六、超级管理员和动态租户:越方便的能力,越应该严格限制
做 SaaS 平台一定会出现:
text
Platform Super Admin
普通医院管理员:
text
只能访问当前医院
但平台管理员需要:
text
创建医院
停用医院
查看所有租户
平台统计
进入某个租户排查问题
于是就会出现两个特殊能力:
text
动态切换 Tenant
以及:
text
忽略 Tenant
6.1 动态租户
例如:
java
TenantExecutor.run(
"HOSPITAL_10001",
() -> {
patientService.statistics();
}
);
可以封装:
java
public final class TenantExecutor {
private TenantExecutor() {
}
public static void run(
String tenantId,
Runnable runnable
) {
String oldTenant =
TenantContext
.getTenantIdNullable();
try {
TenantContext.setTenantId(
tenantId
);
runnable.run();
} finally {
if (oldTenant != null) {
TenantContext.setTenantId(
oldTenant
);
} else {
TenantContext.clear();
}
}
}
}
这样比到处:
java
set();
业务代码;
clear();
安全很多。
6.2 Ignore Tenant 是一种高危能力
例如:
java
TenantContext.ignore();
如果实现以后,其真实语义就是:
text
这次查询不要加 tenant_id
等价于暂时拆掉租户数据隔离。
所以:
Ignore Tenant 不应该成为任何普通 Service 都能随便调用的工具方法。
我更推荐架构上明确:
text
普通业务模块
↓
永远自动 Tenant Filter
平台管理模块
↓
经过平台权限认证
↓
才能动态 Tenant / Ignore Tenant
例如:
text
module-hospital
module-patient
module-case
↓
默认租户隔离
module-platform
↓
平台管理员权限
↓
允许跨租户操作
RuoYi-Plus-UniApp 当前的多租户设计中也存在:
text
TenantHelper
动态租户
租户忽略
Redis 隔离
SQL 隔离
等完整能力。
我认为参考这类成熟项目最大的价值,不是直接复制一个 Helper,而是看到:
成熟多租户系统一定会把租户上下文管理抽成一套完整能力,而不是只有一个 getTenantId()。
七、怎么证明租户隔离真的生效?我认为至少要做这些测试
这是我认为很多多租户文章最容易缺少的一部分。
系统"看起来能够自动加 tenant_id"还远远不够。
多租户属于数据安全边界。
因此我更希望通过测试证明:
text
它真的不能越权。
7.1 准备两个租户
测试环境:
text
Tenant A
HOSPITAL_A
Tenant B
HOSPITAL_B
分别创建:
text
A Patient
id = 100
B Patient
id = 200
7.2 列表隔离测试
A 登录:
http
GET /api/patient/list
期望:
text
可以看到 Patient 100
看不到 Patient 200
B 登录:
http
GET /api/patient/list
期望反过来。
7.3 ID 越权测试
A 登录以后主动请求:
http
GET /api/patient/200
其中:
text
patient 200
属于 Tenant B
正确结果应该是:
text
404
或者系统统一定义的:
text
无权限访问
不能返回 Patient 200。
7.4 UPDATE 越权测试
A 用户发送:
http
PUT /api/patient/200
尝试修改 Tenant B 的患者。
数据库最终影响行数必须:
text
0
7.5 DELETE 越权测试
同样:
http
DELETE /api/patient/200
不能删除 B 数据。
7.6 INSERT 租户伪造测试
A 登录:
json
{
"name": "测试患者",
"tenantId": "HOSPITAL_B"
}
最终数据库数据必须仍然属于:
text
HOSPITAL_A
或者 API 根本不接受:
text
tenantId
这个请求字段。
7.7 Redis 隔离测试
A:
text
tenant:HOSPITAL_A:patient:100
B:
text
tenant:HOSPITAL_B:patient:100
必须是两个完全不同的 Key。
7.8 Async 上下文测试
请求线程:
text
Tenant A
启动异步任务以后,要验证异步线程仍然:
text
Tenant A
而不是:
text
null
更不能意外继承:
text
Tenant B
7.9 Ignore Tenant 权限测试
普通医院管理员尝试访问:
text
平台跨租户统计接口
必须:
text
403 Forbidden
只有:
text
Platform Super Admin
才能调用。
最终可以形成一个测试矩阵:
| 场景 | 预期 |
|---|---|
| A 查询 A 数据 | 成功 |
| A 查询 B ID | 拒绝 |
| A 修改 B 数据 | 拒绝 |
| A 删除 B 数据 | 拒绝 |
| A INSERT 伪造 tenantId | 无法改变实际租户 |
| A Redis 读取 B Key | 不应产生 Key 冲突 |
| Async | 正确传播 Tenant |
| Scheduler | 主动建立 Tenant |
| 普通管理员 Ignore Tenant | 禁止 |
| 平台管理员跨租户 | 经授权后允许 |
我认为做到这里,才能从:
text
"我写了一个多租户功能"
升级到:
text
"我验证了租户安全边界"
八、最终架构:tenant_id 应该贯穿整个请求生命周期
最后把整个过程串起来。
用户登录:
text
Login
│
▼
sys_user
│
userId + tenantId
│
▼
JWT
│
▼
HTTP Request
│
▼
JwtAuthenticationFilter
│
┌────────────┴────────────┐
│ │
▼ ▼
SecurityContext TenantContext
│ │
└────────────┬────────────┘
│
▼
Service
│
▼
Mapper
│
▼
TenantLineInnerInterceptor
│
▼
SQL + tenant_id
│
▼
MySQL
同时:
text
TenantContext
│
├── MySQL
│ └── tenant_id
│
├── Redis
│ └── tenant:{tenantId}:...
│
├── Async
│ └── propagate tenant
│
├── Scheduler
│ └── explicit tenant
│
└── Logging
└── tenantId
业务开发最终看到的代码可以非常简单:
java
@GetMapping("/list")
public List<Patient> list() {
return patientService.list();
}
Service:
java
public List<Patient> list() {
return patientMapper
.selectList(null);
}
业务开发人员完全没有:
java
.eq(
Patient::getTenantId,
TenantContext.getTenantId()
)
但是最终数据库查询依然:
sql
SELECT
id,
tenant_id,
patient_no,
name,
gender,
birthday,
phone
FROM patient
WHERE tenant_id = 'HOSPITAL_10001';
这才是我最终想达到的效果:
业务代码负责业务,租户框架负责边界。
工程结构上,我也更倾向把它拆成:
text
project
│
├── framework
│ │
│ ├── framework-security
│ │ ├── JwtAuthenticationFilter
│ │ ├── JwtTokenService
│ │ └── LoginUser
│ │
│ ├── framework-tenant
│ │ ├── TenantContext
│ │ ├── TenantHelper
│ │ ├── TenantExecutor
│ │ ├── TenantLineHandler
│ │ └── TenantProperties
│ │
│ ├── framework-mybatis
│ │ ├── MyBatisPlusConfiguration
│ │ └── MetaObjectHandler
│ │
│ └── framework-redis
│ └── TenantRedisKey
│
├── module
│ │
│ ├── module-system
│ ├── module-tenant
│ ├── module-hospital
│ └── module-platform
│
└── server
└── Application.java
职责也更加清晰:
text
framework-security
↓
"当前用户是谁?"
framework-tenant
↓
"当前租户是谁?"
framework-mybatis
↓
"SQL 怎么自动隔离?"
framework-redis
↓
"缓存怎么自动隔离?"
module-xxx
↓
"具体业务是什么?"
总结
如果现在再问我:
多租户系统到底怎么设计?
我不会再回答:
text
每张表加 tenant_id。
因为:
text
tenant_id
只是最终落到数据库里的数据边界。
真正完整的多租户系统应该形成:
text
用户认证
│
▼
tenantId
│
▼
JWT
│
▼
TenantContext
│
┌─────────┼─────────┐
│ │ │
▼ ▼ ▼
MySQL Redis Logging
│ │
▼ ▼
tenant_id tenant:key
│
│
├───────────────┐
│ │
▼ ▼
Async Scheduler
上下文传播 主动建立租户
这里每一层都有自己解决的问题。
| 层级 | 解决的问题 |
|---|---|
sys_tenant |
系统有哪些租户 |
| JWT | 当前登录身份属于谁 |
| TenantContext | 当前代码正在为哪个租户执行 |
| MyBatis-Plus | SQL 自动增加 tenant_id |
| MetaObjectHandler | INSERT 自动写 tenant_id |
| Redis Prefix | 避免缓存跨租户污染 |
| Async Context | 跨线程传播租户身份 |
| Scheduler | 无请求环境下主动建立租户 |
| Platform Service | 管理跨租户操作 |
| Integration Test | 证明租户边界真实有效 |
而整个设计里,我认为最重要的原则只有三个。
第一:
永远不要相信客户端可以自己决定自己属于哪个租户。
第二:
不要依赖业务开发人员记得在每条 SQL 后面加 tenant_id。
第三:
多租户不是一个查询功能,而是一条安全边界。
如果一个系统拥有:
text
几十张业务表
几百个 Mapper / Service
Redis
异步任务
定时任务
消息队列
多个后台模块
平台管理员
那么:
java
wrapper.eq(
Entity::getTenantId,
tenantId
);
漏掉一次,就有可能成为一次真实的数据越权。
所以相比:
text
"要求所有开发人员永远不要忘记 tenant_id"
我更愿意选择:
text
"默认情况下框架自动隔离,
只有经过明确授权的代码才能跨租户。"
最终:
text
tenant_id 是数据库边界
JWT 是身份载体
TenantContext 是运行时上下文
MyBatis-Plus 是 SQL 防线
Redis Prefix 是缓存防线
权限系统是跨租户操作防线
自动化测试是最后的验证防线
它们一起组成:
text
User
↓
Token
↓
Context
↓
Authorization
↓
SQL
↓
Cache
↓
Data
一条完整的租户隔离链路。
只有到了这个程度,我认为 tenant_id 才真正从数据库中的一个普通字段,变成了系统架构中的一项基础能力。
参考资料与开源项目
本文涉及的设计思路主要参考和对照以下官方资料及成熟开源项目:
1. MyBatis-Plus 官方文档
重点:
TenantLineInnerInterceptorTenantLineHandlerMybatisPlusInterceptor- 多插件执行顺序
- INSERT 租户字段处理
MetaObjectHandler
MyBatis-Plus 官方的多租户插件就是通过 SQL 解析和改写实现行级租户数据隔离,同时支持自定义租户字段以及忽略指定表。
2. RuoYi-Plus-UniApp 多租户模块
值得研究的内容包括:
- Shared Database + Shared Table;
TenantHelper;tenant_idSQL 自动过滤;- 动态租户;
- Ignore Tenant;
- Redis 租户前缀;
- Spring Cache 租户隔离;
- 多租户认证设计。
它比较适合作为 Java / Spring Boot 企业级多租户实现的源码参考。
3. RFC 7519 --- JSON Web Token
重点理解:
- Registered Claims;
- Public Claims;
- Private Claims;
- JWT 是 Claim 的传输载体;
tenantId可以作为应用自定义业务 Claim;- JWT 身份信息不能替代对象级授权。
4. OWASP API Security Top 10 2023
建议重点阅读:
text
API1:2023
Broken Object Level Authorization
它所描述的:
text
通过修改对象 ID,
访问原本不属于自己的数据
正是多租户接口必须重点防御的一类风险。
所以:
http
GET /patient/{id}
PUT /patient/{id}
DELETE /patient/{id}
这类接口绝不能只验证:
text
id 存在
还必须验证:
text
当前身份是否允许访问这个对象
在共享表多租户系统里:
text
tenant_id
正是这一数据边界的重要组成部分。
本文代码主要用于说明设计思路,真实生产项目还需要结合具体认证框架、事务模型、数据库规模、缓存架构和权限体系进一步调整。