基于Mybatis手撸一个分表插件

运维 数据库运维
事情是酱紫的,阿星的上级leader负责记录信息的业务,每日预估数据量是15万左右,所以引入sharding-jdbc做分表。

 [[399518]]

本文转载自微信公众号「程序猿阿星」,作者程序猿阿星。转载本文请联系程序猿阿星公众号。

背景

事情是酱紫的,阿星的上级leader负责记录信息的业务,每日预估数据量是15万左右,所以引入sharding-jdbc做分表。

上级leader完成业务的开发后,走了一波自测,git push后,就忙其他的事情去了。

项目的框架是SpringBoot+Mybaits

出问题了

阿星负责的业务也开发完了,熟练的git pull,准备自测,单元测试run一下,上个厕所回来收工,就是这么自信。

回来后,看下控制台,人都傻了,一片红,内心不禁感叹“如果这是股票基金该多好”。

出了问题就要解决,随着排查深入,我的眉头一皱发现事情并不简单,怎么以前的一些代码都报错了?

随着排查深入,最后跟到了Mybatis源码,发现罪魁祸首是sharding-jdbc引起的,因为数据源是sharding-jdbc的,导致后续执行sql的是ShardingPreparedStatement。

这就意味着,sharding-jdbc影响项目的所有业务表,因为最终数据库交互都由ShardingPreparedStatement去做了,历史的一些sql语句因为sql函数或者其他写法,使得ShardingPreparedStatement无法处理而出现异常。

关键代码如下

发现问题后,阿星马上就反馈给leader了。

唉,本来还想摸鱼的,看来摸鱼的时间是没了,还多了一项任务。

分析

竟然交给阿星来做了,就撸起袖子开干吧,先看看分表功能的需求

  • 支持自定义分表策略
  • 能控制影响范围
  • 通用性

分表会提前建立好,所以不需要考虑表不存在的问题,核心逻辑实现,通过分表策略得到分表名,再把分表名动态替换到sql。

分表策略

为了支持分表策略,我们需要先定义分表策略抽象接口,定义如下

  1. /** 
  2.  * @Author 程序猿阿星 
  3.  * @Description 分表策略接口 
  4.  * @Date 2021/5/9 
  5.  */ 
  6. public interface ITableShardStrategy { 
  7.  
  8.  
  9.     /** 
  10.      * @author: 程序猿阿星 
  11.      * @description: 生成分表名 
  12.      * @param tableNamePrefix 表前缀名 
  13.      * @param value 值 
  14.      * @date: 2021/5/9 
  15.      * @return: java.lang.String 
  16.      */ 
  17.     String generateTableName(String tableNamePrefix,Object value); 
  18.  
  19.     /** 
  20.      * 验证tableNamePrefix 
  21.      */ 
  22.     default void verificationTableNamePrefix(String tableNamePrefix){ 
  23.         if (StrUtil.isBlank(tableNamePrefix)) { 
  24.             throw new RuntimeException("tableNamePrefix is null"); 
  25.         } 
  26.     } 

generateTableName函数的任务就是生成分表名,入参有tableNamePrefix、value,tableNamePrefix为分表前缀,value作为生成分表名的逻辑参数。

verificationTableNamePrefix函数验证tableNamePrefix必填,提供给实现类使用。

为了方便理解,下面是id取模策略代码,取模两张表

  1. /** 
  2.  * @Author 程序猿阿星 
  3.  * @Description 分表策略id 
  4.  * @Date 2021/5/9 
  5.  */ 
  6. @Component 
  7. public class TableShardStrategyId implements ITableShardStrategy { 
  8.     @Override 
  9.     public String generateTableName(String tableNamePrefix, Object value) { 
  10.         verificationTableNamePrefix(tableNamePrefix); 
  11.         if (value == null || StrUtil.isBlank(value.toString())) { 
  12.             throw new RuntimeException("value is null"); 
  13.         } 
  14.         long id = Long.parseLong(value.toString()); 
  15.         //此处可以缓存优化 
  16.         return tableNamePrefix + "_" + (id % 2); 
  17.     } 

传入进来的value是id值,用tableNamePrefix拼接id取模后的值,得到分表名返回。

控制影响范围

分表策略已经抽象出来,下面要考虑控制影响范围,我们都知道Mybatis规范中每个Mapper类对应一张业务主体表,Mapper类的函数对应业务主体表的相关sql。

阿星想着,可以给Mapper类打上注解,代表该Mpaaer类对应的业务主体表有分表需求,从规范来说Mapper类的每个函数对应的主体表都是正确的,但是有些同学可能不会按规范来写。

假设Mpaaer类对应的是B表,Mpaaer类的某个函数写着A表的sql,甚至是历史遗留问题,所以注解不仅仅可以打在Mapper类上,同时还可以打在Mapper类的任意一个函数上,并且保证小粒度覆盖粗粒度。

阿星这里自定义分表注解,代码如下

 

  1. /** 
  2.  * @Author 程序猿阿星 
  3.  * @Description 分表注解 
  4.  * @Date 2021/5/9 
  5.  */ 
  6. @Target(value = {ElementType.TYPE,ElementType.METHOD}) 
  7. @Retention(RetentionPolicy.RUNTIME) 
  8. public @interface TableShard { 
  9.  
  10.     // 表前缀名 
  11.     String tableNamePrefix(); 
  12.  
  13.     //值 
  14.     String value() default ""
  15.  
  16.     //是否是字段名,如果是需要解析请求参数改字段名的值(默认否) 
  17.     boolean fieldFlag() default false
  18.  
  19.     // 对应的分表策略类 
  20.     Class<? extends ITableShardStrategy> shardStrategy(); 
  21.  
  22.  

注解的作用范围是类、接口、函数,运行时生效。

tableNamePrefix与shardStrategy属性都好理解,表前缀名和分表策略,剩下的value与fieldFlag要怎么理解,分表策略分两类,第一类依赖表中某个字段值,第二类则不依赖。

根据企业id取模,属于第一类,此处的value设置企业id入参字段名,fieldFlag为true,意味着,会去解析获取企业id字段名对应的值。

根据日期分表,属于第二类,直接在分表策略实现类里面写就行了,不依赖表字段值,value与fieldFlag无需填写,当然你value也可以设置时间格式,具体看分表策略实现类的逻辑。

通用性

抽象分表策略与分表注解都搞定了,最后一步就是根据分表注解信息,去执行分表策略得到分表名,再把分表名动态替换到sql中,同时具有通用性。

Mybatis框架中,有拦截器机制做扩展,我们只需要拦截StatementHandler#prepare函数,即StatementHandle创建Statement之前,先把sql里面的表名动态替换成分表名。

Mybatis分表拦截器流程图如下

Mybatis分表拦截器代码如下,有点长哈,主流程看intercept函数就好了。

  1. /** 
  2.  * @Author 程序员阿星 
  3.  * @Description 分表拦截器 
  4.  * @Date 2021/5/9 
  5.  */ 
  6. @Intercepts({ 
  7.         @Signature( 
  8.                 type = StatementHandler.class, 
  9.                 method = "prepare"
  10.                 args = {Connection.class, Integer.class} 
  11.         ) 
  12. }) 
  13. public class TableShardInterceptor implements Interceptor { 
  14.  
  15.     private static final ReflectorFactory defaultReflectorFactory = new DefaultReflectorFactory(); 
  16.  
  17.     @Override 
  18.     public Object intercept(Invocation invocation) throws Throwable { 
  19.  
  20.         // MetaObject是mybatis里面提供的一个工具类,类似反射的效果 
  21.         MetaObject metaObject = getMetaObject(invocation); 
  22.         BoundSql boundSql = (BoundSql) metaObject.getValue("delegate.boundSql"); 
  23.         MappedStatement mappedStatement = (MappedStatement) 
  24.                 metaObject.getValue("delegate.mappedStatement"); 
  25.  
  26.         //获取Mapper执行方法 
  27.         Method method = invocation.getMethod(); 
  28.  
  29.         //获取分表注解 
  30.         TableShard tableShard = getTableShard(method,mappedStatement); 
  31.  
  32.         // 如果method与class都没有TableShard注解或执行方法不存在,执行下一个插件逻辑 
  33.         if (tableShard == null) { 
  34.             return invocation.proceed(); 
  35.         } 
  36.  
  37.         //获取值 
  38.         String value = tableShard.value(); 
  39.         //value是否字段名,如果是,需要解析请求参数字段名的值 
  40.         boolean fieldFlag = tableShard.fieldFlag(); 
  41.  
  42.         if (fieldFlag) { 
  43.             //获取请求参数 
  44.             Object parameterObject = boundSql.getParameterObject(); 
  45.  
  46.             if (parameterObject instanceof MapperMethod.ParamMap) { //ParamMap类型逻辑处理 
  47.  
  48.                 MapperMethod.ParamMap parameterMap = (MapperMethod.ParamMap) parameterObject; 
  49.                 //根据字段名获取参数值 
  50.                 Object valueObject = parameterMap.get(value); 
  51.                 if (valueObject == null) { 
  52.                     throw new RuntimeException(String.format("入参字段%s无匹配", value)); 
  53.                 } 
  54.                 //替换sql 
  55.                 replaceSql(tableShard, valueObject, metaObject, boundSql); 
  56.  
  57.             } else { //单参数逻辑 
  58.  
  59.                 //如果是基础类型抛出异常 
  60.                 if (isBaseType(parameterObject)) { 
  61.                     throw new RuntimeException("单参数非法,请使用@Param注解"); 
  62.                 } 
  63.  
  64.                 if (parameterObject instanceof Map){ 
  65.                     Map<String,Object>  parameterMap =  (Map<String,Object>)parameterObject; 
  66.                     Object valueObject = parameterMap.get(value); 
  67.                     //替换sql 
  68.                     replaceSql(tableShard, valueObject, metaObject, boundSql); 
  69.                 } else { 
  70.                     //非基础类型对象 
  71.                     Class<?> parameterObjectClass = parameterObject.getClass(); 
  72.                     Field declaredField = parameterObjectClass.getDeclaredField(value); 
  73.                     declaredField.setAccessible(true); 
  74.                     Object valueObject = declaredField.get(parameterObject); 
  75.                     //替换sql 
  76.                     replaceSql(tableShard, valueObject, metaObject, boundSql); 
  77.                 } 
  78.             } 
  79.  
  80.         } else {//无需处理parameterField 
  81.             //替换sql 
  82.             replaceSql(tableShard, value, metaObject, boundSql); 
  83.         } 
  84.         //执行下一个插件逻辑 
  85.         return invocation.proceed(); 
  86.     } 
  87.  
  88.  
  89.     @Override 
  90.     public Object plugin(Object target) { 
  91.         // 当目标类是StatementHandler类型时,才包装目标类,否者直接返回目标本身, 减少目标被代理的次数 
  92.         if (target instanceof StatementHandler) { 
  93.             return Plugin.wrap(target, this); 
  94.         } else { 
  95.             return target; 
  96.         } 
  97.     } 
  98.  
  99.  
  100.     /** 
  101.      * @param object 
  102.      * @methodName: isBaseType 
  103.      * @author: 程序员阿星 
  104.      * @description: 基本数据类型验证,true是,false否 
  105.      * @date: 2021/5/9 
  106.      * @return: boolean 
  107.      */ 
  108.     private boolean isBaseType(Object object) { 
  109.         if (object.getClass().isPrimitive() 
  110.                 || object instanceof String 
  111.                 || object instanceof Integer 
  112.                 || object instanceof Double 
  113.                 || object instanceof Float 
  114.                 || object instanceof Long 
  115.                 || object instanceof Boolean 
  116.                 || object instanceof Byte 
  117.                 || object instanceof Short) { 
  118.             return true
  119.         } else { 
  120.             return false
  121.         } 
  122.     } 
  123.  
  124.     /** 
  125.      * @param tableShard 分表注解 
  126.      * @param value      值 
  127.      * @param metaObject mybatis反射对象 
  128.      * @param boundSql   sql信息对象 
  129.      * @author: 程序猿阿星 
  130.      * @description: 替换sql 
  131.      * @date: 2021/5/9 
  132.      * @return: void 
  133.      */ 
  134.     private void replaceSql(TableShard tableShard, Object value, MetaObject metaObject, BoundSql boundSql) { 
  135.         String tableNamePrefix = tableShard.tableNamePrefix(); 
  136.         //获取策略class 
  137.         Class<? extends ITableShardStrategy> strategyClazz = tableShard.shardStrategy(); 
  138.         //从spring ioc容器获取策略类 
  139.  
  140.         ITableShardStrategy tableShardStrategy = SpringUtil.getBean(strategyClazz); 
  141.         //生成分表名 
  142.         String shardTableName = tableShardStrategy.generateTableName(tableNamePrefix, value); 
  143.         // 获取sql 
  144.         String sql = boundSql.getSql(); 
  145.         // 完成表名替换 
  146.         metaObject.setValue("delegate.boundSql.sql", sql.replaceAll(tableNamePrefix, shardTableName)); 
  147.     } 
  148.  
  149.     /** 
  150.      * @param invocation 
  151.      * @author: 程序猿阿星 
  152.      * @description: 获取MetaObject对象-mybatis里面提供的一个工具类,类似反射的效果 
  153.      * @date: 2021/5/9 
  154.      * @return: org.apache.ibatis.reflection.MetaObject 
  155.      */ 
  156.     private MetaObject getMetaObject(Invocation invocation) { 
  157.         StatementHandler statementHandler = (StatementHandler) invocation.getTarget(); 
  158.         // MetaObject是mybatis里面提供的一个工具类,类似反射的效果 
  159.         MetaObject metaObject = MetaObject.forObject(statementHandler, 
  160.                 SystemMetaObject.DEFAULT_OBJECT_FACTORY, 
  161.                 SystemMetaObject.DEFAULT_OBJECT_WRAPPER_FACTORY, 
  162.                 defaultReflectorFactory 
  163.         ); 
  164.  
  165.         return metaObject; 
  166.     } 
  167.  
  168.     /** 
  169.      * @author: 程序猿阿星 
  170.      * @description: 获取分表注解 
  171.      * @param method 
  172.      * @param mappedStatement 
  173.      * @date: 2021/5/9 
  174.      * @return: com.xing.shard.interceptor.TableShard 
  175.      */ 
  176.     private TableShard getTableShard(Method method, MappedStatement mappedStatement) throws ClassNotFoundException { 
  177.         String id = mappedStatement.getId(); 
  178.         //获取Class 
  179.         final String className = id.substring(0, id.lastIndexOf(".")); 
  180.         //分表注解 
  181.         TableShard tableShard = null
  182.         //获取Mapper执行方法的TableShard注解 
  183.         tableShard = method.getAnnotation(TableShard.class); 
  184.         //如果方法没有设置注解,从Mapper接口上面获取TableShard注解 
  185.         if (tableShard == null) { 
  186.             // 获取TableShard注解 
  187.             tableShard = Class.forName(className).getAnnotation(TableShard.class); 
  188.         } 
  189.         return tableShard; 
  190.     } 
  191.  

到了这里,其实分表功能就已经完成了,我们只需要把分表策略抽象接口、分表注解、分表拦截器抽成一个通用jar包,需要使用的项目引入这个jar,然后注册分表拦截器,自己根据业务需求实现分表策略,在给对应的Mpaaer加上分表注解就好了。

实践跑起来

这里阿星单独写了一套demo,场景是有两个分表策略,表也提前建立好了

  • 根据id分表
    • tb_log_id_0
    • tb_log_id_1
  • 根据日期分表
    • tb_log_date_202105
    • tb_log_date_202106

预警:后面都是代码实操环节,请各位读者大大耐心看完(非Java开发除外)。

TableShardStrategy定义

  1. /** 
  2.  * @Author wx 
  3.  * @Description 分表策略日期 
  4.  * @Date 2021/5/9 
  5.  */ 
  6. @Component 
  7. public class TableShardStrategyDate implements ITableShardStrategy { 
  8.  
  9.     private static final String DATE_PATTERN = "yyyyMM"
  10.  
  11.     @Override 
  12.     public String generateTableName(String tableNamePrefix, Object value) { 
  13.         verificationTableNamePrefix(tableNamePrefix); 
  14.         if (value == null || StrUtil.isBlank(value.toString())) { 
  15.             return tableNamePrefix + "_" +DateUtil.format(new Date(), DATE_PATTERN); 
  16.         } else { 
  17.             return tableNamePrefix + "_" +DateUtil.format(new Date(), value.toString()); 
  18.         } 
  19.     } 
  20.  
  21.  
  22.  
  23. ** 
  24.  * @Author 程序猿阿星 
  25.  * @Description 分表策略id 
  26.  * @Date 2021/5/9 
  27.  */ 
  28. @Component 
  29. public class TableShardStrategyId implements ITableShardStrategy { 
  30.     @Override 
  31.     public String generateTableName(String tableNamePrefix, Object value) { 
  32.         verificationTableNamePrefix(tableNamePrefix); 
  33.         if (value == null || StrUtil.isBlank(value.toString())) { 
  34.             throw new RuntimeException("value is null"); 
  35.         } 
  36.         long id = Long.parseLong(value.toString()); 
  37.         //可以加入本地缓存优化 
  38.         return tableNamePrefix + "_" + (id % 2); 
  39.     } 

Mapper定义

Mapper接口

  1. /** 
  2.  * @Author 程序猿阿星 
  3.  * @Description 
  4.  * @Date 2021/5/8 
  5.  */ 
  6. @TableShard(tableNamePrefix = "tb_log_date",shardStrategy = TableShardStrategyDate.class) 
  7. public interface LogDateMapper { 
  8.  
  9.     /** 
  10.      * 查询列表-根据日期分表 
  11.      */ 
  12.     List<LogDate> queryList(); 
  13.  
  14.     /** 
  15.      * 单插入-根据日期分表 
  16.      */ 
  17.     void  save(LogDate logDate); 
  18.  
  19.  
  20.  
  21. ------------------------------------------------------------------------------------------------- 
  22.  
  23.  
  24. /** 
  25.  * @Author 程序猿阿星 
  26.  * @Description 
  27.  * @Date 2021/5/8 
  28.  */ 
  29. @TableShard(tableNamePrefix = "tb_log_id",value = "id",fieldFlag = true,shardStrategy = TableShardStrategyId.class) 
  30. public interface LogIdMapper { 
  31.  
  32.     /** 
  33.      * 根据id查询-根据id分片 
  34.      */ 
  35.     LogId queryOne(@Param("id") long id); 
  36.  
  37.     /** 
  38.      * 单插入-根据id分片 
  39.      */ 
  40.     void save(LogId logId); 
  41.  
  42.  

Mapper.xml

  1. <?xml version="1.0" encoding="UTF-8" ?> 
  2. <!DOCTYPE mapper 
  3.         PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 
  4.         "http://mybatis.org/dtd/mybatis-3-mapper.dtd"
  5. <mapper namespace="com.xing.shard.mapper.LogDateMapper"
  6.      
  7.     //对应LogDateMapper#queryList函数 
  8.     <select id="queryList" resultType="com.xing.shard.entity.LogDate"
  9.         select 
  10.         id as id, 
  11.         comment as comment, 
  12.         create_date as createDate 
  13.         from 
  14.         tb_log_date 
  15.     </select
  16.      
  17.     //对应LogDateMapper#save函数 
  18.     <insert id="save" > 
  19.         insert into tb_log_date(id, comment,create_date) 
  20.         values (#{id}, #{comment},#{createDate}) 
  21.     </insert
  22. </mapper> 
  23.  
  24. ------------------------------------------------------------------------------------------------- 
  25.  
  26. <?xml version="1.0" encoding="UTF-8" ?> 
  27. <!DOCTYPE mapper 
  28.         PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 
  29.         "http://mybatis.org/dtd/mybatis-3-mapper.dtd"
  30. <mapper namespace="com.xing.shard.mapper.LogIdMapper"
  31.      
  32.     //对应LogIdMapper#queryOne函数 
  33.     <select id="queryOne" resultType="com.xing.shard.entity.LogId"
  34.         select 
  35.         id as id, 
  36.         comment as comment, 
  37.         create_date as createDate 
  38.         from 
  39.         tb_log_id 
  40.         where 
  41.         id = #{id} 
  42.     </select
  43.      
  44.     //对应save函数 
  45.     <insert id="save" > 
  46.         insert into tb_log_id(id, comment,create_date) 
  47.         values (#{id}, #{comment},#{createDate}) 
  48.     </insert
  49.  
  50. </mapper> 

执行下单元测试

日期分表单元测试执行

  1. @Test 
  2.   void test() { 
  3.       LogDate logDate = new LogDate(); 
  4.       logDate.setId(snowflake.nextId()); 
  5.       logDate.setComment("测试内容"); 
  6.       logDate.setCreateDate(new Date()); 
  7.       //插入 
  8.       logDateMapper.save(logDate); 
  9.       //查询 
  10.       List<LogDate> logDates = logDateMapper.queryList(); 
  11.       System.out.println(JSONUtil.toJsonPrettyStr(logDates)); 
  12.   } 

输出结果

id分表单元测试执行

  1. @Test 
  2. void test() { 
  3.     LogId logId = new LogId(); 
  4.     long id = snowflake.nextId(); 
  5.     logId.setId(id); 
  6.     logId.setComment("测试"); 
  7.     logId.setCreateDate(new Date()); 
  8.     //插入 
  9.     logIdMapper.save(logId); 
  10.     //查询 
  11.     LogId logIdObject = logIdMapper.queryOne(id); 
  12.     System.out.println(JSONUtil.toJsonPrettyStr(logIdObject)); 

输出结果

小结一下

本文可以当做对Mybatis进阶的使用教程,通过Mybatis拦截器实现分表的功能,满足基本的业务需求,虽然比较简陋,但是Mybatis这种扩展机制与设计值得学习思考。

有兴趣的读者也可以自己写一个,或基于阿星的做改造,毕竟是简陋版本,还是有很多场景没有考虑到。

 

另外分表的demo项目,阿星放到了Gitee和公众号,大家按需自取

 

责任编辑:武晓燕 来源: 程序猿阿星
相关推荐

2021-10-27 06:49:34

线程池Core函数

2021-10-04 09:29:41

对象池线程池

2022-03-01 11:38:51

RPC框架后端

2022-02-08 09:09:45

智能指针C++

2021-11-02 14:19:15

插件MavengroupId

2022-01-21 07:35:06

LRU缓存java

2021-11-04 17:23:03

Java对象 immutable

2022-04-22 08:22:50

MVCCMySQLC++

2021-10-12 09:24:02

Java线程池源码

2022-05-20 12:24:45

分库分表Java依赖

2022-02-14 07:34:23

工具类GET、POST

2022-03-01 08:21:32

工具类代码封装网络请求

2020-12-09 08:34:24

css生成器设计师

2022-11-08 15:14:17

MyBatis插件

2021-05-31 07:22:46

ORM框架程序

2020-11-04 07:56:19

工具Linux 翻译

2021-02-22 11:13:17

VS Code代码编程

2024-02-19 00:00:00

Redis分布式

2021-06-07 12:08:06

iOS Python API

2020-08-03 08:10:52

UDPTCP通信
点赞
收藏

51CTO技术栈公众号