知识点编号:266
难度系数:⭐⭐⭐⭐⭐
实用指数:💯💯💯💯💯
📖 开篇:一个房东的智慧
想象你是一个房东,有一栋10层的大楼 🏢。现在有三种出租方式:
🏠 方案A:独栋别墅(独立部署)
每个租客都给他盖一栋独立别墅。
- 优点:完全隔离,互不干扰 ✅
- 缺点:成本高,维护累,土地不够用 ❌
🏘️ 方案B:公寓楼(多租户)
把一栋楼分成多个房间,每个租客一间。
- 优点:成本低,统一管理,资源利用率高 ✅
- 缺点:需要做好隔离 ⚠️
🏨 方案C:胶囊旅馆(资源共享)
所有人睡一个大通铺。
- 优点:成本最低 💰
- 缺点:隐私?不存在的!❌
多租户系统就是方案B:在一套系统中服务多个客户(租户),既节约成本,又保证隔离!🎯
🎯 什么是多租户系统?
定义 📚
多租户(Multi-Tenant) 是一种软件架构,一套系统实例可以同时为多个客户(租户) 提供服务,每个租户的数据和配置相互隔离。
生活中的多租户 🌍
| 场景 | 说明 | 多租户体现 |
|---|---|---|
| 云盘 📦 | 百度网盘、阿里云盘 | 一套系统,每个用户独立空间 |
| SaaS系统 💼 | 钉钉、企业微信、Salesforce | 一套代码,服务成千上万企业 |
| 电商平台 🛒 | 拼多多、淘宝 | 一个平台,无数商家开店 |
| 在线教育 📚 | 腾讯课堂 | 一套系统,每个机构独立管理 |
🏗️ 多租户的三种隔离方案
┌──────────────────────────────────────────────────────────┐
│ 多租户数据隔离三大流派 │
└──────────────────────────────────────────────────────────┘
方案1: 独立数据库(Database Per Tenant)
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 租户A │ │ 租户B │ │ 租户C │
│ DB_A │ │ DB_B │ │ DB_C │
└─────────┘ └─────────┘ └─────────┘
方案2: 共享数据库,独立Schema(Schema Per Tenant)
┌────────────────────────────────┐
│ 数据库实例 │
│ ┌────────┐ ┌────────┐ │
│ │Schema A│ │Schema B│ ... │
│ └────────┘ └────────┘ │
└────────────────────────────────┘
方案3: 共享数据库+Schema(Shared Database)
┌────────────────────────────────┐
│ 数据库实例 │
│ ┌──────────────────────┐ │
│ │ user表 │ │
│ │ id | name | tenant_id│ │
│ │ 1 | 张三 | A │ │
│ │ 2 | 李四 | B │ │
│ └──────────────────────┘ │
└────────────────────────────────┘
🎨 方案一:独立数据库(壕无人性版)
架构图
应用层
┌───────────────┐
│ 统一入口 │
│ (路由分发) │
└───────┬───────┘
│
┌────────┴────────┐
│ 数据源路由器 │
└────┬────┬───┬───┘
│ │ │
┌──────┘ │ └──────┐
↓ ↓ ↓
┌───────┐ ┌───────┐ ┌───────┐
│DB_A │ │DB_B │ │DB_C │
│ │ │ │ │ │
│租户A │ │租户B │ │租户C │
└───────┘ └───────┘ └───────┘
生活比喻 🏘️
每个租客都有自己的独立别墅,想怎么装修就怎么装修,互不影响。
代码实现
1. 配置多数据源
# application.yml
spring:
datasource:
tenants:
tenant-a:
url: jdbc:mysql://localhost:3306/tenant_a
username: root
password: 123456
tenant-b:
url: jdbc:mysql://localhost:3306/tenant_b
username: root
password: 123456
tenant-c:
url: jdbc:mysql://localhost:3306/tenant_c
username: root
password: 123456
2. 动态数据源配置
@Configuration
public class DynamicDataSourceConfig {
@Bean
public DataSource dynamicDataSource() {
DynamicRoutingDataSource dataSource = new DynamicRoutingDataSource();
// 默认数据源
dataSource.setDefaultTargetDataSource(tenantADataSource());
// 租户数据源映射
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("tenant-a", tenantADataSource());
targetDataSources.put("tenant-b", tenantBDataSource());
targetDataSources.put("tenant-c", tenantCDataSource());
dataSource.setTargetDataSources(targetDataSources);
return dataSource;
}
private DataSource tenantADataSource() {
return DataSourceBuilder.create()
.url("jdbc:mysql://localhost:3306/tenant_a")
.username("root")
.password("123456")
.build();
}
// tenantBDataSource()、tenantCDataSource() 类似...
}
3. 动态路由数据源
public class DynamicRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// 从上下文中获取当前租户ID
return TenantContext.getCurrentTenant();
}
}
4. 租户上下文
public class TenantContext {
private static final ThreadLocal<String> TENANT_HOLDER = new ThreadLocal<>();
public static void setCurrentTenant(String tenantId) {
TENANT_HOLDER.set(tenantId);
}
public static String getCurrentTenant() {
return TENANT_HOLDER.get();
}
public static void clear() {
TENANT_HOLDER.remove();
}
}
5. 租户拦截器
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// 从请求头获取租户ID
String tenantId = request.getHeader("X-Tenant-Id");
if (StringUtils.isEmpty(tenantId)) {
// 或者从JWT Token中解析
tenantId = getTenantFromToken(request);
}
// 设置到上下文
TenantContext.setCurrentTenant(tenantId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
// 清理上下文
TenantContext.clear();
}
private String getTenantFromToken(HttpServletRequest request) {
String token = request.getHeader("Authorization");
// 解析JWT获取tenantId
return JwtUtil.getTenantId(token);
}
}
6. 业务代码(无感知)
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
public List<User> getUsers() {
// 自动路由到对应租户的数据库
return userMapper.selectList(null);
}
public void addUser(User user) {
// 写入当前租户的数据库
userMapper.insert(user);
}
}
优缺点分析
| 维度 | 评价 | 说明 |
|---|---|---|
| 隔离性 | ⭐⭐⭐⭐⭐ | 物理隔离,最安全 |
| 性能 | ⭐⭐⭐⭐⭐ | 互不影响 |
| 扩展性 | ⭐⭐⭐⭐ | 可独立扩展某个租户 |
| 成本 | ⭐⭐ | 数据库实例多,成本高 |
| 维护 | ⭐⭐ | 升级、备份麻烦 |
| 个性化 | ⭐⭐⭐⭐⭐ | 可定制表结构 |
| 适用场景 | 💰 | 大客户、高安全要求 |
🎪 方案二:独立Schema(中产阶级版)
架构图
应用层
┌──────────────┐
│ 统一入口 │
└──────┬───────┘
│
┌────┴────┐
│ DB实例 │
│ ┌──────┴───────┐
│ │ Schema A │
│ │ - user │
│ │ - order │
│ └──────────────┘
│ ┌──────────────┐
│ │ Schema B │
│ │ - user │
│ │ - order │
│ └──────────────┘
└──────────────────┘
生活比喻 🏢
一栋楼里的不同楼层,每层都有独立的房间布局。
代码实现
1. 动态Schema切换
public class SchemaRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
String tenantId = TenantContext.getCurrentTenant();
// 每个租户对应一个Schema
return "schema_" + tenantId;
}
@Override
public Connection getConnection() throws SQLException {
Connection connection = super.getConnection();
String schema = determineCurrentLookupKey().toString();
// 切换Schema
connection.setSchema(schema);
// 或者执行SQL: USE schema_xxx
return connection;
}
}
2. MyBatis拦截器方式
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class,
RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "update",
args = {MappedStatement.class, Object.class})
})
public class TenantSchemaInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 获取当前租户
String tenantId = TenantContext.getCurrentTenant();
// 切换Schema
Connection connection = getConnection(invocation);
String schema = "tenant_" + tenantId;
connection.createStatement().execute("USE " + schema);
// 执行原SQL
return invocation.proceed();
}
}
3. 租户初始化
@Service
public class TenantInitService {
@Autowired
private DataSource dataSource;
/**
* 新租户注册,创建独立Schema
*/
public void createTenant(String tenantId) throws SQLException {
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
// 1. 创建Schema
String schema = "tenant_" + tenantId;
stmt.execute("CREATE SCHEMA " + schema);
// 2. 切换到新Schema
stmt.execute("USE " + schema);
// 3. 创建表结构
String[] initSqls = loadInitSql();
for (String sql : initSqls) {
stmt.execute(sql);
}
log.info("✅ 租户{}初始化成功!", tenantId);
}
}
private String[] loadInitSql() {
// 从文件或资源加载建表SQL
return new String[]{
"CREATE TABLE user (...)",
"CREATE TABLE order (...)",
// ...
};
}
}
优缺点分析
| 维度 | 评价 | 说明 |
|---|---|---|
| 隔离性 | ⭐⭐⭐⭐ | Schema级别隔离 |
| 性能 | ⭐⭐⭐⭐ | 共享连接池,略有影响 |
| 扩展性 | ⭐⭐⭐ | 单实例有上限 |
| 成本 | ⭐⭐⭐ | 共享DB实例,成本适中 |
| 维护 | ⭐⭐⭐ | 统一升级,但Schema多 |
| 个性化 | ⭐⭐⭐⭐ | 可定制表结构 |
| 适用场景 | 🏢 | 中型企业,平衡之选 |
🎯 方案三:共享表+租户字段(经济适用版)
架构图
应用层
┌──────────────┐
│ 统一入口 │
└──────┬───────┘
│
┌────┴────┐
│ 数据库 │
│ │
│ ┌──────────────────────┐
│ │ user表 │
│ ├──────────────────────┤
│ │ id | name | tenant_id│
│ │ 1 | 张三 | A │
│ │ 2 | 李四 | A │
│ │ 3 | 王五 | B │
│ └──────────────────────┘
└──────────────────────────┘
生活比喻 🏨
一个大通铺,但每个人的床位贴了名字标签。
数据库设计
1. 表结构设计
-- 用户表
CREATE TABLE user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL,
password VARCHAR(100) NOT NULL,
email VARCHAR(100),
tenant_id VARCHAR(50) NOT NULL, -- 租户ID ⭐
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_tenant (tenant_id), -- 必须建索引!
KEY idx_tenant_user (tenant_id, username)
);
-- 订单表
CREATE TABLE `order` (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(50) NOT NULL,
user_id BIGINT NOT NULL,
amount DECIMAL(10,2),
tenant_id VARCHAR(50) NOT NULL, -- 租户ID ⭐
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_tenant (tenant_id),
KEY idx_tenant_order (tenant_id, order_no)
);
-- 所有业务表都要加 tenant_id 字段!
代码实现
1. 实体类设计
@Data
public class BaseEntity {
@TableField(fill = FieldFill.INSERT)
private String tenantId; // 自动填充
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@Data
@TableName("user")
public class User extends BaseEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String username;
private String password;
private String email;
}
2. MyBatis-Plus自动填充
@Component
public class TenantMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
// 插入时自动填充租户ID
String tenantId = TenantContext.getCurrentTenant();
this.strictInsertFill(metaObject, "tenantId", String.class, tenantId);
}
@Override
public void updateFill(MetaObject metaObject) {
// 更新不需要改租户ID
}
}
3. MyBatis-Plus租户插件(自动拼接SQL)
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 添加租户插件
TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor();
// 租户处理器
tenantInterceptor.setTenantLineHandler(new TenantLineHandler() {
@Override
public Expression getTenantId() {
// 当前租户ID
String tenantId = TenantContext.getCurrentTenant();
return new StringValue(tenantId);
}
@Override
public String getTenantIdColumn() {
// 租户字段名
return "tenant_id";
}
@Override
public boolean ignoreTable(String tableName) {
// 哪些表不需要租户隔离
return "tenant".equals(tableName)
|| "sys_config".equals(tableName);
}
});
interceptor.addInnerInterceptor(tenantInterceptor);
return interceptor;
}
}
4. 业务代码(完全无感)
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
public List<User> getUsers() {
// 原始SQL: SELECT * FROM user
// 自动改写: SELECT * FROM user WHERE tenant_id = 'A'
return userMapper.selectList(null);
}
public User getUserById(Long id) {
// 原始SQL: SELECT * FROM user WHERE id = 1
// 自动改写: SELECT * FROM user WHERE id = 1 AND tenant_id = 'A'
return userMapper.selectById(id);
}
public void addUser(User user) {
// 自动填充tenant_id
userMapper.insert(user);
}
}
5. SQL改写示例
改写前:
SELECT * FROM user WHERE username = 'zhangsan';
改写后:
SELECT * FROM user
WHERE username = 'zhangsan'
AND tenant_id = 'tenant-a'; ← 自动添加
复杂查询改写:
-- 改写前
SELECT u.*, o.order_no
FROM user u
LEFT JOIN `order` o ON u.id = o.user_id
WHERE u.status = 1;
-- 改写后
SELECT u.*, o.order_no
FROM user u
LEFT JOIN `order` o ON u.id = o.user_id
AND o.tenant_id = 'tenant-a' ← 自动添加
WHERE u.status = 1
AND u.tenant_id = 'tenant-a'; ← 自动添加
优缺点分析
| 维度 | 评价 | 说明 |
|---|---|---|
| 隔离性 | ⭐⭐ | 逻辑隔离,有风险 |
| 性能 | ⭐⭐⭐ | 大租户影响小租户 |
| 扩展性 | ⭐⭐ | 数据量大后分表困难 |
| 成本 | ⭐⭐⭐⭐⭐ | 成本最低 |
| 维护 | ⭐⭐⭐⭐⭐ | 统一升级,简单 |
| 个性化 | ⭐ | 无法定制 |
| 适用场景 | 🏢 | 小微企业,SaaS启动 |
🔐 多租户的安全问题
问题1:租户越权访问 🚨
场景:租户A的用户访问到租户B的数据!
攻击方式:
# 正常请求
curl -H "X-Tenant-Id: tenant-a" \
http://api.com/users/1
# 恶意请求(修改租户ID)
curl -H "X-Tenant-Id: tenant-b" \ ← 伪造租户ID
http://api.com/users/1
解决方案:
方案A:JWT中包含租户ID
public class JwtUtil {
public static String generateToken(String userId, String tenantId) {
return JWT.create()
.withClaim("userId", userId)
.withClaim("tenantId", tenantId) // 租户ID写入Token
.withExpiresAt(new Date(System.currentTimeMillis() + 3600000))
.sign(Algorithm.HMAC256(SECRET));
}
public static String getTenantId(String token) {
DecodedJWT jwt = JWT.require(Algorithm.HMAC256(SECRET))
.build()
.verify(token);
return jwt.getClaim("tenantId").asString();
}
}
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String token = request.getHeader("Authorization");
// 从Token解析租户ID(不相信Header)
String tenantId = JwtUtil.getTenantId(token);
// Header中的租户ID(用户传的)
String headerTenantId = request.getHeader("X-Tenant-Id");
// 校验一致性
if (!tenantId.equals(headerTenantId)) {
throw new SecurityException("租户ID不匹配!");
}
TenantContext.setCurrentTenant(tenantId);
return true;
}
}
方案B:服务端验证
@Service
public class UserService {
public User getUserById(Long id) {
User user = userMapper.selectById(id);
// 二次校验租户ID
String currentTenant = TenantContext.getCurrentTenant();
if (!currentTenant.equals(user.getTenantId())) {
throw new SecurityException("无权访问该数据!");
}
return user;
}
}
问题2:SQL注入绕过租户过滤
攻击SQL:
-- 恶意构造的条件
WHERE id = 1 OR tenant_id = 'tenant-b'
解决方案:使用预编译,永远不要拼接SQL!
// ❌ 错误示范
String sql = "SELECT * FROM user WHERE id = " + id;
// ✅ 正确做法
String sql = "SELECT * FROM user WHERE id = ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setLong(1, id);
🎨 多租户的高级特性
1️⃣ 租户个性化配置
@Data
@TableName("tenant_config")
public class TenantConfig {
@TableId
private String tenantId;
// 个性化配置
private String logo; // 租户Logo
private String primaryColor; // 主题色
private Integer maxUsers; // 最大用户数
private Boolean enableSms; // 是否开通短信
private String customDomain; // 自定义域名
// 功能开关
private JSONObject features; // {"报表": true, "导出": false}
}
@Service
public class TenantConfigService {
@Autowired
private TenantConfigMapper configMapper;
@Cacheable(value = "tenant-config", key = "#tenantId")
public TenantConfig getConfig(String tenantId) {
return configMapper.selectById(tenantId);
}
public boolean isFeatureEnabled(String featureName) {
String tenantId = TenantContext.getCurrentTenant();
TenantConfig config = getConfig(tenantId);
JSONObject features = config.getFeatures();
return features.getBooleanValue(featureName);
}
}
// 业务中使用
@RestController
public class ReportController {
@Autowired
private TenantConfigService configService;
@GetMapping("/report/export")
public void export() {
// 检查租户是否开通导出功能
if (!configService.isFeatureEnabled("导出")) {
throw new BizException("您的套餐不支持此功能");
}
// 导出逻辑...
}
}
2️⃣ 租户级别的数据统计
@Service
public class TenantStatService {
/**
* 统计租户使用情况
*/
public TenantStat getStat(String tenantId) {
TenantStat stat = new TenantStat();
// 切换到对应租户
TenantContext.setCurrentTenant(tenantId);
try {
// 用户数
stat.setUserCount(userMapper.selectCount(null));
// 订单数
stat.setOrderCount(orderMapper.selectCount(null));
// 存储空间
stat.setStorageUsed(calculateStorage(tenantId));
// API调用次数
stat.setApiCalls(getApiCalls(tenantId));
return stat;
} finally {
TenantContext.clear();
}
}
}
3️⃣ 租户生命周期管理
@Service
public class TenantLifecycleService {
/**
* 创建租户
*/
@Transactional
public void createTenant(TenantCreateDTO dto) {
// 1. 创建租户记录
Tenant tenant = new Tenant();
tenant.setTenantId(generateTenantId());
tenant.setName(dto.getName());
tenant.setStatus(TenantStatus.ACTIVE);
tenant.setExpireTime(calculateExpireTime(dto.getPackageType()));
tenantMapper.insert(tenant);
// 2. 初始化数据库(如果是独立DB方案)
if (isIndependentDbMode()) {
tenantInitService.createTenantDatabase(tenant.getTenantId());
}
// 3. 创建管理员账号
createAdminUser(tenant.getTenantId(), dto.getAdminEmail());
// 4. 初始化默认配置
initDefaultConfig(tenant.getTenantId());
log.info("✅ 租户{}创建成功", tenant.getTenantId());
}
/**
* 冻结租户(欠费)
*/
public void freezeTenant(String tenantId) {
Tenant tenant = tenantMapper.selectById(tenantId);
tenant.setStatus(TenantStatus.FROZEN);
tenantMapper.updateById(tenant);
// 清除缓存
cacheManager.getCache("tenant-config").evict(tenantId);
log.warn("⚠️ 租户{}已被冻结", tenantId);
}
/**
* 删除租户
*/
@Transactional
public void deleteTenant(String tenantId) {
// 1. 备份数据
backupTenantData(tenantId);
// 2. 删除业务数据
TenantContext.setCurrentTenant(tenantId);
try {
userMapper.delete(null); // 删除所有用户
orderMapper.delete(null); // 删除所有订单
// ... 删除其他业务数据
} finally {
TenantContext.clear();
}
// 3. 删除租户记录
tenantMapper.deleteById(tenantId);
// 4. 删除数据库(独立DB方案)
if (isIndependentDbMode()) {
dropTenantDatabase(tenantId);
}
log.info("✅ 租户{}已删除", tenantId);
}
}
🚀 性能优化
1️⃣ 租户配置缓存
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1)) // 缓存1小时
.serializeValuesWith(RedisSerializationContext
.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}
@Service
public class TenantConfigService {
@Cacheable(value = "tenant-config", key = "#tenantId")
public TenantConfig getConfig(String tenantId) {
return configMapper.selectById(tenantId);
}
@CacheEvict(value = "tenant-config", key = "#tenantId")
public void updateConfig(String tenantId, TenantConfig config) {
configMapper.updateById(config);
}
}
2️⃣ 连接池隔离(防止大租户占满连接)
@Configuration
public class DataSourceConfig {
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://localhost:3306/db");
config.setUsername("root");
config.setPassword("123456");
// 总连接池大小
config.setMaximumPoolSize(100);
// 每个租户最多占用20个连接(需自定义实现)
config.addDataSourceProperty("maxConnectionsPerTenant", 20);
return new HikariDataSource(config);
}
}
3️⃣ 索引优化
-- ❌ 错误:只有tenant_id索引
CREATE INDEX idx_tenant ON user(tenant_id);
-- 查询时:
SELECT * FROM user WHERE tenant_id = 'A' AND username = 'zhangsan';
-- 索引只用到tenant_id,username仍需全表扫描
-- ✅ 正确:联合索引
CREATE INDEX idx_tenant_username ON user(tenant_id, username);
-- 查询时:
SELECT * FROM user WHERE tenant_id = 'A' AND username = 'zhangsan';
-- 完美利用索引,快如闪电!⚡
🎓 面试题解析
Q1:如何防止租户之间的数据泄露?
答:
- Token绑定租户ID:不信任客户端传参
- SQL自动改写:MyBatis-Plus租户插件
- 二次校验:关键数据访问时再次验证
- 审计日志:记录跨租户访问尝试
- 定期巡检:检查是否有tenant_id为空的脏数据
Q2:大租户影响小租户性能怎么办?
答:
- 独立数据库:大客户升级到独立DB
- 读写分离:大租户走从库
- 限流:每个租户QPS限制
- 降级:大租户高峰期降级非核心功能
- 分库分表:按租户维度分片
Q3:租户数据如何迁移?
答:
@Service
public class TenantMigrationService {
/**
* 从共享表迁移到独立DB
*/
public void migrateToIndependentDb(String tenantId) {
// 1. 创建目标数据库
createTargetDatabase(tenantId);
// 2. 导出数据
TenantContext.setCurrentTenant(tenantId);
List<User> users = userMapper.selectList(null);
List<Order> orders = orderMapper.selectList(null);
// ...
// 3. 导入目标库
DataSource targetDs = getTargetDataSource(tenantId);
importData(targetDs, users, orders);
// 4. 数据校验
if (verifyData(tenantId)) {
// 5. 切换数据源配置
updateTenantConfig(tenantId, "DB_MODE", "INDEPENDENT");
// 6. 清理原数据
cleanupOldData(tenantId);
}
}
}
📝 总结
三种方案选择指南
┌─────────────────────────────────────────────────────┐
│ 租户隔离方案选型决策树 │
└─────────────────────────────────────────────────────┘
租户数量?
│
┌───────────────┼───────────────┐
↓ ↓ ↓
< 10 10-100 > 100
│ │ │
独立数据库 独立Schema 共享表
(方案1) (方案2) (方案3)
│ │ │
成本高 成本中 成本低
隔离好 隔离好 隔离弱
可定制 可定制 难定制
核心要点 🎯
-
隔离三件套
- 数据隔离(核心)
- 功能隔离(配置)
- 性能隔离(限流)
-
安全第一
- Token绑定租户
- SQL自动改写
- 定期巡检
-
渐进式架构
- 起步:共享表(方案3)
- 成长:独立Schema(方案2)
- 成熟:混合模式(大客户独立DB)
加油,SaaS创业者! 🚀🚀🚀