mybatis plus+spring boot 多租户动态数据源实现方案

2,339 阅读4分钟

这是我参与8月更文挑战的第11天,活动详情查看:8月更文挑战

需求

在很多具体应用场景中,我们需要用到动态数据源的情况,比如多租户的场景,系统登录时需要根据用户信息切换到用户对应的数据库。又比如业务A要访问A数据库,业务B要访问B数据库等,都可以使用动态数据源方案进行解决。 作为合格的程序员第一时间肯定是去百度,但是呢既然我写了这篇博客那么肯定是没能很好的集成到我项目中,网上写的一篇文章说基于spring的AbstractRoutingDataSource 就可以实现,但是我试了不行,因为我自己项目不是用的jdbcTemplate 也可能是我使用姿势不对,反正没有用上,但是也确实给了我灵感 首先我肯定要知道是通过什么方式去获取的db connect,看了源码心中有数

源码调试

首先mybatis plus也是集成的mybatis,那么最核心的一定是 SqlSessionFactory, 这是一个接口,项目中只有 DefaultSqlSessionFactory 实现了此接口 此时只要项目中打个断点就知道是在 org.apache.ibatis.session.defaults.DefaultSqlSessionFactory#openSessionFromDataSource

private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {
    Transaction tx = null;
    try {
      final Environment environment = configuration.getEnvironment();
      final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);
      //关键就在于 environment.getDataSource()
      tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);
      final Executor executor = configuration.newExecutor(tx, execType);
      return new DefaultSqlSession(configuration, executor, autoCommit);
    } catch (Exception e) {
      closeTransaction(tx); // may have fetched a connection so lets call close()
      throw ExceptionFactory.wrapException("Error opening session.  Cause: " + e, e);
    } finally {
      ErrorContext.instance().reset();
    }
  }

这里的 dataSource 是在 org.springframework.boot.autoconfigure.jdbc.DataSourceConfiguration 注入的(别问为啥知道的,问就是百度的) 因为小编项目中用的就是spring默认的连接池,所以是通过如下代码注入

@ConditionalOnClass(HikariDataSource.class)
	@ConditionalOnMissingBean(DataSource.class)
	@ConditionalOnProperty(name = "spring.datasource.type", havingValue = "com.zaxxer.hikari.HikariDataSource", matchIfMissing = true)
	static class Hikari {

		@Bean
		@ConfigurationProperties(prefix = "spring.datasource.hikari")
		public HikariDataSource dataSource(DataSourceProperties properties) {
			HikariDataSource dataSource = createDataSource(properties,
					HikariDataSource.class);
			if (StringUtils.hasText(properties.getName())) {
				dataSource.setPoolName(properties.getName());
			}
			return dataSource;
		}

	}

思路

源码分析发现数据源是通过environment.getDataSource() 方式获取的,第一时间我想到我们替换掉这个 environment,后来发现不行,这个类牵扯的代码太多,不可能重写 又随即想到这是一个接口

public interface DataSource  extends CommonDataSource, Wrapper {

  Connection getConnection() throws SQLException;

  Connection getConnection(String username, String password)
    throws SQLException;
}

就是说不管怎么样,要进行数据库操作一定要调用实现这个接口的方法,那么我们可以替换掉 org.apache.ibatis.mapping.Environment中的 dataSource即可实现我们的目的,上文分析这个类也是注入的,这样修改很方便,此时有两种方案:

  • 自定义一个dataSource,然后声明一个属性,通过租户的key去选择指定的dataSource,再调用 getConnection 方法
	 private static ConcurrentMap<String, DataSource> dataSourceConcurrentMap = new ConcurrentHashMap<>();
	 
	 @Override
  	 public Connection getConnection() throws SQLException {
   	String dbKey = threadLocal.get();
       DataSource dataSource = dataSourceConcurrentMap.get(dbKey);
       return getDataSource().getConnection();
   }
 
  • 也是重写一个dataSource,但是重写更彻底,相当于重写一个数据库连接池,也是类似操作,根据租户的信息创建指定的 connect,并且自己维护数据库连接的生命周期

虽然后者实现起来更加优雅和刺激,但是呢我还是选择前者 不管从实现难度还是速度来看都是前者更好(后者我还没这个能力)

代码实现

import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.core.NamedThreadLocal;
import org.springframework.util.StringUtils;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;


@Slf4j
public class DyDataSource extends HikariDataSource {
	// 全局的数据源类型
    Class<? extends DataSource> type;

	// 当前线程的租户信息
    private static ThreadLocal<String> threadLocal = new NamedThreadLocal<>("TARGET_DATA_SOURCE");

 	// 数据源容器
    private static ConcurrentMap<String, DataSource> dataSourceConcurrentMap = new ConcurrentHashMap<>();

    public DyDataSource(Class<? extends DataSource> type) {
        super();
        this.type = type;
        log.info("DyDataSource init ...........");
    }

    @Override
    public Connection getConnection() throws SQLException {

        return getDataSource().getConnection();
    }

    @Override
    public Connection getConnection(String username, String password) throws SQLException {
        return getDataSource().getConnection(username, password);
    }

    public static void setDbKey(String dbKey) {
        threadLocal.set(dbKey);
    }

	// 根据租户信息获取数据源
    public DataSource getDataSource(){

        String dbKey = threadLocal.get();
        if (StringUtils.isEmpty(dbKey)) {
            throw new RuntimeException("未指定 dbKey");
        }

       DataSource dataSource = dataSourceConcurrentMap.get(dbKey);
        if (dataSource == null) {
            // 初始化
            synchronized (dbKey.intern()) {
                if (dataSource == null) {
                    dataSource = initDataSource(dbKey);
                }
            }
        }

        if (dataSource == null) {
            throw new RuntimeException("dataSource is null");
        }

        return dataSource;

    }
	// 根据租户信息初始化
    private DataSource initDataSource(String dbKey){

        DataSourceProperties dataSourceProperties = new DataSourceProperties();
        dataSourceProperties.setDriverClassName("com.mysql.jdbc.Driver");
        if (dbKey.equals("inner")) {
            dataSourceProperties.setUrl("jdbc:mysql://inner.com:3307/crawler?zeroDateTimeBehavior=convertToNull&characterEncoding=utf-8&useUnicode=true&useSSL=false");
            dataSourceProperties.setUsername("root");
            dataSourceProperties.setPassword("123456");
        } else if (dbKey.equals("local")) {
            dataSourceProperties.setUrl("jdbc:mysql://inner.com:3307/more_db?zeroDateTimeBehavior=convertToNull&characterEncoding=utf-8&useUnicode=true&useSSL=false");
            dataSourceProperties.setUsername("root");
            dataSourceProperties.setPassword("123456");
        }else {
            return null;
        }

        // 从源码中抄的,大概就是绑定数据
        return dataSourceProperties.initializeDataSourceBuilder().type(type).build();
    }


}

注入到spring 容器

@Configuration
public class DbConfig {
    @Bean
    public DyDataSource dataSource() {
        return new DyDataSource(HikariDataSource.class);
    }
}

此时调用dao层需要手动设置租户信息


@RestController
@RequestMapping("/test")
public class TestController {
    @Autowired
    TableTestServiceImpl testService;

    @GetMapping("getById")
    public TableTest getById(){
        DyDataSource.setDbKey("uuu");
        return testService.getById(1);
    }
}

总结

虽然代码实现了但是还有些隐患

  • 目前的dataSource 只有新建没有回收,这样的话需要我们去写一个生命周期的维护,否则 dataSource 越来越多,但是可能需要用到的不足10%,因为dataSource 多了,其中的数据库连接也是一直存活的,极端情况下会占用大量的tcp连接
  • 在初始化dataSource那块的代码虽然加了dcl但是也是有小概率出现线程问题(自行百度 dcl 缺陷)

第一个问题兴许可以通过redis这种做lru的过期淘汰策略,但是第二种目前还没找到方案,有想法的小伙伴可以评论下,让我学习下,谢谢!