【Java项目技术亮点】多租户架构数据隔离

写在前面

说实话,我见过太多SaaS项目,前期没想清楚多租户怎么搞,上线三个月后客户A看到了客户B的数据------这种事故一旦发生,信任就彻底崩塌了。我自己就踩过这个坑,当时为了赶工期,直接在SQL里手动拼tenant_id,结果一个漏写,数据全暴露了。今天把多租户的三种方案、MyBatis-Plus实现、以及踩过的坑,一次性讲清楚。

文章目录


一、为什么需要多租户?

1.1 场景引入

假设你开发了一个 企业级SaaS系统------比如CRM、ERP、OA。你的目标是:

  • 一套代码,服务 100家企业
  • 每家企业只能看到自己的数据
  • 每家企业有自己的管理员、员工、权限配置

如果没有多租户架构,你可能要给每家企业部署一套独立系统------运维成本直接爆炸。

1.2 生活类比:公寓楼

想象一栋公寓楼:

  • 整栋楼 = 一套系统(共用电梯、走廊、物业)
  • 每户人家 = 一个租户(有自己的钥匙、家具、隐私)
  • 门锁 = 数据隔离(没有钥匙进不去别人家)

多租户的核心就是:共享基础设施,隔离数据资产

1.3 核心挑战

挑战 说明
数据隔离 租户A绝对不能看到租户B的数据
性能隔离 租户A的疯狂查询不能拖垮租户B
配置隔离 每家企业的Logo、主题、功能开关都不一样

二、三种数据隔离方案

2.1 方案1:共享数据库 + 租户ID字段

原理 :所有租户共用一套数据库,每张表加一个 tenant_id 字段,查询时自动拼接条件。

sql 复制代码
-- 用户表
CREATE TABLE sys_user (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    tenant_id BIGINT NOT NULL COMMENT '租户ID',
    username VARCHAR(50) NOT NULL,
    email VARCHAR(100),
    -- ...
    INDEX idx_tenant_id (tenant_id)
);

-- 查询时自动拼接 tenant_id
SELECT * FROM sys_user WHERE tenant_id = 1001 AND username = 'admin';

优点

  • 成本最低,只需要维护一套数据库
  • 部署简单,升级只需改一次

缺点

  • 隔离性弱,一旦漏写 tenant_id 条件,数据直接暴露
  • 单表数据量大,100个租户 × 10万用户 = 1000万行
  • 数据备份和恢复粒度粗,只能全量备份

2.2 方案2:独立Schema

原理:同一数据库实例,每个租户一个Schema(命名空间)。

sql 复制代码
-- 租户A的Schema
CREATE SCHEMA tenant_1001;
CREATE TABLE tenant_1001.sys_user (...);

-- 租户B的Schema
CREATE SCHEMA tenant_1002;
CREATE TABLE tenant_1002.sys_user (...);

-- 查询时切换Schema
USE tenant_1001;
SELECT * FROM sys_user WHERE username = 'admin';

优点

  • 数据隔离较好,Schema之间天然隔离
  • 备份灵活,可以按Schema导出

缺点

  • Schema管理复杂,新增租户要自动创建Schema和表
  • 跨Schema查询困难(比如统计全平台用户总数)
  • 数据库连接池需要支持动态Schema切换

2.3 方案3:独立数据库

原理:每个租户一个独立的数据库实例。

复制代码
租户A -> jdbc:mysql://db-server:3306/tenant_1001
租户B -> jdbc:mysql://db-server:3306/tenant_1002
租户C -> jdbc:mysql://db-server:3306/tenant_1003

优点

  • 完全隔离,安全性最高
  • 每个租户可以独立扩容、独立备份
  • 符合某些行业的合规要求(金融、医疗)

缺点

  • 成本最高,数据库连接数可能爆掉
  • 运维复杂,升级要逐个数据库执行
  • 跨租户查询几乎不可能(需要数据仓库)

2.4 三种方案对比

维度 共享数据库+租户ID 独立Schema 独立数据库
隔离性 低(依赖代码保证) 中(数据库层隔离) 高(物理隔离)
成本
复杂度
性能 单表数据量大可能慢 较好 最好
扩展性 垂直扩展为主 水平扩展较容易 最容易
适用场景 中小SaaS、初创项目 中型SaaS、数据量中等 大型SaaS、金融医疗

踩坑提醒:我见过太多项目一开始选"共享数据库+租户ID",结果数据量上来后想迁移到"独立数据库"------这简直是灾难。选型时一定要预估未来3年的数据规模,宁可前期过度设计,也不要后期推倒重来。


三、MyBatis-Plus多租户实现

3.1 TenantLineInnerInterceptor原理

MyBatis-Plus 从 3.4.0 版本开始内置了多租户插件 TenantLineInnerInterceptor。它的核心原理是 SQL解析 + 条件自动拼接

复制代码
原始SQL:    SELECT * FROM sys_user WHERE username = 'admin'
拦截后SQL:  SELECT * FROM sys_user WHERE tenant_id = 1001 AND username = 'admin'
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                        自动拼接的租户条件

3.2 完整配置代码

java 复制代码
/**
 * MyBatis-Plus 多租户配置
 */
@Configuration
public class MybatisPlusConfig {

    /**
     * 当前租户ID提供者(从请求上下文获取)
     */
    @Bean
    public TenantLineInnerInterceptor tenantLineInnerInterceptor() {
        return new TenantLineInnerInterceptor(new TenantLineHandler() {
            
            /**
             * 获取当前租户ID
             * 通常从登录用户的上下文、请求Header、或ThreadLocal中获取
             */
            @Override
            public Expression getTenantId() {
                Long tenantId = TenantContextHolder.getCurrentTenantId();
                if (tenantId == null) {
                    throw new RuntimeException("租户ID不能为空");
                }
                return new LongValue(tenantId);
            }
            
            /**
             * 租户ID字段名
             */
            @Override
            public String getTenantIdColumn() {
                return "tenant_id";
            }
            
            /**
             * 是否忽略拼接租户条件
             * 返回true表示不拼接(用于平台级查询)
             */
            @Override
            public boolean ignoreTable(String tableName) {
                // 忽略不需要租户隔离的表
                return "sys_tenant".equalsIgnoreCase(tableName)      // 租户表本身
                    || "sys_dict".equalsIgnoreCase(tableName)        // 全局字典
                    || "sys_config".equalsIgnoreCase(tableName);     // 全局配置
            }
        });
    }
    
    /**
     * 配置MyBatis-Plus插件
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 添加多租户插件
        interceptor.addInnerInterceptor(tenantLineInnerInterceptor());
        // 添加分页插件
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

3.3 租户上下文持有者

java 复制代码
/**
 * 租户上下文持有者(基于ThreadLocal)
 */
public class TenantContextHolder {

    private static final ThreadLocal<Long> TENANT_ID = new ThreadLocal<>();
    
    /**
     * 设置当前租户ID
     */
    public static void setCurrentTenantId(Long tenantId) {
        TENANT_ID.set(tenantId);
    }
    
    /**
     * 获取当前租户ID
     */
    public static Long getCurrentTenantId() {
        return TENANT_ID.get();
    }
    
    /**
     * 清除租户ID(防止线程池复用导致数据泄露)
     */
    public static void clear() {
        TENANT_ID.remove();
    }
}

3.4 拦截器:从请求中解析租户ID

java 复制代码
/**
 * 租户ID解析拦截器
 * 从请求Header或JWT Token中解析租户ID
 */
@Component
public class TenantInterceptor implements HandlerInterceptor {

    private static final String HEADER_TENANT_ID = "X-Tenant-Id";
    
    @Override
    public boolean preHandle(HttpServletRequest request, 
                             HttpServletResponse response, 
                             Object handler) {
        // 方式1:从Header获取
        String tenantIdStr = request.getHeader(HEADER_TENANT_ID);
        
        // 方式2:从JWT Token解析(推荐)
        // String token = request.getHeader("Authorization");
        // Long tenantId = JwtUtil.parseTenantId(token);
        
        if (StrUtil.isBlank(tenantIdStr)) {
            throw new RuntimeException("请求头缺少租户ID");
        }
        
        Long tenantId = Long.valueOf(tenantIdStr);
        TenantContextHolder.setCurrentTenantId(tenantId);
        
        return true;
    }
    
    @Override
    public void afterCompletion(HttpServletRequest request,
                                HttpServletResponse response,
                                Object handler, Exception ex) {
        // 请求结束后必须清理,防止线程池复用
        TenantContextHolder.clear();
    }
}

3.5 注册拦截器

java 复制代码
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Autowired
    private TenantInterceptor tenantInterceptor;
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(tenantInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns("/login", "/register", "/public/**");
    }
}

3.6 忽略租户过滤的场景

有时候你需要执行 跨租户查询 (比如平台管理员查看所有租户的数据),MyBatis-Plus 提供了 @InterceptorIgnore 注解:

java 复制代码
@Mapper
public interface SysUserMapper extends BaseMapper<SysUser> {
    
    /**
     * 平台管理员查询所有用户(忽略租户过滤)
     */
    @InterceptorIgnore(tenantLine = "1")
    List<SysUser> selectAllUsers();
    
    /**
     * 统计所有租户的用户数量(忽略租户过滤)
     */
    @InterceptorIgnore(tenantLine = "1")
    @Select("SELECT tenant_id, COUNT(*) as count FROM sys_user GROUP BY tenant_id")
    List<Map<String, Object>> countByTenant();
}

踩坑提醒:@InterceptorIgnore 只在 Mapper 接口方法上生效,Service 层调用时无效。另外,这个注解要慎用------每加一次,就多一个潜在的数据泄露风险点。建议配合权限注解一起用,比如 @RequiresRoles("platform_admin")

3.7 动态数据源切换(多数据源 + 租户路由)

如果你用的是 独立Schema独立数据库 方案,需要动态切换数据源:

java 复制代码
/**
 * 动态数据源上下文
 */
public class DynamicDataSourceContextHolder {
    
    private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
    
    public static void setDataSourceKey(String key) {
        CONTEXT.set(key);
    }
    
    public static String getDataSourceKey() {
        return CONTEXT.get();
    }
    
    public static void clear() {
        CONTEXT.remove();
    }
}

/**
 * 动态数据源(继承AbstractRoutingDataSource)
 */
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
    
    @Override
    protected Object determineCurrentLookupKey() {
        // 根据租户ID返回对应的数据源key
        Long tenantId = TenantContextHolder.getCurrentTenantId();
        return "tenant_" + tenantId;
    }
}

/**
 * 动态数据源配置
 */
@Configuration
public class DynamicDataSourceConfig {
    
    @Bean
    public DataSource dataSource() {
        // 主数据源(存储租户列表、全局配置)
        HikariDataSource masterDataSource = createDataSource(
            "jdbc:mysql://localhost:3306/master_db", "root", "password");
        
        // 动态数据源
        TenantRoutingDataSource dynamicDataSource = new TenantRoutingDataSource();
        Map<Object, Object> dataSourceMap = new HashMap<>();
        dataSourceMap.put("master", masterDataSource);
        
        // 可以在这里预加载所有租户的数据源
        // 或者懒加载:第一次请求时动态创建
        
        dynamicDataSource.setTargetDataSources(dataSourceMap);
        dynamicDataSource.setDefaultTargetDataSource(masterDataSource);
        
        return dynamicDataSource;
    }
    
    private HikariDataSource createDataSource(String url, String username, String password) {
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl(url);
        dataSource.setUsername(username);
        dataSource.setPassword(password);
        dataSource.setMaximumPoolSize(10);
        return dataSource;
    }
}

四、多租户进阶设计

4.1 租户注册与初始化

java 复制代码
/**
 * 租户注册服务
 */
@Service
public class TenantInitService {
    
    @Autowired
    private TenantMapper tenantMapper;
    
    @Autowired
    private DataSource dataSource;
    
    /**
     * 注册新租户
     */
    @Transactional
    public Tenant registerTenant(TenantRegisterDTO dto) {
        // 1. 保存租户基本信息
        Tenant tenant = new Tenant();
        tenant.setTenantName(dto.getTenantName());
        tenant.setContactEmail(dto.getContactEmail());
        tenant.setStatus(1);
        tenantMapper.insert(tenant);
        
        // 2. 初始化租户数据(根据隔离方案不同)
        if (isSchemaIsolation()) {
            initSchemaForTenant(tenant.getId());
        } else if (isDatabaseIsolation()) {
            initDatabaseForTenant(tenant.getId());
        }
        
        // 3. 初始化默认数据(管理员账号、基础配置)
        initDefaultData(tenant.getId());
        
        return tenant;
    }
    
    /**
     * 为租户创建Schema(独立Schema方案)
     */
    private void initSchemaForTenant(Long tenantId) {
        String schemaName = "tenant_" + tenantId;
        JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
        
        // 创建Schema
        jdbcTemplate.execute("CREATE SCHEMA IF NOT EXISTS " + schemaName);
        
        // 执行建表脚本
        ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
        populator.addScript(new ClassPathResource("sql/init-tenant.sql"));
        populator.execute(DataSourceUtils.getConnection(dataSource));
    }
    
    /**
     * 初始化默认数据
     */
    private void initDefaultData(Long tenantId) {
        // 创建租户管理员账号
        SysUser admin = new SysUser();
        admin.setTenantId(tenantId);
        admin.setUsername("admin");
        admin.setPassword(passwordEncoder.encode("123456"));
        admin.setRole("TENANT_ADMIN");
        userMapper.insert(admin);
        
        // 初始化租户配置
        TenantConfig config = new TenantConfig();
        config.setTenantId(tenantId);
        config.setMaxUsers(100);
        config.setStorageQuota(1024 * 1024 * 1024L); // 1GB
        config.setFeatures("[\"crm\", \"report\"]");
        tenantConfigMapper.insert(config);
    }
}

4.2 租户级配置管理

java 复制代码
/**
 * 租户配置表
 */
@Data
@TableName("sys_tenant_config")
public class TenantConfig {
    
    private Long id;
    private Long tenantId;              // 租户ID
    private String configKey;           // 配置项
    private String configValue;         // 配置值
    private String description;         // 说明
}

/**
 * 租户配置服务(带缓存)
 */
@Service
public class TenantConfigService {
    
    @Autowired
    private TenantConfigMapper configMapper;
    
    private final Map<String, String> globalCache = new ConcurrentHashMap<>();
    
    /**
     * 获取租户配置(优先读缓存)
     */
    public String getConfig(Long tenantId, String key) {
        String cacheKey = tenantId + ":" + key;
        
        // 先查本地缓存
        String value = globalCache.get(cacheKey);
        if (value != null) {
            return value;
        }
        
        // 查数据库
        LambdaQueryWrapper<TenantConfig> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(TenantConfig::getTenantId, tenantId)
               .eq(TenantConfig::getConfigKey, key);
        TenantConfig config = configMapper.selectOne(wrapper);
        
        if (config != null) {
            globalCache.put(cacheKey, config.getConfigValue());
            return config.getConfigValue();
        }
        
        return null;
    }
    
    /**
     * 更新配置(同步更新缓存)
     */
    public void updateConfig(Long tenantId, String key, String value) {
        // 更新数据库...
        // 更新缓存
        globalCache.put(tenantId + ":" + key, value);
    }
}

4.3 租户级限流

java 复制代码
/**
 * 租户级限流拦截器(基于Guava RateLimiter或Redis)
 */
@Component
public class TenantRateLimitInterceptor implements HandlerInterceptor {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    // 每个租户每秒最大请求数
    private static final int DEFAULT_QPS = 100;
    
    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {
        Long tenantId = TenantContextHolder.getCurrentTenantId();
        if (tenantId == null) {
            return true;
        }
        
        String key = "rate_limit:tenant:" + tenantId;
        
        // Redis滑动窗口限流
        Long current = System.currentTimeMillis();
        String windowKey = key + ":" + (current / 1000);
        
        Long count = redisTemplate.opsForValue().increment(windowKey);
        if (count == 1) {
            // 设置1秒过期
            redisTemplate.expire(windowKey, 1, TimeUnit.SECONDS);
        }
        
        if (count > DEFAULT_QPS) {
            response.setStatus(429); // Too Many Requests
            throw new RuntimeException("当前租户请求过于频繁,请稍后再试");
        }
        
        return true;
    }
}

4.4 数据迁移方案

java 复制代码
/**
 * 租户数据迁移服务(租户升级时)
 */
@Service
public class TenantMigrationService {
    
    /**
     * 将租户从共享数据库迁移到独立数据库
     */
    public void migrateToIndependentDatabase(Long tenantId) {
        // 1. 创建目标数据库
        createTargetDatabase(tenantId);
        
        // 2. 导出租户数据
        List<SysUser> users = exportTenantData(tenantId);
        
        // 3. 导入目标数据库
        importToTargetDatabase(tenantId, users);
        
        // 4. 切换租户数据源配置
        updateTenantDataSource(tenantId, "INDEPENDENT");
        
        // 5. 验证数据一致性
        validateMigration(tenantId);
    }
    
    /**
     * 增量同步(迁移期间的数据变更)
     */
    public void syncIncrementalData(Long tenantId, Date lastSyncTime) {
        // 查询变更日志表,获取lastSyncTime之后的变更
        // 同步到目标数据库
    }
}

五、踩坑指南

坑1:租户ID透传的线程安全问题

如果你用 ThreadLocal 存租户ID,但接口是异步的(比如用了 @AsyncCompletableFuture),子线程会丢失租户ID。解决方案:使用 TransmittableThreadLocal 或者手动传递租户ID。
坑2:忘记加租户条件导致数据泄露

即使用了 MyBatis-Plus 的 TenantLineInnerInterceptor,自定义的 XML SQL 和原生 JDBC 查询不会被拦截。建议:

  • 代码审查时重点检查手写 SQL
  • 集成 SQL 审计工具,检测缺少 tenant_id 条件的查询
  • 单元测试覆盖每个查询接口
    坑3:跨租户查询的需求处理

平台管理员经常需要查看全平台数据(比如统计各租户用户数)。如果所有接口都强制拼接 tenant_id,这种需求就实现不了。建议:

  • 定义特殊的租户ID(如 0-1)表示平台级查询
  • TenantLineHandler.ignoreTable() 中按需放行
  • 配合 RBAC 权限控制,只有平台管理员能执行跨租户查询
    坑4:租户注销后的数据清理

租户退订后,数据怎么处理?直接删除可能违反法规(某些行业要求保留N年)。建议:

  • 软删除:标记租户状态为 DISABLED,数据保留但不可访问
  • 归档:将数据迁移到冷存储(如OSS、归档数据库)
  • 延迟删除:设置保留期(如90天),到期后自动清理

六、问题与解答

Q1:MyBatis-Plus的多租户插件会影响所有SQL吗?自定义XML里的SQL怎么办?

A: TenantLineInnerInterceptor 通过 JSqlParser 解析并改写 SQL,理论上会拦截所有经过 MyBatis 执行的 SQL。但有两个例外:

  1. 自定义XML里的SQL:同样会被拦截,因为最终都走 MyBatis 的执行器
  2. 手写 JDBC 的SQL:完全绕过 MyBatis,不会被拦截

如果你发现某个 XML SQL 没被拼接租户条件,检查:

  • 是否用了 @InterceptorIgnore(tenantLine = "1")
  • 是否走了存储过程(存储过程不会被解析改写)

Q2:租户ID应该存在哪里?Header、Token、还是Session?

A: 各有利弊:

  • Header:简单直接,但容易被伪造(需要配合签名验证)
  • JWT Token:推荐方案。租户ID写入Token Payload,防篡改且自包含
  • Session:传统方案,分布式环境下需要共享Session(Redis),不太推荐

我的建议是:JWT Token 为主,Header 为辅 。Token里带上 tenant_id,同时支持 Header 传入用于调试和特殊场景。

Q3:多租户架构下,Redis缓存怎么隔离?

A: 三种方案:

  1. Key前缀隔离tenant:1001:user:123,最常用,简单有效
  2. 独立Redis实例:每个租户一个Redis,成本太高
  3. 独立Redis DB:一个实例分16个DB,每个租户一个DB,DB数量有限

推荐 Key前缀隔离 ,配合 RedisTemplateKeySerializer 自动拼接前缀:

java 复制代码
public class TenantRedisKeySerializer implements RedisSerializer<String> {
    @Override
    public byte[] serialize(String key) {
        Long tenantId = TenantContextHolder.getCurrentTenantId();
        String tenantKey = "tenant:" + tenantId + ":" + key;
        return tenantKey.getBytes(StandardCharsets.UTF_8);
    }
}

七、面试高频考点

考点1:什么是多租户架构?有哪些数据隔离方案?

答案: 多租户架构是指 一套系统服务于多个客户(租户),每个租户的数据相互隔离。三种主流的数据隔离方案:

  1. 共享数据库 + 租户ID字段 :所有租户共用一套数据库,每张表加 tenant_id 字段区分。成本低、维护简单,但隔离性弱。
  2. 独立Schema:同一数据库实例,每个租户一个Schema。隔离性较好,但Schema管理复杂。
  3. 独立数据库:每个租户一个独立的数据库实例。完全隔离、安全性最高,但成本和运维复杂度也最高。

选型需综合考虑隔离性要求、成本预算、运维能力和数据规模。

考点2:MyBatis-Plus的TenantLineInnerInterceptor是如何实现的?

答案: TenantLineInnerInterceptor 是 MyBatis-Plus 提供的多租户拦截器,基于 JSqlParser 实现 SQL 解析和改写。工作原理:

  1. 拦截所有执行的 SQL
  2. 使用 JSqlParser 将 SQL 解析为 AST(抽象语法树)
  3. 在 WHERE 条件中自动追加 tenant_id = ? 条件
  4. 将改写后的 SQL 交给底层执行

通过实现 TenantLineHandler 接口,可以自定义租户ID的获取方式、租户字段名、以及忽略拦截的表。

考点3:ThreadLocal存储租户ID有什么风险?怎么解决?

答案: ThreadLocal 存储租户ID的主要风险是 线程池复用导致的数据泄露。如果请求处理完后没有清理 ThreadLocal,下一个请求复用该线程时,会读到上一个请求的租户ID。

解决方案:

  1. 请求结束后清理 :在拦截器的 afterCompletion 中调用 TenantContextHolder.clear()
  2. 使用 TransmittableThreadLocal :如果用了线程池(如 @Async),TTL 可以自动传递 ThreadLocal 值到子线程
  3. 防御性编程:获取租户ID时校验其合法性,发现异常立即告警

考点4:多租户架构下如何保证性能隔离?

答案: 性能隔离防止某个租户的高负载影响其他租户,常用手段:

  1. 租户级限流:为每个租户设置独立的 QPS 上限,防止单租户刷接口
  2. 数据库连接池隔离:独立数据库方案天然隔离;共享数据库方案可以限制单租户的最大连接数
  3. 慢查询监控:记录每个租户的慢查询,超过阈值自动告警或熔断
  4. 资源配额:限制单租户的存储空间、并发数、导出数据量等

考点5:多租户系统从共享数据库迁移到独立数据库,怎么设计迁移方案?

答案: 迁移方案需要保证 数据一致性服务可用性

  1. 双写阶段:新数据同时写入旧库和新库,保持同步
  2. 全量迁移:使用分批查询(如每次1000条)将历史数据迁移到新库
  3. 增量同步:通过 binlog 或变更日志表,同步双写期间产生的增量数据
  4. 数据校验:对比新旧库的数据条数、校验和,确保一致
  5. 流量切换:灰度切换,先切读流量验证,再切写流量
  6. 回滚预案:切换失败时,能快速切回旧库

八、模拟面试官提问

场景题1:你的SaaS系统有1000个租户,某天发现数据库CPU飙到90%,怎么定位是哪个租户导致的?

参考答案:

可以从三个层面定位:

  1. 应用层:在拦截器里记录每个租户的请求耗时和QPS,存入 Prometheus / ELK,按租户维度聚合
  2. 数据库层 :开启 MySQL 的 performance_schema,通过 events_statements_summary_by_digest 查看慢SQL,结合应用层的租户ID日志关联
  3. 实时熔断:如果某个租户的QPS超过阈值,临时将其限流或降级,保护整体系统

长期方案:给高频租户单独分配数据库实例或只读副本。

场景题2:平台管理员需要导出全平台所有租户的数据报表,但每个接口都强制拼接了tenant_id,怎么实现?

参考答案:

三种思路:

  1. 专用接口 + 权限控制 :定义一个 @PlatformAdmin 注解,只有平台管理员能调用。该接口使用 @InterceptorIgnore(tenantLine = "1") 跳过租户过滤,但必须在Service层手动校验调用者身份
  2. 数据仓库:将各租户数据通过ETL同步到独立的数据仓库(如ClickHouse),报表查询走数仓,不影响业务库
  3. 分租户聚合:先按租户分批查询,再在内存中聚合。适合数据量小的场景

推荐方案2,数仓专门做分析查询,不影响业务库性能。

场景题3:租户A的员工误操作删除了大量数据,要求恢复。你的系统怎么设计数据恢复能力?

参考答案:

数据恢复需要前置设计:

  1. 逻辑删除 :所有业务表加 deleted 字段,删除时只标记不物理删除。配合 MyBatis-PlusLogicDelete 插件
  2. 操作审计日志:记录每条数据的变更历史(谁、什么时候、改了什么),存到独立的审计表或 Kafka
  3. 数据库备份
    • 共享数据库方案:按 tenant_id 过滤导出,单独恢复
    • 独立数据库方案:直接恢复该租户的数据库备份
  4. Binlog回放:通过 Canal / Maxwell 订阅 binlog,实现按时间点恢复(PITR)

场景题4:你的多租户系统要支持"子租户"------比如企业客户下面还有多个分公司,每个分公司数据也要隔离,怎么设计?

参考答案:

这是 多级租户 的场景,可以设计为 "租户 + 组织" 的两级模型:

  1. 一级租户(Tenant):对应企业客户,决定数据隔离的边界(数据库/Schema/租户ID)
  2. 二级组织(Organization):对应分公司,在同一租户内再做数据隔离

实现方式:

  • 表结构增加 org_id 字段
  • 查询时同时拼接 tenant_id = ? AND org_id = ?
  • 权限模型扩展:用户-角色-组织 的关联关系
  • 数据权限框架(如 MyBatis-Plus 的 DataPermissionInterceptor)实现 org_id 的自动拼接

场景题5:多租户系统要支持"白标"(White Label)------每家企业的登录页、Logo、主题色都不一样,怎么实现?

参考答案:

白标的核心是 "配置驱动 + 动态渲染"

  1. 租户配置表:存储每家企业的品牌配置

    sql 复制代码
    CREATE TABLE tenant_branding (
        tenant_id BIGINT PRIMARY KEY,
        logo_url VARCHAR(500),
        primary_color VARCHAR(20),
        login_bg_url VARCHAR(500),
        company_name VARCHAR(100),
        favicon_url VARCHAR(500)
    );
  2. 前端动态加载 :登录页根据域名或请求参数中的 tenant_id,调用 /api/branding 接口获取配置,动态设置样式

  3. 域名绑定 :每家企业可以绑定自己的子域名(如 companyA.saas.com),后端根据域名解析出 tenant_id

  4. 静态资源隔离 :Logo、背景图等资源按租户ID分目录存储(如 /static/tenant_1001/logo.png


九、互动话题

你们公司的SaaS系统用的是哪种多租户方案?共享数据库、独立Schema还是独立数据库?有没有遇到过"租户A看到租户B数据"这种惊魂时刻?或者你在实现多租户时踩过什么独特的坑?评论区聊聊,我选最精彩的几个故事单独开一篇。


十、参考资料

MyBatis-Plus 多租户插件官方文档