Spring Boot 4.0 动态数据源与多租户数据库设计
前言
多租户系统里,A 公司绝不能看到 B 公司的订单。隔离不只是 SQL 加一个 tenant_id 条件,还要考虑连接复用、缓存、日志、备份和运维成本。
本章采用 Schema-per-tenant:
tenant_acme对应 Acme 公司。tenant_globex对应 Globex 公司。- 请求头
X-Tenant-Id选择连接池。 TenantContext保存当前请求的租户。
配套源码:dvsusan/spring-boot4-demo。
最终结果
向 Acme 写一条记录:
curl -X PUT \
"http://localhost:8080/api/v1/db/tenant-notes/theme?value=blue" \
-H "X-Tenant-Id: tenant_acme"向 Globex 写同一个 key:
curl -X PUT \
"http://localhost:8080/api/v1/db/tenant-notes/theme?value=green" \
-H "X-Tenant-Id: tenant_globex"分别读取,得到 blue 和 green。相同业务 key 在不同租户中互不覆盖。

图 1:请求进入时设置租户,连接选择发生在数据访问层,请求结束必须清理上下文。
第 1 步:比较三种多租户模型
| 模型 | 隔离强度 | 单租户成本 | 运维复杂度 |
|---|---|---|---|
共享表 + tenant_id | 低到中 | 最低 | 低 |
| Schema-per-tenant | 中高 | 中 | 中高 |
| Database-per-tenant | 最高 | 高 | 高 |
共享表适合大量小租户,但每条 SQL 都必须带租户条件。Schema 隔离更容易做独立备份和迁移,但连接池、迁移脚本和租户数量管理更复杂。
安全要求高且租户数量有限时,Schema-per-tenant 是常见折中。
第 2 步:创建租户 Schema
Flyway V3:
-- 两个租户拥有同名表,但位于不同 Schema。
CREATE SCHEMA IF NOT EXISTS tenant_acme;
CREATE SCHEMA IF NOT EXISTS tenant_globex;
CREATE TABLE tenant_acme.tenant_notes (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
note_key VARCHAR(64) NOT NULL UNIQUE,
note_value VARCHAR(200) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE tenant_globex.tenant_notes (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
note_key VARCHAR(64) NOT NULL UNIQUE,
note_value VARCHAR(200) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);真实系统中,新增租户通常需要自动化创建 Schema、执行全部迁移、创建账号和初始化数据。
第 3 步:定义请求级 TenantContext
public final class TenantContext {
private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
public static void set(String tenantId) {
CURRENT.set(tenantId);
}
public static String get() {
return CURRENT.get();
}
public static void clear() {
// Web 线程会复用,不清理会把上一个租户带给下一个请求。
CURRENT.remove();
}
}ThreadLocal 只保存当前请求状态,不能当作登录态或永久配置。
第 4 步:实现动态路由
/**
* 根据当前请求租户选择真实 DataSource。
*/
public class TenantRoutingDataSource
extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TenantContext.get();
}
}AbstractRoutingDataSource 在拿到连接时读取 lookup key,再到 targetDataSources 中查找实际 DataSource。
路由键必须来自可信身份。示例读取请求头只是教学;生产中租户通常由登录 Token 或网关注入的签名上下文决定,不能让客户端任意指定。
第 5 步:为每个 Schema 建连接池
// 构造时创建各租户连接池并完成路由表初始化。
public TenantDataSourceRegistry(
@Value("${spring.datasource.url}") String baseUrl,
@Value("${spring.datasource.username}") String username,
@Value("${spring.datasource.password}") String password,
@Value("${spring.datasource.driver-class-name:org.postgresql.Driver}")
String driver
) {
dataSources.put(
ACME,
createDataSource(baseUrl, username, password, driver, ACME)
);
dataSources.put(
GLOBEX,
createDataSource(baseUrl, username, password, driver, GLOBEX)
);
routingDataSource.setTargetDataSources(
new LinkedHashMap<>(dataSources)
);
routingDataSource.setDefaultTargetDataSource(dataSources.get(ACME));
routingDataSource.afterPropertiesSet();
}每个连接池设置 currentSchema:
private String appendSchema(String url, String schema) {
// 简单示例使用 URL 参数;生产还要处理已有参数和特殊字符。
String separator = url.contains("?") ? "&" : "?";
return url + separator + "currentSchema=" + schema;
}每个租户一个池会放大数据库连接总数。租户多时要评估池上限、空闲回收和按需创建。
第 6 步:在过滤器中设置和清理租户
@Component
@Profile("db")
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class TenantFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String tenantId = request.getHeader("X-Tenant-Id");
if (tenantId != null && !tenantId.isBlank()) {
TenantContext.set(tenantId);
}
try {
filterChain.doFilter(request, response);
} finally {
// 正常返回、异常和异步路径都不能泄漏租户上下文。
TenantContext.clear();
}
}
}异步任务不会自动继承 ThreadLocal。如果把 Servlet 请求交给其他线程,必须显式传递已验证的租户上下文。
第 7 步:在服务中校验允许的租户
// 所有租户查询都必须先经过允许列表校验。
public List<TenantNote> findAll() {
String tenantId = TenantContext.get();
if (!ACME.equals(tenantId) && !GLOBEX.equals(tenantId)) {
throw new UnknownTenantException(tenantId);
}
return jdbcTemplate.query("""
SELECT note_key, note_value
FROM tenant_notes
ORDER BY note_key
""", (resultSet, rowNum) -> new TenantNote(
resultSet.getString("note_key"),
resultSet.getString("note_value"),
tenantId
));
}这里 SQL 没有 tenant_id 条件,因为连接已经限定到目标 Schema。若路由键可能由用户控制,这种设计非常危险,必须增加身份校验。
第 8 步:验证隔离
启动 db Profile,依次调用:
curl -X PUT \
"http://localhost:8080/api/v1/db/tenant-notes/theme?value=blue" \
-H "X-Tenant-Id: tenant_acme"
curl -X PUT \
"http://localhost:8080/api/v1/db/tenant-notes/theme?value=green" \
-H "X-Tenant-Id: tenant_globex"读取:
curl "http://localhost:8080/api/v1/db/tenant-notes" \
-H "X-Tenant-Id: tenant_acme"
curl "http://localhost:8080/api/v1/db/tenant-notes" \
-H "X-Tenant-Id: tenant_globex"两个响应中的 tenantId 和值必须对应,不能看到另一租户的记录。
自动化测试 ShardingAndTenantRoutingTest 使用两个独立 H2 DataSource 验证同一路由算法:
mvn test常见问题排查
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 下一请求读到上一租户数据 | ThreadLocal 没清理 | finally 调用 clear() |
| 所有请求都进默认租户 | lookup key 为 null | 检查过滤器顺序和请求头 |
| 连接池迅速耗尽 | 每租户池上限过大 | 按租户规模动态分配和回收 |
| 新租户表不存在 | 只建 Schema 没执行迁移 | 自动化迁移所有租户 |
| 租户可通过请求头伪造 | 把客户端头当可信身份 | 从认证 Token 或网关上下文读取 |
| 定时任务串租户 | 异步线程没有上下文 | 显式传值并建立独立执行上下文 |
本章小结
动态数据源的核心是“在正确时机拿到正确 DataSource”:
- 从可信身份解析租户。
- 请求线程设置上下文。
- 数据访问前完成路由。
- 请求结束清理 ThreadLocal。
- 新租户自动建 Schema 并迁移。
下一章会继续使用 PostgreSQL,实现 JSONB、GIN 和全文检索。
练习题
增加租户配置表:
- 记录租户 ID、Schema 名、连接池上限。
- 启动时只加载状态为 active 的租户。
- 新租户首次访问时按需创建连接池。
- 空闲租户超过 30 分钟后释放连接池。
上一节:Flyway 数据库版本管理
下一节:PostgreSQL JSONB、索引与全文检索
相关阅读:
13 个企业级实战项目 · 源码 + 教程 + 答疑
简历包装指导AI Agent 实战高并发·微服务Offer 导向持续更新3000+ 同行交流1V1 答疑3 天包退
覆盖高并发、微服务、分布式与 AI Agent:100万QPS短链、企业智能知识库、商城微服务、SaaS点餐、秒杀、刷题小程序、代码生成器、AI Agent 等。券后 ¥129(原价 ¥159),不满意 3 天包退。
