项目背景

  项目中使用oracle数据库 + mybatis框架,由于数据量较大,需要使用日表。而我们又不希望对mybatis的mapper文件做较大的改动,比如在SQL中添加日表后续,通过变量符的方式操作日表,因为这样的话就不能使用mybatis预编译的SQL影响性能,而且将来如果使用分布式数据库的话,意味着将来还要改动mapper文件。虽然当当有sharding-jdbc框架,但是不支持oracle,因此,自己开发了简单的mybatis插件,通过sql改写的方式操作日表。

特性

  • 支持oracle、mysql
  • 支持pagehelper分页插件
  • 简单实用,出于项目实际情况考虑,该插件目前只支持编码的方式指定要操作的日表,不支持根据某个字段进行拆表

quick start

添加插件支持

  如果项目中用到了pagehelper分页插件,需要将该插件放到分表插件前面,因为mybatis对拦截器进行了处理,顺序靠后的的拦截器越先执行,下面是InterceptorChain中的pluginAll方法,返回的是最后被代理的对象

public Object pluginAll(Object target) {
    for (Interceptor interceptor : interceptors) {
      target = interceptor.plugin(target);
    }
    return target;
}

  下面是mybatis的xml配置,添加了分表插件

<bean id="dataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource" destroy-method="close">
        <property name="driverClass" value="oracle.jdbc.driver.OracleDriver" />
        <property name="jdbcUrl" value="jdbc:oracle:thin:@localhost:1521:dwade" />
        <property name="user" value="******" />
        <property name="password" value="******" />
        <property name="minPoolSize" value="20" />
        <property name="maxPoolSize" value="200" />
        <property name="initialPoolSize" value="20" />
        <property name="acquireIncrement" value="20" />
        <property name="checkoutTimeout" value="10000" />
        <property name="idleConnectionTestPeriod" value="600" />
        <property name="maxIdleTime" value="600" />
    </bean>
    <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
        <property name="dataSource" ref="dataSource" />
        <property name="configuration" ref="mybatisConfig" />
        <property name="plugins">
            <array>
                <!-- 具体参数请查看wiki: https://github.com/pagehelper/Mybatis-PageHelper/blob/master/wikis/zh/HowToUse.md -->
                <bean class="com.github.pagehelper.PageInterceptor">
                    <property name="properties">
                        <value>
                            helperDialect=oracle
                            reasonable=true
                            supportMethodsArguments=true
                        </value>
                    </property>
                </bean>
                <!-- 日表插件 -->
                <bean id="tableSegInterceptor"
                    class="net.dwade.plugins.mybatis.ShardingInterceptor">
                </bean>
            </array>
        </property>
        <property name="mapperLocations" value="classpath*:net/dwade/payment/dao/**/*Mapper.xml" />
        <property name="typeAliasesPackage" value="net.dwade.payment.dao.*.model" />
    </bean>
    <bean id="mybatisConfig" class="org.apache.ibatis.session.Configuration">
        <property name="logImpl" value="org.apache.ibatis.logging.log4j2.Log4j2Impl" />
    </bean>
    <bean id="paymentMapperScanner" class="org.mybatis.spring.mapper.MapperScannerConfigurer">
        <property name="basePackage" value="net.dwade.payment.dao" />
        <property name="sqlSessionFactoryBeanName" value="sqlSessionFactory" />
    </bean>

编码

  使用方式和分页插件相同,通过将日表条件绑定到ThreadLocal中,简单的调用ShardingHolder的api即可。在项目中,我们会在主键中体现日期,很多情况下是知道数据存放在哪张表里面的。
* 插入日表
假设我们需要将数据插入到T_USER_20170603这张表中,如下面的代码所示。

User user = new User();
user.setUserId( "12341234" );
user.setCreateTime( new Date() );
user.setUserName( "15567899876" );
user.setEmail( "xxx@163.com" );

ShardingHolder.set( "20170603" );
userDao.insert( user );
  • 日表查询
ShardingHolder.set( "20170603" );
userDao.selectByPrimaryKey( "2017060312341234" );
  • 多表、分页关联查询
ShardingHolder.set( "20170601", "20170602" );
PageHelper.startPage( xxx, xxx );
userDao.selectXXX( param1, param2 );

注意事项

  • 多个表关联查询,需要按照表名在sql出现的顺序,依次设置,如果涉及到其中的某个表为全表,设为null即可,eg:ShardingHolder.set( “20170712”, null, “20170712” );
  • 为了保证分页条件的准确性,调用ShardingHolder的set方法之后必须紧接着调用dao方法,错误示例:
ShardingHolder.set( "20170601", "20170602" );
// do something 1
// do something 2
payUserDao.selectXXX( "12341234" );

源码说明

该插件的原理非常简单,通过拦截StatementHalder接口,对sql进行解析、改写,mybatis使用改写的SQL执行,最终获得我们想要的结果。

mybatis拦截器基本原理

  mybatis允许我们对四大接口的方法进行拦截,所以要先了解Mybatis的四大接口对象Executor, StatementHandler, ResultSetHandler, ParameterHandler各自的作用,分别代表执行器,SQL语法处理、结果集处理、参数处理。

核心代码

  该分表插件拦截了StatementHandler的prepare方法,用于对SQL进行改写,如Signature注解所示。其中,args代表方法的参数,因为只有指定了接口、方法名、参数,mybatis才能确定需要拦截哪个方法,值得一提的是,低版本的mybatis的StatementHandler接口中的prepare方法只有一个参数(如3.2.8版本只有一个参数,而我的项目里面用的是3.4.2),因此注解中args指定了Connection和Integer。此外,还拦截了Executor的query和update方法,主要的作用是为了支持pagehelper分页插件,因为在分页插件中,先是调用了Executor接口执行了一次count (1)的SQL语句,然后才是执行查询数据的SQL。这样一来,执行count (1)的SQL会调用我们拦截器的interceptor方法,如果不做额外的处理,分表条件便会清除,所以我们还拦截了Executor接口,并且在其执行完毕之后才清理ThreadLocal中的分表条件,如代码中的54行所示。
  对于SQL解析,我们使用的是开源的jsqlparse,简单的封装了下,只获取SQL中的表结构,具体请参考net.dwade.plugins.mybatis.parser.JSqlParserFactory.java

/**
* mybatis分表拦截器,<em>如果同时和分页插件一起使用,需要配置在分页插件之后</em><br/>
* <p>mybatis拦截器的执行顺序:Executor-->StatementHandler(ParameterHandler)-->ResultSetHandler</p>
* <p>
* 该分表插件拦截了Executor的query和update方法,Executor执行完毕之后将分表条件清除,
* 否则会把全表操作误认为分表操作,此外,由于分页插件也拦截了Executor的query方法,因此和分页插件同时
* 使用时需要将分页插件配置在该分表插件前面,因为InterceptorChain.pluginAll(Object target)返回的
* 是最后一个拦截器的代理,因此会先执行最后一个拦截器的intercept方法
* </p>
* <p>为了避免对非日表的操作带来影响,该插件在Executor执行完毕的时候清除ThreadLocal中的分表条件。</p>
* <strong>为什么不在获取分表条件之后就清理ThreadLocal中的分表条件?</strong>
* 因为分页插件拦截的是Executor,并且自己创建了BoundSql进行调用,先是count操作,再是查询数据,如果拦截的是获取之后就清除,
* 那么只会对count操作的分表起作用,对分页插件的数据查询操作是不会起作用的
* @author huangxf
* @date 2017年6月29日
*/
@Intercepts({ 
    @Signature(type = StatementHandler.class, method = "prepare", args = { java.sql.Connection.class, Integer.class }),
    @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
    @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
    @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class})
})
public class ShardingInterceptor implements Interceptor {

    private Logger logger = LoggerFactory.getLogger( this.getClass() );

    private final SqlParserFactory parserFactory = new JSqlParserFactory();

    private final Field boundSqlField;

    public final String DEFAULT_SEPARATOR = "_";

    /**
    * 分表的连接符,T_ORDER_20160629,其中T_ORDER为逻辑表名,_代表separator
    */
    private String separator = DEFAULT_SEPARATOR;

    public ShardingInterceptor() {
        try {
            boundSqlField = BoundSql.class.getDeclaredField("sql");
            boundSqlField.setAccessible(true);
        } catch (Exception e) {
            throw new RuntimeException( e );
        }
    }

    @Override
    public Object intercept(Invocation invocation) throws Throwable {

        //---------------------------------------------------------------
        // 对于分页插件而言,它自己调用了count的SQL查询,最后还是会进入intercept方法,只不过
        // invocation的target是StatementHandler了,而不再是Executor
        //---------------------------------------------------------------
        if ( invocation.getTarget() instanceof Executor ) {
            try {
                return invocation.proceed();
            } finally {
                ShardingHolder.remove();
            }
        }

        StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
        BoundSql boundSql = statementHandler.getBoundSql();

        // 判断是否设置分表条件,ThreadLocal中的变量在commit或者rollback的时候清除
        final String[] actualTables = ShardingHolder.get();
        if ( ArrayUtils.isEmpty( actualTables ) ) {
            return invocation.proceed();
        }

        // 进行SQL解析
        SqlParser sqlParser = parserFactory.createParser( boundSql.getSql() );
        List<Table> tables = sqlParser.getTables();
        if ( tables.isEmpty() ) {
            return invocation.proceed();
        }

        // 如果设置的表名数量和实际不一致,抛出SQL异常
        if ( tables.size() != actualTables.length ) {
            throw new SQLException( "Table sharding exception, tables in sql not equals to actual settings" );
        }

        // 设置实际的表名
        for ( int index = 0; index < tables.size(); index++ ) {
            if ( StringUtils.isEmpty( actualTables[ index ] ) ) {
                continue;
            }
            Table table = tables.get( index );
            String targetName = table.getName() + separator + actualTables[ index ];
            logger.info( "Sharding table, {}-->{}", table, targetName );
            table.setName( targetName );
        }

        // 修改实际的SQL
        String targetSQL = sqlParser.toSQL();
        boundSqlField.set( boundSql, targetSQL );

        return invocation.proceed();
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap( target, this );
    }

    @Override
    public void setProperties(Properties properties) {
        this.separator = properties.getProperty( "separator", DEFAULT_SEPARATOR );
    }

}