第六章 · Redis 缓存与多租户(redis + tenant Starter)
这一章讲两个”横切能力”:Redis 缓存如何被封装成”只写一个注解就能用”(
@Cacheable命名空间 + 动态 TTL),以及 SaaS 多租户如何做到”业务代码一行不改,SQL 自动按租户隔离”。后者是本平台最值得反复琢磨的能力之一。
1. 模块概述
1.1 yudao-spring-boot-starter-redis(5 个文件)
它不是冗长的 Redis 工具库,而是精准地对齐 Spring Cache:
| 类 | 职责 |
|---|---|
YudaoRedisAutoConfiguration |
自定义 RedisTemplate<String,Object>,key 用 string 序列化、value 用 JSON(注册 JavaTimeModule 支持 LocalDateTime) |
YudaoCacheAutoConfiguration |
装配 Spring Cache 的 @Cacheable 基础设施,定义 RedisCacheConfiguration |
YudaoCacheProperties |
redisScanBatchSize(扫描批次,默认 30) |
TimeoutRedisCacheManager |
支持 cacheName#ttl 约定:在缓存名字后跟 # + 过期时长 |
1.2 yudao-spring-boot-starter-biz-tenant(25 个类)
SaaS 多租户全套:TenantContextHolder(线程上下文)、三个 Filter(解析/校验/安全)、TenantDatabaseInterceptor(SQL 注入)、TenantRedisCacheManager(缓存按租户隔离)、TenantUtils(上下文切换工具)、TenantProperties(配置)。
graph TD
subgraph Redis[redis Starter]
RT[RedisTemplate 自定义配序化]
CM[CacheManager 命名空间 + TTL]
end
subgraph Tenant[tenant Starter]
H[TenantContextHolder 线程上下文]
F[Web Filter 解析租户]
I[TenantDatabaseInterceptor SQL 拦截]
T[TenantRedisCacheManager 缓存隔离]
P[TenantProperties 配置]
end
RT --> CM
F --> H
I --> H
T --> CM
2. 命名体系与易混淆对象对比
2.1 命名规律拆解(tenant 核心)
| 前缀/命名 | 模块/对象 | 操作 | 含义 |
|---|---|---|---|
TenantContext |
Holder |
set/get/clear |
线程上下文中存取租户 id |
TenantDatabase |
Interceptor |
getTenantId/ignoreTable |
MyBatis 拦截器提供租户值 |
Tenant |
Utils |
execute(tenantId, runnable) |
指定租户执行一段逻辑 |
Tenant |
RedisCacheManager |
getCache |
缓存名带租户后缀 |
Tenant |
Properties |
— | enable/ignoreUrls/ignoreTables/ignoreCaches 配置 |
命名规律总结:TenantXxx 命名族表达了”多租户”这一横切关注点被系统地拆到”上下文、过滤、拦截、缓存、工具、配置”六个角色,各司其职。
2.2 易混淆对象对比:TenantContextWebFilter vs TenantSecurityWebFilter
| 对比维度 | TenantContextWebFilter |
TenantSecurityWebFilter |
|---|---|---|
| 优先级 | 更早(TENANT_CONTEXT_FILTER=-104) |
稍后(TENANT_SECURITY_FILTER=-99) |
| 职责 | 解析租户 id 写入上下文 | 校验跨租户访问、过期禁用 |
| 失败表现 | 无订阅则不设置 | 越租户/disabled → 抛异常 |
| 涉及 URL | 所有请求 | 有 ignoreUrls 豁免 |
| 一句话区分 | “把租户装进上下文”;”检查这个租户能不能这么访问”。 |
再对比两个”租户 id 获取”入口:WebFrameworkUtils.getTenantId(request)(从 Header tenant-id 解析)与 TenantContextHolder.getTenantId()(从线程上下文取)——前者是”报文来源”,后者是”运行期权威值”。
3. API Signatures
3.1 Redis/Cache
graph TD
BIZ["业务类方法 @Cacheable(value=ROLE, key=#id)"] --> CM["TimeoutRedisCacheManager.createRedisCache"]
CM --> TTL["解析 cacheName#ttl → entryTtl"]
CM --> PRE["computePrefixWith 拼接前缀"]
CM --> CACHEIMAGE["RedisCache 实例"]
CACHEIMAGE --> RESP["命中直接返回"]
1 | // —— 缓存配置:命名空间 + TTL —— |
3.2 Tenant
1 | // —— 线程上下文(用 TTL 支撑异步穿透)—— |
4. 数据结构深度解析
4.a 结构体存在的理由:TenantContextHolder
多租户的最大难点是”怎么让所有查询都知道当前是谁的租户“。把它放进线程上下文是最优雅的方案:一次请求内,所有后续调用(Service/Mapper/缓存)都能取到租户 id,而无需显式传参。用 TransmittableThreadLocal(TTL) 而非原生
ThreadLocal,是关键的健壮性设计——@Async异步线程、消息消费者、线程池复用场景下,子线程能继承父线程的租户上下文,避免”异步就丢租户”的隐蔽 bug。
4.b 代码:TenantContextWebFilter
📍 源码:TenantContextWebFilter.java(节选)
1 | public class TenantContextWebFilter extends OncePerRequestFilter { |
4.c 字段三层分析表(TenantProperties)
| 字段 | 设计动机(为什么需要) | 反事实(如果去掉会怎样) | 替代方案(还能怎么做) |
|---|---|---|---|
enable |
一键开关多租户(@ConditionalOnProperty 控制装配) |
要么强制多租户、要么移除 | 拆分多套工程,成本高 |
ignoreUrls |
某些接口不需带租户(验证码、回调) | 回调/验证码被打回 400 | 硬编码,不灵活 |
ignoreTables |
全局表(租户表、菜单表、错误码表)不应按租户过滤 | 全局表数据被租户隔离,登录取不到菜单 | 逐表豁免,散落 |
ignoreCaches |
全局缓存(oauth_client 等)不加租户后缀 | 缓存被租户打散,命中率大跌 | 无 |
4.d 生命周期状态图(租户上下文)
stateDiagram-v2
[*] --> 空: 请求进入(上下文无租户)
空 --> 有租户: Web Filter 解析到 tenant-id 并 setTenantId
空 --> 忽略: 命中 ignoreUrls / ignoreTables → setIgnore
有租户 --> 有租户: 后续查询/缓存取 getTenantId
有租户 --> 被清理: 请求结束 finally clear()
忽略 --> 被清理
被清理 --> [*]: 线程归还池,不留脏数据
5. 函数逐行精讲
5.a 场景卡片
接口:
TenantDatabaseInterceptor(MyBatis-Plus 的TenantLineHandler)
- 调用时机:
TenantLineInnerInterceptor(MyBatis-Plus 插件)解析 SQL 时,需向每个主表查询注入租户条件。- 典型调用者:MyBatis-Plus
TenantLineInnerInterceptor,由 第五章 的插件链驱动。- 前置条件:租户上下文已设置(有租户或忽略)。
- 目的:为 SQL 追加
tenant_id = ?,实现透明多租户过滤。
5.b 逐行注释式精讲
1 | public class TenantDatabaseInterceptor implements TenantLineHandler { |
🔍 懂了这层,就懂多租户的本质:业务 SQL 写成
SELECT * FROM xxx WHERE ...,MyBatis-Plus 插件在 SQL 语法树层面自动把它变成SELECT * FROM xxx WHERE ... AND tenant_id = ?;对于 INSERT,甚至会自动补上 tenant_id 列。业务层零感知。
5.c 场景卡片:TenantUtils
函数:
TenantUtils.execute(tenantId, runnable/callable)
- 调用时机:系统跨租户操作(如运营端切换租户执行逻辑、刷新某租户缓存)。
- 典型调用者:tenant 管理相关 Service。
- 前置条件:需明确指定要操作哪个租户。
- 目的:在不污染外层上下文的前提下,临时切到指定租户跑一段逻辑。
1 | public static void execute(Long tenantId, Runnable runnable) { |
6. 关键机制剖析
6.1 缓存命名空间 + 动态 TTL
@Cacheable(value = "role:xxx", key = "#id") 的 cacheName 支持 #ttl 后缀(如 "role#1h"),TimeoutRedisCacheManager 解析后设置对应过期时间。前缀拼接用 key-prefix + cacheName + ":",而非 Spring 默认的双冒号——避免嵌套 key 混淆。所有缓存业务 key 集中在 RedisKeyConstants(user_role_ids、permission_menu_ids 等,见 第八章),且这些全局缓存被加进 ignore-caches,保证多租户下仍共享。
6.2 多租户如何”双向”生效
flowchart LR
subgraph "读取 [查询/缓存]"
A1["Mapper 查询"] --> SQL["TenantLineInnerInterceptor 追加 tenant_id=?"]
C1["@Cacheable"] --> CM["TenantRedisCacheManager 追加租户后缀到缓存名"]
end
subgraph "写入 [写入]"
A2["insert"] --> SQL
end
subgraph "过滤 [豁免]"
I1["全局表 ignoreTables"] --> SKIP["跳过"]
U1["接口 ignoreUrls"] --> SKIP
end
- 查询:SQL 自动带
tenant_id = ?; - 缓存:缓存名自动带
:<tenantId>后缀,天然按租户隔离; - 写库/写缓存:同样自动处理。
6.3 防”线程池串租户”
OncePerRequestFilter 的 finally { clear() } + TransmittableThreadLocal,双保险:请求结束清空防止线程复用把 A 租户带进 B 请求;异步线程通过 TTL 正确继承。
7. 设计决策分析
- 为什么租户注入放在 MyBatis 层而非业务层? 它把”多租户”从业务代码中彻底剥离——业务只写”自己想表达的业务条件”,租户隔离是数据访问层的强制语义,不会因为某个开发者忘了写
tenant_id而漏数据。代价是 SQL 拦截有性能与调试成本,需通过ignoreTables精细化管理全局表。 - 为什么缓存 key 也按租户隔离? 同一
@Cacheable在不同租户下结果必须不同;若不隔离,租户 A 会读到租户 B 的缓存。TenantRedisCacheManager在getCache时追加租户后缀,代价是全局表缓存(如 oauth_client)命中率下降,因此用ignoreCaches豁免确实需要全局共享的缓存。 - 为什么用 TTL 而非上下文传递参数? 无论如何,租户 id 要抵达深处的 Mapper;用线程上下文省去所有中间层签名改造,且 TTL 兼容异步。这是”flyweight 模式的多租户变体“。
8. 学习检查点
📝 本章小结
- Redis 层用
TimeoutRedisCacheManager支持cacheName#ttl的动态过期约定,命名空间用单冒号前缀。 TenantContextHolder用TransmittableThreadLocal存租户 id,支持异步穿透、请求结束清理。TenantLineHandler.getTenantId()让 MyBatis-Plus 自动给 SQL 追加tenant_id = ?,实现透明隔离。ignoreTables / ignoreUrls / ignoreCaches三套豁免机制,覆盖全局表/免登接口/全局缓存。
🤔 思考题
异步并发(
@Async)下,若不用 TTL 而用普通ThreadLocal,会发生什么?为什么平台选了 TTL?参考答案
普通
ThreadLocal只在创建线程的当前线程内可见;当@Async或线程池把任务丢到另一个线程执行时,子线程读不到父线程的租户 id,于是本次异步操作丢失租户上下文:查询可能拿到别的租户数据,或因为getRequiredTenantId()抛 NPE。平台用TransmittableThreadLocal:在向线程池提交任务时,TTL 会把父线程的上下文透传给子线程,从而保证异步场景下租户一致。为什么
getTenantId(从 Header 解析)和getRequiredTenantId(从上下文取)语义不同?前者返回 null 而后者抛异常意味着什么?(提示:Web Filter vs 深层 Mapper)参考答案
WebFrameworkUtils.getTenantId(request)从请求头解析租户,请求可能确实没带租户头(如某些公开接口),所以返回 null 是合法、宽松的;而TenantContextHolder.getRequiredTenantId()在数据库查询那一刻被调用,此刻若既没设置租户、又不是忽略场景,就说明上层逻辑有 bug(该带却没带租户),因此直接抛 NPE 来快速暴露问题。一个”允许没有”,一个”必须有”,正是两处调用时机不同的写照。如果你新增一张业务表,却发现它的查询没被租户过滤,最可能的原因是什么?(提示:
ignoreTables/TenantBaseDO)参考答案
两个高概率原因:①该表名被误加进了
yudao.tenant.ignore-tables;②该表的 DO **没有继承TenantBaseDO**(多租户实体都继承它拿到tenantId字段),导致插件无法为其构造租户列条件。检查这两处即可定位。