CKPeng
2024-04-18 Victor (CKPeng) SaaS 架构 / 多租户设计 / 数据安全 / 源码实战 深度阅读约 16 分钟

企业级 SaaS 多租户架构演进:从数据物理隔离到千万级行级安全防护

SaaS 多租户架构

多租户(Multi-Tenancy)是 SaaS 软件商业模式成立的底层技术支柱。在传统的单租户时代,为每一个企业客户单独部署一套独立服务器和数据库,虽然安全可控,但高昂的硬件资源冗余、极其繁重的版本升级与运维成本让企业软件难以实现规模化盈利。而多租户架构让上千家企业租户共享一套计算基础设施,大幅摊薄了运营成本。然而,随之而来的“数据串租越权、大租户抢占资源、租户定制化需求冲突”成为架构师必须直面的头号技术挑战。

“在企业级 SaaS 体系中,数据隔离绝不能依赖业务开发人员的‘自觉与细心’。优秀的架构设计必须从底层 ORM 拦截、RPC 上下文透传、网关统一鉴权到存储层建立一道无法逾越的自动化安全防护网。”

一、三种主流多租户数据隔离模式深度剖析

在设计多租户系统之初,技术选型必须建立在对客户体量、付费客单价(ARPU)、合规要求与运维成本的全面权衡之上:

隔离模式 实现原理 安全性与隔离度 硬件资源与运维成本 适用业务场景
独立数据库模式
(DB per Tenant)
每个租户独享独立的物理数据库或 RDS 实例 ⭐⭐⭐⭐⭐
物理级绝对隔离,支持独立备份恢复与定制字段
⭐⭐⭐⭐⭐
成本极高,连接池随租户数线性暴增,运维升级繁重
政企大客户、KA 核心客户、金融合规私有化交付
独立 Schema 模式
(Schema per Tenant)
所有租户共享单一数据库集群,但为每个租户创建独立 Schema(如 PostgreSQL) ⭐⭐⭐⭐
逻辑隔离强,单租户误删可单 Schema 还原
⭐⭐⭐
中等,单库内过多 Schema 会占用元数据锁与系统字典表
中大型企业客户、对数据自主可控有一定诉求的客户
共享表行级隔离
(Shared DB & Table)
所有租户数据混存在同一张表中,通过字段 tenant_id 进行逻辑划分 ⭐⭐⭐
依赖程序与 ORM 严格注入,防止 SQL 越权泄露

极致性价比,资源利用率最高,单实例支撑上万租户
标准 SaaS 免费/试用版、中小微企业批量注册场景
架构经验:混合多租户架构(Hybrid Multi-Tenancy)

在大型企业 SaaS 实践中,我们通常采用“共享表基础池 + 独立库 VIP 池”的混合模式。系统底层通过动态数据源路由(`DynamicRoutingDataSource`),根据租户等级自动分流:小微租户路由至公共共享表集群;年费百万级 KA 客户无缝调度至独立专属 RDS 实例,业务代码完全零感知。

二、基于 MyBatis-Plus 的行级租户自动化隔离核心实现

在共享表模式下,绝不能允许在业务 SQL 中手动拼接 `WHERE tenant_id = 1001`。我们基于 JSqlParser 与 MyBatis-Plus 插件机制,实现了全自动的 SQL AST 语法树改写:

@Configuration
@Slf4j
public class SaaSMybatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 添加多租户内部拦截器
        interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
            
            @Override
            public Expression getTenantId() {
                Long tenantId = TenantContextHolder.getTenantId();
                if (tenantId == null) {
                    // 若无租户上下文且非白名单,抛出安全异常
                    throw new BusinessException(ErrorCode.TENANT_NOT_FOUND, "未检测到有效的租户上下文信息");
                }
                return new LongValue(tenantId);
            }

            @Override
            public String getTenantIdColumn() {
                return "tenant_id";
            }

            @Override
            public boolean ignoreTable(String tableName) {
                // 1. 检查是否在当前线程中显式标注了 @IgnoreTenant
                if (TenantContextHolder.isIgnoreTenant()) {
                    return true;
                }
                // 2. 检查全局公共基础字典表(如行政区划、系统参数表)
                return CommonTenantConstants.GLOBAL_IGNORE_TABLES.contains(tableName.toLowerCase());
            }
        }));

        // 添加乐观锁插件
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
        // 添加分页插件
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

2.1 注解驱动的租户旁路与跨租户统计(@IgnoreTenant)

在平台运营超级管理员端,往往需要统计全平台所有租户的 GMV 总额,此时必须安全旁路租户注入。我们通过 AOP 切面实现了声明式注解:

@Aspect
@Component
@Order(-10) // 确保在事务注解前执行
public class IgnoreTenantAspect {

    @Around("@annotation(ignoreTenant) || @within(ignoreTenant)")
    public Object around(ProceedingJoinPoint joinPoint, IgnoreTenant ignoreTenant) throws Throwable {
        try {
            TenantContextHolder.setIgnoreTenant(true);
            return joinPoint.proceed();
        } finally {
            TenantContextHolder.setIgnoreTenant(false);
        }
    }
}

三、租户上下文全链路传递与线程池脏数据防御

在异步处理、MQ 消费及微服务远程调用中,传统的 `ThreadLocal` 极易由于线程池复用而引发灾难性的“租户上下文串租”事故:

public class TenantContextHolder {
    // 采用阿里开源的 TransmittableThreadLocal 解决线程池上下文传递问题
    private static final ThreadLocal<Long> TENANT_HOLDER = new TransmittableThreadLocal<>();
    private static final ThreadLocal<Boolean> IGNORE_TENANT_HOLDER = new TransmittableThreadLocal<>();

    public static void setTenantId(Long tenantId) {
        TENANT_HOLDER.set(tenantId);
    }

    public static Long getTenantId() {
        return TENANT_HOLDER.get();
    }

    public static void setIgnoreTenant(boolean ignore) {
        IGNORE_TENANT_HOLDER.set(ignore);
    }

    public static boolean isIgnoreTenant() {
        return Boolean.TRUE.equals(IGNORE_TENANT_HOLDER.get());
    }

    /**
     * 必须在 Web 拦截器 afterCompletion 中强制清理,彻底杜绝线程复用污染
     */
    public static void clear() {
        TENANT_HOLDER.remove();
        IGNORE_TENANT_HOLDER.remove();
    }
}

四、租户级资源配额与高并发 API 频控

SaaS 系统必须防止个别租户的突发爬虫或恶意死循环调用打爆全局数据库,导致其他无辜租户受牵连(“吵闹邻居”问题,Noisy Neighbor)。我们在 API 网关层落地了租户维度的 Redis Lua 令牌桶限流算法:

-- Redis Lua 租户级分布式令牌桶限流脚本
local key = KEYS[1] -- 格式: ratelimit:tenant:{tenantId}:{api}
local limit = tonumber(ARGV[1]) -- 桶容量
local rate = tonumber(ARGV[2]) -- 每秒补充令牌数
local now = tonumber(ARGV[3]) -- 当前时间戳
local requested = tonumber(ARGV[4]) -- 本次请求消耗令牌数

local info = redis.pcall('HMGET', key, 'tokens', 'last_time')
local tokens = tonumber(info[1])
local last_time = tonumber(info[2])

if not tokens then
    tokens = limit
    last_time = now
else
    local delta = math.max(0, now - last_time)
    tokens = math.min(limit, tokens + delta * rate)
    last_time = now
end

if tokens >= requested then
    tokens = tokens - requested
    redis.pcall('HMSET', key, 'tokens', tokens, 'last_time', last_time)
    redis.pcall('EXPIRE', key, 3600)
    return 1 -- 允许放行
else
    return 0 -- 触发限流
end

五、数据安全与合规总结

除了上述数据访问层控制,我们还落地了:

  • 敏感数据列级加密:租户银行卡、手机号等 PII 敏感信息基于 AES-256 加密落库,且各租户使用独立的 KMS 密钥;
  • 对象存储文件路径隔离:OSS/MinIO 统一采用 /tenants/{tenantId}/uploads/... 路径前缀,配合 STS 临时凭证校验跨租户越权;
  • 全量操作审计日志:所有涉及租户数据增删改的操作,全量异步记入 Elasticsearch 审计中枢,满足等保三级与企业合规要求。

通过这套严密完备的多租户隔离与安全防护架构,平台顺利通过多轮国家信息安全合规认证,并在承载 300+ 品牌客户高并发运营中做到了连续 3 年零越权、零串租事故。

Victor

Victor (CKPeng)

资深技术负责人 (CTO) / 企业级架构师

12年互联网研发与团队管理经验,深耕企业级 SaaS 架构、分布式微服务云原生体系及 AI 计算机视觉中台设计。