Skip to content
第 56 / 250 章后端⏱ 10 分钟阅读

第 56 章:多租户与动态表名

学习目标

  • 理解 SaaS 多租户的三种隔离方案
  • 掌握 MyBatis-Plus 多租户插件
  • 学会动态表名实现分表

一、多租户隔离方案

方案隔离性成本扩展性适用
独立数据库最强最高差(几百个租户就管不动)大客户、金融/医疗合规要求
独立 Schema中型 SaaS
共享表 + tenant_id弱(靠代码保证)最低最好绝大多数 SaaS

混合方案是实践中的最优解:普通客户走共享表,付费大客户提供独立库。

二、共享表方案实现

配置多租户插件

java
@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 系统最严重的安全事故(数据越权)。宁可报错也不能放行

效果

java
// 你写的
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);

三、租户上下文

java
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 解决了这个问题。

从请求中提取租户

java
@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-Id Header。攻击者改一下 Header 就能访问其他租户的全部数据。租户 ID 必须来自服务端签发的 JWT 或 Session。

四、多租户的坑

坑 1:唯一索引必须包含 tenant_id

sql
-- ❌ 全局唯一:租户 A 用了 "admin",租户 B 就不能用了
UNIQUE KEY uk_username (username)

-- ✅ 租户内唯一
UNIQUE KEY uk_tenant_username (tenant_id, username)

坑 2:自定义 SQL 也会被改写,但要注意子查询

sql
-- 插件会处理主查询,但复杂子查询可能遗漏
SELECT * FROM sys_user WHERE dept_id IN (SELECT id FROM sys_dept)
-- 改写后:主查询加了 tenant_id,子查询可能没加

对策:复杂 SQL 手动加 tenant_id 条件,别完全依赖插件。上线前用 log-impl 打印 SQL 逐条检查。

坑 3:定时任务没有租户上下文

java
@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

java
// ❌ 租户 A 缓存的数据,租户 B 读到了 —— 严重数据泄漏
@Cacheable(key = "#id")

// ✅
@Cacheable(key = "T(com.taskflow.TenantContext).getTenantId() + ':' + #id")

更好的做法:自定义 CacheKeyGenerator,统一在 key 前面拼租户 ID,避免每个注解都要写、避免漏写。

坑 5:索引设计

sql
-- ❌ tenant_id 不在索引第一位,无法有效过滤
KEY idx_status_tenant (status, tenant_id)

-- ✅ tenant_id 放最前面(因为每条 SQL 都有这个条件)
KEY idx_tenant_status (tenant_id, status)

五、动态表名(分表)

场景:订单表按月分表 t_order_202601t_order_202602

java
@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;
}
java
// 使用
TableSuffixContext.set("202601");
try {
    orderMapper.selectList(wrapper);      // 实际查 t_order_202601
} finally {
    TableSuffixContext.clear();
}

分表的现实问题

问题说明
跨表查询查最近 3 个月订单要查 3 张表再合并
分页排序跨表分页极其复杂(要取每张表的前 N 条再重新排序)
聚合统计countsum 要逐表算再汇总
表创建需要定时任务提前建下个月的表
JOIN 失效无法和其他表直接关联

强烈建议

  1. 单表 2000 万行以内不要分表(MySQL 8 + 好索引 + SSD 完全扛得住)
  2. 先做:加索引、冷热分离(历史数据归档到 t_order_history)、读写分离
  3. 真要分库分表,用 ShardingSphere,别自己造轮子——它处理了跨库事务、分布式主键、结果归并等一大堆细节

六、多数据源

场景:主库写、从库读;或者对接多个业务库。

xml
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>dynamic-datasource-spring-boot3-starter</artifactId>
    <version>4.3.0</version>
</dependency>
yaml
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}
java
@Service
@DS("master")                            // ① 类级别:默认走主库
public class UserServiceImpl {

    @DS("slave")                          // ② 方法级别:覆盖类级别
    public List<User> listForReport() {
        return list();                    // 走从库
    }
}

⚠️ 主从延迟的坑

java
userService.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 章:数据源调优

本站基于 VitePress 构建 · 由 Codebook 团队维护