在开发SaaS应用时,多租户数据隔离是个绕不开的话题。我经历过一个电商平台的改造项目,最初采用共享数据库+共享表结构的设计,结果在一次促销活动中,某个租户的复杂查询拖垮了整个数据库,导致所有租户的服务都受到影响。这种"一颗老鼠屎坏一锅粥"的情况,让我深刻认识到租户隔离的重要性。
传统做法中,我们可能会为每个租户创建独立的数据库实例,或者在同一个数据库中用不同schema隔离数据。但手动维护这些连接就像在杂技表演中同时抛接十几个球——迟早会手忙脚乱。这时候@DS注解的价值就凸显出来了,它就像个智能调度员,能根据当前租户自动选择正确的数据源。
举个实际场景:假设我们有个在线教育平台,A机构使用MySQL,B机构偏好PostgreSQL。通过@DS注解,我们可以在同一个服务中无缝切换:
java复制@DS("tenant_a")
public List<Course> getCoursesA() {
return courseMapper.selectList();
}
@DS("tenant_b")
public List<Course> getCoursesB() {
return courseMapper.selectList();
}
在设计多租户系统时,我踩过最大的坑就是过早优化。曾经有个项目,我们为每个租户预留了50个字段的扩展空间,结果90%的字段永远空着。现在我的经验是:先用共享表结构,等真实需求出现再考虑独立表或独立库。
三种主流多租户模型对比:
| 方案类型 | 隔离级别 | 维护成本 | 适用场景 |
|---|---|---|---|
| 独立数据库 | 最高 | 最高 | 金融、医疗等强隔离需求 |
| 共享数据库独立Schema | 中等 | 中等 | 中大型企业客户 |
| 共享表结构 | 最低 | 最低 | 中小型SaaS应用 |
对于大多数SaaS场景,我推荐采用动态Schema策略配合@DS注解。比如在MySQL中:
yaml复制spring:
datasource:
dynamic:
datasource:
tenant_1:
url: jdbc:mysql://localhost:3306/main_db?currentSchema=tenant_1
tenant_2:
url: jdbc:mysql://localhost:3306/main_db?currentSchema=tenant_2
很多开发者以为加上@DS注解就万事大吉,其实这里面门道不少。去年我们团队就遇到过缓存导致的数据源切换失效问题——A租户的操作跑到了B租户的库上,那场面简直像车祸现场。
必须注意的配置项:
yaml复制spring:
datasource:
dynamic:
strict: true # 开启严格模式,未匹配数据源时抛出异常
hikari:
connection-timeout: 30000
max-lifetime: 1800000
aop:
enabled: true # 必须开启AOP支持
对于高并发场景,我强烈建议加上连接池配置。有次大促,默认连接池设置导致系统在高峰期直接崩溃,后来我们调整为:
java复制@Bean
@ConfigurationProperties(prefix = "spring.datasource.dynamic.hikari")
public HikariConfig hikariConfig() {
return new HikariConfig();
}
识别租户是数据源切换的前提。我见过最糟糕的实现是在每个Controller方法里硬编码租户ID,这种写法维护起来简直是噩梦。现在我的最佳实践是:
java复制public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String tenantId = JwtUtil.getTenantId(request.getHeader("Authorization"));
DynamicDataSourceContextHolder.push(tenantId);
return true;
}
}
java复制@Aspect
@Component
public class TenantAspect {
@Before("execution(* com..service.*.*(..))")
public void before() {
String subdomain = RequestContextHolder.getRequestAttributes()
.getDomain().split("\\.")[0];
DynamicDataSourceContextHolder.push(subdomain);
}
}
数据隔离最怕出现"串门"现象。我们曾经因为一个未清理的ThreadLocal导致用户看到其他公司的财务数据,差点引发法律纠纷。现在团队强制实施这些防护措施:
防御性编程 checklist:
特别要注意异步场景的处理:
java复制@Async
public void asyncProcess(String tenantId) {
try {
DynamicDataSourceContextHolder.push(tenantId);
// 业务逻辑
} finally {
DynamicDataSourceContextHolder.clear();
}
}
下面是我在一个真实电商项目中提炼出的核心代码结构:
code复制src/main/java
├── config
│ ├── DynamicDataSourceConfig.java
│ └── TenantConfig.java
├── interceptor
│ └── TenantInterceptor.java
├── annotation
│ └── TenantId.java
└── service
├── impl
│ ├── OrderServiceImpl.java
│ └── ProductServiceImpl.java
└── TenantService.java
关键配置示例:
java复制// 动态数据源配置
@Configuration
@EnableTransactionManagement
@MapperScan(basePackages = "com.ecommerce.mapper")
public class DynamicDataSourceConfig {
@Bean
public DataSource dataSource() {
DynamicRoutingDataSource ds = new DynamicRoutingDataSource();
ds.setPrimary("master");
ds.setStrict(true);
return ds;
}
}
业务层使用示例:
java复制@Service
public class OrderServiceImpl implements OrderService {
@DS("#header.tenantId")
public Order createOrder(OrderDTO dto, @TenantId String tenantId) {
// 会自动使用tenantId对应的数据源
return orderMapper.insert(dto);
}
}
这个Demo已经在GitHub上获得200+星,核心在于它实现了:
在大规模部署时,我们发现数据源切换本身会成为性能瓶颈。通过JProfiler分析,90%的延迟发生在数据源查找阶段。最终我们采用了两级缓存方案:
java复制@Bean
public DynamicDataSourceProvider dynamicDataSourceProvider() {
return new AbstractDataSourceProvider() {
@Override
public Map<String, DataSource> loadDataSources() {
return Caffeine.newBuilder()
.maximumSize(1000)
.build()
.asMap();
}
};
}
常见问题排查指南:
对于需要支持自助注册的SaaS平台,动态注册是关键能力。我们实现的方案是:
java复制public void registerTenantDataSource(TenantRegisterDTO dto) {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(dto.getJdbcUrl());
config.setUsername(dto.getUsername());
config.setPassword(dto.getPassword());
DataSource newDataSource = new HikariDataSource(config);
DynamicDataSource dynamicDataSource = (DynamicDataSource) dataSource;
dynamicDataSource.addDataSource(dto.getTenantId(), newDataSource);
}
配合健康检查机制,定期验证数据源可用性:
java复制@Scheduled(fixedRate = 300000)
public void checkDataSources() {
dynamicDataSource.getDataSources().keySet().forEach(key -> {
try (Connection conn = dynamicDataSource.getDataSource(key).getConnection()) {
conn.createStatement().execute("SELECT 1");
} catch (SQLException e) {
logger.error("DataSource {} check failed", key);
}
});
}
这套机制使我们平台的租户上线时间从小时级缩短到分钟级,运维效率提升显著。