
本文介绍如何在 Spring Boot 中基于 HTTP 路径参数(如 /api/v1/{db_name}/...)安全、线程安全地动态路由到不同 MySQL 数据库,避免“No database selected”异常,并支持 Flyway 自动建库建表,兼顾多租户隔离与高并发稳定性。
本文介绍如何在 spring boot 中基于 http 路径参数(如 `/api/v1/{db_name}/...`)安全、线程安全地动态路由到不同 mysql 数据库,避免“no database selected”异常,并支持 flyway 自动建库建表,兼顾多租户隔离与高并发稳定性。
在微服务或 SaaS 场景中,常需按租户(tenant)隔离数据,典型模式是为每个租户分配独立数据库(Database-per-Tenant)。若租户标识直接来自 HTTP 路径(如 GET /api/v1/acme-corp/statistics),则需在单次请求生命周期内动态切换数据库连接。但直接修改共享 DataSource(如 DriverManagerDataSource)的 URL 或 schema 是危险且线程不安全的——正如问题中所示,多请求并发时会因连接被覆盖导致 No database selected 错误,根本原因在于:Spring 的 JdbcTemplate 默认复用全局 DataSource 实例,而 DriverManagerDataSource 并非连接池,其 setUrl() 操作会污染所有后续请求的连接上下文。
✅ 正确解法不是“运行时改 DataSource”,而是采用 多数据源 + 动态路由 + 线程绑定 架构。核心思路如下:
一、使用 AbstractRoutingDataSource 实现运行时数据源路由
AbstractRoutingDataSource 是 Spring 提供的抽象类,允许在每次获取连接时根据上下文(如当前租户 ID)动态选择目标 DataSource。它天然支持线程安全——路由键(lookup key)存储在 ThreadLocal 中,完全隔离各请求。
@Component
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TenantContext.getCurrentTenant(); // 从 ThreadLocal 获取当前租户名
}
}配合 TenantContext 工具类管理租户上下文:
public class TenantContext {
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
public static void setCurrentTenant(String tenant) {
CURRENT_TENANT.set(tenant);
}
public static String getCurrentTenant() {
return CURRENT_TENANT.get();
}
public static void clear() {
CURRENT_TENANT.remove();
}
}二、按需初始化租户专属数据源(带 Flyway 自动迁移)
为避免启动时加载全部租户库,我们采用懒加载 + 缓存策略:首次访问某租户时,动态构建其 HikariDataSource 并执行 Flyway 迁移。关键点:
- 使用 ConcurrentHashMap 缓存已初始化的数据源;
- Flyway 配置需指定 defaultSchema 为租户库名,并确保库存在(Flyway 会自动创建);
- 必须显式设置 dataSource.setCatalog(tenantName)(MySQL 中等价于 USE tenant_db),这是解决 No database selected 的关键!
@Service
public class DataSourceFactory {
private final Map<String, DataSource> dataSourceCache = new ConcurrentHashMap<>();
private final String baseJdbcUrl; // e.g., "jdbc:mysql://localhost:3306/"
private final String username;
private final String password;
public DataSourceFactory(@Value("${spring.datasource.url}") String fullUrl,
@Value("${spring.datasource.username}") String username,
@Value("${spring.datasource.password}") String password) {
this.baseJdbcUrl = fullUrl.replaceFirst("/[^/]*$", "/"); // 提取基础URL,如 jdbc:mysql://h:3306/
this.username = username;
this.password = password;
}
public DataSource getDataSource(String tenant) {
return dataSourceCache.computeIfAbsent(tenant, this::createAndInitDataSource);
}
private DataSource createAndInitDataSource(String tenant) {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(baseJdbcUrl + tenant);
ds.setUsername(username);
ds.setPassword(password);
ds.setCatalog(tenant); // ✅ 强制设置默认 catalog,避免 "No database selected"
// Flyway 自动建库 & 迁移
Flyway flyway = Flyway.configure()
.dataSource(ds)
.locations("classpath:db/migration") // 迁移脚本位置
.baselineOnMigrate(true) // 首次运行时自动 baseline
.defaultSchema(tenant)
.load();
flyway.migrate();
return ds;
}
}三、在 Controller 中注入租户上下文并清理资源
通过 @PathVariable 提取租户名,设置 TenantContext,并在请求结束前清除 ThreadLocal(推荐使用 HandlerInterceptor 或 @RestControllerAdvice 统一处理):
@RestController
@RequestMapping("/api/v1")
public class TenantAwareController {
@PostMapping("/{tenant}/statistics")
public ResponseEntity<?> updateStats(
@PathVariable String tenant,
@RequestBody StatisticData data) {
TenantContext.setCurrentTenant(tenant); // 设置当前租户
try {
// 使用全局 JdbcTemplate(已配置为 TenantRoutingDataSource)
jdbcTemplate.update("INSERT INTO ...", ...);
return ResponseEntity.ok().build();
} finally {
TenantContext.clear(); // ✅ 必须清理,防止线程复用导致脏数据
}
}
}⚠️ 重要注意事项
- 永远不要在生产环境直接暴露数据库名到 URL:{db_name} 可能成为 SQL 注入或越权访问入口。务必添加白名单校验(如正则 ^[a-z0-9_]{3,32}$)和租户授权检查。
- 连接池选择:HikariCP 是首选,DriverManagerDataSource 仅用于测试;动态数据源必须启用连接池以保证性能。
- Flyway 安全性:确保迁移脚本由可信源提供,禁用 flyway.clean() 等危险命令。
- 事务边界:跨租户操作无法使用声明式事务(@Transactional),需手动管理或设计为单租户事务。
- 监控与限流:对高频租户访问添加熔断(如 Resilience4j)和连接数限制,防止单租户耗尽 DB 连接。
通过以上设计,系统可在毫秒级完成租户数据库切换,支持高并发请求,彻底规避连接污染与 No database selected 异常,同时满足自动化建库、多租户隔离及生产级稳定性要求。










