多租户架构如何设计多数据源

业务背景

文章中的所有代码均在Gitee链接,可直接下载项目下来阅读:gitee.com/xhyym/multi...

最近我们决定将老系统进行重构,为了整合我们自身的 ERP 产品系统,决定采用多租户架构,不过多租户架构也必然涉及到多数据源的问题;

最开始打算采用 MyBatis-Plus 中的 dynamic-data-source 包,后来与我们产品底层 jar 包有冲突,所以用不了。

在了解其一番原理后,也是对多数据源有了一个基本的认知,主要核心的两个点就是采用 ThreadLocal 和 AbstractRoutingDataSource 两个类来完成多数据源切换;

并且我们将多数据源模块抽象成一个jar包,方便公司的整个产品线都能直接使用产品流程图如下;

看不清的同学可以直接打开网址:www.processon.com/view/link/6...

技术实现

源码分析

首先,在使用 AbstractRoutingDataSource 之前,我们得先了解一下他的原理,为什么支持多数据源;

java 复制代码
public abstract class AbstractRoutingDataSource extends AbstractDataSource {
    // 目标数据源集合:key 是路由键(如"master"、"slave"),value 是具体的 DataSource
    @Nullable
    private Map<Object, Object> targetDataSources;

    // 默认数据源(当路由键不存在时使用)
    @Nullable
    private Object defaultTargetDataSource;

    // 解析后的目标数据源(内部缓存,key 为路由键,value 为 DataSource 实例)
    private Map<Object, DataSource> resolvedDataSources;

    // 解析后的默认数据源
    @Nullable
    private DataSource resolvedDefaultDataSource;

    // 初始化时解析数据源(将 targetDataSources 转换为实际的 DataSource 实例)
    @Override
    public void afterPropertiesSet() {
        if (this.targetDataSources == null) {
            throw new IllegalArgumentException("Property 'targetDataSources' is required");
        }
        this.resolvedDataSources = new HashMap<>(this.targetDataSources.size());
        // 遍历 targetDataSources,将 value 转换为 DataSource 实例(支持 Spring 表达式解析)
        this.targetDataSources.forEach((key, value) -> {
            Object lookupKey = resolveSpecifiedLookupKey(key);
            DataSource dataSource = resolveSpecifiedDataSource(value);
            this.resolvedDataSources.put(lookupKey, dataSource);
        });
        // 解析默认数据源
        if (this.defaultTargetDataSource != null) {
            this.resolvedDefaultDataSource = resolveSpecifiedDataSource(this.defaultTargetDataSource);
        }
    }

    // 核心方法:获取数据库连接(路由逻辑的入口)
    @Override
    public Connection getConnection() throws SQLException {
        // 1. 确定当前路由键(由子类实现)
        Object lookupKey = determineCurrentLookupKey();
        // 2. 根据路由键获取目标数据源
        DataSource dataSource = determineTargetDataSource(lookupKey);
        // 3. 由目标数据源提供连接
        return dataSource.getConnection();
    }

    // 根据路由键查找目标数据源
    protected DataSource determineTargetDataSource(Object lookupKey) {
        DataSource dataSource = this.resolvedDataSources.get(lookupKey);
        if (dataSource == null && (this.resolvedDefaultDataSource == null)) {
            throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
        }
        // 若未找到,返回默认数据源
        return (dataSource != null ? dataSource : this.resolvedDefaultDataSource);
    }

    // 抽象方法:由子类实现,返回当前线程的路由键(核心扩展点)
    @Nullable
    protected abstract Object determineCurrentLookupKey();

    // 省略 setter 方法(用于配置 targetDataSources 和 defaultTargetDataSource)
}

大致的意思就是,在 AbstractRoutingDataSource 中维护了一个 DataSourceMap,这个Map中存放了非常多我们项目中的数据源,然后根据 #determineCurrentLookupKey 这个方法返回一个key,通过这个 key 去Map中找对应的DataSource信息,将这个DataSource信息返回后,会自动调用 #determineTargetDataSource 方法设置当前线程最终需要去执行SQL的DataSource。

那么到这里我们就明白了,如果要实现多数据源,就只需要去继承这个 AbstructRoutingDataSource ,然后将我们自己存放数据源的Map替换掉他默认的,然后在Bean注入时,替换成我们自己继承的这个类就能实现多数据源的效果;

测试准备

需要模拟多数据源的效果,我们需要准备多个数据库;

在我的业务中,可以根据上图看到,我们将存放租户和DB信息的库单独做了一个隔离,所以让AI生成了多个一系列的信息,需要可自取:

sql 复制代码
-- 1. 创建租户管理库(tenant_manager)
CREATE DATABASE IF NOT EXISTS tenant_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE tenant_manager;

-- 2. 创建数据源表(server)
CREATE TABLE IF NOT EXISTS server (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '数据源ID',
    db_name VARCHAR(50) NOT NULL COMMENT '数据库名称',
    url VARCHAR(255) NOT NULL COMMENT 'JDBC连接地址',
    username VARCHAR(50) NOT NULL COMMENT '数据库用户名',
    password VARCHAR(100) NOT NULL COMMENT '数据库密码',
    driver_class VARCHAR(100) DEFAULT 'com.mysql.cj.jdbc.Driver' COMMENT '驱动类',
    status TINYINT DEFAULT 1 COMMENT '状态(1-可用,0-禁用)',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_db_name (db_name)
) ENGINE=InnoDB COMMENT '数据源管理表';

-- 3. 创建租户表(tenant)
CREATE TABLE IF NOT EXISTS tenant (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '租户ID',
    tenant_name VARCHAR(50) NOT NULL COMMENT '租户名称',
    server_id BIGINT NOT NULL COMMENT '关联数据源ID',
    status TINYINT DEFAULT 1 COMMENT '状态(1-启用,0-禁用)',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_tenant_name (tenant_name),
    KEY idx_server_id (server_id),
    CONSTRAINT fk_tenant_server FOREIGN KEY (server_id) REFERENCES server (id)
) ENGINE=InnoDB COMMENT '租户表';

-- 4. 创建5个业务数据库及order表(先删除可能存在的存储过程)
DROP PROCEDURE IF EXISTS create_tenant_dbs;
DELIMITER $$
CREATE PROCEDURE create_tenant_dbs(IN count INT)
BEGIN
    DECLARE i INT DEFAULT 1;
    DECLARE db_name VARCHAR(50);
    WHILE i <= count DO
        SET db_name = CONCAT('tenant_db_', i);
        -- 创建数据库
        SET @create_db_sql = CONCAT('CREATE DATABASE IF NOT EXISTS ', db_name, ' CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
        PREPARE stmt FROM @create_db_sql;
        EXECUTE stmt;
        DEALLOCATE PREPARE stmt;

        -- 创建order表
        SET @create_table_sql = CONCAT(
            'CREATE TABLE IF NOT EXISTS ', db_name, '.`t_order` (',
            'id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT ''主键'',',
            'tenant_id BIGINT NOT NULL COMMENT ''租户ID'',',
            'order_no VARCHAR(50) NOT NULL COMMENT ''订单号'',',
            'amount DECIMAL(10,2) NOT NULL COMMENT ''订单金额'',',
            'remark VARCHAR(255) DEFAULT NULL COMMENT ''订单备注'',',
            'status TINYINT NOT NULL COMMENT ''订单状态(0-待支付,1-已支付)'',',
            'create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'',',
            'update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''更新时间'',',
            'UNIQUE KEY uk_order_no (order_no)',
            ') ENGINE=InnoDB COMMENT ''订单表'''
        );
        PREPARE stmt FROM @create_table_sql;
        EXECUTE stmt;
        DEALLOCATE PREPARE stmt;

        -- 插入数据源信息
        INSERT INTO server (db_name, url, username, password)
        VALUES (db_name, CONCAT('jdbc:mysql://192.168.1.7:33060/', db_name, '?useSSL=false&serverTimezone=UTC'), 'root', 'mysql_f8ArP3')
        ON DUPLICATE KEY UPDATE url = VALUES(url), username = VALUES(username), password = VALUES(password);

        SET i = i + 1;
    END WHILE;
END$$
DELIMITER ;

CALL create_tenant_dbs(5);
DROP PROCEDURE IF EXISTS create_tenant_dbs;

-- 5. 创建100个租户(先删除可能存在的存储过程)
DROP PROCEDURE IF EXISTS create_100_tenants;
DELIMITER $$
CREATE PROCEDURE create_100_tenants()
BEGIN
    DECLARE i INT DEFAULT 1;
    DECLARE max_server_id INT;
    DECLARE random_server_id INT;

    SELECT MAX(id) INTO max_server_id FROM server;

    WHILE i <= 100 DO
        SET random_server_id = FLOOR(1 + RAND() * max_server_id);
        INSERT INTO tenant (tenant_name, server_id)
        VALUES (CONCAT('tenant_', i), random_server_id);
        SET i = i + 1;
    END WHILE;
END$$
DELIMITER ;

CALL create_100_tenants();
DROP PROCEDURE IF EXISTS create_100_tenants;

-- 6. 插入订单数据(关键:先删除已存在的存储过程)
DROP PROCEDURE IF EXISTS insert_order_data;
DELIMITER $$
CREATE PROCEDURE insert_order_data()
BEGIN
    DECLARE i INT DEFAULT 1;
    DECLARE db_count INT;
    DECLARE tenant_count INT;
    DECLARE current_db VARCHAR(50);
    DECLARE j INT DEFAULT 1;
    DECLARE random_tenant_id BIGINT;
    DECLARE random_status TINYINT;
    DECLARE random_amount DECIMAL(10,2);
    DECLARE order_no_suffix BIGINT; -- 全局唯一计数器

    SELECT COUNT(*) INTO db_count FROM server;
    SELECT COUNT(*) INTO tenant_count FROM tenant;
    SET order_no_suffix = 1; -- 初始化自增后缀

    -- 循环每个业务库
    WHILE i <= db_count DO
        SET current_db = CONCAT('tenant_db_', i);
        SET j = 1; -- 重置内部循环计数器

        -- 每个库插入1000条订单
        WHILE j <= 1000 DO
            -- 随机获取租户ID(确保存在)
            SELECT id INTO random_tenant_id FROM tenant
            WHERE id = FLOOR(1 + RAND() * tenant_count)
            LIMIT 1;
            IF random_tenant_id IS NULL THEN
                SET random_tenant_id = 1;
            END IF;

            -- 生成随机状态和金额
            SET random_status = FLOOR(RAND() * 2);
            SET random_amount = ROUND(RAND() * 10000, 2);

            -- 生成唯一订单号:ORD_时间戳_库序号_自增后缀
            SET @order_no = CONCAT(
                'ORD_',
                UNIX_TIMESTAMP(),
                '_',
                i,  -- 数据库序号(1-5)
                '_',
                order_no_suffix  -- 全局自增(1-5000)
            );

            -- 插入订单数据
            SET @insert_sql = CONCAT(
                'INSERT INTO ', current_db, '.t_order (tenant_id, order_no, amount, remark, status) ',
                'VALUES (',
                random_tenant_id, ', ',
                '''', @order_no, ''', ',
                random_amount, ', ',
                '''订单备注_', j, ''', ',
                random_status,
                ')'
            );

            PREPARE stmt FROM @insert_sql;
            EXECUTE stmt;
            DEALLOCATE PREPARE stmt;

            SET j = j + 1;
            SET order_no_suffix = order_no_suffix + 1; -- 递增后缀确保唯一
        END WHILE;

        SET i = i + 1;
    END WHILE;
END$$
DELIMITER ;

-- 执行插入订单数据
CALL insert_order_data();
DROP PROCEDURE IF EXISTS insert_order_data;

上述代码,创建了一个租户库:tenant_manager以及五个单独的业务db,tenant_db_1 ~ tenant_db_5;

另外给每个业务创建了订单表为每个租户生成了订单数据用来做测试;

抽象jar包

遵循插件化开发思想,我们项目决定采用自定义 starter 来将多数据源进行抽离,业务层无需关注具体的数据源实现,只需在统一的顶层配置中心配置好租户库的DB信息(为啥说统一,因为我们项目用的是Apollo配置中心,配置可以实现父子继承)

用到的版本如下:

springboot:2.7.18

mybatis-plus:3.5.3.1 (演示环境,所以用了plus,为了快捷方便)

首先为当前环境定义一个上下文,使用ThreadLocal(为什么用ThreadLocal这里不做过多赘述)

java 复制代码
public class TenantContextHolder {

    private static final ThreadLocal<BigInteger> tenantId = new ThreadLocal<>();
    private static final ThreadLocal<String> dataSource = new ThreadLocal<>();
    public static void setTenantId(BigInteger tenantId) {
        TenantContextHolder.tenantId.set(tenantId);
    }
    public static BigInteger getTenantId() {
        return tenantId.get();
    }
    public static void clear() {
        tenantId.remove();
        dataSource.remove();
    }
    public static void setDataSource(String dataSource) {
        TenantContextHolder.dataSource.set(dataSource);
    }
    public static String getDataSource() {
        return dataSource.get();
    }
}

然后创建对应的 Properties 和 AutoConfiguration 文件:

java 复制代码
# TenantConfigurationProperties.class
@Data
@ConfigurationProperties(prefix = "biz.default.tenant")
public class TenantConfigurationProperties {

    private String jdbcUrl;

    private String username;

    private String password;

    private String driverClassName;

    /**
     * 获取租户库默认数据源
     */
    public DataSource getDefaultProfileDataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(jdbcUrl);
        config.setUsername(username);
        config.setPassword(password);
        config.setDriverClassName(driverClassName);
        config.setConnectionTestQuery("SELECT 1");
        return new HikariDataSource(config);
    }

}

# TenantAutoConfiguration.class
@Configuration
@EnableConfigurationProperties(TenantConfigurationProperties.class)
@MapperScan(basePackages = "com.zb.mapper")
public class TenantAutoConfiguration {

    @Bean
    public TenantDynamicDataSource tenantDynamicDataSource(TenantConfigurationProperties properties) {
        TenantDynamicDataSource dataSource = new TenantDynamicDataSource();
        HashMap<Object, Object> map = new HashMap<>();
        map.put("default", properties.getDefaultProfileDataSource());

        dataSource.setTargetDataSources(map);
        dataSource.setDefaultTargetDataSource(properties.getDefaultProfileDataSource());
        return dataSource;
    }

    @Bean(name = "tenantSqlSessionFactory")
    public MybatisSqlSessionFactoryBean sqlSessionFactory(
            @Qualifier("tenantDynamicDataSource") DataSource dataSource)
            throws IOException {
        MybatisSqlSessionFactoryBean sessionFactory = new MybatisSqlSessionFactoryBean();
        // 绑定动态数据源
        sessionFactory.setDataSource(dataSource);
        // 配置核心模块的 Mapper 映射文件路径
        return sessionFactory;
    }

    @Bean(name = "tenantSqlSessionTemplate")
    public SqlSessionTemplate sqlSessionTemplate(
            @Qualifier("tenantSqlSessionFactory") MybatisSqlSessionFactoryBean sqlSessionFactory)
            throws Exception {
        return new SqlSessionTemplate(Objects.requireNonNull(sqlSessionFactory.getObject()));
    }

    /**
     * 配置分页插件  也可以选择自己手搓
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }

    @Bean
    public ConfigurationCustomizer configurationCustomizer() {
        // 配置驼峰和标准化日志输出
        return configuration -> {
            configuration.setMapUnderscoreToCamelCase(true);
            configuration.setLogImpl(org.apache.ibatis.logging.stdout.StdOutImpl.class);
        };
    }

    /**
     * 注入事务管理器
     */
    @Bean
    public PlatformTransactionManager transactionManager(DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

最后,继承 AbstructRoutingDataSource 来实现我们自己的数据源信息:

java 复制代码
public class TenantDynamicDataSource extends AbstractRoutingDataSource {

    private final Map<Object,Object> targetDataSources = new ConcurrentHashMap<>();


    @Override
    public void setTargetDataSources(Map<Object, Object> targetDataSources) {
        this.targetDataSources.putAll(targetDataSources);
        super.setTargetDataSources(this.targetDataSources);
        super.afterPropertiesSet();
    }

    /**
     * 动态添加数据源
     */
    public void addDataSource(String key,Object dataSource) {

        if (!this.targetDataSources.containsKey(key)) {
            this.targetDataSources.put(key,dataSource);
            super.setTargetDataSources(this.targetDataSources);
        }
        afterPropertiesSet();
    }

    @Override
    protected Object determineCurrentLookupKey() {
        String key = TenantContextHolder.getDataSource();
        return StringUtils.hasText(key) ? key : null;
    }
}

当上述代码全部定义好了之后,我们就可以在启动时查询我们所有的server_info信息,将其组装成需要的格式来进行数据源初始化:

java 复制代码
@Component
@Order(Integer.MIN_VALUE)
public class InitTenantDataSource {

    @Autowired
    private ServerMapper serverMapper;

    @Autowired
    private DataSource dataSource;

    @Scheduled(
            cron = "${0 0/2 * * * ?}"
    )
    @PostConstruct
    private void initDataSource() {
        List<Server> servers = serverMapper.selectList(null);
        for (Server server : servers) {
            DataSource hikariDataSource = this.getHikariDataSource(server);
            ((TenantDynamicDataSource) dataSource).addDataSource(server.getDbName(), hikariDataSource);
        }
    }


    private DataSource getHikariDataSource(Server server) {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(server.getUrl());
        config.setUsername(server.getUsername());
        config.setPassword(server.getPassword());
        config.setDriverClassName(server.getDriverClass());
        config.setConnectionTestQuery("SELECT 1");
        config.setPoolName("hikari-poolName-" + server.getDbName());
        return new HikariDataSource(config);
    }
}

并且引入一个定时器,用来定时更新server_ifno表中的数据源信息来实现新增或者删除数据源的功能(公司运维团队有专门的管理面板,这里只是提供一个思路);

当数据源设置进去了之后,我们需要提供一个组件,让租户登录时来调用一个方法来设置上下文(拦截器,AOP中都可以)

java 复制代码
@Slf4j
@Component
public class ActiveTenantDataSourceComp {

    @Autowired
    private TenantMapper tenantMapper;
    @Autowired
    private ServerMapper serverMapper;

    public void activeTenantDataSource(BigInteger tenantId) {
        Tenant tenant = tenantMapper.selectById(tenantId);
        Assert.notNull(tenant,"tenantId is not found Tenant!!!");

        Server server = serverMapper.selectById(tenant.getServerId());
        Assert.notNull(server,"serverId is not found Server!!!");
        TenantContextHolder.setTenantId(tenantId);
        TenantContextHolder.setDataSource(server.getDbName());

        log.info("当前线程上下文租户Id tenantId:{},对应服务器信息:{}",tenantId,server);
    }
}

当完成上述所有代码后,就可以将源码进行打包:

mvn clean package -DskipTests

然后在其他项目中引用;

重点注意:其他项目中配置数据源时,需要在启动类中排除掉DataSourceAutoConfiguration,这是因为我们在项目中引入了自定义的business-datasource包后,加载的优先级问题

测试效果

java 复制代码
@SpringBootTest(classes = OrderApplication.class)
public class OrderAppTest {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private ActiveTenantDataSourceComp activeTenantDataSourceComp;

    @Test
    public void test() {

        // 激活数据源
        activeTenantDataSourceComp.activeTenantDataSource(BigInteger.valueOf(1));

        Order order = orderMapper.selectById(1);
        Page<Order> page = new Page<>(1, 10);
        LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(Order::getTenantId, 1);
        List<Order> records = orderMapper.selectPage(page, wrapper).getRecords();
        System.out.println(order);
        System.out.println(records);
    }
}
相关推荐
苏三说技术4 小时前
SpringBoot开发使用Mybatis,还是Spring Data JPA?
后端
canonical_entropy4 小时前
最小信息表达:软件框架设计的第一性原理
后端·架构·编译原理
自由的疯4 小时前
Java Docker部署RuoYi框架的jar包
java·后端·架构
自由的疯5 小时前
Java Docker本地部署Java服务
java·后端·架构
绝无仅有5 小时前
面试真实经历某商银行大厂计算机网络问题和答案总结
后端·面试·github
绝无仅有5 小时前
面试真实经历某商银行大厂系统,微服务,分布式问题和答案总结
后端·面试·github
IT_陈寒5 小时前
5个Java 21新特性实战技巧,让你的代码性能飙升200%!
前端·人工智能·后端
paishishaba5 小时前
JAVA面试复习笔记(待完善)
java·笔记·后端·面试
Victor3566 小时前
Redis(72)Redis分布式锁的常见使用场景有哪些?
后端