Java框架(Spring+Mybatis+Druid)多数据源方案
2018年04月07日


可选方案

1.基于Spring的AbstractRoutingDataSource并用AOP动态切换

2.基于Mybatis多SqlSession实例分开扫描各自Mapper


1、基于Spring的AbstractRoutingDataSource并用AOP动态切换

优点:能灵活的控制多数据源,支持读写分离。

缺点:

1)AOP表达式需要自己配置,切换需要写对应的注解。

2)开启事务时,需要注意AOP顺序( 数据库事务的注解 要在 数据源切换的注解 之后)。

3)数据源动态切换,会有一定性能开销。


2、基于Mybatis多SqlSession实例分开扫描各自Mapper

优点:使用简单,配置后就和单数据源使用一样。性能好,不存在AOP那样的性能开销。

缺点:不支持读写分离。


3、结合方案一和方案二

方案一和方案二,并不冲突,可以结合使用。


应用场景和常见问题

1.多个独立数据源,每个数据源一套代码,独立使用。

2.读写分离,一套代码,根据不同方法,使用不同的数据源。

3.一套代码,同样的方法,根据传入的参数,使用不同的目标数据源。


1、多个独立数据源,每个数据源一套代码,独立使用

这个场景,方案一和方案二都可以解决,但是方案二更好。


2、读写分离,一套代码,根据不同方法,使用不同的数据源

    考虑项目使用了一个可写数据源和多个只读数据源( 一主多从 模式),为了减少数据库压力,使用轮循的方式选择只读数据源。但是应当避免在同一个 Service 方法中写入后立即查询,因为此时可能 从库 数据还未同步,如果必须在执行写入操作后立即读取(有这种情况吗?),应该强制读取使用主数据源,即,一些实时性要求很高的select语句,可能需要放到master上执行,因为slave上可能存在延时。(为了解决这个问题,可以在查询的方法上加上数据源AOP注解,指定读取主库)

问题:数据库事务@Transactional应该放在Service层还是Dao层?它和数据源AOP注解,哪个在前,哪个在后?

    开启事务时,肯定就要操作数据源了,DataSource此时必然做出选择,如果此时还没有使用AOP注解选择数据源,那将直接使用默认数据源。如果开启了事务后,又遇到了AOP注解切换数据源,那么势必造成数据源错乱。所以,数据源AOP必须在事务注解之前,如果数据源AOP注解在Dao方法上,那么事务注解也必须在Dao方法上,不能在Service层,另外,建议把@Transactional 注解加在方法上,不要加在类上。

    这样就要考虑,Dao方法调用Dao方法的情况,每个Dao方法都有AOP切面,要保证 层层调用后,数据源不改变,不丢失。举个例子,UserDao.foo() 方法,调用了同源的MenuDao.bar() 方法、UserDao.insert() 方法 和 不同数据源的RemoteDao.query() 方法,如下所示:

@Transactional
UserDao.foo() {
    MenuDao.bar();
    RemoteDao.query();
    UserDao.insert();
    ....
}

进入UserDao.foo()时,数据源会切换成 testDB,

进入MenuDao.bar() 时,又切换成 testDB,退出 MenuDao.bar() 时,数据源被清空,

再进入RemoteDao.query() 方法,数据源被切换成 remoteDB,退出RemoteDao.query() 方法时,数据源被清空,

再进入UserDao.insert() 方法,数据源被切换成 testDB,退出时,数据源被清空,

最后,退出UserDao.foo(),数据源被清空。(注意,上面说的退出,包括了异常退出,切面的@After能捕获异常的退出)


但是这个方案存在以下问题或疑问:

    问题:当调用UserDao.insert() 方法时,由于和 外层的UserDao.foo() 方法处于同一个类中,就直接调用,AOP不会起作用,所以此时insert方法的数据源不能正常切换。为了解决这个问题,foo方法,不能放在UserDao中,必须得单独建一个类,建议放到Service中,然后在Service层就先切换好数据源。如下:

@Transactional
@SelectDB("testDB")
UserService.foo() {
    MenuDao.bar();
    RemoteDao.query();
    UserDao.insert();
    ....
}

    这样一来,首先切换了testDB数据源并开启基于testDB事务,然后执行bar、query、insert,每一个AOP都会切换一次数据源,执行完后又清空,最后退出 foo(),事务提交,清空数据源。

    疑问:如果UserDao.insert() 之后执行过程中报异常,要回滚insert,那么此时数据源被清空了,能否回滚?答案:能回滚,因为事务的开启和提交都是基于Connection连接的,而不是基于DataSource,如下所示:

public void transferAccounts(double money) {
    Connection conn = null;
    try {
        conn = JdbcUtils.getConnection();
        conn.setAutoCommit(false);
        someDao.update(con, -money);
        someDao.update(con, +money);
        // 提交事务
        conn.commit();
    } catch (Exception e) {
        // 回滚
        conn.rollback();
    }
}

    开启事务时就保留了数据源连接,执行完之后,就可以在原数据源连接的基础上进行提交或者回滚。所以即使数据源丢失了,但是原来的连接Connection不变,故不会影响提交或回滚。详细分析如下:

    由于外层开启了事务,那么方法内在同一个数据源下的所有连接,执行完成后,理论上都不会提交(只有外层方法执行完后,所有连接才能提交),或者说在事务内同一个数据源下只会开启一个连接,所有操作共用一个连接,那么Spring的Transaction机制到达是怎么实现的呢?翻看源码才能解答!

public void create(String name, Integer age, Integer marks, Integer year) {
    TransactionDefinition def = new DefaultTransactionDefinition();
    TransactionStatus status = transactionManager.getTransaction(def);
    try {
         String SQL1 = "insert into Student (name, age) values (?, ?)";
         jdbcTemplate.update( SQL1, name, age);
         
         String SQL2 = "select max(id) from Student";
         int sid = jdbcTemplate.queryForInt( SQL2 );
         
         String SQL3 = "insert into Marks(sid, marks, year) values (?, ?, ?)";
         jdbcTemplate.update( SQL3, sid, marks, year);
         
         System.out.println("Created Name = " + name + ", Age = " + age);
         transactionManager.commit(status);
    } catch (DataAccessException e) {
         System.out.println("Error in creating record, rolling back");
         transactionManager.rollback(status);
         throw e;
    }
}

    从这段代码可以看出,jdbc的连接,在最后才提交,那么一共开启了多少个连接,最后是怎么提交的呢?得看看JdbcTemplate源码和transactionManager.commit(status)。从JdbcTemplate源码可以查到,它getConnection是通过TransactionSynchronizationManager.getResource(dataSource),而这个方法最终会从下面的Map获取:

    ThreadLocal<Map<Object, Object>> resources = new NamedThreadLocal<>("Transactional resources");

    和我的预期一样,说明Spring的Transaction会保留所有的连接,就拿以上示例的方法为例,它在一个事务里面,执行了3个SQL,这3次数据库操作都是在同一个Connection下的,而这个Connection在TransactionManager的掌握之中,最终可以进行commit或者rollback。就拿jdbcTemplate.update() 来分析,它执行完后会调用releaseConnection,但是是否真的关闭连接,还是TransactionManager说了算,下面一段代码为证:

void doReleaseConnection(Connection con, DataSource dataSource) {

    ConnectionHolder conHolder = TransactionSynchronizationManager.getResource(dataSource);

    if (conHolder != null && connectionEquals(conHolder, con)) {

        // It's the transactional Connection: Don't close it.

        conHolder.released();

        return;

    } else {

        logger.debug("Returning JDBC Connection to DataSource");

        doCloseConnection(con, dataSource);

    }

    同时,也可以看到,是根据DataSource来获取连接的,所以如果不是当前的DataSource,连接就会交给JDBC去close。

    注意到foo()方法连接了一个其他数据源,对这个数据源,执行完成后就会立即提交,无法进行统一的事务控制。需要用到分布式事务,可以考虑结合Atomikos来处理。

    现在,再回到上面的问题,最外层foo()方法针对于testDB开启了事务,内部insert也是基于testDB数据源的,所以它执行完后不会提交,当foo()结束后才会提交或回滚。


3、一套代码,同样的方法,根据传入的参数,使用不同的目标数据源

对于这个场景,可以将数据源参数,在入口(Controller)传入ThreadLocal,然后执行数据源选择时,根据当前Thread的key来决定数据源目标。这样,就能和场景2公用一套解决方案。



参考资料:

1.基于Mybatis多SqlSession实例分开扫描各自Mapper

https://blog.csdn.net/isea533/article/details/46815385

http://www.cnblogs.com/ityouknow/p/6102399.html

https://blog.csdn.net/maoyeqiu/article/details/74011626

https://blog.csdn.net/neosmith/article/details/61202084

https://www.cnblogs.com/Alandre/p/6611813.html


2.动态AOP数据源

https://github.com/helloworlde/SpringBoot-DynamicDataSource/blob/roundrobin/src/main/java/cn/com/hellowood/dynamicdatasource/configuration/DynamicDataSourceContextHolder.java

https://github.com/baomidou/dynamic-datasource-spring-boot-starter/tree/master/src/main/java/com/baomidou/dynamic/datasource


3.另外一种思路:

https://github.com/hs-web/hsweb-framework/blob/master/hsweb-commons/hsweb-commons-dao/hsweb-commons-dao-mybatis/src/main/java/org/hswebframework/web/dao/mybatis/dynamic/DynamicSqlSessionFactory.java