全部学科
NodeJS全栈
nodejs
Python全栈
python
小程序首页
📅 2026-05-20 8 分钟 ✍️ juanwangdev

多租户架构设计

多租户架构的核心是在同一套系统中为多个租户提供服务,同时确保租户间数据的隔离性与安全性。本文梳理三种主流架构模式及基于 MyBatis 拦截器的自动租户识别方案。

多租户架构三种模式

模式隔离级别成本运维复杂度适用场景
独立数据库(DB per Tenant)物理隔离金融、医疗等强合规场景
独立 Schema(Schema per Tenant)逻辑隔离中型 SaaS、企业级应用
共享数据库(Shared DB + tenant_id)行级隔离轻量级 SaaS、海量租户

架构对比图

Java
模式一:独立数据库
┌──────────┐     ┌──────────┐     ┌──────────┐
│ Tenant A │     │ Tenant B │     │ Tenant C │
│   DB_A   │     │   DB_B   │     │   DB_C   │
└────┬─────┘     └────┬─────┘     └────┬─────┘
     │                │                │
     └────────────────┼────────────────┘
                      │
              ┌───────┴───────┐
              │  MyBatis App  │
              │ (动态数据源)   │
              └───────────────┘

模式二:独立 Schema
┌──────────────────────────────────┐
│         单个 PostgreSQL           │
│  ┌─────────┬─────────┬─────────┐ │
│  │schema_A │schema_B │schema_C │ │
│  │ table1  │ table1  │ table1  │ │
│  │ table2  │ table2  │ table2  │ │
│  └─────────┴─────────┴─────────┘ │
│              │                   │
│      ┌───────┴───────┐           │
│      │  MyBatis App  │           │
│      │ (切换schema)  │           │
│      └───────────────┘           │
└──────────────────────────────────┘

模式三:共享数据库
┌──────────────────────────────────┐
│         单个数据库                │
│  ┌────────────────────────────┐  │
│  │       共享 Table            │  │
│  │  tenant_id │ col1 │ col2   │  │
│  │     A      │ ...  │ ...    │  │
│  │     B      │ ...  │ ...    │  │
│  │     C      │ ...  │ ...    │  │
│  └────────────────────────────┘  │
│              │                   │
│      ┌───────┴───────┐           │
│      │  MyBatis App  │           │
│      │ (拦截器过滤)   │           │
│      └───────────────┘           │
└──────────────────────────────────┘

模式一:独立数据库实现

动态数据源路由

Java
public class TenantContextHolder {
    private static final ThreadLocal<String> TENANT_KEY = new ThreadLocal<>();

    public static void setTenant(String tenantId) {
        TENANT_KEY.set(tenantId);
    }

    public static String getTenant() {
        return TENANT_KEY.get();
    }

    public static void clear() {
        TENANT_KEY.remove();
    }
}
YAML
public class TenantRoutingDataSource extends AbstractRoutingDataSource {

    private final Map<String, DataSource> dataSourceMap;

    public TenantRoutingDataSource(Map<String, DataSource> dataSourceMap) {
        this.dataSourceMap = dataSourceMap;
        setTargetDataSources(new HashMap<>(dataSourceMap));
        setDefaultTargetDataSource(dataSourceMap.get("default"));
        afterPropertiesSet();
    }

    @Override
    protected Object determineCurrentLookupKey() {
        String tenantId = TenantContextHolder.getTenant();
        if (tenantId == null || !dataSourceMap.containsKey(tenantId)) {
            return "default";
        }
        return tenantId;
    }

    /** 运行时动态注册新租户数据源 */
    public synchronized void registerTenant(String tenantId, DataSource ds) {
        dataSourceMap.put(tenantId, ds);
        setTargetDataSources(new HashMap<>(dataSourceMap));
        afterPropertiesSet();
    }
}

Spring Boot 配置

Java
# application.yml
tenant:
  datasources:
    tenant_a:
      url: jdbc:mysql://host1:3306/tenant_a_db
      username: root
      password: xxx
    tenant_b:
      url: jdbc:mysql://host2:3306/tenant_b_db
      username: root
      password: xxx
    default:
      url: jdbc:mysql://localhost:3306/default_db
      username: root
      password: xxx
Java
@Configuration
public class TenantDataSourceConfig {

    @Bean
    @ConfigurationProperties(prefix = "tenant.datasources")
    public Map<String, DataSourceProperties> dataSourcePropertiesMap() {
        return new HashMap<>();
    }

    @Bean
    public TenantRoutingDataSource tenantRoutingDataSource(
            Map<String, DataSourceProperties> propsMap) {
        Map<String, DataSource> dsMap = new HashMap<>();
        propsMap.forEach((key, props) -> {
            HikariDataSource ds = new HikariDataSource();
            ds.setJdbcUrl(props.getUrl());
            ds.setUsername(props.getUsername());
            ds.setPassword(props.getPassword());
            dsMap.put(key, ds);
        });
        return new TenantRoutingDataSource(dsMap);
    }

    @Bean
    public SqlSessionFactory sqlSessionFactory(TenantRoutingDataSource ds) throws Exception {
        SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
        factory.setDataSource(ds);
        return factory.getObject();
    }
}

模式三:共享数据库 + MyBatis 拦截器

租户拦截器核心实现

Java
@Intercepts({
    @Signature(type = Executor.class, method = "query",
        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
    @Signature(type = Executor.class, method = "update",
        args = {MappedStatement.class, Object.class})
})
public class TenantInterceptor implements Interceptor {

    private static final String TENANT_COLUMN = "tenant_id";

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement) args[0];
        Object parameter = args[1];

        // 获取当前租户ID
        String tenantId = TenantContextHolder.getTenant();
        if (tenantId == null) {
            return invocation.proceed();
        }

        // 获取原始 BoundSql
        BoundSql boundSql = ms.getBoundSql(parameter);
        String originalSql = boundSql.getSql();

        // 判断是否为 SELECT/UPDATE/DELETE 且未包含 tenant_id
        if (!originalSql.contains(TENANT_COLUMN) && isTenantAwareStatement(ms)) {
            String newSql = appendTenantCondition(originalSql, tenantId);

            // 创建新的 MappedStatement 和 BoundSql
            MappedStatement newMs = copyMappedStatement(ms, newSql);
            args[0] = newMs;
        }

        return invocation.proceed();
    }

    private boolean isTenantAwareStatement(MappedStatement ms) {
        // 通过自定义注解或表名判断是否需要租户过滤
        String id = ms.getId();
        // 排除系统表、租户管理表等
        return !id.contains("SysTenant") && !id.contains("TenantConfig");
    }

    private String appendTenantCondition(String sql, String tenantId) {
        String lowerSql = sql.toLowerCase().trim();
        if (lowerSql.startsWith("select")) {
            return sql.replaceAll("(?i)WHERE", "WHERE tenant_id = '" + tenantId + "' AND ")
                      .replaceAll("(?i)FROM", "FROM")
                      .replaceFirst("(?i)(FROM\\s+\\w+)", "$0 WHERE tenant_id = '" + tenantId + "'");
        } else if (lowerSql.startsWith("update")) {
            return sql.replaceAll("(?i)SET", "SET").replaceFirst(
                "(?i)(UPDATE\\s+\\w+)", "$0 WHERE tenant_id = '" + tenantId + "' AND ");
        } else if (lowerSql.startsWith("delete")) {
            return sql.replaceFirst("(?i)(DELETE\\s+FROM\\s+\\w+)", "$0 WHERE tenant_id = '" + tenantId + "' AND ");
        }
        return sql;
    }

    private MappedStatement copyMappedStatement(MappedStatement original, String newSql) {
        MappedStatement.Builder builder = new MappedStatement.Builder(
            original.getConfiguration(),
            original.getId(),
            new BoundSqlSqlSource(original.getConfiguration(), newSql, original.getBoundSql(null)),
            original.getSqlCommandType()
        );
        builder.resource(original.getResource());
        builder.fetchSize(original.getFetchSize());
        builder.statementType(original.getStatementType());
        builder.parameterMap(original.getParameterMap());
        builder.resultMaps(original.getResultMaps());
        builder.cache(original.getCache());
        builder.useCache(original.isUseCache());
        return builder.build();
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties props) {
        // 可配置忽略的表名、租户字段名等
    }

    // 内部 SqlSource 实现
    private static class BoundSqlSqlSource implements SqlSource {
        private final BoundSql boundSql;
        BoundSqlSqlSource(Configuration cfg, String sql, BoundSql original) {
            this.boundSql = new BoundSql(cfg, sql, original.getParameterMappings(), original.getParameterObject());
        }
        @Override
        public BoundSql getBoundSql(Object parameterObject) {
            return boundSql;
        }
    }
}

租户拦截器注册

Java
@Configuration
public class MyBatisConfig {

    @Bean
    public ConfigurationCustomizer configurationCustomizer() {
        return configuration -> {
            configuration.addInterceptor(new TenantInterceptor());
        };
    }
}

租户识别与上下文传递

Filter 层识别

Java
@Component
public class TenantFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        String tenantId = req.getHeader("X-Tenant-ID");
        if (tenantId != null) {
            TenantContextHolder.setTenant(tenantId);
        }
        try {
            chain.doFilter(request, response);
        } finally {
            TenantContextHolder.clear();
        }
    }
}

Feign/Dubbo 跨服务传递

Java
public class TenantFeignInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        String tenantId = TenantContextHolder.getTenant();
        if (tenantId != null) {
            template.header("X-Tenant-ID", tenantId);
        }
    }
}

混合模式策略

生产环境中常采用混合模式:大租户独立数据库,小租户共享。

text
public class HybridTenantRouter {

    private final Map<String, DataSource> dedicatedDsMap;  // 大租户独立DB
    private final DataSource sharedDs;                      // 小租户共享DB
    private final Set<String> dedicatedTenants;             // 大租户ID集合

    public DataSource route(String tenantId) {
        if (dedicatedTenants.contains(tenantId)) {
            return dedicatedDsMap.get(tenantId);
        }
        return sharedDs;
    }
}
text
混合模式架构
┌──────────────────────────────────────────────┐
│              租户路由器                        │
│     tenantId in [A, B, C]? → 独立DB          │
│     其他? → 共享DB (tenant_id 过滤)           │
└──────────┬─────────────────┬─────────────────┘
           │                 │
    ┌──────┴──────┐   ┌──────┴──────┐
    │  独立DB集群  │   │  共享数据库  │
    │  A/B/C各一个 │   │  行级隔离    │
    └─────────────┘   └─────────────┘

注意事项

  1. INSERT 不自动加租户条件:INSERT 需手动在业务层设置 tenant_id,不应由拦截器自动注入,避免掩盖业务逻辑遗漏
  2. 跨租户查询豁免:统计分析、后台管理等场景需绕过租户过滤,通过自定义注解 @IgnoreTenant 标识
  3. 线程池场景清理:异步任务中使用 ThreadLocal 存储租户ID时,必须在 finally 中清理,防止线程复用导致数据泄漏
  4. DDL 操作排除:拦截器仅作用于 DML,表结构变更不应携带租户条件
  5. SQL 解析精度:简单字符串替换无法处理子查询、JOIN 等复杂场景,生产环境建议使用 JSqlParser 进行 AST 级 SQL 改写

要点总结

  • 多租户三种模式:独立数据库(物理隔离)、独立 Schema(逻辑隔离)、共享数据库(行级隔离),隔离级别与成本依次递减
  • 独立数据库模式通过 AbstractRoutingDataSource + ThreadLocal 实现运行时数据源切换
  • 共享数据库模式通过 MyBatis Interceptor 拦截 Executor,在 SQL 中自动注入 tenant_id 条件
  • 租户识别通过 HTTP Header / RPC Header 传递,使用 ThreadLocal 存储并在请求结束后清理
  • 生产环境常采用混合模式:大租户独立数据库,小租户共享数据库 + 行级过滤
  • 复杂 SQL 改写应使用 JSqlParser 进行 AST 级解析,而非简单字符串替换

存放路径:D:\git2\jwdev\articles\MYBATIS\专家\多租户与数据权限\多租户架构设计.md

📝 发现内容有误?点击此处直接编辑

← 上一篇 动态数据源切换
下一篇 → 数据权限拦截器
想查看更多题目和详细解析?
小程序提供完整的题库、模拟考试和详细解析
马上就来

长按或扫描二维码,立即体验

扫码体验小程序
马上就来
使用微信扫描二维码
立即体验完整题库