如何构建一个支持多租户的数据源路由?

wen java案例 46

本文目录导读:

如何构建一个支持多租户的数据源路由?

  1. 核心挑战与设计原则
  2. 核心架构组件
  3. 详细实现步骤(以 Java & Spring Boot + MyBatis 为例)
  4. 高级优化与注意事项
  5. 架构模式选择总结
  6. 最终测试验证

构建一个支持多租户的数据源路由(Database Routing),核心目标是:根据当前请求的租户信息,动态选择对应的数据库连接

以下是构建该系统的完整思路、架构模式和关键实现细节。


核心挑战与设计原则

  1. 隔离级别:租户数据是完全隔离(独立数据库)、共享数据库但独立Schema(模式),还是共享表但通过 tenant_id 字段隔离?本文假设最常见的隔离数据库模式(每个租户一个数据库实例/Schema)进行路由。
  2. 性能:路由决策必须极快,通常基于线程局部变量或请求上下文,避免频繁查询数据库。
  3. 安全性:防止租户 A 的数据被误路由到租户 B 的数据库。

核心架构组件

一个典型的多租户数据源路由系统包含以下4个核心组件:

graph TD
    A[客户端请求] --> B{全局拦截器/过滤器}
    B --> C[租户识别器]
    C --> D[线程上下文绑定]
    D --> E[数据库路由实现]
    E --> F{选择数据源}
    F --> G[租户1 数据库]
    F --> H[租户2 数据库]
  1. 租户识别器:从请求头、URL域名、JWT Token 或参数中提取租户 ID。
  2. 上下文持有者:使用 ThreadLocal 存储当前线程的租户 ID。
  3. 动态数据源注册表:存储租户 ID 与数据源(DataSource)的映射关系(通常使用 Map<String, DataSource>)。
  4. 路由策略实现:实现 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_readdb_for_write 方法。
  • Go(Gin):使用 context.WithValue 传递租户ID,在数据库访问层使用 map[string]*sql.DB 选择连接池。

架构模式选择总结

模式 数据隔离度 实现复杂度 运维成本 适用场景
独立数据库 最高 高(需创建和管理多个DB) 金融、医疗、大型SaaS
共享数据库,独立Schema 中型SaaS,需要自定义备份
共享数据库,共享表 小型客户量,原生多租户

如果你的系统租户数量较小(<1000)且对隔离要求高,本文的“独立数据库 + 动态路由”方案是最佳选择。


最终测试验证

  1. 启动两个数据库实例(或同一实例两个库)。
  2. 发送请求,Header 添加 X-Tenant-Id: tenant1
  3. 执行 SQL 查询,确认数据被路由到 tenant1 对应的库。
  4. 清除线程上下文,模拟线程池复用,验证不会出现数据错乱。

抱歉,评论功能暂时关闭!