多租户架构设计与数据隔离
❓ 面试官:你们公司的 SaaS 平台需要支持多个企业客户(租户)使用,如何设计多租户架构?不同租户的数据如何实现隔离?【中高级】
频率:🔥🔥🔥🔥🔥
💡 一句话总结(先抛结论): 多租户架构的核心是"一套代码、多套数据"。根据隔离级别从低到高分为三种方案:字段级隔离(共享数据库+共享 Schema)、Schema 级隔离(共享数据库+独立 Schema)、数据库级隔离(独立数据库)。选择哪种方案取决于业务对安全性、成本、运维复杂度的权衡。
📝 三种主流方案的详细对比与实现:
方案一:字段级隔离(Tenant ID 列)—— 最常用、性价比最高
适用场景:中小型企业 SaaS、对成本敏感、租户数量多但单租户数据量不大。
核心思路:所有租户共用一张表,通过 tenant_id 字段区分数据归属。
-- 示例:订单表
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL COMMENT '租户ID',
order_no VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
amount DECIMAL(10,2),
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_tenant_id (tenant_id), -- 必须加索引!
INDEX idx_tenant_order (tenant_id, order_no) -- 联合索引优化查询
);✅ 优点:
- 成本最低:只需一套数据库,硬件资源利用率最高。
- 运维简单:备份、升级、扩容都是统一操作。
- 跨租户统计方便:做全局数据分析时不需要跨库查询。
⚠️ 缺点与风险:
- 数据泄露风险高:如果代码里忘记加
WHERE tenant_id = ?,就会查错数据甚至跨租户泄露!这是致命问题。 - 性能瓶颈:单表数据量会指数级增长,即使分库分表也很复杂。
- 备份恢复困难:无法单独为某个租户做数据恢复。
🛡️ 如何保证数据隔离不泄露(关键考点):
MyBatis 拦截器 / Hibernate Filter:在 ORM 层自动注入
tenant_id条件,开发者写 SQL 时无感知。java// MyBatis Plus 的多租户插件配置示例 @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(); tenantInterceptor.setTenantLineHandler(new TenantLineHandler() { @Override public Expression getTenantId() { // 从 ThreadLocal 或 SecurityContext 获取当前租户ID return new LongValue(TenantContextHolder.getTenantId()); } @Override public String getTenantIdColumn() { return "tenant_id"; } @Override public boolean ignoreTable(String tableName) { // 某些公共表(如字典表)不需要租户隔离 return "sys_dict".equals(tableName); } }); interceptor.addInnerInterceptor(tenantInterceptor); return interceptor; }代码审查与单元测试:强制要求所有查询必须带租户上下文,CI/CD 流程中加入自动化检测。
数据库权限控制:应用层账号只允许访问自己的 Schema,防止越权。
方案二:Schema 级隔离(PostgreSQL Schema / MySQL Database)
适用场景:中大型企业、对数据安全性要求较高、租户数量适中(几十到几百个)。
核心思路:每个租户拥有独立的 Schema(PostgreSQL)或 Database(MySQL),但物理上仍在同一台数据库服务器。
-- PostgreSQL 示例:为每个租户创建独立 Schema
CREATE SCHEMA tenant_1001;
CREATE SCHEMA tenant_1002;
-- 在每个 Schema 下创建相同的表结构
CREATE TABLE tenant_1001.orders (
id BIGINT PRIMARY KEY,
order_no VARCHAR(64),
...
);
CREATE TABLE tenant_1002.orders (
id BIGINT PRIMARY KEY,
order_no VARCHAR(64),
...
);✅ 优点:
- 逻辑隔离清晰:天然避免了"忘写 WHERE tenant_id"的 bug。
- 独立备份恢复:可以单独备份某个租户的数据。
- 性能相对较好:不同租户的数据不会互相干扰索引和缓存。
⚠️ 缺点:
- 运维复杂度增加:新增租户时需要动态创建 Schema 并执行建表脚本。
- 跨租户查询困难:如果需要做全局报表,需要 UNION 多个 Schema 的表。
- 连接池管理复杂:需要根据租户路由到不同的 Schema。
🔧 技术实现要点:
// Spring Boot + Dynamic DataSource 实现 Schema 切换
@Component
public class TenantDataSourceRouter extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// 根据当前租户ID返回对应的 DataSource Key
Long tenantId = TenantContextHolder.getTenantId();
return "tenant_" + tenantId;
}
}
// 配置多个数据源
@Configuration
public class DataSourceConfig {
@Bean("tenant_1001")
public DataSource tenant1001DataSource() {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl("jdbc:postgresql://localhost:5432/saas_db");
ds.setUsername("saas_user");
ds.setPassword("password");
// 设置 search_path 指定 Schema
ds.addDataSourceProperty("currentSchema", "tenant_1001");
return ds;
}
@Bean("tenant_1002")
public DataSource tenant1002DataSource() {
// 类似配置...
}
@Primary
@Bean
public DataSource routingDataSource() {
TenantDataSourceRouter router = new TenantDataSourceRouter();
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("tenant_1001", tenant1001DataSource());
targetDataSources.put("tenant_1002", tenant1002DataSource());
router.setTargetDataSources(targetDataSources);
return router;
}
}方案三:数据库级隔离(独立数据库实例)
适用场景:超大型企业客户、政府/金融等对数据安全极度敏感的场景、愿意支付高价的 VIP 租户。
核心思路:每个租户拥有完全独立的数据库实例,甚至可以部署在不同的物理服务器上。
✅ 优点:
- 物理隔离、安全性最高:完全不用担心数据泄露。
- 性能互不影响:大租户不会拖垮小租户。
- 灵活定制:可以为 VIP 租户单独优化数据库配置、索引策略。
⚠️ 缺点:
- 成本极高:每个租户都需要独立的计算和存储资源。
- 运维噩梦:100 个租户就要维护 100 个数据库实例,升级、备份、监控工作量爆炸。
- 资源浪费:小租户的数据库大部分时间空闲,但依然占用资源。
🔧 实现方式: 通常结合数据库中间件(如 ShardingSphere、MyCat)或云数据库服务(如 AWS RDS、阿里云 PolarDB)来实现自动化 provisioning。
❓ 面试官:在多租户系统中,有些数据是需要所有租户共享的(比如字典表、省份城市数据、系统公告),这种"数据共享"如何实现?
频率:🔥🔥🔥🔥
💡 一句话总结(先抛结论): 共享数据采用"公共表 + 特殊标识"的设计,在 ORM 层通过白名单机制跳过租户隔离逻辑。
📝 具体实现方案:
方案一:公共表不加 tenant_id 字段
-- 系统字典表(所有租户共享)
CREATE TABLE sys_dict (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
dict_type VARCHAR(64) NOT NULL COMMENT '字典类型',
dict_key VARCHAR(64) NOT NULL COMMENT '字典键',
dict_value VARCHAR(255) NOT NULL COMMENT '字典值',
sort_order INT DEFAULT 0,
is_system TINYINT DEFAULT 1 COMMENT '是否系统内置'
);
-- 省份城市表(全国通用)
CREATE TABLE sys_region (
id BIGINT PRIMARY KEY,
parent_id BIGINT,
region_name VARCHAR(64),
region_code VARCHAR(32)
);ORM 层配置白名单:
// 在 MyBatis Plus 租户插件中忽略这些表
@Override
public boolean ignoreTable(String tableName) {
// 这些表不需要租户隔离
return Arrays.asList("sys_dict", "sys_region", "sys_announcement")
.contains(tableName);
}方案二:共享表 + tenant_id = 0 或 NULL
有些场景下,共享数据和私有数据结构相同,可以用特殊值标识。
CREATE TABLE product_category (
id BIGINT PRIMARY KEY,
tenant_id BIGINT COMMENT '租户ID,0表示全局共享',
category_name VARCHAR(64),
parent_id BIGINT
);
-- 插入共享数据
INSERT INTO product_category VALUES (1, 0, '电子产品', NULL);
INSERT INTO product_category VALUES (2, 0, '服装', NULL);
-- 插入租户自定义数据
INSERT INTO product_category VALUES (101, 1001, '手机配件', 1);
INSERT INTO product_category VALUES (102, 1001, '男装', 2);查询逻辑:
// 查询时合并共享数据和租户私有数据
List<Category> categories = mapper.selectList(
new LambdaQueryWrapper<Category>()
.and(wrapper -> wrapper
.eq(Category::getTenantId, 0) // 共享数据
.or()
.eq(Category::getTenantId, currentTenantId) // 私有数据
)
);方案三:混合模式(推荐)
核心思想:共享数据放在公共表,租户可以基于共享数据做"个性化扩展"。
-- 基础商品分类(共享)
CREATE TABLE base_category (
id BIGINT PRIMARY KEY,
category_name VARCHAR(64),
level INT COMMENT '分类层级'
);
-- 租户自定义分类扩展
CREATE TABLE tenant_category_extension (
id BIGINT PRIMARY KEY,
tenant_id BIGINT NOT NULL,
base_category_id BIGINT COMMENT '关联的基础分类ID',
custom_name VARCHAR(64) COMMENT '租户自定义名称',
sort_order INT,
is_enabled TINYINT DEFAULT 1
);业务逻辑:
- 租户 A 看到的基础分类是"电子产品"。
- 租户 A 可以在后台把"电子产品"重命名为"数码家电"(只改自己的扩展表)。
- 其他租户不受影响,依然显示"电子产品"。
❓ 面试官:如果采用"字段级隔离"方案,随着业务发展,单表数据量达到几亿条,如何优化?
频率:🔥🔥🔥🔥
💡 一句话总结(先抛结论): 分库分表 + 热点数据缓存 + 历史数据归档。
📝 优化策略组合拳:
垂直拆分:把大字段(如商品详情 HTML)拆到扩展表,主表只保留核心字段。
水平分表(Sharding):
- 按租户 ID 哈希分表:
orders_{tenant_id % 16},保证同一个租户的数据在同一张表。 - 按时间范围分表:
orders_202401、orders_202402,适合有时间维度的查询。
- 按租户 ID 哈希分表:
冷热数据分离:
- 最近 3 个月的订单放主表(热数据)。
- 3 个月前的订单迁移到历史表或归档到 HBase/OSS(冷数据)。
读写分离 + 缓存:
- 高频查询走 Redis 缓存。
- 读请求打到从库,写请求走主库。
搜索引擎辅助查询:
- 复杂的多条件筛选(如"某租户、某时间段、金额大于 1000 的订单")同步到 Elasticsearch。
❓ 面试官:多租户系统中,如何实现"租户级别的功能开关"?比如 VIP 租户才能使用导出 Excel 功能。
频率:🔥🔥🔥
💡 一句话总结(先抛结论): 采用"租户套餐表 + 功能权限矩阵"的设计,在网关或拦截器层做功能鉴权。
📝 实现方案:
-- 租户套餐表
CREATE TABLE tenant_package (
id BIGINT PRIMARY KEY,
package_name VARCHAR(64) COMMENT '套餐名称:免费版/专业版/企业版',
price DECIMAL(10,2),
max_users INT COMMENT '最大用户数',
max_storage_gb INT COMMENT '最大存储空间'
);
-- 功能权限配置表
CREATE TABLE package_feature (
id BIGINT PRIMARY KEY,
package_id BIGINT,
feature_code VARCHAR(64) COMMENT '功能编码:export_excel、api_access',
is_enabled TINYINT DEFAULT 1
);
-- 租户信息表
CREATE TABLE tenant_info (
id BIGINT PRIMARY KEY,
tenant_name VARCHAR(128),
package_id BIGINT,
expire_date DATE COMMENT '套餐到期时间'
);后端拦截器实现:
@Component
public class FeaturePermissionInterceptor implements HandlerInterceptor {
@Autowired
private TenantFeatureService featureService;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 获取当前租户ID
Long tenantId = TenantContextHolder.getTenantId();
// 获取当前请求的功能点(从注解或 URL 解析)
String featureCode = extractFeatureCode(request);
// 检查该租户是否有此功能权限
if (!featureService.hasFeature(tenantId, featureCode)) {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.getWriter().write("您的套餐不支持此功能,请升级到专业版");
return false;
}
return true;
}
}
// 使用注解标记需要权限的功能
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireFeature {
String value(); // 功能编码
}前端配合: 根据租户套餐信息,动态隐藏或禁用不支持的功能按钮,提升用户体验。