Skip to content

上一篇《SaaS 多租户数据隔离》讲了字段级隔离的全景:租户插件自动改写 SQL,业务代码零感知。但“零感知”的前提是插件每改一条 SQL 都能答对一个问题——当前租户是谁

这个问题看起来不起眼,其实被问得极频繁:一次分页查询,主表问一次、count 再问一次、每张关联表各问一次;一次页面渲染,几十条 SQL 就是上百次提问。而答案可能来自四个完全不同的地方——线程里、请求缓存里、Redis 里、token 里。动态租户的实现,本质就是围绕“这个值存在哪、活多久、谁负责清理”展开的

这篇基于 RuoYi-Vue-Plus 的 TenantHelperruoyi-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 返回。现在把读路径代码完整看一遍,每一步都对应上面表里的一行:

java
// 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 是手动挡。手动挡有一个致命问题:清理依赖人的记性。两种死法:

  1. 业务代码抛了异常,clearDynamic 那行永远没执行到;
  2. 更隐蔽的——线程池。线程是复用的:任务 A 在池子里某个线程上 setDynamic("100002"),没清就归还了线程;下一个任务 B(属于租户 100001)恰好复用这个线程,读到的动态租户还是 100002——任务 A 的上下文泄漏给了任务 B,而且 B 完全无辜。

所以框架强烈建议用回调式 API,把 set 和清理焊死在同一个调用里:

java
// TenantHelper.dynamic —— 回调式:try-finally 保证必清
public static void dynamic(String tenantId, Runnable handle) {
    setDynamic(tenantId, false);     // false:线程级,只影响这一段执行
    try {
        handle.run();
    } finally {
        clearDynamic();              // 异常也必执行
    }
}

手动 set/clear 与回调式 dynamic 在线程池下的两种结局

对照图里两条路径:手动挡在异常或线程复用面前会漏,回调式靠 finally 把两种风险一起堵死。这也是通用的并发纪律——谁设置上下文,谁负责清理,且清理必须绑定在设置方的控制流里,交给下游“记得清”等于没设计。

四、ignore 的可重入:一个 Deque 栈

ignore 是另一把“越界钥匙”:临时让租户插件罢工,跨租户操作完再恢复。它也要回答同样的问题——什么时候恢复?尤其嵌套的时候:

java
// controller 层包一层
TenantHelper.ignore(() -> tenantService.insertByBo(bo));
    // service 内部又包一层(创建租户时要查全量租户 ID 去重)
    TenantHelper.ignore(() -> baseMapper.selectObjs(...));

如果 ignore 用的是简单的布尔开关,内层退出时把开关复原成“开”,外层的包裹就被内层误关了——外层后续的 SQL 会突然恢复租户过滤,在“本来不该过滤”的流程里拼出错误条件。

框架的实现委托给 MyBatis-Plus 的 InterceptorIgnoreHelper,内核是一个Deque 栈(简化示意):

java
// 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 只压入和弹出自己那一层,嵌套多少层都互不干扰:

ignore 的可重入实现:嵌套时的 Deque 栈变化

这就是“可重入”的正确打开方式——不是计数(计数分不清“谁的层数”),而是把每一层的身份压进栈,退出时只摘自己。它和《Java 并发编程》里讲的锁可重入是同一类设计:重入时不破坏外层状态,退出一层只还原一层,LIFO 保证恢复顺序和设置顺序严格相反。

使用它的铁律不变,现在可以从实现层面理解为什么:

  1. 范围最小化——栈非空期间整个线程的租户过滤都是关的,裹越大,“裸奔”的 SQL 越多;
  2. 关掉过滤必须手动圈范围——插件不拼 tenant_id 了,数据边界全靠你自己写的 in(tenantIds)

五、跨不过线程边界:异步场景的正确姿势

三级存储里有两级(① ThreadLocal、② SaStorage)都长在线程上(SaStorage 基于 ThreadLocal 实现)。ThreadLocal 的铁律:不跨线程。于是:

  • @Async、线程池、CompletableFuture.supplyAsync 里的代码,①② 都取不到值;
  • ③(Redis)虽然跨线程,但那是“超管全局切换”的语义,不该被业务任务借用。

有人会想到 InheritableThreadLocal——子线程创建时继承父线程的值。但它在线程池场景下依然失效:池子里的线程是启动时创建的,继承的是“池子创建那一刻”的上下文,之后每个提交任务的线程上下文各不相同,复用线程拿到的是上一个任务残留的值——比拿不到更危险。

所以异步场景没有魔法,正确姿势是显式传递 + 回调包裹

java
// 提交前:在同步线程里把租户 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 系列导航