上一篇拆了 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% 的场景,个别大租户走独立库。
设计目标拆下来是五条:
- 业务代码零侵入------不写租户条件,SQL 自动追加
- 平台表(全局字典、菜单)不受租户过滤
- 定时任务、异步线程的上下文能传递
- 缺租户上下文时宁可报错,不可静默放行
- 同一套代码,支持行隔离和独立库两种模式切换
全景图:一次请求的租户之旅
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 条诀窍
- 多租户上下文一律 TTL,普通 ThreadLocal 在线程池场景就是定时炸弹
- 嵌套切换上下文必须保存-恢复 ,
executeIgnore/executeWithTenant的 finally 里恢复旧值,别直接 remove - @Target 含 TYPE 的注解,切面要 @annotation + @within 双写,否则类级标注静默失效
- strictMode 渐进切换:宽松上线 → 盯 ERROR 日志补路径 → 严格锁死
- 事务边界校验前置,跨数据源事务在切面里拦,别等运行时数据错乱再查
和 datascope 怎么共存
预告一下下一篇:tenant 拦截器和 datascope 拦截器都是 InnerInterceptor,都注册进 MybatisPlusInterceptor,注册顺序决定 SQL 改写顺序 。tenant 先追加 tenant_id = ?,datascope 再叠加部门权限条件------顺序反了,datascope 的 AST 解析可能撞上 tenant 改写后的 SQL 结构。还有分页 count 语句(_mpCount 后缀)被两个拦截器先后处理时的坑。下篇展开。
这一篇如果帮你理清了多租户的上下文传递和隔离边界,点个赞让我知道,评论区聊聊你们踩过哪个坑------"定时任务查不到数据"那个坑,我赌至少一半人遇过。
系列前作:
- 《数据权限拦截器源码拆解:SQL 改写》(forge-starter-datascope)
项目相关:
- Gitee:gitee.com/ForgeLab/fo...
- 在线演示:www.dlforgelab.com:8084/forge/login... / 123456)
#Java #SpringBoot #MyBatis #多租户 #架构设计