上一篇《SaaS 多租户数据隔离》讲了字段级隔离的全景:租户插件自动改写 SQL,业务代码零感知。但“零感知”的前提是插件每改一条 SQL 都能答对一个问题——当前租户是谁。
这个问题看起来不起眼,其实被问得极频繁:一次分页查询,主表问一次、count 再问一次、每张关联表各问一次;一次页面渲染,几十条 SQL 就是上百次提问。而答案可能来自四个完全不同的地方——线程里、请求缓存里、Redis 里、token 里。动态租户的实现,本质就是围绕“这个值存在哪、活多久、谁负责清理”展开的。
这篇基于 RuoYi-Vue-Plus 的 TenantHelper(ruoyi-common-tenant 模块)把这套上下文机制拆到实现层。文中代码是框架源码的摘录整理(静态梳理,机制描述以框架行为为准)。
一、三级存储:三种生命期,一条优先级链
先看全景。getTenantId() 的解析链是四级,四级解析优先级图在《SaaS 多租户数据隔离》4.2 节画过,这里换一个视角——每一级对应一种“生命期”:
| 级别 | 存储位置 | 生命期 | 谁设置 | 典型来路 |
|---|---|---|---|---|
| ① | ThreadLocal | 一段代码块 | setDynamic(id, false) | 定时任务、回调包裹 |
| ② | SaStorage(请求缓存) | 一次 HTTP 请求 | 框架自动(首次查 Redis 后) | 读路径的缓存层 |
| ③ | Redis(按 userId) | 写入起直到手动清除 | setDynamic(id, true) | 超管切换端点 |
| ④ | token extra | 整个登录会话 | 登录时绑定 | 所有普通用户 |
读取顺序 ①→②→③→④,取到第一个非空值就停。为什么是这个顺序?生命期短的优先——越靠前的值越“临时”、越贴近当前执行的代码,也越能精确表达“此刻该用哪个租户”。
把四级串起来理解,其实是两类:
- ④ 是静态兜底:登录那刻写死,保证“没有任何动态设置时”普通用户的每个请求都有正确答案;
- ①②③ 共同维护一个动态值:③ 让“切一次、跨请求生效”成为可能(不然超管每发一个请求都要重新切一遍);② 是读路径的请求内缓存(不然每次提问都查一次 Redis);① 是最短生命期的临时切换,服务于没有 HTTP 请求的场合(定时任务、异步任务、一段代码块)。
global 参数的双语义就在这里:true 走 ③②(Redis + 请求缓存),false 只走 ①(ThreadLocal)。
二、-1 哨兵:一次请求最多查一次 Redis
② 为什么必须存在?算一笔账。超管切了动态租户后,动态值躺在 Redis 里。此后他发出的每个请求,每一次 SQL 改写都会调用 getDynamic()——如果每次都直查 Redis,一次页面渲染上百次网络往返,就为了拿同一个不变的值。
所以框架把“查过的结果”缓存在请求级存储里,一次 HTTP 请求内只有第一次真正读 Redis。
这个缓存有个不好处理的点:它大多数时候要缓存的答案是“没有”。超管没切动态租户才是常态,此时查 Redis 返回 null——而“空”恰恰是存储里最难表达的状态。② 在逻辑上要区分三种情况:
| ② 里的状态 | 含义 | 下一步 |
|---|---|---|
| 什么都没存 | 没查过 Redis | 去查 Redis |
| 存了真实租户 ID | 查过,答案非空 | 直接返回这个值 |
| 查过,但答案是空 | ??? | 不该再查,但怎么表达? |
第三行卡住了。你可能会想:查到 null 就把 null 存进去呗——但 SaStorage 这类 key-value 存储存不进 null(写 null 等于没写,读回来还是“什么都没存”)。于是一个高频出现的答案(“没有动态租户”)和“没查过”长得一模一样,每次读到都只能当成没查过,再穿透一次 Redis——缓存形同虚设,空查一次变百次。
哨兵就是给“空”找一个能存进去的化身:放一个业务上永远不可能出现的约定值进去占位,这里选了字符串 "-1"。它的含义不是“租户 ID 是 -1”,而是“我查过了,答案是空,别再查了”。下次读到它,直接翻译成 null 返回。现在把读路径代码完整看一遍,每一步都对应上面表里的一行:
// TenantHelper.getDynamic —— 读路径三级缓存(有删减)
String tenantId = TEMP_DYNAMIC_TENANT.get();
if (StringUtils.isNotBlank(tenantId)) return tenantId; // ① 线程级,最优先
SaStorage storage = SaHolder.getStorage();
String cacheKey = DYNAMIC_TENANT_KEY + ":" + LoginHelper.getUserId();
tenantId = storage.getString(cacheKey);
if (StringUtils.isNotBlank(tenantId)) {
return tenantId.equals("-1") ? null : tenantId; // ② 有值返回值;是哨兵翻译回 null
}
tenantId = RedisUtils.getCacheObject(cacheKey); // ③ 首次:查一次 Redis
storage.set(cacheKey, StringUtils.isBlank(tenantId) ? "-1" : tenantId);
return tenantId; // ③ 写缓存:空答案也缓存,存的是哨兵"-1" 这个值本身没有魔力,选它只是因为安全:租户 ID 是 6 位数字,"-1" 永远不可能是真值,不会被误认。这类“用一个特殊值代表正常值表达不了的状态”的做法有个通用名字——哨兵值(sentinel value),你其实早就见过它:缓存穿透防护里“查不到也把空结果缓存一小段时间”、C 语言字符串结尾的 \0、文件读到最后返回的 EOF,全是同一个思想。
两个配套细节保证了哨兵不出错:
- 哨兵只在请求内存活。SaStorage 跟着请求走,请求结束即弃——不存在“缓存了一个旧哨兵”的长期污染问题(如果这个哨兵存进 Redis,反而要操心失效时机了);
- 清理时同步清缓存。
clearDynamic()删 Redis 的同时会SaHolder.getStorage().delete(cacheKey),把 ② 里的值(无论真值还是哨兵)一起删掉,同一个请求内“先清再读”拿到的也是最新状态,不会读到切换前的残影。
三、清理这件事不能靠人记:回调式 dynamic()
setDynamic / clearDynamic 是手动挡。手动挡有一个致命问题:清理依赖人的记性。两种死法:
- 业务代码抛了异常,
clearDynamic那行永远没执行到; - 更隐蔽的——线程池。线程是复用的:任务 A 在池子里某个线程上
setDynamic("100002"),没清就归还了线程;下一个任务 B(属于租户 100001)恰好复用这个线程,读到的动态租户还是 100002——任务 A 的上下文泄漏给了任务 B,而且 B 完全无辜。
所以框架强烈建议用回调式 API,把 set 和清理焊死在同一个调用里:
// TenantHelper.dynamic —— 回调式:try-finally 保证必清
public static void dynamic(String tenantId, Runnable handle) {
setDynamic(tenantId, false); // false:线程级,只影响这一段执行
try {
handle.run();
} finally {
clearDynamic(); // 异常也必执行
}
}对照图里两条路径:手动挡在异常或线程复用面前会漏,回调式靠 finally 把两种风险一起堵死。这也是通用的并发纪律——谁设置上下文,谁负责清理,且清理必须绑定在设置方的控制流里,交给下游“记得清”等于没设计。
四、ignore 的可重入:一个 Deque 栈
ignore 是另一把“越界钥匙”:临时让租户插件罢工,跨租户操作完再恢复。它也要回答同样的问题——什么时候恢复?尤其嵌套的时候:
// controller 层包一层
TenantHelper.ignore(() -> tenantService.insertByBo(bo));
// service 内部又包一层(创建租户时要查全量租户 ID 去重)
TenantHelper.ignore(() -> baseMapper.selectObjs(...));如果 ignore 用的是简单的布尔开关,内层退出时把开关复原成“开”,外层的包裹就被内层误关了——外层后续的 SQL 会突然恢复租户过滤,在“本来不该过滤”的流程里拼出错误条件。
框架的实现委托给 MyBatis-Plus 的 InterceptorIgnoreHelper,内核是一个Deque 栈(简化示意):
// InterceptorIgnoreHelper —— 简化示意,非源码原文
private static final ThreadLocal<Deque<IgnoreStrategy>> STACK = new ThreadLocal<>();
public static void handle(IgnoreStrategy strategy) {
Deque<IgnoreStrategy> deque = STACK.get();
if (deque == null) {
deque = new ArrayDeque<>();
STACK.set(deque);
}
deque.push(strategy); // 进入:压栈
}
public static void clearIgnoreStrategy() {
Deque<IgnoreStrategy> deque = STACK.get();
if (deque != null) {
deque.pop(); // 退出:只弹自己那一层
if (deque.isEmpty()) {
STACK.remove();
}
}
}栈非空 = 插件罢工;栈空 = 恢复改写。每个 ignore 只压入和弹出自己那一层,嵌套多少层都互不干扰:
这就是“可重入”的正确打开方式——不是计数(计数分不清“谁的层数”),而是把每一层的身份压进栈,退出时只摘自己。它和《Java 并发编程》里讲的锁可重入是同一类设计:重入时不破坏外层状态,退出一层只还原一层,LIFO 保证恢复顺序和设置顺序严格相反。
使用它的铁律不变,现在可以从实现层面理解为什么:
- 范围最小化——栈非空期间整个线程的租户过滤都是关的,裹越大,“裸奔”的 SQL 越多;
- 关掉过滤必须手动圈范围——插件不拼
tenant_id了,数据边界全靠你自己写的in(tenantIds)。
五、跨不过线程边界:异步场景的正确姿势
三级存储里有两级(① ThreadLocal、② SaStorage)都长在线程上(SaStorage 基于 ThreadLocal 实现)。ThreadLocal 的铁律:不跨线程。于是:
@Async、线程池、CompletableFuture.supplyAsync里的代码,①② 都取不到值;- ③(Redis)虽然跨线程,但那是“超管全局切换”的语义,不该被业务任务借用。
有人会想到 InheritableThreadLocal——子线程创建时继承父线程的值。但它在线程池场景下依然失效:池子里的线程是启动时创建的,继承的是“池子创建那一刻”的上下文,之后每个提交任务的线程上下文各不相同,复用线程拿到的是上一个任务残留的值——比拿不到更危险。
所以异步场景没有魔法,正确姿势是显式传递 + 回调包裹:
// 提交前:在同步线程里把租户 ID 解析成参数
String tenantId = TenantHelper.getTenantId();
CompletableFuture.supplyAsync(() -> {
// 任务内:用回调式重建上下文,finally 自动清理
return TenantHelper.dynamic(tenantId, () -> assetMapper.selectList(null));
}, executor);这不是框架的局限,而是这类设计的标准取舍:隐式上下文换取业务零侵入,代价就是隐式传播的边界——边界之外(跨线程、跨进程),必须回到显式传参。真实的翻车与修复过程(supplyAsync 并行统计导致跨租户泄漏)见《工作台接口性能优化实战》。
六、收个尾
动态租户上下文的全部实现,收进一张表:
| 问题 | 方案 | 关键点 |
|---|---|---|
| 值存哪 | ThreadLocal / SaStorage / Redis 三级 + token 兜底 | 生命期越短优先级越高 |
| 高频读怎么扛 | 请求级缓存 + -1 哨兵 | 区分“没查过”和“查过为空”,一次请求最多一次 Redis |
| 谁负责清理 | 回调式 dynamic(),try-finally 焊死 | 手动挡在线程池复用面前必然泄漏 |
| 嵌套怎么办 | ignore 委托 Deque 栈 | 栈非空即罢工,退出只摘自己那层 |
| 跨线程 | 显式传参 + 任务内重建上下文 | InheritableThreadLocal 治不了线程池 |
这套机制的设计口味和很多并发工具一脉相承:用自动化的上下文管理换使用者的心智负担,但每一条“自动化”都配一条“边界”——回调式的边界是作用域,哨兵的边界是单次请求,ignore 的边界是栈。越界不报错,只会静默出错,所以针对隔离的专项测试(空壳租户对照、跨租户断言)依然是最后的防线。
相关阅读:动态租户在整套隔离体系里的位置(SQL 层 / 缓存层 / 会话层怎么配合),见《SaaS 多租户数据隔离》;异步翻车的完整案例见《工作台接口性能优化实战》。
SaaS 系列导航
- 上一篇:《SaaS 多租户数据隔离》——隔离全景:三级防线与租户开通流程
- 下一篇:《租户生命周期管理》——租户一生:试用、冻结、注销与保留期
- 《SaaS 套餐功能开关设计》——隔离之外,套餐还要管“能用什么”
- 《SaaS 套餐与计费设计》——租户机制之上的商业模式落地
