多租户系统到底怎么设计?从 tenant_id 到 JWT 租户隔离的真实实践

多租户系统到底怎么设计?从 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 官方文档

重点:

  • TenantLineInnerInterceptor
  • TenantLineHandler
  • MybatisPlusInterceptor
  • 多插件执行顺序
  • INSERT 租户字段处理
  • MetaObjectHandler

MyBatis-Plus 官方的多租户插件就是通过 SQL 解析和改写实现行级租户数据隔离,同时支持自定义租户字段以及忽略指定表。


2. RuoYi-Plus-UniApp 多租户模块

值得研究的内容包括:

  • Shared Database + Shared Table;
  • TenantHelper
  • tenant_id SQL 自动过滤;
  • 动态租户;
  • 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

正是这一数据边界的重要组成部分。


本文代码主要用于说明设计思路,真实生产项目还需要结合具体认证框架、事务模型、数据库规模、缓存架构和权限体系进一步调整。

相关推荐
Bs_MoneyMagnet1 小时前
基于springboot+vue的旅游行程分享与推荐小程序的设计与实现 源码+文档
java·vue.js·spring boot·后端·微信小程序·毕业设计·计算机毕业设计
计算机魔术师1 小时前
硅谷四大AI巨头联手踩刹车,是真心怕失控还是怕被超越?
前端
EatFan1 小时前
我为什么把患者与病例从 1:1 改成 1:N?一次真实数据库模型重构记录
数据库·后端·重构·健康医疗·全栈
岁岁种桃花儿1 小时前
Vue组件第六篇:Vue单文件组件
开发语言·前端·javascript·vue.js
yivifu1 小时前
创建支持交叉链接且鼠标悬停显示注释框的HTML文件
前端·javascript·html
wno7042 小时前
Spring Boot整合MongoDB
spring boot·后端·mongodb
GreenTea2 小时前
🔥别再用压缩了,Codex、NVIDIA、DeepSeek、Uber 给出 Agent 上下文管理的新答案
前端·后端·算法
码事漫谈2 小时前
全球最聪明的几个人,本周突然一起说“别卷了”
后端
IT_陈寒2 小时前
React重渲染这坑,我跳进去又爬出来了
前端·人工智能·后端