很多同学写完一个后台列表接口之后,大概率会被安全测试的同学追问一句:我把 URL 里的id手动改一下,为什么还能看到别人的数据?这个问题和本文标题那句“你不必嫌弃我的收入低,因为我的钱一分都到不了你手上”其实是一个意思——边界。钱到不了你手上,是财务边界;数据到不了你手上,是系统边界。本文围绕后台管理系统里最常见的几个隔离问题展开:认证、授权、行级数据隔离、横向越权修复,以及多租户场景下的落地方式。整套内容基于 Spring Boot + Spring Security + MyBatis-Plus + MySQL 组合,适合写过 CRUD 但没系统做过权限隔离的后端同学,也适合正在给老系统补安全边界的开发者。读完之后,你至少能独立完成一套“租户隔离 + 归属校验 + 越权拦截”的最小可用方案,并且知道线上踩坑点在哪里。
1. 从一个“边界”说起:数据隔离在解决什么问题
1.1 标题背后的技术隐喻:钱到不了你手上就是隔离
先把这句话翻译成技术语言。“我的钱”是我这个账号名下的资产记录,“到不了你手上”意味着即使你和我登录的是同一个系统、调用的是同一批接口,你也无法读取、修改、删除属于我的那部分数据。这就是数据隔离(Data Isolation)要解决的问题:在共享一套代码、一套数据库、一套服务的前提下,让不同主体只能触碰属于自己的数据子集。
这件事在真实系统里非常普遍。SaaS 产品里,A 公司不能看到 B 公司的订单;多门店系统里,门店店员不能看到其他门店的库存;普通用户不能通过改 URL 参数看到别人的账单。所有这些需求,本质上都是同一件事:在数据访问链路的某个位置,强制注入一个“归属条件”,让查询结果天然不包含越界数据。
很多新手会误以为“隔离”是一个很高深的东西,其实它的最小形态就是 SQL 里多一个where条件。难点不在于这个条件本身,而在于:你怎么保证这个条件在每一次查询里都被加上,并且加得对、加得不多不少。
1.2 三种隔离级别:物理隔离、Schema 隔离、行级隔离
在动手写代码之前,先把隔离的粒度分清楚。不同粒度对应完全不同的成本,选错了后面很难改。
| 隔离方式 | 实现形态 | 隔离强度 | 成本 | 适用场景 |
|---|---|---|---|---|
| 物理隔离 | 每个租户一套独立数据库实例 | 最高 | 最高,运维和扩容复杂 | 金融、医疗等强合规场景 |
| Schema 隔离 | 同一实例,每租户一个 schema | 较高 | 中等,需动态切换数据源 | 租户数量可控的中大型系统 |
| 行级隔离 | 同一张表加tenant_id字段 | 一般 | 最低,改造成本小 | 绝大多数中小型 SaaS、内部系统 |
本文选择行级隔离作为主线,原因很实际:它改造成本最低,绝大多数团队能在一到两周内落地,而且能覆盖 90% 以上的业务诉求。至于物理隔离和 Schema 隔离,等你真的遇到合规审计或者单租户数据量超过千万级的时候再考虑,过早引入只会让部署复杂度翻倍。
1.3 “前端不显示”不等于“后端隔离”
这是本节最想强调的一点。很多项目所谓的“权限控制”,只是把按钮v-if掉了,或者把菜单隐藏了。前端隐藏只能防住不会用浏览器开发者工具的人,防不住任何人。
真正的隔离必须发生在数据出口,也就是数据库查询这一层。只要接口返回了数据,哪怕前端不渲染,数据也已经泄露了——抓包能看到,日志里能看到,浏览器的网络面板里能看到。所以判断一个系统隔离做得好不好,有个很简单的办法:直接拿越权账号的 Token 去跑一遍接口,看返回体里有没有不该出现的东西。有,就是没做;没有,才算真的做了。
这条判断标准贯穿全文,后面所有代码都是为它服务的。
2. 环境准备与依赖版本说明
2.1 运行环境与技术栈选择
先把环境交代清楚,避免读者照着抄的时候因为版本对不上而卡住。
| 组件 | 本文示例版本 | 说明 |
|---|---|---|
| JDK | 8 或 11 | Spring Boot 2.7.x 均支持 |
| Spring Boot | 2.7.x | 稳定组合,生态成熟 |
| Spring Security | 随 Spring Boot 版本引入 | 5.7 及以上使用SecurityFilterChain写法 |
| MyBatis-Plus | 3.5.x | 多租户插件所在版本区间 |
| MySQL | 5.7 或 8.0 | 示例 SQL 两者均兼容 |
这里要特别提醒一点:如果你的项目用的是Spring Boot 3.x,MyBatis-Plus 的 starter 坐标需要换成对应的 Spring Boot 3 版本 starter,包名和自动配置机制都有变化。本文示例按 Spring Boot 2.7.x 编写,代码逻辑完全一致,只是依赖坐标要按实际版本调整。版本这块不要硬抄,先看一眼你项目pom.xml里的父工程版本,再决定用哪套坐标。
2.2 项目结构与依赖清单
先看目录结构,后面的代码都能对上位置:
demo-security/ ├── pom.xml └── src/main/ ├── java/com/example/security/ │ ├── SecurityApplication.java │ ├── config/ │ │ ├── SecurityConfig.java │ │ └── MybatisPlusConfig.java │ ├── context/ │ │ └── UserContext.java │ ├── tenant/ │ │ └── CustomTenantLineHandler.java │ ├── entity/ │ │ └── Account.java │ ├── mapper/ │ │ └── AccountMapper.java │ ├── service/ │ │ └── AccountService.java │ └── controller/ │ └── AccountController.java └── resources/ ├── application.yml └── db/schema.sqlpom.xml中需要的关键依赖如下,这是核心片段,请按你项目的实际版本号补齐:
<!-- 文件路径:pom.xml --> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>依赖里没有引入 JWT 库,是因为本文重点在隔离而不在令牌方案,示例里用最简化的方式模拟“已登录用户”的注入过程,实际项目把令牌解析逻辑替换进去即可。
2.3 表结构设计与字段约定
隔离要靠字段承载,所以表设计必须先定规矩。下面这张账户表同时带了tenant_id(租户维度)和owner_id(归属人维度),两个维度叠加使用,基本能覆盖“公司内不同人”和“不同公司”两类隔离需求。
-- 文件路径:src/main/resources/db/schema.sql CREATE TABLE `t_account` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `tenant_id` bigint(20) NOT NULL COMMENT '租户ID', `owner_id` bigint(20) NOT NULL COMMENT '归属用户ID', `account_name` varchar(64) NOT NULL COMMENT '账户名称', `balance` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '余额', `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), KEY `idx_tenant_owner` (`tenant_id`, `owner_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户表';两个设计细节值得说明。第一,tenant_id和owner_id都必须NOT NULL,不允许为 NULL 是刻意的——一旦允许 NULL,where tenant_id = ?就永远匹配不到这些行,它们会变成“谁都能看见的幽灵数据”。第二,索引是(tenant_id, owner_id)复合索引,因为隔离条件永远会带上tenant_id,查询条件常常再带owner_id,这个顺序能覆盖绝大多数查询。
3. 核心原理拆解:隔离的三个关口
3.1 第一个关口:认证,先搞清楚“你是谁”
认证(Authentication)解决的是身份问题。没有身份,后面的隔离条件根本无从拼接,因为你不知道该拿什么值去过滤。在 Spring Security 里,认证的产物是一个Authentication对象,它会被放进SecurityContextHolder,供整条请求链路读取。
理解这一点的关键在于:隔离条件不是从请求参数里拿的,而是从认证结果里拿的。这一点极其重要。如果你的tenantId是从前端传来的Header或者QueryParam里读的,那这个“隔离”等于没有——攻击者改个请求头就绕过去了。租户标识和用户标识必须来自服务端自己解析的令牌或会话,前端传什么都不算数。
实际项目里常见的错误写法是这样的:
// 错误示例:从请求参数里取租户ID Long tenantId = Long.valueOf(request.getParameter("tenantId"));如果非要给这段代码挑毛病,一句话就够了:这个值由调用方决定,不叫隔离,叫“自愿申报”。
3.2 第二个关口:授权,判断“你能做什么”
授权(Authorization)解决的是操作权限问题。同样一个/api/account/list接口,普通员工能调,财务能调,但只有财务能调导出接口。这一层通常用角色(Role)或者权限码(Authority)来控制,Spring Security 提供了方法级注解来简化。
需要区分清楚的是,“能做什么”和“能看到哪些行”是两回事。前者的粒度是接口级别的,后者的粒度是数据行级别的。一个用户完全可能有“查询账户”的权限,但没有“查询某个特定账户”的权限。很多人把这两件事混在一起,写出来的代码就会在 Controller 里堆满if判断。
3.3 第三个关口:数据隔离,决定“你能看到哪些行”
这是本文最重要的部分。前两个关口都有成熟的框架支持,而数据行级隔离往往需要开发者自己在每一处查询上落实。它有三个典型实现层次,从弱到强:
第一层是手工在 Service 里加条件。简单直接,但靠人自觉,只要有人漏写一处,就是一个漏洞。
第二层是封装统一的查询入口,把“当前用户归属”作为隐式参数注入,业务代码不允许绕过。这一层能防住大部分疏忽。
第三层是框架层面的自动注入,比如 MyBatis-Plus 的多租户插件,直接在 SQL 解析阶段改写语句,业务代码完全无感,漏写的概率最低。
实际落地通常是第二层和第三层结合:租户维度用插件自动注入,归属人维度用统一封装,两层兜底。
4. 实战一:认证上下文与资源归属校验
4.1 搭建用户上下文 Holder
不管后面用插件还是封装,都需要一个地方存放“当前请求是谁”。最轻量的方案是ThreadLocal,在过滤器里写入,请求结束时清理。
// 文件路径:src/main/java/com/example/security/context/UserContext.java package com.example.security.context; import lombok.AllArgsConstructor; import lombok.Data; public class UserContext { @Data @AllArgsConstructor public static class LoginUser { private Long userId; private Long tenantId; private String username; } private static final ThreadLocal<LoginUser> HOLDER = new ThreadLocal<>(); public static void set(LoginUser user) { HOLDER.set(user); } public static LoginUser get() { return HOLDER.get(); } public static Long getUserId() { LoginUser user = HOLDER.get(); return user == null ? null : user.getUserId(); } public static Long getTenantId() { LoginUser user = HOLDER.get(); return user == null ? null : user.getTenantId(); } public static void clear() { HOLDER.remove(); } }这段代码本身很简单,但有一个地方必须提前埋好伏笔:ThreadLocal只在当前线程有效。只要你的业务里出现了@Async、CompletableFuture、线程池,上下文就会丢。这个问题在第 7 节会单独展开讲,现在先记住这个隐患。
4.2 过滤器注入上下文并保证清理
写入ThreadLocal的时机应该在认证之后、业务之前,并且必须用try-finally保证清理。否则线程池复用线程时,上一个请求的用户身份会被下一个请求读到,那就是更严重的串数据事故。
// 文件路径:src/main/java/com/example/security/config/UserContextFilter.java package com.example.security.config; import com.example.security.context.UserContext; import org.springframework.stereotype.Component; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import java.io.IOException; @Component public class UserContextFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; try { // 真实项目中这里应替换为:解析 Token / Session 得到身份信息 String userId = req.getHeader("X-User-Id"); String tenantId = req.getHeader("X-Tenant-Id"); if (userId != null && tenantId != null) { UserContext.set(new UserContext.LoginUser( Long.valueOf(userId), Long.valueOf(tenantId), "demo-user")); } chain.doFilter(request, response); } finally { // 关键:无论成功失败都要清理,防止线程复用导致身份串号 UserContext.clear(); } } }需要说明的是,示例里从请求头读取身份是为了让代码能被直接跑起来,这是演示用的简化手段。真实场景一定要换成服务端自行解析的令牌,原因在 3.1 已经讲过——调用方可控的字段不能作为隔离依据。
4.3 安全配置放行与拒绝策略
Spring Security 的配置在 5.7 之后推荐用SecurityFilterChain的写法。这里给一个最小可用版本,重点是把无状态访问和请求放行规则定清楚。
// 文件路径:src/main/java/com/example/security/config/SecurityConfig.java package com.example.security.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; @Configuration public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf().disable() .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/actuator/health").permitAll() .antMatchers("/api/**").authenticated() .anyRequest().denyAll(); return http.build(); } }这里的anyRequest().denyAll()是一个值得坚持的习惯:默认全部拒绝,只显式放行需要的路径。反过来写(默认放行、个别拦截)在新接口不断增加的过程中,极容易出现“某个新接口忘记加规则”的漏洞。
5. 实战二:用 MyBatis-Plus 插件实现租户自动隔离
5.1 实现 TenantLineHandler
MyBatis-Plus 的多租户插件思路是:在 SQL 解析阶段识别表名,自动给WHERE追加租户条件,并在INSERT时自动补齐租户字段。核心是实现TenantLineHandler接口,告诉插件“租户字段叫什么”“租户值从哪来”“哪些表不用管”。
// 文件路径:src/main/java/com/example/security/tenant/CustomTenantLineHandler.java package com.example.security.tenant; import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler; import com.example.security.context.UserContext; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.LongValue; import org.springframework.stereotype.Component; import java.util.Arrays; import java.util.HashSet; import java.util.Set; @Component public class CustomTenantLineHandler implements TenantLineHandler { /** 无需租户隔离的表,统一小写 */ private static final Set<String> IGNORE_TABLES = new HashSet<>(Arrays.asList( "t_user", "t_tenant", "sys_config" )); @Override public Expression getTenantId() { Long tenantId = UserContext.getTenantId(); if (tenantId == null) { // 取不到租户时直接抛出,绝不允许退化成“查全部” throw new IllegalStateException("租户上下文缺失,拒绝执行 SQL"); } return new LongValue(tenantId); } @Override public String getTenantIdColumn() { return "tenant_id"; } @Override public boolean ignoreTable(String tableName) { return IGNORE_TABLES.contains(tableName.toLowerCase()); } }这段代码里最关键的是getTenantId()里的那个空值判断。很多事故都出在这里:上下文取不到值的时候,如果返回null或者干脆跳过拼接,SQL 就变成全表查询,所有租户的数据一次性暴露。宁可报错让请求失败,也不能静默放行。
5.2 注册插件并注意插件顺序
插件需要注册到MybatisPlusInterceptor里。这里有个容易被忽略的细节:多租户插件必须排在分页插件之前。因为分页插件需要改写count语句,如果多租户插件在它后面执行,count语句可能拿不到租户条件,导致总数统计错误。
// 文件路径:src/main/java/com/example/security/config/MybatisPlusConfig.java package com.example.security.config; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; import com.example.security.tenant.CustomTenantLineHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor(CustomTenantLineHandler tenantLineHandler) { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 顺序很重要:多租户在前,分页在后 interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(tenantLineHandler)); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }顺带说一个使用体验上的坑:这个插件是直接改写 SQL 的,所以你写的自定义 XML 里如果有复杂的子查询、UNION、INSERT ... SELECT,插件可能解析不了或者解析得很奇怪。上线前一定要把日志级别调到能打印 SQL 的程度,肉眼确认改出来的语句是对的。
5.3 配置文件中打开 SQL 日志
验证插件是否生效,最直接的办法就是看日志。开发环境建议打开 SQL 打印:
# 文件路径:src/main/resources/application.yml server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/demo_security?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0配置好之后,随便调一个查询接口,控制台应该能看到类似这样的输出:
==> Preparing: SELECT id,tenant_id,owner_id,account_name,balance FROM t_account WHERE deleted=0 AND tenant_id = 1001 ==> Parameters: <== Total: 2看到AND tenant_id = 1001出现在WHERE里,说明插件生效了。如果没看到,按这个顺序排查:插件是否注册成@Bean、CustomTenantLineHandler是否被 Spring 扫描到、表名是否误加进了忽略列表。
6. 实战三:横向越权复现与修复
6.1 复现:一个只按主键查询的接口
租户插件解决了“跨公司”的问题,但没解决“同公司不同人”的问题。看下面这个接口,它只按id查,同租户内的任何用户都能查到别人的账户:
// 文件路径:src/main/java/com/example/security/controller/AccountController.java(问题版本) @GetMapping("/api/accounts/{id}") public Account getAccount(@PathVariable Long id) { // 危险:只按主键查询,任何人拿到 id 就能读 return accountService.getById(id); }这就是经典的 IDOR(Insecure Direct Object Reference,不安全的直接对象引用),也叫横向越权。攻击成本极低,把id从 1 遍历到 1000 就行。判断标准很简单:如果一个接口的查询条件里,只有调用方可以自由指定的字段,那它大概率有越权问题。
6.2 修复:把归属条件作为强制条件
修复思路是把owner_id作为查询的固有条件,而不是可选项。
// 文件路径:src/main/java/com/example/security/service/AccountService.java package com.example.security.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.example.security.context.UserContext; import com.example.security.entity.Account; import com.example.security.mapper.AccountMapper; import org.springframework.stereotype.Service; @Service public class AccountService extends ServiceImpl<AccountMapper, Account> { /** * 按主键查询,但强制附加归属人条件。 * 查不到归属人匹配的记录时返回 null,由上层统一转换为 404。 */ public Account getOwnedById(Long id) { Long userId = UserContext.getUserId(); if (userId == null) { throw new IllegalStateException("用户上下文缺失"); } return this.getOne(new LambdaQueryWrapper<Account>() .eq(Account::getId, id) .eq(Account::getOwnerId, userId)); } }注意这里返回null而不是抛“无权限”异常,是一个刻意的选择:对外统一返回 404,避免攻击者通过 403 和 404 的差异来探测“这个 id 到底存不存在”。这类信息泄露在校验严格的系统里是要被提的。
6.3 实体类与 Mapper 定义
为了让上面的代码能跑起来,实体和 Mapper 也要补齐:
// 文件路径:src/main/java/com/example/security/entity/Account.java package com.example.security.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.math.BigDecimal; @Data @TableName("t_account") public class Account { @TableId(type = IdType.AUTO) private Long id; private Long tenantId; private Long ownerId; private String accountName; private BigDecimal balance; @TableLogic private Integer deleted; }// 文件路径:src/main/java/com/example/security/mapper/AccountMapper.java package com.example.security.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.security.entity.Account; import org.apache.ibatis.annotations.Mapper; @Mapper public interface AccountMapper extends BaseMapper<Account> { }实体上不需要给tenantId加任何“自动填充”注解,因为多租户插件会在INSERT语句里自动补上这个字段,业务代码里即使不赋值也没问题——这算是插件带来的一个小便利,但也容易让人误以为字段没写就有问题,调试时看到插入成功却查不到tenant_id,先别慌,看一眼日志里的INSERT语句就明白了。
6.4 运行验证与预期结果
准备两条不同归属的测试数据:
INSERT INTO t_account (tenant_id, owner_id, account_name, balance) VALUES (1001, 1, '用户1的账户', 100.00), (1001, 2, '用户2的账户', 200.00);然后用用户 1 的身份访问用户 2 的账户:
curl -H "X-User-Id: 1" -H "X-Tenant-Id: 1001" \ http://localhost:8080/api/accounts/2预期返回的是空结果或者统一的“资源不存在”错误,而不是用户 2 的账户信息。再访问自己的账户:
curl -H "X-User-Id: 1" -H "X-Tenant-Id: 1001" \ http://localhost:8080/api/accounts/1这次应该正常返回用户1的账户。两步都符合预期,说明横向越权的口子堵上了。最后再补一个跨租户的验证:用租户 1001 的身份去查租户 1002 的数据,应该同样查不到,这一步验证的是多租户插件是否真的生效。
7. 常见问题与排查思路
隔离相关的 bug 有个共同特点:不会报错,只会“多出数据”或者“少了数据”。所以排查时不能只看有没有异常,要看 SQL 和返回结果。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 查询结果包含其他租户数据 | 表名被加进忽略列表、插件未注册 | 检查ignoreTable逻辑,确认拦截器已加入MybatisPlusInterceptor |
| 分页总数偏大 | 插件顺序错误,count 语句未带租户条件 | 把TenantLineInnerInterceptor放在PaginationInnerInterceptor之前 |
| 异步任务里查全表 | ThreadLocal上下文丢失 | 用任务参数显式传租户ID,或在提交任务时复制上下文 |
插入报tenant_id不能为 null | 自定义 SQL 绕过了插件解析 | 检查是否是手写INSERT,补全字段或改用BaseMapper |
| 单元测试正常,线上串数据 | 线程池复用导致上下文未清理 | 确认过滤器有finally清理,或改用请求作用域方案 |
| 定时报表数据不准 | 任务本身需要跨租户统计 | 为这类任务单独配置忽略规则,并限制只有内部服务可调用 |
7.1 异步线程中上下文丢失的处理
这是实践中最容易踩的坑。@Async方法执行在另一个线程,UserContext.getTenantId()直接返回null,插件抛异常或者(更糟的情况)静默跳过条件。处理方式有两种:
第一种是显式传参,把tenantId作为方法参数传进去,在异步方法内部重新设置上下文:
// 片段示例:异步任务中手动恢复上下文 public void asyncProcess(Long tenantId) { try { UserContext.set(new UserContext.LoginUser(null, tenantId, "system")); // 业务逻辑 } finally { UserContext.clear(); } }第二种是用可传递的ThreadLocal实现,在任务提交时复制上下文到子线程。这种方式改动小,但要额外引入依赖,且对线程池的实现有要求。团队规模小的话,第一种更直白,出错也好排查。
7.2 跨租户统计的正确做法
运营后台往往需要看全部租户的数据。这种需求不要靠“给超级管理员账号把tenantId设成某个特殊值”来实现,那样会把隔离逻辑搞得非常隐晦。更清晰的做法是走独立的数据源或独立的查询通道,用显式的注解标记某个 Mapper 方法忽略租户隔离。
需要强调的是,这类“忽略隔离”的能力必须严格管控:只允许内部服务调用,不能暴露给前端接口,并且要有审计日志记录谁在什么时间查了全量数据。否则这个口子迟早会被滥用。
7.3 排查清单
遇到“数据不对”的问题,按下面顺序看,比乱猜快很多:
- 打开 SQL 日志,确认实际执行的语句里有没有租户条件。
- 确认
WHERE里的租户值和当前登录用户是否一致。 - 确认表名没有被误加进忽略列表。
- 确认是否走了自定义 XML,插件对复杂 SQL 的解析能力有限。
- 确认方法有没有走异步或者消息消费,上下文是否还在。
- 确认数据库连接池有没有做读写分离,从库的数据是否同步滞后。
8. 最佳实践与工程建议
8.1 字段命名与表设计规范
统一命名是隔离能长期维护的前提。建议全项目固定使用tenant_id和owner_id这两个名字,不要这个表叫company_id、那个表叫org_id。命名不统一,插件就没法用统一配置,最后只能退化成到处手写条件。
另外建议给所有业务表加上create_by、update_by字段,记录操作人。这不是为了隔离,是为了出事之后能追溯——当发现一条数据被越权修改时,你得知道是谁改的。
8.2 隔离校验应该放在哪一层
对这个问题,我的建议分场景:
- 租户维度放框架层,用插件自动注入,因为它是固定规则,不需要业务判断。
- 归属人维度放Service 层,通过统一方法封装,比如
getOwnedById、listOwned。 - 接口层只做参数校验,不写归属判断逻辑。
最不推荐的是把归属判断写在 Controller 里,因为 Controller 通常有多个入口(Web、定时任务、消息消费),任何一个入口漏写就是漏洞。放在 Service 层,所有入口都得经过它。
8.3 日志与审计
安全相关的操作必须留痕。至少记录这几类:登录成功与失败、权限校验失败、跨租户访问尝试、批量导出操作。日志里不要打印完整的数据内容,尤其是金额、身份证、手机号这类字段,脱敏之后再输出。
审计日志建议落到独立的表里,和业务库分离,避免被业务方顺手删掉。这一点在合规要求比较高的项目里几乎是硬性要求。
8.4 性能与索引
加了隔离条件之后,SQL 的过滤精度变高了,索引设计也要跟着调整。经验做法是:把tenant_id放在复合索引的最左列,后面跟查询中最常用的那个字段。这样绝大多数查询都能命中索引,不会因为多了一个条件而退化成全表扫描。
另外,分页查询在数据量大时容易慢,ORDER BY的字段最好也在索引里。如果业务允许,尽量用“上次查询的最大 id”这种方式做游标分页,比LIMIT offset在深分页场景下快得多。
8.5 生产变更与回滚
给已有系统补隔离,属于典型的高风险变更。落地时建议按这个节奏走:
- 先在测试库加字段、造多租户数据,验证隔离是否生效。
- 灰度阶段只对一个租户开启,观察日志和慢查询。
- 全量前做一次完整的数据库备份,保留可回滚的版本。
- 上线后第一周重点盯接口报错率和数据异常反馈。
尤其要注意历史数据。老表加tenant_id之后,存量数据这一列是空的,插件的过滤条件会让它们全部“消失”。补数据的脚本必须先写好、在测试环境验证过,再拿到生产执行,执行前记得备份。
9. 收尾:一份可以直接拿去用的检查清单
把前面几节的内容压缩成一份可执行的清单,做后台系统的同学可以对照着过一遍:
- 隔离依据是否来自服务端解析的身份,而不是请求参数?
- 所有业务表是否有
tenant_id且设置了NOT NULL? - 多租户插件是否注册,顺序是否排在分页插件之前?
- 上下文取不到租户时,是抛异常还是静默放行?必须是前者。
- 所有按主键查询的接口,是否都带上了归属条件?
- 返回 403 和返回 404 是否统一处理,避免泄露资源存在性?
- 异步任务、定时任务、消息消费这几条链路,上下文是否补齐?
- 忽略隔离的表和方法,是否有清单和审批记录?
- 上线前是否在测试环境用越权账号实际跑过接口?
这份清单里最容易漏的是最后一条。很多团队的隔离方案设计得不错,但从来没被真正攻击过,谁也不知道有没有洞。拿一个只有普通权限的账号,把系统里所有带 id 的接口手动跑一遍,往往十分钟就能发现好几个问题。
至于“钱到不了你手上”这句话,落到工程上就是:接口可以共享,代码可以共享,数据库可以共享,但数据的可见范围必须由服务端说了算。把这条原则守住,大部分越权问题都能在设计和评审阶段被挡掉。