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

文章目录
-
- 一、为什么需要多租户?
-
- [1.1 场景引入](#1.1 场景引入)
- [1.2 生活类比:公寓楼](#1.2 生活类比:公寓楼)
- [1.3 核心挑战](#1.3 核心挑战)
- 二、三种数据隔离方案
-
- [2.1 方案1:共享数据库 + 租户ID字段](#2.1 方案1:共享数据库 + 租户ID字段)
- [2.2 方案2:独立Schema](#2.2 方案2:独立Schema)
- [2.3 方案3:独立数据库](#2.3 方案3:独立数据库)
- [2.4 三种方案对比](#2.4 三种方案对比)
- 三、MyBatis-Plus多租户实现
-
- [3.1 TenantLineInnerInterceptor原理](#3.1 TenantLineInnerInterceptor原理)
- [3.2 完整配置代码](#3.2 完整配置代码)
- [3.3 租户上下文持有者](#3.3 租户上下文持有者)
- [3.4 拦截器:从请求中解析租户ID](#3.4 拦截器:从请求中解析租户ID)
- [3.5 注册拦截器](#3.5 注册拦截器)
- [3.6 忽略租户过滤的场景](#3.6 忽略租户过滤的场景)
- [3.7 动态数据源切换(多数据源 + 租户路由)](#3.7 动态数据源切换(多数据源 + 租户路由))
- 四、多租户进阶设计
-
- [4.1 租户注册与初始化](#4.1 租户注册与初始化)
- [4.2 租户级配置管理](#4.2 租户级配置管理)
- [4.3 租户级限流](#4.3 租户级限流)
- [4.4 数据迁移方案](#4.4 数据迁移方案)
- 五、踩坑指南
- 六、问题与解答
- 七、面试高频考点
- 八、模拟面试官提问
-
- 场景题1:你的SaaS系统有1000个租户,某天发现数据库CPU飙到90%,怎么定位是哪个租户导致的?
- 场景题2:平台管理员需要导出全平台所有租户的数据报表,但每个接口都强制拼接了tenant_id,怎么实现?
- 场景题3:租户A的员工误操作删除了大量数据,要求恢复。你的系统怎么设计数据恢复能力?
- 场景题4:你的多租户系统要支持"子租户"------比如企业客户下面还有多个分公司,每个分公司数据也要隔离,怎么设计?
- [场景题5:多租户系统要支持"白标"(White Label)------每家企业的登录页、Logo、主题色都不一样,怎么实现?](#场景题5:多租户系统要支持"白标"(White Label)——每家企业的登录页、Logo、主题色都不一样,怎么实现?)
- 九、互动话题
- 十、参考资料
一、为什么需要多租户?
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,但接口是异步的(比如用了
@Async或CompletableFuture),子线程会丢失租户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。但有两个例外:
- 自定义XML里的SQL:同样会被拦截,因为最终都走 MyBatis 的执行器
- 手写 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: 三种方案:
- Key前缀隔离 :
tenant:1001:user:123,最常用,简单有效 - 独立Redis实例:每个租户一个Redis,成本太高
- 独立Redis DB:一个实例分16个DB,每个租户一个DB,DB数量有限
推荐 Key前缀隔离 ,配合 RedisTemplate 的 KeySerializer 自动拼接前缀:
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:什么是多租户架构?有哪些数据隔离方案?
答案: 多租户架构是指 一套系统服务于多个客户(租户),每个租户的数据相互隔离。三种主流的数据隔离方案:
- 共享数据库 + 租户ID字段 :所有租户共用一套数据库,每张表加
tenant_id字段区分。成本低、维护简单,但隔离性弱。 - 独立Schema:同一数据库实例,每个租户一个Schema。隔离性较好,但Schema管理复杂。
- 独立数据库:每个租户一个独立的数据库实例。完全隔离、安全性最高,但成本和运维复杂度也最高。
选型需综合考虑隔离性要求、成本预算、运维能力和数据规模。
考点2:MyBatis-Plus的TenantLineInnerInterceptor是如何实现的?
答案: TenantLineInnerInterceptor 是 MyBatis-Plus 提供的多租户拦截器,基于 JSqlParser 实现 SQL 解析和改写。工作原理:
- 拦截所有执行的 SQL
- 使用 JSqlParser 将 SQL 解析为 AST(抽象语法树)
- 在 WHERE 条件中自动追加
tenant_id = ?条件 - 将改写后的 SQL 交给底层执行
通过实现 TenantLineHandler 接口,可以自定义租户ID的获取方式、租户字段名、以及忽略拦截的表。
考点3:ThreadLocal存储租户ID有什么风险?怎么解决?
答案: ThreadLocal 存储租户ID的主要风险是 线程池复用导致的数据泄露。如果请求处理完后没有清理 ThreadLocal,下一个请求复用该线程时,会读到上一个请求的租户ID。
解决方案:
- 请求结束后清理 :在拦截器的
afterCompletion中调用TenantContextHolder.clear() - 使用 TransmittableThreadLocal :如果用了线程池(如
@Async),TTL 可以自动传递 ThreadLocal 值到子线程 - 防御性编程:获取租户ID时校验其合法性,发现异常立即告警
考点4:多租户架构下如何保证性能隔离?
答案: 性能隔离防止某个租户的高负载影响其他租户,常用手段:
- 租户级限流:为每个租户设置独立的 QPS 上限,防止单租户刷接口
- 数据库连接池隔离:独立数据库方案天然隔离;共享数据库方案可以限制单租户的最大连接数
- 慢查询监控:记录每个租户的慢查询,超过阈值自动告警或熔断
- 资源配额:限制单租户的存储空间、并发数、导出数据量等
考点5:多租户系统从共享数据库迁移到独立数据库,怎么设计迁移方案?
答案: 迁移方案需要保证 数据一致性 和 服务可用性:
- 双写阶段:新数据同时写入旧库和新库,保持同步
- 全量迁移:使用分批查询(如每次1000条)将历史数据迁移到新库
- 增量同步:通过 binlog 或变更日志表,同步双写期间产生的增量数据
- 数据校验:对比新旧库的数据条数、校验和,确保一致
- 流量切换:灰度切换,先切读流量验证,再切写流量
- 回滚预案:切换失败时,能快速切回旧库
八、模拟面试官提问
场景题1:你的SaaS系统有1000个租户,某天发现数据库CPU飙到90%,怎么定位是哪个租户导致的?
参考答案:
可以从三个层面定位:
- 应用层:在拦截器里记录每个租户的请求耗时和QPS,存入 Prometheus / ELK,按租户维度聚合
- 数据库层 :开启 MySQL 的
performance_schema,通过events_statements_summary_by_digest查看慢SQL,结合应用层的租户ID日志关联 - 实时熔断:如果某个租户的QPS超过阈值,临时将其限流或降级,保护整体系统
长期方案:给高频租户单独分配数据库实例或只读副本。
场景题2:平台管理员需要导出全平台所有租户的数据报表,但每个接口都强制拼接了tenant_id,怎么实现?
参考答案:
三种思路:
- 专用接口 + 权限控制 :定义一个
@PlatformAdmin注解,只有平台管理员能调用。该接口使用@InterceptorIgnore(tenantLine = "1")跳过租户过滤,但必须在Service层手动校验调用者身份 - 数据仓库:将各租户数据通过ETL同步到独立的数据仓库(如ClickHouse),报表查询走数仓,不影响业务库
- 分租户聚合:先按租户分批查询,再在内存中聚合。适合数据量小的场景
推荐方案2,数仓专门做分析查询,不影响业务库性能。
场景题3:租户A的员工误操作删除了大量数据,要求恢复。你的系统怎么设计数据恢复能力?
参考答案:
数据恢复需要前置设计:
- 逻辑删除 :所有业务表加
deleted字段,删除时只标记不物理删除。配合MyBatis-Plus的LogicDelete插件 - 操作审计日志:记录每条数据的变更历史(谁、什么时候、改了什么),存到独立的审计表或 Kafka
- 数据库备份 :
- 共享数据库方案:按
tenant_id过滤导出,单独恢复 - 独立数据库方案:直接恢复该租户的数据库备份
- 共享数据库方案:按
- Binlog回放:通过 Canal / Maxwell 订阅 binlog,实现按时间点恢复(PITR)
场景题4:你的多租户系统要支持"子租户"------比如企业客户下面还有多个分公司,每个分公司数据也要隔离,怎么设计?
参考答案:
这是 多级租户 的场景,可以设计为 "租户 + 组织" 的两级模型:
- 一级租户(Tenant):对应企业客户,决定数据隔离的边界(数据库/Schema/租户ID)
- 二级组织(Organization):对应分公司,在同一租户内再做数据隔离
实现方式:
- 表结构增加
org_id字段 - 查询时同时拼接
tenant_id = ? AND org_id = ? - 权限模型扩展:用户-角色-组织 的关联关系
- 数据权限框架(如 MyBatis-Plus 的 DataPermissionInterceptor)实现
org_id的自动拼接
场景题5:多租户系统要支持"白标"(White Label)------每家企业的登录页、Logo、主题色都不一样,怎么实现?
参考答案:
白标的核心是 "配置驱动 + 动态渲染":
-
租户配置表:存储每家企业的品牌配置
sqlCREATE 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) ); -
前端动态加载 :登录页根据域名或请求参数中的
tenant_id,调用/api/branding接口获取配置,动态设置样式 -
域名绑定 :每家企业可以绑定自己的子域名(如
companyA.saas.com),后端根据域名解析出tenant_id -
静态资源隔离 :Logo、背景图等资源按租户ID分目录存储(如
/static/tenant_1001/logo.png)
九、互动话题
你们公司的SaaS系统用的是哪种多租户方案?共享数据库、独立Schema还是独立数据库?有没有遇到过"租户A看到租户B数据"这种惊魂时刻?或者你在实现多租户时踩过什么独特的坑?评论区聊聊,我选最精彩的几个故事单独开一篇。