多租户隔离怎么落地?拆完这1600行Starter源码,我把5个坑全踩明白了

上一篇拆了 forge-starter-datascope 的 SQL 改写(681 行拦截器),文末留了个尾巴:tenant 和 datascope 共存时谁先谁后。这篇先补另一半拼图------多租户 starter 的完整实现。下一篇讲两者共存。

一个"玄学"Bug 开场

先说一个我真实踩过的坑,也是这套源码里最有价值的部分。

某个定时任务,每天凌晨同步租户的业务数据。本地跑得好好的,上了测试环境,死活查不到数据

不报错,不抛异常,就是查出来 0 条。日志打出来 SQL 也正常。折腾了半天才发现------定时任务跑在调度线程里,那个线程里没有租户上下文 。多租户拦截器把 SQL 改写成了 WHERE tenant_id = NULL,当然一条都查不出来。

最阴的是它不报错。空结果在业务代码里看起来太"正常"了。

这个坑的解法,以及另外 4 个坑,都在这 1586 行源码里。下面按数据流顺序拆。

先想清楚:多租户到底要解决什么

写代码之前,多租户方案先选型。五种主流方案摆一起:

方案 隔离级别 改造成本 适用场景
手写 WHERE tenant_id 高(每处都写,必漏) 不推荐
字段级拦截(行隔离) 低(SQL 自动改写) 中小租户量,通用
独立 Schema 租户量中等,合规要求高
独立数据库(分库) 最高 大客户、金融/政企
ES/搜索隔离域 看场景 检索类业务

ForgeAdmin 的答案是:字段级拦截做默认兜底 + 数据源级分库做增强,两层可以叠加。行隔离管住 90% 的场景,个别大租户走独立库。

设计目标拆下来是五条:

  1. 业务代码零侵入------不写租户条件,SQL 自动追加
  2. 平台表(全局字典、菜单)不受租户过滤
  3. 定时任务、异步线程的上下文能传递
  4. 缺租户上下文时宁可报错,不可静默放行
  5. 同一套代码,支持行隔离和独立库两种模式切换

全景图:一次请求的租户之旅

ini 复制代码
HTTP 请求
   │
   ▼
┌─────────────────────────────────────────────────┐
│ TenantInterceptor(Web层,order=10)              │
│  · 从 Session 取 tenantId                        │
│  · @IgnoreTenant / API配置 needTenant=false → 跳过 │
│  · OAuth/OpenAPI 独立令牌入口 → 放行自建上下文      │
│  · afterCompletion 清理上下文(防泄漏)             │
└─────────────────────────────────────────────────┘
   │ TenantContextHolder.setTenantId(tenantId)
   ▼
┌─────────────────────────────────────────────────┐
│ TenantContextHolder(TransmittableThreadLocal)   │
│  · tenantId + ignoreFlag                        │
│  · 线程池场景自动传递(TTL)                        │
└─────────────────────────────────────────────────┘
   │
   ▼
┌─────────────────────────────────────────────────┐
│ TenantLineInnerInterceptor(MyBatis-Plus SQL层)  │
│  · DefaultTenantLineHandler.ignoreTable() 四级判断 │
│  · 命中租户表 → SQL 追加 WHERE tenant_id = ?       │
└─────────────────────────────────────────────────┘
   │
   ▼
┌─────────────────────────────────────────────────┐
│ TenantBusinessDataSourceAspect(可选增强)         │
│  · @TenantBusinessDataSource → 切租户独立库        │
│  · 事务边界校验:跨数据源事务直接抛异常              │
│  · TaskDecorator:异步线程带上下文跑               │
└─────────────────────────────────────────────────┘

下面按这个顺序,从下往上拆核心源码。

1. 上下文:为什么是 TransmittableThreadLocal

java 复制代码
public class TenantContextHolder {

    /**
     * 使用阿里的TransmittableThreadLocal,支持线程池场景下的上下文传递
     */
    private static final ThreadLocal<Long> TENANT_ID_HOLDER = new TransmittableThreadLocal<>();

    private static final ThreadLocal<Boolean> IGNORE_TENANT = new TransmittableThreadLocal<>();

    public static Long getTenantId() {
        return TENANT_ID_HOLDER.get();
    }

    /**
     * 执行忽略租户的操作(嵌套安全)
     */
    public static void executeIgnore(Runnable runnable) {
        Boolean oldIgnore = IGNORE_TENANT.get();
        try {
            IGNORE_TENANT.set(true);
            runnable.run();
        } finally {
            if (oldIgnore != null) {
                IGNORE_TENANT.set(oldIgnore);
            } else {
                IGNORE_TENANT.remove();
            }
        }
    }

    /**
     * 执行指定租户的操作(带返回值版本)
     */
    public static <T> T executeWithTenant(Long tenantId, Supplier<T> supplier) {
        Long oldTenantId = TENANT_ID_HOLDER.get();
        try {
            TENANT_ID_HOLDER.set(tenantId);
            return supplier.get();
        } finally {
            if (oldTenantId != null) {
                TENANT_ID_HOLDER.set(oldTenantId);
            } else {
                TENANT_ID_HOLDER.remove();
            }
        }
    }
}

三个细节值得停下来看:

为什么不用普通 ThreadLocal? 线程池会复用线程,普通 ThreadLocal 在提交任务时不会把主线程的值带过去。InheritableThreadLocal 只在"新建线程"时复制,线程池复用场景照样失效。TTL 是唯一正解------这也是开头那个定时任务 Bug 的解药之一。

为什么 oldIgnore 判空再恢复? executeIgnore 是可嵌套的。外层已经 ignore,内层再 ignore,内层结束后要恢复到外层的状态,而不是直接 remove。直接 remove 会把外层的标记也干掉。

为什么 remove 而不是 set(null)? ThreadLocal 的 key 是弱引用,value 是强引用。set(null) 只是值变 null,Entry 还在,长跑服务里有内存泄漏风险。

2. SQL 拦截核心:ignoreTable 的四级判断

java 复制代码
@Override
public Expression getTenantId() {
    Long tenantId = TenantContextHolder.getTenantId();
    if (tenantId == null) {
        if (Boolean.TRUE.equals(tenantProperties.getStrictMode())) {
            throw new IllegalStateException("租户隔离已启用,但当前上下文中没有租户ID");
        }
        log.error("租户宽松模式放行了缺少租户ID的SQL,请尽快补齐上下文或显式忽略");
        return new NullValue();
    }
    return new LongValue(tenantId);
}

@Override
public boolean ignoreTable(String tableName) {
    // 1. 上下文显式忽略(@IgnoreTenant / API配置)
    if (TenantContextHolder.isIgnore()) {
        return true;
    }
    // 2. 配置文件的忽略表清单(平台表:菜单、字典)
    if (tenantProperties.getIgnoreTables() != null &&
        tenantProperties.getIgnoreTables().contains(tableName)) {
        return true;
    }
    // 3. 运行时手动添加的忽略缓存
    if (ignoreTableCache.contains(tableName)) {
        return true;
    }
    // 4. 自动检测:表里压根没有租户列,不要求上下文
    if (tenantProperties.getAutoDetectTenantColumn() && tenantTableChecker != null) {
        if (!tenantTableChecker.hasTenantColumn(tableName)) {
            return true;
        }
    }
    // 5. 需要租户条件的表,缺上下文时失败关闭
    if (TenantContextHolder.getTenantId() == null) {
        if (Boolean.TRUE.equals(tenantProperties.getStrictMode())) {
            throw new IllegalStateException("访问租户表[" + tableName + "]时缺少租户上下文");
        }
        log.error("租户宽松模式跳过表[{}]的租户条件,请尽快补齐上下文或显式忽略", tableName);
        return true;
    }
    return false;
}

最值得说的是 strictMode(严格模式)这个开关。

严格模式:缺租户上下文 → 抛异常,请求失败。 宽松模式:缺租户上下文 → 打 ERROR 日志放行。

为什么留宽松模式?因为存量系统接入时,总有漏网的调用路径(MQ 消费者、第三方回调)。一上来就严格模式,系统直接瘫。正确姿势是:先宽松跑着,盯着 ERROR 日志把漏网路径一个个补齐,最后切严格模式锁死。 渐进式收紧,而不是一步到位。

第 4 级的自动检测也值得展开------它解决的是"新表忘了配"的问题。

3. TenantTableChecker:启动时扫一遍库结构

java 复制代码
public class TenantTableChecker implements SmartInitializingSingleton {

    private final Set<String> tablesWithTenantColumn = ConcurrentHashMap.newKeySet();
    private final Set<String> tablesWithoutTenantColumn = ConcurrentHashMap.newKeySet();

    @Override
    public void afterSingletonsInstantiated() {
        init();
    }

    public void init() {
        // ...
        // 扫描期间禁用租户拦截,避免扫描 SQL 自己被拦截
        TenantContextHolder.setIgnore(true);
        try (Connection connection = dataSource.getConnection()) {
            DatabaseMetaData metaData = connection.getMetaData();
            try (ResultSet tables = metaData.getTables(catalog, schema, "%", new String[]{"TABLE"})) {
                while (tables.next()) {
                    String tableName = tables.getString("TABLE_NAME");
                    boolean hasTenantColumn =
                        checkTableHasColumn(metaData, catalog, schema, tableName, tenantColumn);
                    if (hasTenantColumn) {
                        tablesWithTenantColumn.add(tableName);
                    }
                }
            }
        }
    }
}

它做的事:启动时用 JDBC 的 DatabaseMetaData 把所有表扫一遍,哪些表有 tenant_id 列记进缓存。之后 ignoreTable 判断就有依据了------没租户列的表不要求上下文,新业务表建出来不用改任何配置。

两个细节:

  • 为什么是 SmartInitializingSingleton 而不是 ApplicationReadyEvent?前者在所有单例 Bean 初始化完就执行,早于 ApplicationReady。启动过程中的 SQL(比如数据初始化)还没被拦截时,表结构缓存已经就绪了。
  • 扫描自己的 SQL 也会被租户拦截器拦------所以扫描前先 setIgnore(true),自己给自己开个通行证。

4. @IgnoreTenant 切面:方法级和类级必须双匹配

java 复制代码
@Aspect
@Component
@Order(1)
public class IgnoreTenantAspect {

    /**
     * 注解声明了 @Target({TYPE, METHOD}),因此需同时匹配
     * 方法级(@annotation)与类级(@within)标注,
     * 否则类级标注会被静默忽略。
     */
    @Around("@annotation(com.mdframe.forge.starter.core.annotation.tenant.IgnoreTenant) "
            + "|| @within(com.mdframe.forge.starter.core.annotation.tenant.IgnoreTenant)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        // ...
        return TenantContextHolder.executeIgnore(() -> {
            try {
                return joinPoint.proceed();
            } catch (Throwable e) {
                throw new RuntimeException(e);
            }
        });
    }
}

注释里那句话就是血泪:@Target({TYPE, METHOD}) 的注解,切面只写 @annotation 的话,标在类上会被静默忽略,不报错 。你以为整个 Controller 都跳过租户了,实际只有你单独标注的那两个方法生效了。必须 @annotation@within 双写,中间用 || 连。

典型受害者就是定时任务 Handler:类级标注 @IgnoreTenant,没生效,于是调度线程里 tenant_id = NULL,查不到数据------就是开头那个 Bug 的另一种形态。

5. 数据源级隔离:事务边界不能破

行隔离之上,个别租户可以走独立库。核心就一个切面:

java 复制代码
@Aspect
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class TenantBusinessDataSourceAspect {

    @Around("@within(...TenantBusinessDataSource) "
            + "|| @annotation(...TenantBusinessDataSource)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        TenantBusinessDataSourceInfo info = dataSourceResolver.resolve();
        validateTransactionBoundary(info);
        try (TenantBusinessDataSourceScope ignored = dataSourceResolver.use(info)) {
            return joinPoint.proceed();
        }
    }

    private void validateTransactionBoundary(TenantBusinessDataSourceInfo info) {
        if (info == null || !info.shouldRoute() || !TransactionSynchronizationManager.isActualTransactionActive()) {
            return;
        }
        String currentDsKey = DynamicDataSourceContextHolder.peek();
        if (!info.getDsKey().equals(currentDsKey)) {
            throw new BusinessException("不能在已开启的其他数据源事务中切换到租户业务数据源");
        }
    }
}

validateTransactionBoundary 这 9 行是保命的。Spring 事务绑定连接,事务里换数据源,会出现"主库的事务里跑了从库的 SQL",提交回滚全乱套。这里直接抛异常拒绝,宁可失败,不可错账

异步场景靠 TaskDecorator 把上下文带过去,快照-恢复模式,finally 里逐项还原:

java 复制代码
@Override
public Runnable decorate(Runnable runnable) {
    Long tenantId = TenantContextHolder.getTenantId();
    boolean ignoreTenant = TenantContextHolder.isIgnore();
    String dsKey = DynamicDataSourceContextHolder.peek();
    return () -> {
        Long previousTenantId = TenantContextHolder.getTenantId();
        // ...保存现场
        applyTenantContext(tenantId, ignoreTenant);
        try {
            runnable.run();
        } finally {
            // ...逐项恢复现场
            applyTenantContext(previousTenantId, previousIgnoreTenant);
        }
    };
}

线程池复用,不恢复现场的话,下一个跑在这个线程上的任务会捡到上一个任务残留的租户 ID------又一个"数据串了但不报错"的暗雷。

5 个坑,串起来复习

现象 根因 解法
定时任务查不到数据 空结果,不报错 调度线程无上下文,tenant_id = NULL 类级 @IgnoreTenant 生效(切面双匹配)
线程池丢上下文 异步任务里租户错乱 ThreadLocal 不跨线程池 TransmittableThreadLocal
启动 SQL 被拦截 起不来/初始化空数据 表结构缓存未就绪就拦截 SmartInitializingSingleton + 扫描期 ignore
事务内切数据源 数据不一致 事务绑连接,跨源必乱 事务边界校验,直接抛异常
宽松模式漏数据 偶发跨租户查询 缺上下文静默放行 先宽松观察,逐步切严格模式

可以带走的 5 条诀窍

  1. 多租户上下文一律 TTL,普通 ThreadLocal 在线程池场景就是定时炸弹
  2. 嵌套切换上下文必须保存-恢复executeIgnore/executeWithTenant 的 finally 里恢复旧值,别直接 remove
  3. @Target 含 TYPE 的注解,切面要 @annotation + @within 双写,否则类级标注静默失效
  4. strictMode 渐进切换:宽松上线 → 盯 ERROR 日志补路径 → 严格锁死
  5. 事务边界校验前置,跨数据源事务在切面里拦,别等运行时数据错乱再查

和 datascope 怎么共存

预告一下下一篇:tenant 拦截器和 datascope 拦截器都是 InnerInterceptor,都注册进 MybatisPlusInterceptor注册顺序决定 SQL 改写顺序 。tenant 先追加 tenant_id = ?,datascope 再叠加部门权限条件------顺序反了,datascope 的 AST 解析可能撞上 tenant 改写后的 SQL 结构。还有分页 count 语句(_mpCount 后缀)被两个拦截器先后处理时的坑。下篇展开。


这一篇如果帮你理清了多租户的上下文传递和隔离边界,点个赞让我知道,评论区聊聊你们踩过哪个坑------"定时任务查不到数据"那个坑,我赌至少一半人遇过。

系列前作:

  • 《数据权限拦截器源码拆解:SQL 改写》(forge-starter-datascope)

项目相关:

#Java #SpringBoot #MyBatis #多租户 #架构设计

相关推荐
ly76891 小时前
Spring 事件机制在微服务中实现最终一致性的设计
java·spring·微服务·异步处理·最终一致性·spring事件·事务事件
Dawson Zhu1 小时前
Agent 记忆调用的上下文感知:从“能召回“到“敢开口“的决策框架
人工智能·语言模型·架构·aigc·agi
Brilliantwxx1 小时前
【STM32 】把 printf 搬到串口上 —— C 标准 IO 函数重定向与多文件工程搭建(实战串口电灯)
c语言·开发语言·stm32·单片机·嵌入式硬件·架构·ecmascript
wuminyu1 小时前
Kafka利用sendfile与Page Cache实现高性能传输剖析
java·linux·c语言·jvm·c++
我命由我123451 小时前
Android 开发问题:android.permission.CAMERA...duplicated with element declared at
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
宫水三叶的刷题日记1 小时前
铁打的影视飓风,流水的新 iPhone
后端
Bs_MoneyMagnet1 小时前
基于springboot+vue的医院陪诊服务预约平台的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring
SimonKing1 小时前
一个Docker命令,40万首古诗词API开箱即用
java·后端·程序员
FW-Linker1 小时前
多卡聚合的数据包级并行传输原理在广电直播场景中详解
网络·5g·架构·智能路由器