基于 Java 21 ScopedValue 的多租户动态数据源完整实践
关键词:Java 21、ScopedValue、多租户、SaaS、动态数据源、Spring Boot 3
本文给出一套可直接用于生产的 SaaS 多租户核心基础设施实现,覆盖:
- 域名 → 租户解析
- ScopedValue 承载租户上下文
- 动态数据源注册
- RoutingDataSource 路由
- Filter 级生命周期管理
目标:彻底摆脱 ThreadLocal,构建 Java 21 时代的"正确解"。
一、总体架构设计
HTTP Request
↓
DomainScopeFilter
↓
TenantResolver (domain → TenantContext)
↓
DataSourceRegistry.ensure()
↓
ScopedValue<TenantContext>
↓
DynamicRoutingDataSource
↓
Spring Transaction / JPA / MyBatis
核心原则:
数据源不是全局状态,而是请求作用域状态
二、核心模型定义
1. TenantContext
java
public record TenantContext(
String tenantId,
String domain,
String datasourceKey
) {}
2. TenantScope(ScopedValue 定义)
java
public final class TenantScope {
public static final ScopedValue<TenantContext> TENANT =
ScopedValue.newInstance();
public static TenantContext current() {
return TENANT.get();
}
private TenantScope() {}
}
三、TenantResolver(域名 → 租户)
1. 接口定义
java
public interface TenantResolver {
TenantContext resolve(String domain);
}
2. 默认实现(带缓存)
java
@Component
public class DefaultTenantResolver implements TenantResolver {
@Resource
private DatabaseExtApi databaseExtApi;
private final ConcurrentMap<String, TenantContext> cache =
new ConcurrentHashMap<>();
@Override
public TenantContext resolve(String domain) {
String key = normalize(domain);
return cache.computeIfAbsent(key, this::load);
}
private TenantContext load(String domain) {
ResponseObject<DatabaseSimple> res =
databaseExtApi.selectByDomain(domain);
if (res == null || res.getCode() != 200 || res.getData() == null) {
throw new SysException("无效应用: " + domain);
}
DatabaseSimple db = res.getData();
return new TenantContext(
db.getTenantId(),
domain,
"ds_" + db.getTenantId()
);
}
private String normalize(String domain) {
return domain == null ? "" : domain.toLowerCase(Locale.ROOT);
}
}
四、DynamicRoutingDataSource(ScopedValue 版)
java
public class DynamicRoutingDataSource
extends AbstractRoutingDataSource {
private final Map<Object, Object> targets = new ConcurrentHashMap<>();
@Override
protected Object determineCurrentLookupKey() {
if (!TenantScope.TENANT.isBound()) {
throw new IllegalStateException("TenantContext not bound");
}
return TenantScope.current().datasourceKey();
}
public void addTargetDataSource(String key, DataSource ds) {
targets.put(key, ds);
super.setTargetDataSources(targets);
super.afterPropertiesSet();
}
}
五、DataSourceRegistry(动态注册)
java
@Component
public class DataSourceRegistry {
private final ConcurrentMap<String, DataSource> registry =
new ConcurrentHashMap<>();
private final DynamicRoutingDataSource routingDataSource;
public DataSourceRegistry(DynamicRoutingDataSource routingDataSource) {
this.routingDataSource = routingDataSource;
}
public void ensure(TenantContext ctx) {
registry.computeIfAbsent(ctx.datasourceKey(), k -> create(ctx));
}
private DataSource create(TenantContext ctx) {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl("jdbc:mysql://localhost:3306/" + ctx.tenantId());
ds.setUsername("root");
ds.setPassword("123456");
routingDataSource.addTargetDataSource(ctx.datasourceKey(), ds);
return ds;
}
}
六、DomainScopeFilter(生命周期绑定)
java
public class DomainScopeFilter implements Filter {
@Resource
private TenantResolver tenantResolver;
@Resource
private DataSourceRegistry registry;
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
String domain = request.getServerName();
try {
TenantContext ctx = tenantResolver.resolve(domain);
registry.ensure(ctx);
ScopedValue.where(TenantScope.TENANT, ctx)
.run(() -> {
try {
chain.doFilter(req, res);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
} catch (SysException e) {
response.setStatus(403);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"msg\":\"" + e.getMessage() + "\"}");
}
}
}
七、Spring Boot 配置
java
@Configuration
public class DataSourceConfig {
@Bean
@Primary
public DataSource dataSource() {
return new DynamicRoutingDataSource();
}
}
java
@Bean
public FilterRegistrationBean<DomainScopeFilter> domainFilter() {
FilterRegistrationBean<DomainScopeFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new DomainScopeFilter());
bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
return bean;
}
八、关键架构结论
- 不再使用 ThreadLocal
- 租户上下文是 ScopedValue,而不是全局变量
- RoutingDataSource 只做路由,不做管理
- 动态数据源创建必须幂等
- 事务开始前必须绑定 ScopedValue
九、总结
ScopedValue + DynamicRoutingDataSource 是 Java 21 时代多租户 SaaS 的"终局架构"
这套实现具备:
- 高并发安全性
- 清晰的职责边界
- 对虚拟线程天然友好
- 可长期演进的基础设施结构
建议直接作为 SaaS 内核模板使用。