多租户路由流程(SaaS灵魂)
基于模块:
db-spring-boot-starter+common-spring-boot-starter+security-spring-boot-starter多租户框架: MyBatis-Plus TenantLineInnerInterceptor
核心组件
| 组件 | 文件 | 作用 |
|---|---|---|
HttpInterceptor | HttpInterceptor.java | HTTP 请求拦截器 |
AuthenticationContextConfiguration | AuthenticationContextConfiguration.java | 认证上下文配置 |
UserInfoDetails | UserInfoDetails.java | 用户信息实体 |
ThreadLocalHolder | ThreadLocalHolder.java | 线程上下文持有器 |
BaseMybatisConfiguration | BaseMybatisConfiguration.java | MyBatis Plus 配置 |
DynamicDataSourceWebMvcAutoConfiguration | DynamicDataSourceWebMvcAutoConfiguration.java | 动态数据源拦截器 |
DatabaseProperties | DatabaseProperties.java | 数据库配置属性 |
完整流程时序图
┌─────────┐ ┌──────────────┐ ┌────────────────────────┐ ┌──────────────┐
│ 前端 │ │ 拦截器链 │ │ AuthenticationContext │ │ MyBatis Plus │
└────┬────┘ └──────┬───────┘ └───────────┬────────────┘ └──────┬───────┘
│ │ │ │
│ 1. HTTP请求 │ │ │
│ Header: │ │ │
│ Authorization: Token │ │
│ (可选)Tenant-Code: 0000 │ │
│───────────────>│ │ │
│ │ │ │
│ │ 2. HttpInterceptor │ │
│ │ preHandle() │ │
│ │ - 清理 ThreadLocal │ │
│ │ - 设置 Locale │ │
│ │ │ │
│ │ 3. SaInterceptor │ │
│ │ 校验 Token │ │
│ │ - StpUtil.checkLogin() │ │
│ │ │ │
│ │ 4. DynamicDataSourceInterceptor (可选) │
│ │ - 获取 Tenant-Code │ │
│ │ - 切换数据源 │ │
│ │ │ │
│ │ 5. Controller 方法 │ │
│ │ 执行业务逻辑 │ │
│ │ ↓ │ │
│ │ 6. 调用 context.tenantId() │
│ │───────────────────────>│ │
│ │ │ 7. getContext() │
│ │ │ - 从 ThreadLocalHolder 获取
│ │ │ - 或从 SaToken 获取 │
│ │ │ - 返回 UserInfoDetails │
│ │ │ - 提取 tenantId │
│ │<───────────────────────│ │
│ │ │ │
│ │ 8. MyBatis 查询 │ │
│ │ userMapper.selectList()│ │
│ │───────────────────────────────────────────────>│
│ │ │ │ 9. TenantLineInnerInterceptor
│ │ │ │ - getTenantId()
│ │ │ │ - 调用 context.tenantId()
│ │ │ 10. 返回 tenantId ────────│──────────────────>│
│ │ │ │ 11. ignoreTable()
│ │ │ │ - 检查表是否在 includeTables
│ │ │ │ 12. 追加 SQL
│ │ │ │ WHERE ... AND tenant_id = 1001
│ │<───────────────────────────────────────────────│
│ │ │ │
│ 13. 返回数据 │ │ │
│<───────────────│ │ │
│ │ │ │
│ 14. 请求结束 │ │ │
│ │ 15. afterCompletion() │ │
│ │ - ThreadLocalHolder.clear() │
│ │ - DynamicDataSourceContextHolder.clear() │
│ │ │ │流程详解
第一步:HTTP 请求进入
请求头示例:
POST /api/user/list
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Tenant-Code: 0000
Content-Type: application/json第二步:HTTP 拦截器处理
代码位置: HttpInterceptor.java:42-54
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 1. 清理上次请求的 ThreadLocal(避免内存泄漏)
ThreadLocalHolder.clear();
// 2. 从请求头中获取地区信息并存入 ThreadLocal
Locale locale = request.getLocale();
ThreadLocalHolder.setLocale(locale);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object object, Exception exception) {
// 3. 请求结束后清理 ThreadLocal
ThreadLocalHolder.clear();
}第三步:Sa-Token 拦截器校验
代码位置: OAuth2AutoConfiguration.java:48-70
registry.addInterceptor(new SaInterceptor(handler -> {
SaRouter.match("/**")
.notMatch(extProperties.getDefaultIgnoreUrls())
.check(r -> {
// 校验 Token 是否有效
StpUtil.checkLogin();
});
}));校验通过后:
- Sa-Token 将用户信息存入
TokenSession - 后续可通过
StpUtil.getTokenSession()获取
第四步:认证上下文 - 获取用户信息
代码位置: AuthenticationContextConfiguration.java:57-177
@Bean
public AuthenticationContext authenticationContext(SecurityExtProperties properties) {
return new AuthenticationContext() {
@Override
public UserInfoDetails getContext() {
// 1. 先尝试从 ThreadLocalHolder 获取(异步线程场景)
Object cached = ThreadLocalHolder.get("USER_INFO_KEY");
if (cached != null) {
return (UserInfoDetails) cached;
}
// 2. 主线程场景:从 SaToken 获取并缓存到 ThreadLocalHolder
try {
if (!StpUtil.isLogin()) {
return null;
}
// 从 TokenSession 中获取用户信息
var tokenInfo = StpUtil.getTokenSession()
.get(properties.getServer().getTokenInfoKey());
UserInfoDetails userInfo = (UserInfoDetails) tokenInfo;
// 缓存到 ThreadLocalHolder,支持异步线程通过 TTL 获取
ThreadLocalHolder.set("USER_INFO_KEY", userInfo);
return userInfo;
} catch (Exception e) {
return null;
}
}
@Override
public Long tenantId() {
// 从 UserInfoDetails 中提取租户ID
return Optional.ofNullable(getContext())
.map(UserInfoDetails::getTenantId)
.orElse(null);
}
@Override
public String tenantCode() {
// 从 UserInfoDetails 中提取租户编码
return Optional.ofNullable(getContext())
.map(UserInfoDetails::getTenantCode)
.orElse(null);
}
// ... 其他方法
};
}UserInfoDetails 结构:
@Data
public class UserInfoDetails {
private Long userId; // 用户ID
private String username; // 用户名
private Long tenantId; // 租户ID ⭐
private String tenantCode; // 租户编码 ⭐
private String tenantName; // 租户名称
private String nickname; // 昵称
private String mobile; // 手机号
private UserType type; // 用户类型
private Collection<String> funcPermissions; // 功能权限
private Collection<String> roles; // 角色列表
private DataPermission dataPermission; // 数据权限
}第五步:动态数据源切换(可选)
适用场景: DATASOURCE 模式 - 每个租户独立数据库
代码位置: DynamicDataSourceWebMvcAutoConfiguration.java:52-97
@Override
public void preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
var multiTenant = properties.getMultiTenant();
if (context.anonymous()) {
// 匿名用户使用默认数据源
return true;
}
// 1. 从 AuthenticationContext 获取租户编码
var tenantCode = StrUtil.blankToDefault(
context.tenantCode(),
request.getHeader(multiTenant.getTenantCodeColumn())
);
// 2. 尝试从 header 获取
if (StringUtils.isBlank(tenantCode)) {
tenantCode = request.getHeader(multiTenant.getTenantCodeColumn());
}
// 3. 尝试从 params 获取
if (StringUtils.isBlank(tenantCode)) {
tenantCode = request.getParameter(multiTenant.getTenantCodeColumn());
}
// 4. 构建数据源名称
String prefix = multiTenant.getDsPrefix(); // "wemirr_tenant_"
String dsKey;
if (multiTenant.isSuperTenant(tenantCode)) {
// 超级租户使用主库
dsKey = multiTenant.getDefaultDsName(); // "master"
} else {
// 普通租户使用租户库
dsKey = prefix + tenantCode; // "wemirr_tenant_8888"
}
// 5. 切换数据源
DynamicDataSourceContextHolder.push(dsKey);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
// 清理数据源上下文
DynamicDataSourceContextHolder.clear();
}配置属性 DatabaseProperties.java:
@Data
public static class MultiTenant {
// 多租户模式: COLUMN | DATASOURCE | SCHEMA | NONE
private MultiTenantType type = MultiTenantType.COLUMN;
// 包含租户字段的表
private List<String> includeTables = Lists.newArrayList();
// 默认数据源名称
private String defaultDsName = "master";
// 租户 ID 列名(数据库字段)
private String tenantIdColumn = "tenant_id";
// 租户编码列名(前端传递的 Header 名称)
private String tenantCodeColumn = "Tenant-Code";
// 超级租户编码
private String superTenantCode = "0000";
// 租户数据库前缀
private String dsPrefix = "wemirr_tenant_";
}第六步:MyBatis Plus 多租户插件
代码位置: BaseMybatisConfiguration.java:74-105
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
final DatabaseProperties.MultiTenant multiTenant = properties.getMultiTenant();
// 只有多租户模式不为 NONE 时才启用
if (MultiTenantType.NONE != multiTenant.getType()) {
// 新增多租户拦截器
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
// ⭐ 关键:从 AuthenticationContext 获取当前租户ID
log.debug("当前租户ID - {}", context.tenantId());
return context.tenantId() == null ? null : new LongValue(context.tenantId());
}
@Override
public boolean ignoreTable(String tableName) {
// 判断哪些表不需要进行多租户判断
final List<String> tables = multiTenant.getIncludeTables();
// 返回 true 表示忽略该表(不追加租户条件)
return context.anonymous() || !tables.contains(tableName);
}
@Override
public String getTenantIdColumn() {
// 返回租户ID的列名
return multiTenant.getTenantIdColumn(); // "tenant_id"
}
}));
}
// 加载其它插件...
return interceptor;
}第七步:SQL 自动追加
原始 SQL:
SELECT * FROM sys_user WHERE username = 'admin'TenantLineInnerInterceptor 处理后:
SELECT * FROM sys_user
WHERE username = 'admin'
AND tenant_id = 1001 -- 自动追加SQL 解析与重写流程:
- MyBatis Plus 解析 SQL 为 JSqlParser AST
- 遍历 AST,找到 WHERE 子句
- 调用
getTenantId()获取租户ID - 调用
ignoreTable()检查表是否需要追加租户条件 - 如果需要,在 WHERE 中追加
AND tenant_id = ? - 设置参数值
多租户模式对比
| 模式 | 说明 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| NONE | 非租户模式 | 无额外开销 | 不支持多租户 | 单租户系统 |
| COLUMN | 字段模式 | 实现简单,成本低 | 数据隔离弱 | 数据量小(<1000w) |
| DATASOURCE | 独立数据源 | 数据隔离强,安全性高 | 运维成本高 | 数据量大,租户多 |
| SCHEMA | Schema 模式 | 折中方案 | 数据库限制 | PostgreSQL 等 |
配置示例
application.yml:
extend:
mybatis-plus:
multi-tenant:
# 多租户模式
type: COLUMN # NONE | COLUMN | DATASOURCE | SCHEMA
# 需要进行多租户控制的表
include-tables:
- sys_user
- sys_role
- sys_menu
- biz_order
- biz_product
# 租户ID列名(数据库字段)
tenant-id-column: tenant_id
# 租户编码列名(前端传递的Header名称)
tenant-code-column: Tenant-Code
# 超级租户编码(不进行数据隔离)
super-tenant-code: "0000"
# 默认数据源名称
default-ds-name: master
# 租户数据库前缀(DATASOURCE模式使用)
ds-prefix: wemirr_tenant_ThreadLocal 传递机制
主线程:
HTTP 请求 → HttpInterceptor → AuthenticationContext
↓
context.tenantId()
↓
ThreadLocalHolder.set("USER_INFO_KEY", userInfo)
↓
UserInfoDetails (tenantId=1001)异步线程:
@Async
CompletableFuture.runAsync(() -> {
// 通过 TTL 自动传递 ThreadLocal
Long tenantId = context.tenantId(); // 仍能获取到
});TTL (TransmittableThreadLocal):
- 基于 Alibaba TTL 实现
- 支持线程池场景下的上下文传递
- 需要配合 Java Agent 或 TtlRunnable 使用
数据流转总结
┌─────────────────────────────────────────────────────────────────┐
│ 数据流转链路 │
└─────────────────────────────────────────────────────────────────┘
1. HTTP 请求头
└─> Authorization: Token (用户凭证)
└─> Tenant-Code: 0000 (可选,用于数据源切换)
2. 拦截器处理
└─> HttpInterceptor: 清理/设置 ThreadLocal
└─> SaInterceptor: 校验 Token
└─> DynamicDataSourceInterceptor: 切换数据源(可选)
3. TokenSession → UserInfoDetails
└─> StpUtil.getTokenSession().get("tokenInfo")
└─> UserInfoDetails { tenantId: 1001, tenantCode: "0000", ... }
4. ThreadLocalHolder 缓存
└─> ThreadLocalHolder.set("USER_INFO_KEY", userInfo)
└─> 支持异步线程通过 TTL 传递
5. AuthenticationContext
└─> context.tenantId() → 1001
└─> 从 ThreadLocalHolder 或 SaToken 获取
6. MyBatis Plus 拦截器
└─> TenantLineInnerInterceptor
└─> getTenantId() → context.tenantId() → 1001
7. SQL 自动追加
└─> WHERE ... AND tenant_id = 1001常见问题
Q1: 为什么 ThreadLocal 需要清理?
答: 避免内存泄漏。
- Tomcat 使用线程池,线程会复用
- 如果不清理,下次请求可能获取到上次的租户信息
afterCompletion中必须调用ThreadLocalHolder.clear()
Q2: 异步线程如何获取租户信息?
答: 通过 TTL (TransmittableThreadLocal) 自动传递。
// 需要引入 alibaba transmittable-thread-local
ThreadLocalHolder.set("USER_INFO_KEY", userInfo);
// 异步线程会自动继承
CompletableFuture.runAsync(() -> {
UserInfoDetails info = (UserInfoDetails)
ThreadLocalHolder.get("USER_INFO_KEY"); // 可获取
});Q3: 如何让某些表不进行多租户过滤?
答: 不在 include-tables 中配置即可。
include-tables:
- sys_user # 会追加 tenant_id
# sys_dict # 不会追加(系统字典表)Q4: COLUMN 模式和 DATASOURCE 模式有什么区别?
| 对比项 | COLUMN 模式 | DATASOURCE 模式 |
|---|---|---|
| 数据隔离 | 同库,通过 tenant_id 字段 | 不同数据库 |
| 成本 | 低(单一数据库) | 高(多个数据库) |
| 性能 | 较好(可能数据量大) | 较好(数据分散) |
| 运维 | 简单 | 复杂 |
| 扩展性 | 受限 | 强 |
Q5: 为什么 ThreadLocal 需要在请求开始时也清理?
答: 避免线程复用导致的数据混乱。
Tomcat 使用线程池处理请求,线程是复用的:
请求1 → 线程A (tenantId = 1001) → 请求结束 → 线程A回到池中
↓
请求2 → 线程A (tenantId = 1001) ⚠️ 错误!应该是 2002不清理的问题:
// 第一次请求 - 租户 1001
ThreadLocalHolder.setTenantId(1001L);
SELECT * FROM sys_user WHERE tenant_id = 1001 ✅
// 第二次请求 - 租户 2002(刚好被同一个线程处理)
// 如果没有清理,ThreadLocal 中还保留着上次的值
SELECT * FROM sys_user WHERE tenant_id = 1001 ❌ 错误!应该是 2002正确做法 HttpInterceptor.java:42-49:
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
ThreadLocalHolder.clear(); // ⭐ 请求开始时先清理
ThreadLocalHolder.setLocale(locale);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object object, Exception exception) {
ThreadLocalHolder.clear(); // ⭐ 请求结束后清理
}简单理解: 就像用杯子喝水,喝水前先洗杯子(确保干净),喝完后再洗杯子(留给下一个人用)。
Q6: 某个业务接口不需要租户过滤,应该怎么配置?
答: 有两种方式,根据场景选择。
方式1:代码中使用 TenantHelper(推荐单个接口)
在业务代码中包裹需要忽略租户的操作:
@Service
@RequiredArgsConstructor
public class UserService {
/**
* 查询所有租户的用户(管理员功能)
*/
public List<User> listAllUsers() {
// 使用 TenantHelper 包裹,忽略租户过滤
return TenantHelper.withIgnoreStrategy(() -> {
return userMapper.selectList(null);
// SQL: SELECT * FROM sys_user (没有 tenant_id 条件)
});
}
/**
* 跨租户查询用户数量
*/
public Long countAllUsers() {
return TenantHelper.withIgnoreStrategy(() -> {
return userMapper.selectCount(null);
});
}
}其他 TenantHelper 方法:
| 方法 | 说明 |
|---|---|
withIgnoreStrategy(() -> {...}) | 临时忽略租户拦截器 |
withIgnoreStrategy(strategy, () -> {...}) | 指定忽略策略(租户+数据权限等) |
executeWithMaster(() -> {...}) | 使用主数据源执行 |
executeWithTenantDb("8888", () => {...}) | 使用指定租户数据源执行 |
方式2:将表排除在多租户配置之外(推荐整个表)
如果某个表本身就不需要租户隔离,在配置文件中不加入 include-tables:
extend:
mybatis-plus:
multi-tenant:
type: COLUMN
# 只配置需要多租户控制的表
include-tables:
- sys_user # ✅ 需要 tenant_id 过滤
- sys_role # ✅ 需要 tenant_id 过滤
- biz_order # ✅ 需要 tenant_id 过滤
# 下面这些表不配置,自动忽略
# sys_dict # ❌ 字典表(所有租户共享)
# sys_config # ❌ 配置表(所有租户共享)查询字典表时,SQL 不会追加 tenant_id:
dictMapper.selectList(null);
// SQL: SELECT * FROM sys_dict (没有 tenant_id 条件) ✅对比
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| TenantHelper | 灵活,针对单个方法 | 每次都要写 | 个别接口需要查询所有数据 |
| 排除表 | 一次性配置 | 整个表都不受租户控制 | 系统表、字典表等 |