本文目录导读:

构建一个支持多租户的数据源路由(Database Routing),核心目标是:根据当前请求的租户信息,动态选择对应的数据库连接。
以下是构建该系统的完整思路、架构模式和关键实现细节。
核心挑战与设计原则
- 隔离级别:租户数据是完全隔离(独立数据库)、共享数据库但独立Schema(模式),还是共享表但通过
tenant_id字段隔离?本文假设最常见的隔离数据库模式(每个租户一个数据库实例/Schema)进行路由。 - 性能:路由决策必须极快,通常基于线程局部变量或请求上下文,避免频繁查询数据库。
- 安全性:防止租户 A 的数据被误路由到租户 B 的数据库。
核心架构组件
一个典型的多租户数据源路由系统包含以下4个核心组件:
graph TD
A[客户端请求] --> B{全局拦截器/过滤器}
B --> C[租户识别器]
C --> D[线程上下文绑定]
D --> E[数据库路由实现]
E --> F{选择数据源}
F --> G[租户1 数据库]
F --> H[租户2 数据库]
- 租户识别器:从请求头、URL域名、JWT Token 或参数中提取租户 ID。
- 上下文持有者:使用
ThreadLocal存储当前线程的租户 ID。 - 动态数据源注册表:存储租户 ID 与数据源(DataSource)的映射关系(通常使用
Map<String, DataSource>)。 - 路由策略实现:实现
AbstractRoutingDataSource(Spring生态)或自定义JDBC拦截器。
详细实现步骤(以 Java & Spring Boot + MyBatis 为例)
定义租户上下文持有者
public class TenantContext {
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
CURRENT_TENANT.set(tenantId);
}
public static String getTenantId() {
return CURRENT_TENANT.get();
}
public static void clear() {
CURRENT_TENANT.remove();
}
}
实现租户识别拦截器
在请求进入时,从请求头或Token中解析租户 ID,并绑定到上下文。
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String tenantId = request.getHeader("X-Tenant-Id"); // 假设从Header获取
if (tenantId == null) {
// 也可从域名解析: tenantA.example.com -> tenantId = "tenantA"
throw new IllegalArgumentException("Tenant ID is required");
}
TenantContext.setTenantId(tenantId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 必须清理,避免线程池复用导致数据错乱
TenantContext.clear();
}
}
构建多数据源配置与路由实现
这是最核心的部分,使用 Spring 的 AbstractRoutingDataSource。
@Component
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
@Autowired
private DataSourceRegistry dataSourceRegistry; // 自定义的注册表
@Override
protected Object determineCurrentLookupKey() {
// 关键:从上下文获取当前租户ID,作为路由键
return TenantContext.getTenantId();
}
// 初始化时设置默认数据源和所有数据源
@PostConstruct
public void init() {
// 设置默认数据源(例如主控数据库,存放租户配置信息)
this.setDefaultTargetDataSource(dataSourceRegistry.getDefaultDataSource());
// 设置所有已注册的租户数据源
this.setTargetDataSources(dataSourceRegistry.getAllDataSource());
}
}
动态数据源注册表(支持新增租户)
为避免每次请求都创建连接,我们用一个 ConcurrentHashMap 缓存所有租户的数据源。
@Service
public class DataSourceRegistry {
private final ConcurrentHashMap<String, DataSource> dataSourceMap = new ConcurrentHashMap<>();
private DataSource defaultDataSource;
public void registerTenant(String tenantId, DataSource ds) {
dataSourceMap.put(tenantId, ds);
// 更新路由数据源,使其重新加载目标数据源
// 注意:需要持有 TenantRoutingDataSource 的引用并调用 afterPropertiesSet()
}
public Map<Object, Object> getAllDataSource() {
return new HashMap<>(dataSourceMap);
}
public DataSource getDefaultDataSource() {
return defaultDataSource;
}
// 根据租户ID获取数据源(可做懒加载)
public DataSource getOrCreateDataSource(String tenantId) {
return dataSourceMap.computeIfAbsent(tenantId, id -> {
// 从数据库或配置读取连接信息,创建新的 HikariDataSource
// 此处需要你实现读取租户数据库配置的业务逻辑
return createDataSourceForTenant(id);
});
}
private DataSource createDataSourceForTenant(String tenantId) {
// 示例:HikariCP
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://localhost:3306/" + tenantId + "_db");
config.setUsername("tenant_" + tenantId);
config.setPassword("password");
config.setMaximumPoolSize(10);
return new HikariDataSource(config);
}
}
配置 Spring 替换默认数据源
在配置类中将 TenantRoutingDataSource 注册为主要的 DataSource。
@Configuration
public class DataSourceConfig {
@Bean
@Primary
public DataSource dataSource(DataSourceRegistry registry) {
TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource();
// 设置数据源注册表(或手动调用init)
routingDataSource.setDataSourceRegistry(registry);
routingDataSource.init(); // 加载已有的数据源
return routingDataSource;
}
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
sessionFactory.setDataSource(dataSource); // 注入我们的路由数据源
// 其他配置...
return sessionFactory.getObject();
}
}
高级优化与注意事项
连接池管理
- 每个租户独立连接池:上述示例中每个租户创建独立的 HikariCP 连接池,这是推荐做法,避免一个租户的慢查询拖垮所有租户。
- 池大小控制:根据租户的预期并发量设置
maximumPoolSize,避免过多连接耗尽数据库资源。
动态新增/移除租户
- 生产者-消费者模式:当新增租户时,调用
registry.registerTenant(tenantId, newDataSource)。 - 自动刷新:让
TenantRoutingDataSource监听ApplicationEvent或在获取数据源前检查是否存在,实现懒加载。
事务管理
- 注意点:
AbstractRoutingDataSource在事务内部会确定一次数据源,并在整个事务中固定,确保你的@Transactional跨服务调用也在同一个事务上下文中。 - 分布式事务:如果一次请求需要操作多个租户数据库,需要引入分布式事务方案(如 Seata),但会引入复杂度,最好避免。
性能监控
- 记录每个租户的数据源使用情况(连接数、响应时间)。
- 实现一个健康检查机制,自动剔除无效或超载的数据源。
安全与隔离
- 永远不要在代码中手动切换
TenantContext,只通过拦截器或过滤器自动设置。 - 在路由逻辑中,如果发现
TenantContext.getTenantId()为空,应使用默认的“管理数据库”或直接报错,而不是使用不对应的数据源。
非 Java 生态(Node.js / Python / Go)
- Node.js(Koa/Express):使用中间件将
tenantId绑定到req对象上,然后在服务层通过req.tenantId动态选择 ORM(如 Sequelize)的连接。 - Python(Django):实现自定义数据库路由,重写
db_for_read和db_for_write方法。 - Go(Gin):使用
context.WithValue传递租户ID,在数据库访问层使用map[string]*sql.DB选择连接池。
架构模式选择总结
| 模式 | 数据隔离度 | 实现复杂度 | 运维成本 | 适用场景 |
|---|---|---|---|---|
| 独立数据库 | 最高 | 中 | 高(需创建和管理多个DB) | 金融、医疗、大型SaaS |
| 共享数据库,独立Schema | 高 | 中 | 中 | 中型SaaS,需要自定义备份 |
| 共享数据库,共享表 | 低 | 低 | 低 | 小型客户量,原生多租户 |
如果你的系统租户数量较小(<1000)且对隔离要求高,本文的“独立数据库 + 动态路由”方案是最佳选择。
最终测试验证
- 启动两个数据库实例(或同一实例两个库)。
- 发送请求,Header 添加
X-Tenant-Id: tenant1。 - 执行 SQL 查询,确认数据被路由到
tenant1对应的库。 - 清除线程上下文,模拟线程池复用,验证不会出现数据错乱。
版权声明:NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
足球直播
足球直播
足球直播
篮球直播
篮球直播
篮球直播
NBA直播
NBA直播
NBA直播
CBA直播
CBA直播
CBA直播
亚冠直播
亚冠直播
亚冠直播
欧冠直播
欧冠直播
欧冠直播
英超直播
英超直播
英超直播
西甲直播
西甲直播
西甲直播
中超直播
中超直播
中超直播
足球直播
足球直播
足球直播
篮球直播
篮球直播
篮球直播
NBA直播
NBA直播
NBA直播
CBA直播
CBA直播
CBA直播
亚冠直播
亚冠直播
亚冠直播
欧冠直播
欧冠直播
欧冠直播
英超直播
英超直播
英超直播
西甲直播
西甲直播
西甲直播
中超直播
中超直播
中超直播
足球直播
足球直播
足球直播
篮球直播
篮球直播
篮球直播
NBA直播
NBA直播
NBA直播
CBA直播
CBA直播
CBA直播
亚冠直播
亚冠直播
亚冠直播
欧冠直播
欧冠直播
欧冠直播
英超直播
英超直播
英超直播
西甲直播
西甲直播
西甲直播
中超直播
中超直播
中超直播
足球直播
足球直播
足球直播
篮球直播
篮球直播
篮球直播
NBA直播
NBA直播
NBA直播
CBA直播
CBA直播
CBA直播
亚冠直播
亚冠直播
亚冠直播
欧冠直播
欧冠直播
欧冠直播
英超直播
英超直播
英超直播
西甲直播
西甲直播
西甲直播
中超直播
中超直播
中超直播
曼联直播
曼联直播
曼联直播
日职联直播
日职联直播
日职联直播
澳超直播
澳超直播
澳超直播
阿森纳直播
阿森纳直播
阿森纳直播
足球直播
足球直播
足球直播
NBA直播
NBA直播
NBA直播
曼联直播
曼联直播
曼联直播
日职联直播
日职联直播
日职联直播
澳超直播
澳超直播
澳超直播
阿森纳直播
阿森纳直播
阿森纳直播
足球直播
足球直播
足球直播
NBA直播
NBA直播
NBA直播
曼联直播
曼联直播
曼联直播
日职联直播
日职联直播
日职联直播
澳超直播
澳超直播
澳超直播
阿森纳直播
阿森纳直播
阿森纳直播
足球直播
足球直播
足球直播
NBA直播
NBA直播
NBA直播
曼联直播
曼联直播
曼联直播
日职联直播
日职联直播
日职联直播
澳超直播
澳超直播
澳超直播
阿森纳直播
阿森纳直播
阿森纳直播
足球直播
足球直播
足球直播
NBA直播
NBA直播
NBA直播
欧冠直播
欧冠直播
欧冠直播
欧冠直播
欧冠直播
欧冠直播
欧冠直播
欧冠直播
欧冠直播
欧冠直播
欧冠直播
欧冠直播
欧冠直播
欧冠直播
欧冠直播
欧冠直播
欧冠直播
欧冠直播
欧冠直播
欧冠直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
足球直播
足球直播
足球直播
足球直播
足球直播
英超直播
英超直播
英超直播
英超直播
英超直播
篮球直播
篮球直播
篮球直播
篮球直播
篮球直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
中超直播
中超直播
中超直播
中超直播
中超直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
足球直播
足球直播
足球直播
足球直播
足球直播
英超直播
英超直播
英超直播
英超直播
英超直播
篮球直播
篮球直播
篮球直播
篮球直播
篮球直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
中超直播
中超直播
中超直播
中超直播
中超直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播
中超直播