Dicky张头像
关注

SaaS数据隔离方案深度对比:独立数据库、共享表与租户字段的权衡

SaaS数据隔离方案深度对比:独立数据库、共享表与租户字段的权衡

数据隔离是SaaS多租户架构的"第一道防线"。选独立数据库还是共享表?租户字段方案到底安不安全?本文基于三个项目的实际经验,对三种主流隔离方案进行全面拆解,包括成本、安全、运维和迁移的全维度对比。

一、三种隔离方案的架构模型

三种方案的核心差异如下表:

对比维度Database per TenantShared DB / Shared SchemaDiscriminator Column
隔离级别物理隔离(最强)Schema级隔离逻辑隔离(最弱)
安全性⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
单租户成本高(独立实例)中(独立Schema)低(共享资源)
运维复杂度高(N个库需维护)
扩展性强(可水平拆分)弱(单库瓶颈)
数据恢复精准恢复单租户精准恢复单租户需全量+过滤
跨租户查询需要联邦查询可cross-schema原生支持

二、方案一:Database per Tenant

2.1 架构实现

每个租户拥有独立的数据库实例或逻辑库,通过数据源路由实现连接切换:

@Component
public class TenantDataSourceRouter extends AbstractRoutingDataSource {
    
    private static final ThreadLocal<String> TENANT_CONTEXT = new ThreadLocal<>();
    
    @Override
    protected Object determineCurrentLookupKey() {
        return TENANT_CONTEXT.get();
    }
    
    public static void setTenant(String tenantId) {
        TENANT_CONTEXT.set(tenantId);
    }
    
    public static void clear() {
        TENANT_CONTEXT.remove();
    }
}

@Configuration
public class DataSourceConfig {
    
    @Bean
    @Primary
    public DataSource routingDataSource() {
        Map<Object, Object> dataSources = new HashMap<>();
        
        // 连接池按租户分配,核心租户可配置更大连接池
        dataSources.put("tenant_a", createHikariPool(
            "jdbc:mysql://host:3306/saas_tenant_a", 20, 50));
        dataSources.put("tenant_b", createHikariPool(
            "jdbc:mysql://host:3307/saas_tenant_b", 10, 30));
        
        TenantDataSourceRouter router = new TenantDataSourceRouter();
        router.setDefaultTargetDataSource(dataSources.get("tenant_a"));
        router.setTargetDataSources(dataSources);
        return router;
    }
    
    private HikariDataSource createHikariPool(String url, int min, int max) {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(url);
        config.setMinimumIdle(min);
        config.setMaximumPoolSize(max);
        return new HikariDataSource(config);
    }
}

// 拦截器自动设置租户上下文
@Component
public class TenantInterceptor implements HandlerInterceptor {
    
    @Override
    public boolean preHandle(HttpServletRequest request, 
                            HttpServletResponse response, Object handler) {
        String tenantId = request.getHeader("X-Tenant-Id");
        if (tenantId == null) {
            tenantId = extractFromJwt(request);
        }
        TenantDataSourceRouter.setTenant(tenantId);
        return true;
    }
    
    @Override
    public void afterCompletion(HttpServletRequest request, 
                               HttpServletResponse response, Object handler, 
                               Exception ex) {
        TenantDataSourceRouter.clear();
    }
}

2.2 优势与代价

核心优势:

  • 安全隔离最强,即使一个租户的数据库被攻击,其他租户不受影响
  • 备份恢复独立,可以按租户粒度执行
  • 资源可差异化配置(大租户用高性能实例,小租户用基础实例)

实际代价:

  • 连接池数量与租户数成正比(1000个租户=1000个连接池),内存压力大
  • Schema变更需要在N个库上执行,DDL运维必须自动化
  • 跨租户的聚合统计需要额外数据仓库层

三、方案二:Shared Database / Shared Schema

3.1 架构实现

共享数据库,但在Schema层面隔离。PostgreSQL天生支持Schema,MySQL可以通过Database来模拟。

-- PostgreSQL Schema隔离
CREATE SCHEMA tenant_a;
CREATE SCHEMA tenant_b;

CREATE TABLE tenant_a.orders (
    id BIGSERIAL PRIMARY KEY,
    product_name VARCHAR(200),
    amount DECIMAL(12,2),
    created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE tenant_b.orders (
    id BIGSERIAL PRIMARY KEY,
    product_name VARCHAR(200),
    amount DECIMAL(12,2),
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- 查询时通过search_path切换
SET search_path TO tenant_a;
SELECT * FROM orders WHERE amount > 100;

-- 跨租户统计
SELECT 'tenant_a' AS tenant, COUNT(*) FROM tenant_a.orders
UNION ALL
SELECT 'tenant_b' AS tenant, COUNT(*) FROM tenant_b.orders;
@Component
public class SchemaBasedTenantFilter implements HibernatePropertiesContributor {
    
    @Override
    public void contributeHibernateProperties(Map<String, Object> properties) {
        // 无需额外配置,通过JDBC Interceptor自动设置search_path
    }
}

// 连接级别的Schema切换
@Aspect
@Component
public class SchemaSwitchingAspect {
    
    private final DataSource dataSource;
    
    @Around("@annotation(transactional)")
    public Object switchSchema(ProceedingJoinPoint pjp) throws Throwable {
        String tenantId = TenantContext.getTenantId();
        try (Connection conn = dataSource.getConnection()) {
            conn.createStatement()
                .execute("SET search_path TO " + sanitize(tenantId));
        }
        return pjp.proceed();
    }
}

3.2 适用场景与局限

Shared Schema方案是"性价比最均衡"的选择:

  • 连接池统一管理,无租户膨胀问题
  • 备份恢复可通过pg_dump指定Schema精准操作
  • 但PostgreSQL的Schema数量上限约为数十万级别(实测远超业务需求)

主要风险: 应用层SQL如果忘记加Schema前缀,可能误写入默认Public Schema。需要在代码审查层面强化。

四、方案三:Discriminator Column

4.1 架构实现

所有租户数据在同一张表中,通过tenant_id列区分。这是实现最简单但风险最高的方案。

CREATE TABLE orders (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id VARCHAR(32) NOT NULL,
    product_name VARCHAR(200),
    amount DECIMAL(12,2),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_tenant_id (tenant_id),
    INDEX idx_tenant_created (tenant_id, created_at)
);
// MyBatis-Plus 拦截器自动注入租户条件
@Component
public class TenantLineInterceptor implements InnerInterceptor {
    
    @Override
    public void beforeQuery(Executor executor, MappedStatement ms, 
                           Object parameter, RowBounds rowBounds, 
                           ResultHandler resultHandler, BoundSql boundSql) {
        // 在所有SQL上自动追加 tenant_id = ? 条件
        String originalSql = boundSql.getSql();
        String tenantId = TenantContext.getTenantId();
        
        // 跳过系统表和管理员查询
        if (shouldSkip(ms.getId())) {
            return;
        }
        
        // 包装原始SQL,追加租户过滤条件
        String wrappedSql = "SELECT * FROM (" + originalSql + 
            ") _tenant_wrapper WHERE _tenant_wrapper.tenant_id = '" + tenantId + "'";
        
        // 通过反射替换BoundSql中的SQL(生产环境建议使用更优雅的包装方式)
        reflectReplaceSql(boundSql, wrappedSql);
    }
    
    /**
     * 写入时强制注入tenant_id
     */
    @Override
    public void beforePrepare(StatementHandler sh, Connection connection, 
                             Integer transactionTimeout) {
        MetaObject metaObject = SystemMetaObject.forObject(sh);
        MappedStatement ms = (MappedStatement) metaObject
            .getValue("delegate.mappedStatement");
        
        if (ms.getSqlCommandType() == SqlCommandType.INSERT) {
            Object parameter = sh.getParameterHandler().getParameterObject();
            if (parameter instanceof Map) {
                @SuppressWarnings("unchecked")
                Map<String, Object> map = (Map<String, Object>) parameter;
                map.putIfAbsent("tenant_id", TenantContext.getTenantId());
            }
        }
    }
}

4.2 安全加固要点

Discriminator Column方案最大的风险是"忘记where tenant_id"导致的数据泄露。防御措施:

/**
 * 数据库层面的最后防线:使用行级安全策略(Row-Level Security)
 * PostgreSQL原生支持
 */
public class RowLevelSecuritySetup {
    
    public void enableRLS(DataSource ds) {
        String sql = """
            -- 启用行级安全
            ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
            
            -- 创建策略:当前租户只能看自己的数据
            CREATE POLICY tenant_isolation_policy ON orders
                USING (tenant_id = current_setting('app.current_tenant_id'));
            
            -- 应用层设置租户上下文
            -- SET app.current_tenant_id = 'tenant_abc';
            """;
    }
}
// MySQL不支持RLS,通过数据库账号+视图来模拟
public class MySQLIsolationHelper {
    
    /**
     * 为每个租户创建受限视图
     */
    public void createTenantView(DataSource ds, String tenantId) {
        String sql = String.format("""
            CREATE OR REPLACE VIEW v_orders_%s AS
            SELECT * FROM orders WHERE tenant_id = '%s'
            WITH CHECK OPTION
            """, tenantId, tenantId);
        // WITH CHECK OPTION 确保通过视图写入时自动校验
    }
}

五、总结

三种方案并非互斥,而是可以在同一平台内组合使用:

租户类型推荐方案原因
企业旗舰版Database per Tenant安全合规要求高,愿意为隔离付费
企业标准版Shared DB / Shared Schema平衡安全与成本
免费版/试用版Discriminator Column极致成本控制

最后强调三个关键经验:

  1. 安全不是信任代码,而是信任机制。Discriminator Column方案必须配合数据库级别的行级安全策略(RLS或视图),不能仅依赖应用代码。
  2. 迁移路径要提前规划。如果从方案三起步,必须保证数据模型设计时就为未来的方案一/二迁移做好准备(避免自增ID的全局依赖,使用分布式ID)。
  3. 成本核算要全面。方案一虽然单租户成本高,但大客户的付费意愿通常能覆盖。关键是把运维自动化做好,否则N个数据库的DDL变更会让人崩溃。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/dicky_zhang3/article/details/163097903

文章来源crawl

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--