tenant-management
SKILL.md
多租户架构设计指南
通用模板。如果项目有专属技能,优先使用。
设计原则
- 隔离性:租户数据必须完全隔离,不可跨租户访问。
- 透明性:业务代码尽量不感知多租户,由框架层自动处理。
- 可扩展:新增租户不需要改代码,配置即可上线。
- 运维友好:备份恢复、数据迁移应以租户为单位。
三种隔离模式对比
| 维度 | 字段隔离 | Schema 隔离 | 物理隔离(独立库) |
|---|---|---|---|
| 实现方式 | 每张表加 tenant_id 字段 |
每个租户独立 Schema | 每个租户独立数据库 |
| 隔离强度 | 低(逻辑隔离) | 中(Schema 级) | 高(物理隔离) |
| 数据安全 | 依赖应用层正确过滤 | 数据库级隔离 | 最安全 |
| 资源利用 | 高(共享一切) | 中(共享实例) | 低(独占资源) |
| 扩展性 | 单库上限制约 | 单实例 Schema 数限制 | 水平扩展灵活 |
| 运维复杂度 | 低 | 中 | 高 |
| 备份恢复 | 复杂(需过滤) | 简单(按 Schema) | 最简单(按库) |
| 租户定制 | 困难 | 可支持 | 完全独立 |
| 成本 | 最低 | 中等 | 最高 |
| 适用场景 | 中小 SaaS、租户量大 | 中等规模、需一定隔离 | 大客户、强合规需求 |
实现模式
模式一:字段隔离(最常见)
// 1. Entity 携带 tenant_id
public class [你的租户基类] extends [你的基础Entity] {
private String tenantId;
}
@TableName("biz_order")
public class BizOrder extends [你的租户基类] {
private Long id;
private String orderNo;
}
// 2. 数据库表必须含 tenant_id
// CREATE TABLE biz_order (
// id BIGINT NOT NULL,
// tenant_id VARCHAR(20) DEFAULT '000000' COMMENT '租户ID',
// order_no VARCHAR(64) NOT NULL,
// PRIMARY KEY (id),
// KEY idx_tenant_id (tenant_id)
// );
// 3. MyBatis 拦截器自动追加条件
// SELECT * FROM biz_order WHERE tenant_id = '000001' AND ...
// INSERT INTO biz_order (tenant_id, ...) VALUES ('000001', ...)
核心工具类模式:
public class [你的租户工具类] {
// 获取当前租户ID(从请求上下文/ThreadLocal)
public static String getTenantId() { ... }
// 忽略租户过滤(查全量数据)
public static <T> T ignore(Supplier<T> supplier) {
// 临时关闭拦截器的租户条件追加
try {
setIgnore(true);
return supplier.get();
} finally {
setIgnore(false);
}
}
// 切换到指定租户执行
public static void dynamic(String tenantId, Runnable runnable) {
String original = getTenantId();
try {
setTenantId(tenantId);
runnable.run();
} finally {
setTenantId(original);
}
}
// 带返回值的租户切换
public static <T> T dynamic(String tenantId, Supplier<T> supplier) {
String original = getTenantId();
try {
setTenantId(tenantId);
return supplier.get();
} finally {
setTenantId(original);
}
}
}
配置排除表(不需要租户过滤的共享表):
tenant:
enable: true
excludes:
- sys_menu
- sys_dict_type
- sys_dict_data
- sys_config
模式二:Schema 隔离
// 动态数据源 / 动态 Schema 切换
public class TenantSchemaResolver {
public String resolveSchema(String tenantId) {
return "tenant_" + tenantId; // tenant_000001, tenant_000002
}
}
// 通过 AbstractRoutingDataSource 或 MyBatis 拦截器切换 Schema
// SET search_path TO tenant_000001; (PostgreSQL)
// USE tenant_000001; (MySQL)
模式三:物理隔离(独立库)
// 每个租户对应独立的数据源配置
// 通过 AbstractRoutingDataSource 或动态数据源框架切换
public class TenantDataSourceRouter extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return [你的租户工具类].getTenantId();
}
}
// 典型用法
[你的租户工具类].doInTenant(tenantId, () -> {
// 自动路由到该租户的数据库
orderMapper.insert(order);
});
[你的租户工具类].doInSystem(() -> {
// 路由到系统公共库
configMapper.selectList(null);
});
常见场景
场景 1:管理员查看所有租户数据
public List<UserVo> listAllTenantUsers() {
return [你的租户工具类].ignore(() -> userMapper.selectVoList(null));
}
场景 2:跨租户数据同步
public void syncConfigToAllTenants(Config config) {
List<String> tenantIds = [你的租户工具类].ignore(() ->
tenantMapper.selectList(null).stream()
.map(Tenant::getTenantId).collect(Collectors.toList())
);
for (String tenantId : tenantIds) {
[你的租户工具类].dynamic(tenantId, () -> {
configMapper.insertOrUpdate(config);
});
}
}
场景 3:定时任务遍历所有租户
@Scheduled(cron = "0 0 2 * * ?")
public void cleanupExpiredOrders() {
List<Tenant> tenants = [你的租户工具类].ignore(() ->
tenantMapper.selectByStatus("ACTIVE")
);
for (Tenant tenant : tenants) {
try {
[你的租户工具类].dynamic(tenant.getTenantId(), () -> {
orderMapper.deleteExpired(30);
});
} catch (Exception e) {
log.error("清理租户 {} 订单失败: {}", tenant.getTenantId(), e.getMessage());
}
}
}
场景 4:Redis 缓存隔离
原始 Key: user:info:1001
实际 Key: 000000:user:info:1001 (租户 000000)
实际 Key: 000001:user:info:1001 (租户 000001)
全局缓存(不隔离):使用特定前缀标识全局 Key。
选型建议
| 维度 | 字段隔离 | Schema 隔离 | 物理隔离 |
|---|---|---|---|
| 租户数量 | 大量(100+) | 中等(10-100) | 少量(<20) |
| 数据量级 | 单租户数据量小 | 中等 | 大 |
| 合规要求 | 一般 | 中等 | 严格(金融、政务) |
| 预算 | 有限 | 中等 | 充足 |
| 定制化 | 几乎不需要 | 少量 | 高度定制 |
常见错误
// 1. Entity 忘记继承租户基类 / 表缺少 tenant_id(字段隔离模式)
public class BizOrder extends BaseEntity { } // 缺少 tenantId
// 表中也没有 tenant_id 字段 -> 数据不隔离
// 2. 手动设置租户后忘记清理
[你的租户工具类].setTenantId("000001");
userMapper.insert(user);
// 忘记恢复原租户ID -> 后续请求使用错误租户
// 应使用 dynamic() 方法(自动恢复)
// 3. 事务中切换租户(字段隔离模式下可能数据错乱)
@Transactional
public void wrongMethod() {
orderMapper.insert(order); // 租户 A
[你的租户工具类].dynamic("B", () -> logMapper.insert(log)); // 租户 B
// 如果回滚,租户 B 的数据不会回滚(不同事务)
}
// 应使用独立事务
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveToOtherTenant(String tenantId, Log log) { ... }
// 4. 共享表未排除租户过滤
// sys_dict_type 等共享表被加上 tenant_id 条件 -> 查不到数据
// 应在配置中排除这些表
// 5. 物理隔离模式下使用 tenant_id 字段(画蛇添足)
// 物理隔离已通过数据源区分租户,表中不需要 tenant_id
Weekly Installs
2
Repository
xu-cell/ai-engi…ing-initGitHub Stars
8
First Seen
7 days ago
Security Audits
Installed on
amp2
cline2
opencode2
cursor2
kimi-cli2
codex2