Mybatis-plus对于多租户的支持以及数据源管理调研

1,113 阅读2分钟

Mybatis-plus多租户方案实现原理

1. 实现原理

核心就是增删改数据库数据时自动拼接商户号,并根据配置文件区分忽略拼接的表.

Mybatis-plus提供了一种多租户的解决方案,实现原理是基于MybatisPlus Interceptor插件【拦截器TenantLineInnerInterceptor)】实现的,如图所示:

2. 具体实现方式

首先需要自定义上下文 用于记录当前请求的租户ID

@Component
public class ApiContext {
    private static final String KEY_CURRENT_TENANT_ID = "KEY_CURRENT_TENANT_ID";
    
    private static final Map<String, Object> M_CONTEXT = new ConcurrentHashMap<>();
    
    public void setCurrentTenantId(Long tenantId) {
        M_CONTEXT.put(KEY_CURRENT_TENANT_ID, tenantId);
    }
    
    public Long getCurrentTenantId() {
        return (Long) M_CONTEXT.get(KEY_CURRENT_TENANT_ID);
    }
}

然后通过拦截器配置拦截规则;需要注意重写TenantLineHandler中的三个方法

  1. getTenantId():通过上下文获取租户ID
  2. ignoreTable():不进行拼接租户的表
  3. getTenantIdColumn():租户字段名称,默认为tenant_id
@Configuration
@MapperScan("com.demo.mapper")
@Slf4j
public class MybatisPlusConfig {
    /**
     * 需要过滤的表
     */
    private static final List<String> IGNORE_TENANT_TABLES = new ArrayList<>();

    static {
        IGNORE_TENANT_TABLES.add("user");
    }
    @Autowired
    private ApiContext apiContext;

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
            @Override
            public Expression getTenantId() {
                // 从当前系统上下文中取出当前请求的服务商ID,通过解析器注入到SQL中。
                Long tenantId = apiContext.getCurrentTenantId();
                if (tenantId == null) {
                    log.info("tenantId:{}", tenantId);
                    return new NullValue();
                }
                return new LongValue(tenantId);
            }

            @Override
            public boolean ignoreTable(String tableName) {
                return IGNORE_TENANT_TABLES.contains(tableName);
            }
        }));
        return interceptor;
    }
}
3. 目前遇到的问题

问题:Column 'tenant_id' specified twice

在mybatis-plus 对于多租户的管理是基于在sql中拼接租户ID实现的.在其3.4版本之前,mybatis-plus进行多租户插入时是不会对已经存在的tenant_id进行过滤的,会重复进行设置数值,这就导致出现Column 'tenant_id' specified twice问题。

解决方式:

  • 使用mybatis-plus3.4.1之前的版本,可以通过自定义一个TenantSqlParser解析器并重写processInsert方法
  • 使用3.4.1之后的版本;之后的版本对tenant_id进行了过滤.在3.5.1的版本中,通过com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler#ignoreInsert方法进行判断过滤.

多租户数据源管理调研

多租户业务场景下,往往每个租户都独立一个数据库(是否独立数据源实例根据实际需要处理),每个租户的数据在数据库层面先做了隔离,在开展详细业务编写时就可以不用考虑不同租户的数据会混淆。但是随之而来的就是数据源灵活切换的需求,需要封装一套方法,在业务编写时可以根据提供的租户代码便捷的切换到对应的数据源

目前调研到有以下两种方式可以支持

  • 基于Springboot提供了AbstractRoutingDataSource抽象类进行切换
  • 基于dynamic-datasource-spring-boot-starter 进行动态切换

基于AbstractRoutingDataSource抽象类进行切换

实现原理

Springboot提供了AbstractRoutingDataSource抽象类,类名意思是数据源路由,让用户可以选择根据需要切换当前数据源.

AbstractRoutingDataSource中提供了如下方法

我们动态切换数据源只需要只需要改变this.targetDataSources,并且触发afterPropertiesSet(),即可改变this.resolvedDataSources;后续改变determineCurrentLookupKey()的返回值(key),在调用getConnection()时即可获取到指定的数据.

基于dynamic-datasource-spring-boot-starter 进行动态切换

源码地址: gitee.com/baomidou/dy…
实现原理

主类: com.baomidou.dynamic.datasource.DynamicRoutingDataSource

也是通过实现javax.sql.DataSource对连接进行动态切换管理

可以看到通过主类提供的方法能够实现对数据源的切换、新增、删除等操作.

具体实现方式

组件本身提供了多数据源管理的方式,可以在yml中配置了数据源之后通过注解

@Ds("dsName")在方法或者类上进行配置,指定某个方法或者类使用指定的数据源.

ps:方法上注解 优先于 类上注解

但是该实现方式只适用于我们提前就知道某个方法使用哪个数据源,并不支持动态添加数据源后切换,并且当租户数量很多存在很多数据源时也不好管理.

初步想法是能够通过方法拦截器,通过从上下文配置中获取到的租户ID,通过维护的租户ID与数据源的映射关系,路由找到对应的数据源的方式进行支持.

/**
 * @ClassName: DynamicDataSourceInterceptor
 * @Description:基于租户ID切换数据源拦截器
 * dynamic-datasource-spring-boot-starter 只提供了基于@Ds注解以及方法与数据源映射的切换方式
 * 这里需要实现基于租户的管理
 * @Author: zhouk
 * @Date: 2022/5/6 3:28 PM
 */
public class DataSourceManagerInterceptor implements MethodInterceptor {

    private ApiContext apiContext;

    public DataSourceManagerInterceptor(ApiContext apiContext) {
        this.apiContext = apiContext;
    }

    /**
     * 模拟租户与数据源的关系
     */
    private static Map<Long, String> tenantAndDataSourceMap = new HashMap<>();

    static {
        tenantAndDataSourceMap.put(1L, "db1");
        tenantAndDataSourceMap.put(2L, "db2");
        tenantAndDataSourceMap.put(3L, "db3");
    }

    @Override
    public Object invoke(MethodInvocation methodInvocation) throws Throwable {
        // 通过从上下文中获取到的租户ID 路由到对应的数据源
        String dsKey = tenantAndDataSourceMap.get(apiContext.getCurrentTenantId());
        // 在ThreadLocal中设置当前操作的数据源
        DynamicDataSourceContextHolder.push(dsKey);
        try {
            return methodInvocation.proceed();
        } finally {
            DynamicDataSourceContextHolder.poll();
        }
    }
}

拦截器配置:

@Configuration
public class DataSourceManagerInterceptorConfig {
    public static final String traceExecution = "execution(* com.demo.service..*.*(..))";
    @Autowired
    private ApiContext apiContext;

    @Bean
    public DefaultPointcutAdvisor defaultPointcutAdvisor() {
        DataSourceManagerInterceptor interceptor = new DataSourceManagerInterceptor(apiContext);
        AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
        pointcut.setExpression(traceExecution);
        // 配置增强类advisor
        DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor();
        advisor.setPointcut(pointcut);
        advisor.setAdvice(interceptor);
        return advisor;
    }
}

注意: 这里如果方法所在的类或者方法上有@Ds注解是不能通过DynamicDataSourceContextHolder.push(dsKey)来指定对应数据源的.

由于多租户场景的特殊性,我们的数据源不会全部配置在yml文件中,可能yml中只需要配置平台对应数据源,租户的数据源需要通过mysql或者文件读取后动态添加.

下面是动态管理数据源的模块;下面有两种方式可以动态增加数据源

  • 通过自定义DynamicDataSourceProvider,在项目启动时加载数据源

下面是实现代码

/**
 * @ClassName: DatasourceConfig
 * @Description: 项目启动加载数据源配置
 * @Author: zhouk
 * @Date: 2022/5/7 9:46 AM
 */
@Configuration
public class DatasourceConfig {
    /**
     * 数据源自定义加载
     *
     * @param properties 数据源配置
     * @return  DynamicDataSourceProvider
     */
    @Bean
    public DynamicDataSourceProvider dynamicDataSourceProvider(DynamicDataSourceProperties properties) {
        Map<String, DataSourceProperty> datasourceMap = properties.getDatasource();
        DataSourceProperty masterDataSourceProperty = datasourceMap.get("db1");
        // 加载主数据源,即平台数据源(yml中配置的数据源)
        return new AbstractJdbcDataSourceProvider(masterDataSourceProperty.getDriverClassName(), masterDataSourceProperty.getUrl(), masterDataSourceProperty.getUsername(), masterDataSourceProperty.getPassword()) {
            @Override
            protected Map<String, DataSourceProperty> executeStmt(Statement statement) throws SQLException {
                // 该地方只能使用statement操作数据库(数据源未加载,注入service操作数据库会出现循环依赖问题)
                ResultSet resultSet = statement.executeQuery("select * from data_source ");
                while (resultSet.next()){
                    String name = resultSet.getString("name");
                    String username = resultSet.getString("username");
                    String password = resultSet.getString("password");
                    String url = resultSet.getString("url");
                    String driver = masterDataSourceProperty.getDriverClassName();
                    DataSourceProperty property = new DataSourceProperty();
                    property.setUsername(username);
                    property.setPassword(password);
                    property.setUrl(url);
                    property.setDriverClassName(driver);
                    datasourceMap.put(name, property);
                }
                return datasourceMap;
            }
        };
    }
}

该方法存在的问题:当某一个数据源加载不成功时,会导致项目无法启动

  • 通过DynamicRoutingDataSource.addDataSource()创建数据源
/**
* 通过DynamicRoutingDataSource.addDataSource()创建数据源
*
* @param dsName
*/
public void addDataSource(String dsName) {
    if (StringUtils.isEmpty(dsName)) {
        return;
    }
    QueryWrapper<DataSourcePo> queryWrapper = new QueryWrapper<>();
    queryWrapper.lambda().eq(DataSourcePo::getName, dsName);
    DataSourcePo dataSourcePo = dataSourceMapper.selectOne(queryWrapper);
    if (ObjectUtils.isEmpty(dataSourcePo)) {
        throw new IllegalArgumentException(String.format("数据源:%s不存在,请检查", dsName));
    }
    DynamicRoutingDataSource ds = (DynamicRoutingDataSource) dataSource;
    //判断当前数据源是否已经存在
    if (!ds.getDataSources().containsKey(dsName)) {
        // 加锁防止重复添加同一个数据源
        synchronized (LOCK) {
            if (!ds.getDataSources().containsKey(dsName)) {
                DataSourceProperty dataSourceProperty = this.getDataSourceProperty(dataSourcePo);
                DataSource dataSource = dataSourceCreator.createDataSource(dataSourceProperty);
                ds.addDataSource(dsName, dataSource);
            }
        }
    }
}

private DataSourceProperty getDataSourceProperty(DataSourcePo dataSourcePo) {
    DataSourceProperty dataSourceProperty = new DataSourceProperty();
    dataSourceProperty.setDriverClassName("com.mysql.jdbc.Driver");
    dataSourceProperty.setUsername(dataSourcePo.getUsername());
    dataSourceProperty.setPassword(dataSourcePo.getPassword());
    dataSourceProperty.setUrl(dataSourcePo.getUrl());
    dataSourceProperty.setPoolName(dataSourcePo.getName());
    return dataSourceProperty;
}