本文目录导读:

构建一个支持多租户的数据源路由(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对应的库。 - 清除线程上下文,模拟线程池复用,验证不会出现数据错乱。