第 56 章:多租户与动态表名
学习目标
- 理解 SaaS 多租户的三种隔离方案
- 掌握 MyBatis-Plus 多租户插件
- 学会动态表名实现分表
一、多租户隔离方案
| 方案 | 隔离性 | 成本 | 扩展性 | 适用 |
|---|---|---|---|---|
| 独立数据库 | 最强 | 最高 | 差(几百个租户就管不动) | 大客户、金融/医疗合规要求 |
| 独立 Schema | 中 | 中 | 中 | 中型 SaaS |
| 共享表 + tenant_id | 弱(靠代码保证) | 最低 | 最好 | 绝大多数 SaaS |
混合方案是实践中的最优解:普通客户走共享表,付费大客户提供独立库。
二、共享表方案实现
配置多租户插件
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// ① 多租户插件必须放在分页插件之前!
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(
new TenantLineHandler() {
@Override
public Expression getTenantId() {
Long tenantId = TenantContext.getTenantId();
if (tenantId == null) {
// ② 取不到租户 ID 时必须报错,不能放行
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "租户上下文缺失");
}
return new LongValue(tenantId);
}
@Override
public String getTenantIdColumn() {
return "tenant_id";
}
@Override
public boolean ignoreTable(String tableName) {
// ③ 全局表(字典、地区、租户表本身)不做租户过滤
return IGNORE_TABLES.contains(tableName);
}
}));
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
private static final Set<String> IGNORE_TABLES = Set.of(
"sys_tenant", "sys_dict", "sys_region", "sys_config"
);① 顺序为什么重要? 插件是链式执行的。多租户插件负责往 SQL 里塞
WHERE tenant_id = ?,分页插件负责改写成LIMIT并生成 count SQL。如果分页在前,它生成的 count SQL 就不带租户条件,会统计出所有租户的总数。
② 为什么租户 ID 为空时必须抛异常? 如果返回 null 或跳过过滤,这条 SQL 就会查出所有租户的数据——这是 SaaS 系统最严重的安全事故(数据越权)。宁可报错也不能放行。
效果
// 你写的
userMapper.selectList(new LambdaQueryWrapper<User>().eq(User::getStatus, 1));
// 实际执行
SELECT * FROM sys_user WHERE status = 1 AND tenant_id = 1001;
// 插入时也会自动补上
INSERT INTO sys_user (username, tenant_id) VALUES ('张三', 1001);三、租户上下文
public final class TenantContext {
// ① 用 TransmittableThreadLocal 支持线程池传递
private static final ThreadLocal<Long> CONTEXT = new TransmittableThreadLocal<>();
public static void set(Long tenantId) {
CONTEXT.set(tenantId);
}
public static Long getTenantId() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
/** ② 临时切换租户(跨租户操作,如管理后台) */
public static <T> T executeAs(Long tenantId, Supplier<T> supplier) {
Long old = CONTEXT.get();
try {
CONTEXT.set(tenantId);
return supplier.get();
} finally {
if (old != null) CONTEXT.set(old); else CONTEXT.remove();
}
}
private TenantContext() { }
}① 为什么用
TransmittableThreadLocal(阿里 TTL)而不是普通 ThreadLocal? 普通ThreadLocal在提交任务到线程池时不会传递(线程池的线程是复用的,不是新建的,InheritableThreadLocal也无效)。异步任务里租户上下文就丢了。TTL 通过装饰 Runnable 解决了这个问题。
从请求中提取租户
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class TenantFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp,
FilterChain chain) throws ServletException, IOException {
try {
Long tenantId = resolveTenantId(req);
if (tenantId != null) {
TenantContext.set(tenantId);
}
chain.doFilter(req, resp);
} finally {
TenantContext.clear(); // ① 必须清理,线程复用
}
}
private Long resolveTenantId(HttpServletRequest req) {
// ✅ 优先从已认证的 JWT 中取 —— 最安全
Long fromToken = SecurityUtils.getCurrentTenantId();
if (fromToken != null) return fromToken;
// ⚠️ 从 Header 取:只能用于内部服务间调用,且必须校验调用方身份
String header = req.getHeader("X-Tenant-Id");
if (StringUtils.hasText(header) && isInternalCall(req)) {
return Long.valueOf(header);
}
// 从域名取:tenant1.example.com
return resolveBySubdomain(req.getServerName());
}
}⚠️ 严重安全警告:绝不能直接信任前端传来的
X-Tenant-IdHeader。攻击者改一下 Header 就能访问其他租户的全部数据。租户 ID 必须来自服务端签发的 JWT 或 Session。
四、多租户的坑
坑 1:唯一索引必须包含 tenant_id
-- ❌ 全局唯一:租户 A 用了 "admin",租户 B 就不能用了
UNIQUE KEY uk_username (username)
-- ✅ 租户内唯一
UNIQUE KEY uk_tenant_username (tenant_id, username)坑 2:自定义 SQL 也会被改写,但要注意子查询
-- 插件会处理主查询,但复杂子查询可能遗漏
SELECT * FROM sys_user WHERE dept_id IN (SELECT id FROM sys_dept)
-- 改写后:主查询加了 tenant_id,子查询可能没加对策:复杂 SQL 手动加
tenant_id条件,别完全依赖插件。上线前用log-impl打印 SQL 逐条检查。
坑 3:定时任务没有租户上下文
@Scheduled(cron = "0 0 2 * * ?")
public void dailyTask() {
// ❌ 没有租户上下文,直接抛异常
// ✅ 遍历所有租户逐个处理
List<Long> tenantIds = tenantService.listActiveTenantIds();
for (Long tenantId : tenantIds) {
TenantContext.executeAs(tenantId, () -> {
doTaskForTenant();
return null;
});
}
}坑 4:缓存 key 必须带租户 ID
// ❌ 租户 A 缓存的数据,租户 B 读到了 —— 严重数据泄漏
@Cacheable(key = "#id")
// ✅
@Cacheable(key = "T(com.taskflow.TenantContext).getTenantId() + ':' + #id")更好的做法:自定义
CacheKeyGenerator,统一在 key 前面拼租户 ID,避免每个注解都要写、避免漏写。
坑 5:索引设计
-- ❌ tenant_id 不在索引第一位,无法有效过滤
KEY idx_status_tenant (status, tenant_id)
-- ✅ tenant_id 放最前面(因为每条 SQL 都有这个条件)
KEY idx_tenant_status (tenant_id, status)五、动态表名(分表)
场景:订单表按月分表 t_order_202601、t_order_202602。
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
DynamicTableNameInnerInterceptor dynamic = new DynamicTableNameInnerInterceptor();
dynamic.setTableNameHandler((sql, tableName) -> {
if ("t_order".equals(tableName)) {
String suffix = TableSuffixContext.get(); // ① 从上下文取分表后缀
if (StringUtils.hasText(suffix)) {
return tableName + "_" + suffix;
}
}
return tableName;
});
interceptor.addInnerInterceptor(dynamic);
return interceptor;
}// 使用
TableSuffixContext.set("202601");
try {
orderMapper.selectList(wrapper); // 实际查 t_order_202601
} finally {
TableSuffixContext.clear();
}分表的现实问题
| 问题 | 说明 |
|---|---|
| 跨表查询 | 查最近 3 个月订单要查 3 张表再合并 |
| 分页排序 | 跨表分页极其复杂(要取每张表的前 N 条再重新排序) |
| 聚合统计 | count、sum 要逐表算再汇总 |
| 表创建 | 需要定时任务提前建下个月的表 |
| JOIN 失效 | 无法和其他表直接关联 |
强烈建议:
- 单表 2000 万行以内不要分表(MySQL 8 + 好索引 + SSD 完全扛得住)
- 先做:加索引、冷热分离(历史数据归档到
t_order_history)、读写分离- 真要分库分表,用 ShardingSphere,别自己造轮子——它处理了跨库事务、分布式主键、结果归并等一大堆细节
六、多数据源
场景:主库写、从库读;或者对接多个业务库。
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot3-starter</artifactId>
<version>4.3.0</version>
</dependency>spring:
datasource:
dynamic:
primary: master
strict: true # ① 找不到指定数据源时报错,不要静默降级
datasource:
master:
url: jdbc:mysql://master:3306/taskflow
username: root
password: ${DB_PASSWORD}
slave:
url: jdbc:mysql://slave:3306/taskflow
username: readonly
password: ${DB_SLAVE_PASSWORD}@Service
@DS("master") // ① 类级别:默认走主库
public class UserServiceImpl {
@DS("slave") // ② 方法级别:覆盖类级别
public List<User> listForReport() {
return list(); // 走从库
}
}⚠️ 主从延迟的坑:
javauserService.save(user); // 写主库 User u = userService.getFromSlave(id); // ❌ 从库可能还没同步到,查不到!对策:写操作后的立即读取必须强制走主库。或者写完直接用内存中的对象,不要回查。
七、本章小结
| 要点 | 关键 |
|---|---|
| 隔离方案 | 共享表 + tenant_id 最常用 |
| 插件顺序 | 多租户 必须在分页之前 |
| 租户 ID 来源 | 只能来自 JWT,绝不信任 Header |
| 上下文为空 | 必须抛异常,不能放行 |
| 唯一索引 | 必须包含 tenant_id |
| 索引顺序 | tenant_id 放第一位 |
| 缓存 key | 必须带租户 ID |
| 线程池 | 用 TransmittableThreadLocal |
| 分表 | 2000 万以内别分;要分用 ShardingSphere |
| 主从 | 写后立即读要走主库 |
动手练习
练习 1:基础题
配置多租户插件,创建两个租户的数据,验证租户 A 登录后查不到租户 B 的数据。
练习 2:思考题
你的 SaaS 系统有个「平台管理后台」需要查看所有租户的数据统计。在共享表 + tenant_id 方案下,这个功能怎么实现才安全?
下一章:第 57 章:数据源调优 →