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

租户隔离策略

租户隔离是多租户架构的基石,决定了租户间数据的隔离强度。本文从物理隔离到行级隔离,逐层分析三种策略的实现细节与选择依据。

三种隔离策略对比

维度独立数据库独立 Schema行级隔离
隔离级别物理级逻辑级行级
数据安全性最高
硬件成本高(每租户独立资源)
运维复杂度
扩展性受限中等极高
备份恢复独立操作Schema 级别需按 tenant_id 筛选
适用租户规模< 100 个大型租户100-1000 中型租户> 1000 小型租户
Java
隔离强度
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  独立DB  ┃█ █ █┃  物理隔离,零交叉风险
  独立SC  ┃████▒┃  逻辑隔离,依赖DB约束
  行级    ┃█████┃  代码隔离,依赖应用层
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
          安全性  →

策略一:独立数据库(DB per Tenant)

每个租户拥有独立的数据库实例,物理层面完全隔离。

架构图

SQL
┌────────────────────────────────────────────────┐
│                   应用层                        │
│              动态数据源路由                      │
│        TenantRoutingDataSource                 │
└─────┬────────────┬──────────────┬──────────────┘
      │            │              │
      ↓            ↓              ↓
  ┌───────┐   ┌───────┐     ┌───────┐
  │ DB_A  │   │ DB_B  │     │ DB_C  │
  │ tenant│   │ tenant│     │ tenant│
  │  table│   │  table│     │  table│
  └───────┘   └───────┘     └───────┘
      │            │              │
  [MySQL-A]    [MySQL-B]      [MySQL-C]

数据源管理

Java
@Component
public class TenantDataSourceRegistry {

    private final Map<String, HikariDataSource> poolMap = new ConcurrentHashMap<>();
    private final TenantRoutingDataSource routingDs;

    public TenantDataSourceRegistry(TenantRoutingDataSource routingDs) {
        this.routingDs = routingDs;
    }

    /** 注册新租户数据源 */
    public synchronized void register(String tenantId, DataSourceConfig config) {
        if (poolMap.containsKey(tenantId)) {
            return;
        }

        HikariConfig hc = new HikariConfig();
        hc.setJdbcUrl(config.getUrl());
        hc.setUsername(config.getUsername());
        hc.setPassword(config.getPassword());
        hc.setMaximumPoolSize(config.getPoolSize());
        hc.setConnectionTimeout(config.getTimeout());

        HikariDataSource ds = new HikariDataSource(hc);
        poolMap.put(tenantId, ds);

        // 注册到路由数据源
        routingDs.registerTenant(tenantId, ds);
    }

    /** 注销租户数据源 */
    public synchronized void deregister(String tenantId) {
        HikariDataSource ds = poolMap.remove(tenantId);
        if (ds != null) {
            ds.close();
        }
        routingDs.removeTenant(tenantId);
    }

    /** 获取租户连接池状态 */
    public PoolStatus getPoolStatus(String tenantId) {
        HikariDataSource ds = poolMap.get(tenantId);
        if (ds == null) return null;
        return new PoolStatus(ds.getHikariPoolMXBean().getActiveConnections(),
                              ds.getHikariPoolMXBean().getIdleConnections());
    }
}

自动建库脚本

Java
-- 新租户初始化脚本模板
CREATE DATABASE IF NOT EXISTS tenant_{id}
    DEFAULT CHARACTER SET utf8mb4
    DEFAULT COLLATE utf8mb4_unicode_ci;

USE tenant_{id};

-- 执行基础表结构
SOURCE /path/to/schema/init.sql;

-- 初始化租户配置数据
INSERT INTO sys_config (tenant_id, config_key, config_value)
VALUES ('{id}', 'initialized', 'true');
SQL
@Service
public class TenantProvisioningService {

    @Autowired
    private JdbcTemplate adminJdbcTemplate;  // 管理员连接

    @Autowired
    private TenantDataSourceRegistry registry;

    @Transactional
    public void provisionTenant(String tenantId, String dbName) {
        // 1. 创建数据库
        adminJdbcTemplate.execute(
            "CREATE DATABASE IF NOT EXISTS `" + dbName + "` " +
            "DEFAULT CHARACTER SET utf8mb4");

        // 2. 执行建表脚本
        Resource resource = new ClassPathResource("schema/init.sql");
        String sql = new String(resource.getInputStream().readAllBytes());
        adminJdbcTemplate.execute("USE `" + dbName + "`; " + sql);

        // 3. 注册数据源
        DataSourceConfig config = new DataSourceConfig();
        config.setUrl("jdbc:mysql://localhost:3306/" + dbName);
        config.setUsername("tenant_user");
        config.setPassword(generatePassword());
        registry.register(tenantId, config);
    }
}

策略二:共享数据库 + 独立 Schema

多个租户共享同一数据库实例,但各自拥有独立的 Schema(命名空间)。

架构图(以 PostgreSQL 为例)

Java
┌──────────────────────────────────────────┐
│           PostgreSQL 实例                  │
│                                          │
│  ┌────────────┐ ┌────────────┐           │
│  │ schema_A   │ │ schema_B   │           │
│  │  users     │ │  users     │           │
│  │  orders    │ │  orders    │           │
│  │  products  │ │  products  │           │
│  └────────────┘ └────────────┘           │
│                                          │
│  ┌────────────┐                          │
│  │ schema_C   │  ...                     │
│  └────────────┘                          │
└──────────────────────────────────────────┘
              │
     ┌────────┴────────┐
     │  search_path切换 │
     │  MyBatis App    │
     └─────────────────┘

Schema 切换实现

Java
public class SchemaRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        String tenantId = TenantContextHolder.getTenant();
        return tenantId;
    }

    @Override
    protected DataSource determineTargetDataSource() {
        // 共享数据源,通过 search_path 切换
        DataSource ds = super.determineTargetDataSource();
        String schema = determineCurrentLookupKey().toString();

        try (Connection conn = ds.getConnection()) {
            conn.createStatement().execute("SET search_path TO " + schema);
        } catch (SQLException e) {
            throw new RuntimeException("Failed to set schema: " + schema, e);
        }

        return ds;
    }
}

MySQL 不支持 Schema 级别的快速切换,此策略更适合 PostgreSQL、Oracle 等支持 Schema 的数据库。MySQL 中可通过数据库名模拟,但本质退化为"独立数据库"模式。

策略三:共享数据库 + 共享 Schema(行级隔离)

所有租户共享同一数据库和 Schema,通过 tenant_id 字段区分数据归属。

表结构设计

Java
-- 所有业务表统一包含 tenant_id
CREATE TABLE orders (
    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id   VARCHAR(64)    NOT NULL COMMENT '租户标识',
    order_no    VARCHAR(32)    NOT NULL,
    user_id     BIGINT         NOT NULL,
    amount      DECIMAL(10,2)  NOT NULL,
    status      TINYINT        DEFAULT 0,
    created_at  DATETIME       DEFAULT CURRENT_TIMESTAMP,
    updated_at  DATETIME       DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    -- 联合索引:tenant_id + 业务字段
    INDEX idx_tenant_order (tenant_id, order_no),
    INDEX idx_tenant_user  (tenant_id, user_id),
    INDEX idx_tenant_status(tenant_id, status),
    INDEX idx_tenant_created(tenant_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Java
行级隔离数据分布
┌─────────────────────────────────────────┐
│  orders 表                               │
│  id │ tenant_id │ order_no │ amount     │
├─────┼───────────┼──────────┼────────────┤
│  1  │ tenant_A  │ A001     │ 100.00     │
│  2  │ tenant_B  │ B001     │ 200.00     │
│  3  │ tenant_A  │ A002     │ 150.00     │
│  4  │ tenant_C  │ C001     │ 300.00     │
│  5  │ tenant_B  │ B002     │ 250.00     │
└─────────────────────────────────────────┘
      数据混合存储,靠 tenant_id 逻辑隔离

MyBatis 拦截器自动注入 tenant_id

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 TenantIsolationInterceptor implements Interceptor {

    private static final String TENANT_COLUMN = "tenant_id";
    private Set<String> ignoreTables = new HashSet<>();  // 不需要租户过滤的表

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

        String tenantId = TenantContextHolder.getTenant();
        if (tenantId == null) {
            return invocation.proceed();
        }

        BoundSql boundSql = ms.getBoundSql(parameter);
        String sql = boundSql.getSql().trim().toLowerCase();

        // 判断是否需要租户过滤
        if (!needsTenantFilter(sql, boundSql)) {
            return invocation.proceed();
        }

        String newSql = injectTenantCondition(boundSql.getSql(), tenantId);
        MappedStatement newMs = rebuildMappedStatement(ms, newSql, boundSql);
        args[0] = newMs;

        return invocation.proceed();
    }

    private boolean needsTenantFilter(String sql, BoundSql boundSql) {
        // 排除系统表、租户管理表
        String table = extractTableName(sql);
        return table != null && !ignoreTables.contains(table);
    }

    private String injectTenantCondition(String originalSql, String tenantId) {
        // 使用 JSqlParser 进行 AST 级改写
        try {
            Statement stmt = CCJSqlParserUtil.parse(originalSql);
            if (stmt instanceof Select) {
                Select select = (Select) stmt;
                PlainSelect plain = (PlainSelect) select.getSelectBody();
                Expression tenantExpr = new EqualsTo(
                    new Column(TENANT_COLUMN),
                    new StringValue(tenantId)
                );

                Expression where = plain.getWhere();
                plain.setWhere(where != null
                    ? new AndExpression(where, tenantExpr)
                    : tenantExpr
                );
                return select.toString();
            }
        } catch (JSQLParserException e) {
            // 降级处理
        }
        return originalSql;
    }

    private MappedStatement rebuildMappedStatement(
            MappedStatement original, String newSql, BoundSql oldBoundSql) {
        BoundSql newBoundSql = new BoundSql(
            original.getConfiguration(), newSql,
            oldBoundSql.getParameterMappings(), oldBoundSql.getParameterObject()
        );

        // 复制原始 MappedStatement 的所有属性
        MappedStatement.Builder builder = new MappedStatement.Builder(
            original.getConfiguration(), original.getId(),
            new BoundSqlSource(newBoundSql), original.getSqlCommandType()
        );
        builder.resource(original.getResource());
        builder.fetchSize(original.getFetchSize());
        builder.statementType(original.getStatementType());
        builder.parameterMap(original.getParameterMap());
        builder.resultMaps(original.getResultMaps());
        builder.resultSetType(original.getResultSetType());
        builder.cache(original.getCache());
        builder.useCache(original.isUseCache());
        builder.flushCacheRequired(original.isFlushCacheRequired());
        builder.timeout(original.getTimeout());
        return builder.build();
    }

    private String extractTableName(String sql) {
        // 简化实现:从 SQL 中提取表名
        // 生产环境应使用 JSqlParser 获取准确的表名
        return null;
    }

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

    @Override
    public void setProperties(Properties props) {
        String ignoreList = props.getProperty("ignoreTables", "");
        if (!ignoreList.isEmpty()) {
            ignoreTables = Set.of(ignoreList.split(","));
        }
    }

    private static class BoundSqlSource implements SqlSource {
        private final BoundSql boundSql;
        BoundSqlSource(BoundSql sql) { this.boundSql = sql; }
        @Override
        public BoundSql getBoundSql(Object p) { return boundSql; }
    }
}

INSERT 时自动填充 tenant_id

text
@Intercepts({
    @Signature(type = Executor.class, method = "update",
        args = {MappedStatement.class, Object.class})
})
public class TenantInsertInterceptor implements Interceptor {

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

        String tenantId = TenantContextHolder.getTenant();
        if (tenantId == null) {
            return invocation.proceed();
        }

        BoundSql boundSql = ms.getBoundSql(parameter);
        String sql = boundSql.getSql().trim().toLowerCase();

        // 仅对 INSERT 处理
        if (!sql.startsWith("insert")) {
            return invocation.proceed();
        }

        // 检查 SQL 中是否已包含 tenant_id
        if (sql.contains("tenant_id")) {
            return invocation.proceed();
        }

        // 在 INSERT 列中追加 tenant_id
        String newSql = injectTenantIntoInsert(sql, tenantId);
        args[0] = rebuildMappedStatement(ms, newSql, boundSql);

        return invocation.proceed();
    }

    private String injectTenantIntoInsert(String sql, String tenantId) {
        // INSERT INTO table (col1, col2) VALUES (?, ?)
        // → INSERT INTO table (col1, col2, tenant_id) VALUES (?, ?, 'tenantId')
        int closeParenIdx = sql.indexOf(')');
        if (closeParenIdx == -1) return sql;

        String columns = sql.substring(0, closeParenIdx);
        String rest = sql.substring(closeParenIdx);

        columns += ", tenant_id";

        // 在 VALUES 部分追加
        int valuesParenIdx = rest.indexOf('(', 1);
        if (valuesParenIdx == -1) return sql;

        int valuesCloseIdx = rest.indexOf(')', valuesParenIdx);
        String valuesPart = rest.substring(0, valuesCloseIdx + 1);
        String afterPart = rest.substring(valuesCloseIdx + 1);

        // 去掉最后一个 ) 然后追加 tenant_id 值
        valuesPart = valuesPart.substring(0, valuesPart.length() - 1)
                     + ", '" + tenantId + "')";

        return columns + valuesPart + afterPart;
    }

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

    @Override
    public void setProperties(Properties props) {}
}

跨租户操作

后台管理场景

text
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CrossTenant {
}
text
public interface AdminMapper {

    // 跨租户统计
    @CrossTenant
    @Select("SELECT tenant_id, COUNT(*) as cnt FROM orders GROUP BY tenant_id")
    List<Map<String, Object>> countOrdersByTenant();

    // 跨租户搜索
    @CrossTenant
    List<Order> globalSearch(@Param("keyword") String keyword);
}

拦截器中处理

text
private boolean isCrossTenant(MappedStatement ms) {
    try {
        String className = ms.getId().substring(0, ms.getId().lastIndexOf("."));
        String methodName = ms.getId().substring(ms.getId().lastIndexOf(".") + 1);
        Class<?> clazz = Class.forName(className);
        for (Method m : clazz.getMethods()) {
            if (m.getName().equals(methodName) && m.isAnnotationPresent(CrossTenant.class)) {
                return true;
            }
        }
    } catch (Exception ignored) {}
    return false;
}

隔离策略选择决策树

text
                   是否有合规要求?
                  /               \
               是                  否
                ↓                   ↓
        独立数据库           租户数量 > 1000?
        (物理隔离)          /              \
                        是                否
                         ↓                 ↓
                   行级隔离          独立 Schema
                  (tenant_id)      (schema 隔离)

注意事项

  1. 索引设计:行级隔离模式下,所有查询必须包含 tenant_id 索引,否则全表扫描导致性能灾难
  2. DDL 变更:独立数据库模式需同步执行 DDL 到所有租户库,可使用 Flyway/Liquibase 多数据源方案
  3. 备份策略:行级隔离按 tenant_id 导出备份时需加 WHERE tenant_id = ?,避免全量导出
  4. 租户迁移:从小租户升级到大租户时,需支持数据迁移(行级 → 独立库),设计迁移脚本和双写过渡
  5. 安全审计:行级隔离需在应用层保证 tenant_id 不被篡改,数据库层可加 Row-Level Security(如 PostgreSQL RLS)

要点总结

  • 三种隔离策略:独立数据库(物理隔离)、独立 Schema(逻辑隔离)、行级隔离(tenant_id 字段),隔离强度与成本依次递减
  • 独立数据库模式通过动态注册 HikariDataSourceAbstractRoutingDataSource 实现运行时切换,支持自动建库和建表
  • 独立 Schema 模式适合 PostgreSQL,通过 SET search_path 切换命名空间,MySQL 不支持此模式
  • 行级隔离通过 MyBatis 拦截器在 SQL AST 中注入 tenant_id 条件,使用 JSqlParser 保证改写精度
  • INSERT 操作通过拦截器自动追加 tenant_id 列和值,避免业务层遗漏
  • 跨租户操作通过 @CrossTenant 注解标识,在拦截器中判断并跳过租户过滤
  • 索引设计是行级隔离的生命线,所有查询必须命中 (tenant_id, xxx) 联合索引

存放路径:D:\git2\jwdev\articles\MYBATIS\专家\多租户与数据权限\租户隔离策略.md

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

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

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

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