Spring Boot Mybatis实现分表

Posted tuacy

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Spring Boot Mybatis实现分表相关的知识,希望对你有一定的参考价值。

      开发过程中可能会碰到分表的场景,数据库的数据量相当大的时候可能需要按天分表或者按月分表啥的(分表策略)。接下来就教大家用最简单的方式实现这一需求。

      咱们接下来主要实现以下两个大功能:

  • 自动建表,当表不存在的时候自动把表创建出来。
  • 自动分表,根据操作数据库的某个参数进行分表。

      自动建表,自动分表核心思想在Mybatis拦截器的使用。强烈建议大家先去了解下Mybatis拦截器的使用(之前也写过一遍关于Mybatis拦截器的使用的文章,有兴趣的可以看下 https://blog.csdn.net/wuyuxing24/article/details/89343951 )。

      根据实际情况我们做如下规定:

  • 每个需要分表的表都有一个基础表名。比如按月分表之后的表名为“XXX-201909”,那么我们认为"XXX"就是基础表名。所有的sql语句里面还是用基础表名,我们会在自定义Mybatis拦截器里面找到基础表名替换成分表表名。
  • 分表的依据来源于操作数据库的参数当中的一个。我们会通过参数注解(TableShardParam)来标识哪个操作作为分表依据。
  • 每个分表需要自己指定分表策略(ITableNameStrategy),针对每个分表我们需要自己去实现自己的分表策略,自己实现ITableNameStrategy接口。

一 自动建表准备

      我们考虑到大部分分表的情况下,都希望在代码里面能够自动建表。操作表之前判断表是否存在,如果表不存在则自动帮我们把表建出来。

      关于自动建表,结合实际情况,我们认为建表是和每个表对应的实体类绑定在一起的。所以我们会有一个建表相关的TableCreate注解,TableCreate注解是添加在每个表对应的实体类上的。TableCreate注解的元数据会告诉我们当前实体类对应表的基础表名,已经去哪里找到相关的建表语句。

TableCreate注解需要添加在表对应的实体类上

/**
 * @name: TableCreate
 * @author: tuacy.
 * @date: 2019/8/29.
 * @version: 1.0
 * @Description: TableCreate注解用于告诉我们怎么找到建表语句(如果表不存在的情况下, 我们程序里面自己去建表)
 * <p>
 * tableName -- 基础表名
 * autoCreateTableMapperClass -- mapper class对应的名字
 * autoCreateTableMapperMethodName -- mapper class 里面对应的方法
 * <p>
 * 最终我们会去mapper class里面找到对应的对应的方法,最终拿到建表语句
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface TableCreate 

    /**
     * table的基础表名
     */
    String tableName();

    /**
     * Mapper类,不能为空
     */
    Class<?> autoCreateTableMapperClass();

    /**
     * Mapper文件里面的函数名字(创建表对应的函数)
     */
    String autoCreateTableMapperMethodName();

      为了方便Mybatis拦截器里面自动建表的使用,每个表对应的建表信息我们用TableCreateConfig类做一个简单的分装。TableCreateConfig会告诉我们基础表名,以及我们需要的建表语句在哪个Mapper类哪个方法里面。


/**
 * @name: TableCreateConfig
 * @author: tuacy.
 * @date: 2019/8/29.
 * @version: 1.0
 * @Description: 自动建表相关的一些配置信息
 * 在拦截器里面我们会根据autoCreateTableMapperClass类的autoCreateTableMapperMethodName方法找到建表语句
 */
@Data
@Accessors(chain = true)
public class TableCreateConfig 

    /**
     * 表名
     */
    private String tableName;

    /**
     * 自动建表Mapper类
     */
    private Class<?> autoCreateTableMapperClass;

    /**
     * 自动建表Mapper中的方法
     */
    private String autoCreateTableMapperMethodName;


      在Spring Boot启动的时候,我们会表实体类对应的包下面读取所有添加了TableCreate注解的相关信息,把读取的信息封装到TableCreateConfig类里面,并且保存在单例类TableCreateManager里面。这一部分内容大家可以看下我给出的源码里面TableCreateScan,TableCreateScanRegister类里面逻辑。

      简单总结下关于自动建表我们做了那些准备工作。我们会在Spring Boot启动的过程中去读取所有添加了TableCreate注解的实体类。把读取到的信息保存在单例类TableCreateManager里面。单例TableCreateManager里面会维护一个Map:key就是每个需要建表的基础表名,value则是建表相关的信息。建表相关的信息会和Mapper里面的某个方法关联起来。具体可以看下下面Mybatis拦截器的具体实现。

二 自动分表准备

      分表,我们需要两个东西:分表策略、分表依据。

2.1 分表策略

      分表策略,我们定义一个分表接口,让每个分表去实现直接的分表策略。分表策略我们给两个参数,一个是基础表名,一个是分表依据。

/**
 * @name: ITableNameStrategy
 * @author: tuacy.
 * @date: 2019/8/13.
 * @version: 1.0
 * @Description: 分表对应的策略
 */
public interface ITableNameStrategy 

    /**
     * 表名字
     *
     * @param oldTableName     表基本名字
     * @param dependFieldValue 根据该字段确定表名(Mapper方法的某个参数对应的值)
     * @return 表名
     */
    String tableName(String oldTableName, String dependFieldValue);


      分表策略的配置,我们把他们放在操作数据库的方法上。在TablePrepare注解里面指定。TablePrepare注解也用于标识是否进入我们自定义的Mybatis拦截器里面去。

/**
 * @name: TablePrepare
 * @author: tuacy.
 * @date: 2019/8/29.
 * @version: 1.0
 * @Description:
 */
@Documented
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TablePrepare 

    /**
     * 启用自动建表,当表不存在的时候,是否创建表
     */
    boolean enableAutoCreateTable() default true;

    /**
     * 启用分表
     */
    boolean enableTableShard() default false;

    /**
     * 指定表,如果设置该值,则只会处理指定的表,没有则会处理sql中的所有表
     * 如果自己设置了基础表的名字,那么我们处理建表和分表的时候只会处理这些指定的表.
     * 如果没有设置基础表的时候,我们会自动去sql语句里面解析出所有的表名.做相应的建表和分表的逻辑
     */
    String[] appointTable() default ;

    /**
     * 表名策略,通过某种规则得到表名
     */
    Class<? extends ITableNameStrategy> strategy() default TableNameStrategyVoid.class;

2.2 分表依据

      结合实际情况,我们认为分表的依据都是来源于操作数据的某个参数(也可能是某个参数的某个字段)。那这里就有问题了,操作数据库有的时候有多个参数,哪个参数作为分表依据呢。我们定义一个参数注解TableShardParam。哪个参数添加了该注解,我们就认为这个参数是分表依据(目前只支持一个参数作为依据)。我们会在我们自定义的Mybatis拦截器里面找到添加了TableShardParam注解的参数对应的值。

      为了应对多种情况。TableShardParam支持以下几种情况(这部分具体的实现,需要仔细看下下面自定义Mybatis拦截器里面这部分的具体实现)。大家可以根据自己的实际情况做相应的修改。

  • TableShardParam添加在java基础类型上,比如int,long等,我们会把基础类型转换为String,最终传递给分表策略。
  • TableShardParam添加在对象类型上,我们可以找到对象的某个属性(反射)对应的值,最终传递给分表策略。
  • TableShardParam添加在List上,我们会找到List对象的一个元素,如果List里面的元素是java基础类型,直接获取到第一个元素对应的值,如果List里面的元素是对象,则获取到对象某个属性对应的值。在最终把他们传递给分表策略。
/**
 * @name: TableShardParam
 * @author: tuacy.
 * @date: 2019/8/30.
 * @version: 1.0
 * @Description: 添加在参数上的注解, 一定要配置mybatis 的Param注解使用
 * <p>
 * 我们是这样考虑的,分表核心在于确定表的名字,表的名字怎么来,肯定是通过某个参数来获取到.
 * 所以,这里我们设计TableShardParam注解,用于添加在参数上,让我们方便的获取到通过那个参数来获取表名
 * 1. int insertItem(@TableShardParam(dependFieldName = "recTime") @Param("item") AccHour item);
 * -- 分表依据对应AccHour对象recTime属性对应的值
 * 2. int insertList(@TableShardParam(dependFieldName = "recTime") @Param("list") List<AccHour> list);
 * -- 分表依据对应list的第一个对象recTime属性对应的值
 * 3. List<AccHour> selectLIst(@TableShardParam() @Param("startTime") Long startTIme, @Param("endTime") Long endTime);
 * -- 分表依据对应endTime对应的值
 */
@Documented
@Inherited
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface TableShardParam 

    @AliasFor("dependFieldName")
    String value() default "";

    /**
     * dependFieldName取到我们需要的获取表名的依据
     */
    @AliasFor("value")
    String dependFieldName() default "";


三 自定义Mybatis拦截器

       关于自定义Mybatis拦截器的具体实现,我这里就直接贴代码了。肯定有些地方是还没有考虑到的,大家需要根据自己的需求做修改,重点关注以下几个部分:

  • 拦截器里面我们是怎么拿到相应的建表语句的。
  • 拦截器里面我们是怎么去执行建表语句的。
  • 拦截器里面我们是怎么拿到分表依据的,里面考虑了多种情况。
  • 每个sql语句,我们是怎么解析出表名的。怎么把我们把我们分表表名替换进去的。
/**
 * @name: TableShardInterceptor
 * @author: tuacy.
 * @date: 2019/8/13.
 * @version: 1.0
 * @Description: 自动建表 + 分表 拦截器的实现
 */
@Intercepts(
        @Signature(
                type = StatementHandler.class,
                method = "prepare",
                args = Connection.class, Integer.class
        )
)
public class TableShardInterceptor implements Interceptor 

    /**
     * sql语句里面去获取表名的依据(主要,全部是小写的)
     * 说白了就是哪些字符串后面会跟上表名
     */
    private final static String[] SQL_TABLE_NAME_FLAG_PREFIX = "from", "join", "update", "insert into";

    private static final ObjectFactory DEFAULT_OBJECT_FACTORY = new DefaultObjectFactory();
    private static final ObjectWrapperFactory DEFAULT_OBJECT_WRAPPER_FACTORY = new DefaultObjectWrapperFactory();
    private static final ReflectorFactory REFLECTOR_FACTORY = new DefaultReflectorFactory();

    @Override
    public Object intercept(Invocation invocation) throws Throwable 
        if (!(invocation.getTarget() instanceof RoutingStatementHandler)) 
            return invocation.proceed();
        

        try 
            RoutingStatementHandler statementHandler = (RoutingStatementHandler) invocation.getTarget();
            // MetaObject是mybatis里面提供的一个工具类,类似反射的效果
            MetaObject metaStatementHandler = MetaObject.forObject(statementHandler, DEFAULT_OBJECT_FACTORY, DEFAULT_OBJECT_WRAPPER_FACTORY, REFLECTOR_FACTORY);
            BoundSql boundSql = (BoundSql) metaStatementHandler.getValue("delegate.boundSql");//获取sql语句
            String originSql = boundSql.getSql();

            if (StringUtils.isEmpty(originSql)) 
                return invocation.proceed();
            

            MappedStatement mappedStatement = (MappedStatement) metaStatementHandler.getValue("delegate.mappedStatement");
            // 判断方法上是否添加了 TableShardAnnotation 注解,因为只有添加了TableShard注解的方法我们才会去做分表处理
            TablePrepare tablePrepare = getTableShardAnnotation(mappedStatement);

            // 没有加@TablePrepare注解则不填家我们自定义的逻辑
            if (tablePrepare == null) 
                return invocation.proceed();
            

            boolean enableAutoCreateTable = tablePrepare.enableAutoCreateTable(); // 表不存在的是哈,事发创建
            boolean enableTableShard = tablePrepare.enableTableShard(); // 事发进行分表逻辑处理
            // 自动建表和分表是否开启,都没有则退出往下走
            if (!enableAutoCreateTable && !enableTableShard) 
                invocation.proceed();
            

            // 获取到需要处理的表名
            String[] appointTable = tablePrepare.appointTable();
            if (appointTable.length == 0) 
                List<String> tableNameList = getTableNamesFromSql(originSql);
                if (tableNameList == null || tableNameList.isEmpty()) 
                    return invocation.proceed();
                 else 
                    // 去掉前后空格和/n
                    tableNameList = tableNameList.stream().map(item -> 
                        if (item == null) 
                            return null;
                        
                        return item.trim().replaceAll("[\\r\\n]", "");
                    ).collect(Collectors.toList());
                    appointTable = new String[tableNameList.size()];
                    tableNameList.toArray(appointTable);
                
            


            // 获取分表表名处理策略
            Class<? extends ITableNameStrategy> strategyClass = tablePrepare.strategy();
            ITableNameStrategy tableStrategy = null;
            if (!strategyClass.equals(TableNameStrategyVoid.class)) 
                tableStrategy = strategyClass.newInstance();
            

            // 分表处理的时候,我们一般是依赖参数里面的某个值来进行的.这里用于获取到参数对应的值.
            String dependValue = getDependFieldValue(tablePrepare, metaStatementHandler, mappedStatement);

            // 自动建表处理逻辑(表不存在的时候,我们会建表)
            if (tablePrepare.enableAutoCreateTable()) 
                SqlSessionTemplate template = SpringContextHolder.getBean(SqlSessionTemplate.class);
                for (String tableName : appointTable) 
                    TableCreateConfig classConfig = TableCreateManager.INSTANCE.getClassConfig(tableName);
                    if (classConfig == null) 
                        // 没有找到建表语句则跳过
                        continue;
                    

                    String createSqlMethodPath = classConfig.getAutoCreateTableMapperClass().getName() + "." + classConfig.getAutoCreateTableMapperMethodName();
                    String sql = template.getConfiguration().getMappedStatement(createSqlMethodPath).getBoundSql("delegate.boundSql").getSql();
                    if (StringUtils.isEmpty(sql)) 
                        // 建表sql为空时不理,直接跳过
                        continue;
                    

                    if (!StringUtils.isEmpty(dependValue) && strategyClass != TableNameStrategyVoid.class) 
                        sql = sql.replace(tableName, tableStrategy.tableName(tableName, dependValue));
                    

                    Connection conn = (Connection) invocation.getArgs()[0];
                    boolean preAutoCommitState = conn.getAutoCommit();
                    conn.setAutoCommit(false);//将自动提交关闭
                    try (PreparedStatement countStmt = conn.prepareStatement(sql)) 
                        // 把新语句设置回去
                        metaStatementHandler.setValue("delegate.boundSql.sql", sql);
                        countStmt.execute();
                        conn.commit();//执行完后,手动提交事务
//                        System.out.println(isSuccess);
                     catch (Exception e) 
                        e.printStackTrace();
                     finally 
                        conn.setAutoCommit(preAutoCommitState);//在把自动提交打开
                    
                
            

            // 分表处理逻辑
            if (strategyClass != TableNameStrategyVoid.class) 
                if (tablePrepare.enableTableShard()) 
                    String updateSql = originSql;
                    for (String tableName : appointTable) 
                        // 策略处理表名
                        String newTableName = tableStrategy.tableName(tableName, dependValue);
                        updateSql = updateSql.replaceAll(tableName, newTableName);
                    

                    // 把新语句设置回去,替换表名
                    metaStatementHandler.setValue("delegate.boundSql.sql", updateSql);
                
             else 
                // fix 启用了自动建表,但是没有启用分表的时候,sql被替换成建表的sql。没有设置回来的问题
                metaStatementHandler.setValue("delegate.boundSql.sql", originSql);
            
         catch (Exception ignored) 
            // ignore 任何一个地方有异常都去执行原始操作 -- invocation.proceed()
        
        return invocation.proceed();
    

    /**
     * 从参数里面找到指定对象指定字段对应的值
     */
    private String getDependFieldValue(TablePrepare tablePrepare, MetaObject metaStatementHandler, MappedStatement mappedStatement) throws Exception 

        // 以上情况下不满足则走@TableShardParam机制
        String id = mappedStatement.getId();
        String className = id.substring(0, id.lastIndexOf("."));
        String methodName = id.substring(id.lastIndexOf(".") + 1);
        Method[] methods = Class.forName(className).getMethods();
        Method method = null;
        for (Method me : methods) 
            if (me.getName().equals(methodName) && me.isAnnotationPresent(tablePrepare.annotationType())) 
                method = me;
            
        

        if (method == null) 
            return null;
        

        Parameter[] parameters = method.getParameters();
        if (parameters.length == 0) 
            return null;
        

        int flag = 0;
        Parameter parameter = null;
        for (Parameter p : parameters) 
            // TableShardParam和Param需要同时添加
            if (p.getAnnotation(TableShardParam.class) != null && p.getAnnotation(Param.class) != null) 
                parameter = p;
                flag++;

            
        

        // 参数没有注解则退出
        if (flag == 0) 
            return null;
        

        // 多个则抛异常
        if (flag > 1) 
            throw new RuntimeException("存在多个指定@TableShardParam的参数,无法处理");
        

        String tableSharedFieldParamKey = parameter.getAnnotation(Param.class).value();
        TableShardParam annotation = parameter.getAnnotation(TableShardParam.class);
        Class<?> parameterType = parameter.getType(); // 参数的类型
        String dependFieldName = StringUtils.isEmpty(annotation.value()) ? annotation.dependFieldName() : annotation.value();
        if (isPrimitive(parameterType) || StringUtils.isEmpty(dependFieldName)) 
            return getPrimitiveParamFieldValue(metaStatementHandler, tableSharedFieldParamKey);
         else 
            return getParamObjectFiledValue(metaStatementHandler, tableSharedFieldParamKey, dependFieldName);
        
    

    /**
     * 判断是否是基础类型 9大基础类型及其包装类
     *
     * @return 是否是基础类型, long, int, Long 等等
     */
    private boolean isPrimitive(Class<?> clazz) 
        if (clazz.isPrimitive()) 
            return true;
        

        try 
            if (((Class) clazz.getField("TYPE").get(null)).isPrimitive()) 
                return true;
            
         catch (Exception e) 
            return false;
        

        return clazz.equals(String.class);
    

    /**
     * 解析sql获取到sql里面所有的表名
     *
     * @param sql sql
     * @return 表名列表
     */
    private List<String> getTableNamesFromSql(String sql) 
        // 对sql语句进行拆分 -- 以','、'\\n'、'\\t'作为分隔符
        List<String> splitterList = Lists.newArrayList(Splitter.on(new CharMatcher() 
            @Override
            public boolean matches(char c) 
                return Character.isWhitespace(c) || c == '\\n' || c == '\\t';
            
        ).omitEmptyStrings().trimResults().split(sql))
                .stream()
                .filter(s -> !s.equals(","))
                .filter(s -> !s.equals("?"))
                .filter(s -> !s.equals("?,"))
                .filter(s -> !s.equals("("))
                .filter(s -> !s.equals(")"))
                .filter(s -> !s.equals("="))
                .collect(Collectors.toList());
        List<String> tableNameList = Lists.newArrayList();
        for (String item : SQL_TABLE_NAME_FLAG_PREFIX) 
            tableNameList.addAll(getTableName(splitterList, Lists.newArrayList(Splitter.on(' ').split(item))));
        
        return tableNameList;
    

    /**
     * 获取表名
     */
    private List<String> getTableName(List<String> splitterList, List<String> list) 
        List<String> retList = Lists.newArrayList();
        if (list == null || list.isEmpty() || splitterList == null || splitterList.isEmpty() || splitterList.size() <= list.size()) 
            return retList;
        
        for (int index = 0; index < splitterList.size(); index = index + list.size()) 

            if (index < splitterList.size() - list.size()) 
                boolean match = true;
                for (int innerIndex = 0; innerIndex < list.size(); innerIndex++) 
                    if (!splitterList.get(index + innerIndex).toLowerCase().equals(list.get(innerIndex).toLowerCase())) 
                        match = false;
                        break;
                    
                
                if (match) 
                    if ("update".toLowerCase().equals(list.get(0).toLowerCase())) 
                        // ON DUPLICATE KEY UPDATE 需要过滤出来
                        if (index < 3 || !(splitterList.get(index - 1).toLowerCase().equals("key".toLowerCase()) &&
                                splitterList.get(index - 2).toLowerCase().equals("DUPLICATE".toLowerCase()) &&
                                splitterList.get(index - 3).toLowerCase().equals("ON".toLowerCase()))) 
                            retList.add(splitterList.get(index + list.size()));
                        
                     else 
                        retList.add(splitterList.get(index + list.size()));
                    
                
            

        
        return retList;
    


    /**
     * 获取方法上的TableShard注解
     *
     * @param mappedStatement MappedStatement
     * @return TableShard注解
     */
    private TablePrepare getTableShardAnnotation(MappedStatement mappedStatement) 
        TablePrepare tablePrepare = null;
        try 
            String id = mappedStatement.getId();
            String className = id.substring(0, id.lastIndexOf("."));
            String methodName = id.substring(id.lastIndexOf(".") + 1);
            final Method[] method = Class.forName(className).getMethods();
            for (Method me : method) 
                if (me.getName().equals(methodName) && me.isAnnotationPresent(TablePrepare.class)) 
                    tablePrepare = me.getAnnotation(TablePrepare.class);
                    break;
                
            
         catch (Exception ex) 
            ex.printStackTrace();
        
        return tablePrepare;
    

    /**
     * 从参数里面找到指定对象指定字段对应的值--基础类型
     */
    private String getPrimitiveParamFieldValue(MetaObject metaStatementHandler, String fieldParamKey) 
        BoundSql boundSql = (BoundSql) metaStatementHandler.getValue("delegate.boundSql");
        Object parameterObject = boundSql.getParameterObject();
        if (parameterObject == null) 
            return null;
        
        Object filterFiledObject = ((MapperMethod.ParamMap) parameterObject).get(fieldParamKey);
        if (filterFiledObject == null) 
            return null;
        
        Object dependObject = recursiveGetEffectiveObject(filterFiledObject);
        return dependObject == null ? null : dependObject.toString();
    

    /**
     * 获取参数里面的对象
     */
    private Object recursiveGetEffectiveObject(Object srcObject) 

        if (!(srcObject instanceof List)) 
            return srcObject;
        
        Object listItemObject = ((List) srcObject).get(0);
        while (listItemObject instanceof List) 
            listItemObject = ((List) listItemObject).get(0);
        
        return listItemObject;
    


    /**
     * 从参数里面找到指定对象指定字段对应的值--对象
     * 如该参数是List.指定对象为第一个元素
     */
    private String getParamObjectFiledValue(MetaObject metaStatementHandler, String fieldParamKey, String dependFieldName) 

        BoundSql boundSql = (BoundSql) metaStatementHandler.getValue("delegate.boundSql");
        Object parameterObject = boundSql.getParameterObject();
        Object filterFiledObject = ((MapperMethod.ParamMap) parameterObject).get(fieldParamKey);

        if (filterFiledObject == null) 
            return null;
        
        Object dependObject = recursiveGetEffectiveObject(filterFiledObject);
        try 
            return ReflectUtil.getFieldValue(dependObject, dependFieldName);
         catch (Exception ignored) 
        

        return null;
    

    @Override
    public Object plugin(Object target) 
        // 当目标类是StatementHandler类型时,才包装目标类,否者直接返回目标本身,减少目标被代理的次数
        return (target instanceof RoutingStatementHandler) ? Plugin.wrap(target, this) : target;
    

    @Override
    public void setProperties(Properties properties) 
    

四 怎么使用

      我们用一个简单实例来教大家怎么使用我们实现的分表功能。基础表名StatisAccHour,

4.1 建表语句

      和我们平常使用Mybatis一样的,一个Mapper接口和一个Mapper xml。

public interface CreateTableMapper 

    int createAccHour();

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tuacy.tableshard.mapper.CreateTableMapper">

    <!-- acc 小时表, 一个小时一张表 -->
    <update id="createAccHour">
        CREATE TABLE IF NOT EXISTS `StatisAccHour` (
            `recTime` bigint(20) NOT NULL,
            `ptId` int(11) NOT NULL,
            `value` double DEFAULT NULL,
            PRIMARY KEY (`RecTime`,`PtId`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    </update>

</mapper>

4.2 表对应实体类

      "StatisAccHour"基础表对应的实体类,三个字段和表里面的字段一一对应。同时我们添加了TableCreate注解在实体了上,指定了基础表名“StatisAccHour”,建表语句在CreateTableMapper类的createAccHour方法里面。

@TableCreate(
        tableName = "StatisAccHour",
        autoCreateTableMapperClass = CreateTableMapper.class,
        autoCreateTableMapperMethodName = "createAccHour"
)
@Getter // lombok 注解,不用手动去写get set方法
@Setter
public class AccHour 

    /**
     * 针对recTime做一个简单说明,
     * 比如当前时间是 2019年08月31日00时31分46秒141微妙
     * 则我们在数据库里面存20190831003146141
     */
    private Long recTime;
    private Long ptId;
    private Double value;


4.3 分表策略

      基础表名和分表依据字段的前十个字符组成分表对应的表名。


/**
 * 分表方案 按照年月日时分表
 */
public class SuffixYYYYMMDDHHNameStrategy implements ITableNameStrategy 

    private static final int SUFFIX_LENGTH = 10; // yyyymmddhh

    @Override
    public String tableName(String baseTableName, String dependFieldValue) 
        return baseTableName + dependFieldValue.substring(0, SUFFIX_LENGTH);
    

4.4 数据库操作

      注意TablePrepare注解的添加,每个sql里面的表名还是用的基础表名。最终会在自定义拦截器里面替换。

/**
 * AccHour 每个小时一张表(多数据源,我们有三个数据源,我们假设该表放在statis数据源下面)
 */
public interface AccHourMapper 

    /**
     * 往数据库里面插入一条记录
     */
    @TablePrepare(enableTableShard = true, strategy = SuffixYYYYMMDDHHNameStrategy.class)
    int insertItem(@TableShardParam(dependFieldName = "recTime") @Param("item") AccHour item);

    /**
     * 往数据库里面插入多条
     */
    @TablePrepare(enableTableShard = true, strategy = SuffixYYYYMMDDHHNameStrategy.class)
    int insertList(@TableShardParam(dependFieldName = "recTime") @Param("list") List<AccHour> list);

    /**
     * 往数据库里面插入多条
     */
    @TablePrepare(enableTableShard = true, strategy = SuffixYYYYMMDDHHNameStrategy.class)
    AccHour selectItem(@TableShardParam(dependFieldName = "recTime") @Param("recvTime") Long recvTime, @Param("pkId") Long pkId);

    /**
     * 查询指定时间范围内的列表
     *
     * @param startTIme 开始时间
     * @param endTime   解释时间
     */
    @TablePrepare(enableTableShard = true, strategy = SuffixYYYYMMDDHHNameStrategy.class)
    List<AccHour> selectLIst(@TableShardParam() @Param("startTime") Long startTIme, @Param("endTime") Long endTime);



<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tuacy.tableshard.mapper.AccHourMapper">

    <!-- 基础表名 StatisAccHour -->

    <!-- 往数据库里面插入一条记录 -->
    <insert id="insertItem">

        insert into StatisAccHour (
            `recTime`,
            `ptId`,
            `value`
        ) value (
            #item.recTime,
            #item.ptId,
            #item.value
        )

    </insert>

    <!-- 批量插入多条记录 -->
    <insert id="insertList">

        insert into StatisAccHour (
            `recTime`,
            `ptId`,
            `value`
        ) values
         <foreach collection="list" item="item" separator=",">
             (
                #item.recTime,
                #item.ptId,
                #item.value
             )
         </foreach>

    </insert>

    <!-- 查询一条记录 -->
    <select id="selectItem" resultType="com.tuacy.tableshard.entity.model.AccHour">

        select
        `recTime` as recTime,
        `ptId` as ptId,
        `value` as value
        from StatisAccHour
        where recTime = #recvTime and ptId = #pkId

    </select>

    <!-- 查询一条记录 -->
    <select id="selectLIst" resultType="com.tuacy.tableshard.entity.model.AccHour">

        select
        `recTime` as recTime,
        `ptId` as ptId,
        `value` as value
        from StatisAccHour
        where recTime >= $startTime and recTime <![CDATA[<=]]> $endTime

    </select>

</mapper>

4.5 DAO使用

      特别要注意,在Dao层我们需要自己保证每一次操作的数据库都是属于同一个分表的。比如插入一批数据的时候,我们需要自己对不同分表的数据做分批次处理。保存每个调用mapper插入的时候都是属于同一个分表的数据。具体可以看看下面insertList()方法的具体实现。

@Repository
public class AccHourDao extends BaseDao implements IAccHourDao 

    /**
     * 基础表名
     */
    private static final String BASE_TABLE_NAME = "StatisAccHour";
    /**
     * 分表策略
     */
    private static final ITableNameStrategy STRATEGY = new SuffixYYYYMMDDHHNameStrategy();

    private AccHourMapper accHourMapper;

    @Autowired
    public void setAccHourMapper(AccHourMapper accHourMapper) 
        this.accHourMapper = accHourMapper;
    

    /**
     * DataSourceAnnotation 用于指定数据源,放到统计数据库里面
     */
    @Override
    @DataSourceAnnotation(sourceType = EDataSourceType.STATIS)
    @Transactional(rollbackFor = Exception.class)
    public int insertItem(AccHour item) 
        return accHourMapper.insertItem(item);
    

    @Override
    @DataSourceAnnotation(sourceType = EDataSourceType.STATIS)
    @Transactional(rollbackFor = Exception.class)
    public int insertList(List<AccHour> list) 
        if (list == null || list.isEmpty()) 
            return 0;
        
        // 首先,我们不能保证list所有的数据都是一张表的,所以我们先得对数据分类,按表来分类
        Map<String, List<AccHour>> groupingByTable = list.stream().collect(Collectors.groupingBy(
                item -> STRATEGY.tableName(BASE_TABLE_NAME, item.getRecTime().toString()),
                (Supplier<Map<String, List<AccHour>>>) HashMap::new,
                Collectors.toList()));
        // 遍历存储(上面的代码我们已经保存了每个Map.)
        int sucessCount = 0;
        for (List<AccHour> mapValueItem : groupingByTable.values()) 
            sucessCount += accHourMapper.insertList(mapValueItem);
        
        return sucessCount;
    

    @Override
    @DataSourceAnnotation(sourceType = EDataSourceType.STATIS)
    @Transactional(rollbackFor = Exception.class)
    public AccHour selectItem(Long recvTime, Long ptId) 
        return accHourMapper.selectItem(recvTime, ptId);
    

    /**
     * 查询指定时间范围的数据
     * 针对time做一个简单说明,
     * 比如当前时间是 2019年08月31日00时31分46秒141微妙
     * 则我们在数据库里面存20190831003146141
     *
     * @param startTime 开始时间
     * @param endTime   结束时间
     * @return 所有查询到的记录
     */
    @Override
    @DataSourceAnnotation(sourceType = EDataSourceType.STATIS)
    @Transactional(rollbackFor = Exception.class)
    public List<AccHour> selectList(Long startTime, Long endTime) 
        // long类型是20190831003146141的形式转换为2019年08月31日00时31分46秒141微妙对应的LocalDateTime
        LocalDateTime startTimeDate = DbDataTimeUtils.long2DateTime(startTime);
        LocalDateTime endTimeDate = DbDataTimeUtils.long2DateTime(endTime);
        if (startTimeDate.isAfter(endTimeDate)) 
            return null;
        
        // 数据库里面所有的表
        List<String> allTableName = allTableName();
        if (allTableName == null || allTableName.isEmpty()) 
            return null;
        
        // 全部转换成小写
        allTableName = allTableName.stream().map(String::toLowerCase).collect(Collectors.toList());
        List<TwoTuple<Long, Long>> singleTableConditionList = Lists.newArrayList();
        // 我们已经确定了当前是按小时分表的,表名类似于 StatisAccHour2019083122 的形式,先确定指定的时间范围里面有多少张表
        while (startTimeDate.isBefore(endTimeDate) || startTimeDate.equals(endTimeDate)) 
            String tableName = STRATEGY.tableName(BASE_TABLE_NAME, String.valueOf(DbDataTimeUtils.dateTime2Long(startTimeDate)));
            if (allTableName.contains(tableName.toLowerCase())) 
                // 有这个表存在
                Long singleTableStartTime = DbDataTimeUtils.dateTime2Long(startTimeDate);
                if (singleTableStartTime < startTime) 
                    singleTableStartTime = startTime;
                
                singleTableConditionList.add(new TwoTuple<>(singleTableStartTime, endTime));
            
            startTimeDate = startTimeDate.plusHours(1);
        
        if (singleTableConditionList.isEmpty()) 
            return null;
        
        List<AccHour> retList = Lists.newArrayList();
        for (TwoTuple<Long, Long> item : singleTableConditionList) 
            retList.addAll(accHourMapper.selectLIst(item.getFirst(), item.getSecond()));
        

        return retList;
    


      关于Spring Boot Mybatis实现分表功能,整个的实现逻辑就这么多。估计上面很多地方我们也没讲明白,可能有些地方认为简单就没讲。所以这里面把整个实现源码的链接地址给出来 https://github.com/tuacy/java-study/tree/master/tableshard 推荐大家去看下具体源码是怎么实现的,有什么疑问可以留言。

以上是关于Spring Boot Mybatis实现分表的主要内容,如果未能解决你的问题,请参考以下文章

mybatis-mapper 示例 spring-boot

mybatis-mapper 示例 spring-boot

spring boot:用shardingjdbc实现多数据源的分库分表(shardingsphere 4.1.1/spring boot 2.3.1)

mybatis-plus 动态数据源读写分离 + shardingJDBC分库分表

Spring boot 入门三:spring boot 整合mybatis 实现CRUD操作

springboot+mybatisplus,再加入shardingjdbc分表玩法