Skip to content

多租户路由流程(SaaS灵魂)

基于模块: db-spring-boot-starter + common-spring-boot-starter + security-spring-boot-starter 多租户框架: MyBatis-Plus TenantLineInnerInterceptor


核心组件

组件文件作用
HttpInterceptorHttpInterceptor.javaHTTP 请求拦截器
AuthenticationContextConfigurationAuthenticationContextConfiguration.java认证上下文配置
UserInfoDetailsUserInfoDetails.java用户信息实体
ThreadLocalHolderThreadLocalHolder.java线程上下文持有器
BaseMybatisConfigurationBaseMybatisConfiguration.javaMyBatis Plus 配置
DynamicDataSourceWebMvcAutoConfigurationDynamicDataSourceWebMvcAutoConfiguration.java动态数据源拦截器
DatabasePropertiesDatabaseProperties.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 请求进入

请求头示例:

http
POST /api/user/list
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Tenant-Code: 0000
Content-Type: application/json

第二步:HTTP 拦截器处理

代码位置: HttpInterceptor.java:42-54

java
@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

java
registry.addInterceptor(new SaInterceptor(handler -> {
    SaRouter.match("/**")
        .notMatch(extProperties.getDefaultIgnoreUrls())
        .check(r -> {
            // 校验 Token 是否有效
            StpUtil.checkLogin();
        });
}));

校验通过后:

  • Sa-Token 将用户信息存入 TokenSession
  • 后续可通过 StpUtil.getTokenSession() 获取

第四步:认证上下文 - 获取用户信息

代码位置: AuthenticationContextConfiguration.java:57-177

java
@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 结构:

java
@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

java
@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:

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

java
@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:

sql
SELECT * FROM sys_user WHERE username = 'admin'

TenantLineInnerInterceptor 处理后:

sql
SELECT * FROM sys_user 
WHERE username = 'admin' 
  AND tenant_id = 1001  -- 自动追加

SQL 解析与重写流程:

  1. MyBatis Plus 解析 SQL 为 JSqlParser AST
  2. 遍历 AST,找到 WHERE 子句
  3. 调用 getTenantId() 获取租户ID
  4. 调用 ignoreTable() 检查表是否需要追加租户条件
  5. 如果需要,在 WHERE 中追加 AND tenant_id = ?
  6. 设置参数值

多租户模式对比

模式说明优点缺点适用场景
NONE非租户模式无额外开销不支持多租户单租户系统
COLUMN字段模式实现简单,成本低数据隔离弱数据量小(<1000w)
DATASOURCE独立数据源数据隔离强,安全性高运维成本高数据量大,租户多
SCHEMASchema 模式折中方案数据库限制PostgreSQL 等

配置示例

application.yml:

yaml
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) 自动传递。

java
// 需要引入 alibaba transmittable-thread-local
ThreadLocalHolder.set("USER_INFO_KEY", userInfo);

// 异步线程会自动继承
CompletableFuture.runAsync(() -> {
    UserInfoDetails info = (UserInfoDetails) 
        ThreadLocalHolder.get("USER_INFO_KEY");  // 可获取
});

Q3: 如何让某些表不进行多租户过滤?

: 不在 include-tables 中配置即可。

yaml
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

不清理的问题:

java
// 第一次请求 - 租户 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:

java
@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(推荐单个接口)

在业务代码中包裹需要忽略租户的操作:

java
@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

yaml
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

java
dictMapper.selectList(null);
// SQL: SELECT * FROM sys_dict (没有 tenant_id 条件) ✅

对比

方式优点缺点适用场景
TenantHelper灵活,针对单个方法每次都要写个别接口需要查询所有数据
排除表一次性配置整个表都不受租户控制系统表、字典表等

相关文档