事情是酱紫的,阿星的上级leader 负责记录信息的业务,每日预估数据量是15 万左右,所以引入sharding-jdbc 做分表。上级leader 完成业务的开发后,走了一波自测,git push 后,就忙其他的事情去了。阿星负责的业务也开发完了,熟练的git pull ,准备自测,单元测试run 一下,上个厕所回来收工,就是这么自信。回来后,看下控制台,人都傻了,一片红,内心不禁感叹“如果这是股票基金该多好”。出了问题就要解决,随着排查深入,我的眉头一皱发现事情并不简单,怎么以前的一些代码都报错了?随着排查深入,最后跟到了Mybatis 源码,发现罪魁祸首是sharding-jdbc 引起的,因为数据源是sharding-jdbc 的,导致后续执行sql 的是ShardingPreparedStatement 。这就意味着,sharding-jdbc 影响项目的所有业务表,因为最终数据库交互都由ShardingPreparedStatement 去做了,历史的一些sql 语句因为sql 函数或者其他写法,使得ShardingPreparedStatement 无法处理而出现异常。唉,本来还想摸鱼的,看来摸鱼的时间是没了,还多了一项任务。竟然交给阿星来做了,就撸起袖子开干吧,先看看分表功能的需求分表会提前建立好,所以不需要考虑表不存在的问题,核心逻辑实现,通过分表策略得到分表名,再把分表名动态替换到sql 。分表策略为了支持分表策略,我们需要先定义分表策略抽象接口,定义如下/** * @Author 程序猿阿星 * @Description 分表策略接口 * @Date 2021/5/9 */ public interface ITableShardStrategy {
/** * @author: 程序猿阿星 * @description: 生成分表名 * @param tableNamePrefix 表前缀名 * @param value 值 * @date: 2021/5/9 * @return: java.lang.String */ String generateTableName(String tableNamePrefix,Object value);
/** * 验证tableNamePrefix */ default void verificationTableNamePrefix(String tableNamePrefix){ if (StrUtil.isBlank(tableNamePrefix)) { throw new RuntimeException("tableNamePrefix is null"); } } }
generateTableName 函数的任务就是生成分表名,入参有tableNamePrefix、value ,tableNamePrefix 为分表前缀,value 作为生成分表名的逻辑参数。verificationTableNamePrefix 函数验证tableNamePrefix 必填,提供给实现类使用。/** * @Author 程序猿阿星 * @Description 分表策略id * @Date 2021/5/9 */ @Component public class TableShardStrategyId implements ITableShardStrategy { @Override public String generateTableName(String tableNamePrefix, Object value) { verificationTableNamePrefix(tableNamePrefix); if (value == null || StrUtil.isBlank(value.toString())) { throw new RuntimeException("value is null"); } long id = Long.parseLong(value.toString()); //此处可以缓存优化 return tableNamePrefix + "_" + (id % 2); } }
传入进来的value 是id 值,用tableNamePrefix 拼接id 取模后的值,得到分表名返回。控制影响范围分表策略已经抽象出来,下面要考虑控制影响范围,我们都知道Mybatis 规范中每个Mapper 类对应一张业务主体表,Mapper 类的函数对应业务主体表的相关sql 。阿星想着,可以给Mapper 类打上注解,代表该Mpaaer 类对应的业务主体表有分表需求,从规范来说Mapper 类的每个函数对应的主体表都是正确的,但是有些同学可能不会按规范来写。假设Mpaaer 类对应的是B 表,Mpaaer 类的某个函数写着A 表的sql ,甚至是历史遗留问题,所以注解不仅仅可以打在Mapper 类上,同时还可以打在Mapper 类的任意一个函数上,并且保证小粒度覆盖粗粒度。/** * @Author 程序猿阿星 * @Description 分表注解 * @Date 2021/5/9 */ @Target(value = {ElementType.TYPE,ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface TableShard {
// 表前缀名 String tableNamePrefix();
//值 String value() default "";
//是否是字段名,如果是需要解析请求参数改字段名的值(默认否) boolean fieldFlag() default false;
// 对应的分表策略类 Class<? extends ITableShardStrategy> shardStrategy();
}
tableNamePrefix 与shardStrategy 属性都好理解,表前缀名和分表策略,剩下的value 与fieldFlag 要怎么理解,分表策略分两类,第一类依赖表中某个字段值,第二类则不依赖。根据企业id 取模,属于第一类,此处的value 设置企业id 入参字段名,fieldFlag 为true ,意味着,会去解析获取企业id 字段名对应的值。根据日期分表,属于第二类,直接在分表策略实现类里面写就行了,不依赖表字段值,value 与fieldFlag 无需填写,当然你value 也可以设置时间格式,具体看分表策略实现类的逻辑。通用性抽象分表策略与分表注解都搞定了,最后一步就是根据分表注解信息,去执行分表策略得到分表名,再把分表名动态替换到sql 中,同时具有通用性。Mybatis 框架中,有拦截器机制做扩展,我们只需要拦截StatementHandler#prepare 函数,即StatementHandle 创建Statement 之前,先把sql 里面的表名动态替换成分表名。Mybatis 分表拦截器代码如下,有点长哈,主流程看intercept 函数就好了。/** * @Author 程序员阿星 * @Description 分表拦截器 * @Date 2021/5/9 */ @Intercepts({ @Signature( type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class} ) }) public class TableShardInterceptor implements Interceptor {
private static final ReflectorFactory defaultReflectorFactory = new DefaultReflectorFactory();
@Override public Object intercept(Invocation invocation) throws Throwable {
// MetaObject是mybatis里面提供的一个工具类,类似反射的效果 MetaObject metaObject = getMetaObject(invocation); BoundSql boundSql = (BoundSql) metaObject.getValue("delegate.boundSql"); MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
//获取Mapper执行方法 Method method = invocation.getMethod();
//获取分表注解 TableShard tableShard = getTableShard(method,mappedStatement);
// 如果method与class都没有TableShard注解或执行方法不存在,执行下一个插件逻辑 if (tableShard == null) { return invocation.proceed(); }
//获取值 String value = tableShard.value(); //value是否字段名,如果是,需要解析请求参数字段名的值 boolean fieldFlag = tableShard.fieldFlag();
if (fieldFlag) { //获取请求参数 Object parameterObject = boundSql.getParameterObject();
if (parameterObject instanceof MapperMethod.ParamMap) { //ParamMap类型逻辑处理
MapperMethod.ParamMap parameterMap = (MapperMethod.ParamMap) parameterObject; //根据字段名获取参数值 Object valueObject = parameterMap.get(value); if (valueObject == null) { throw new RuntimeException(String.format("入参字段%s无匹配", value)); } //替换sql replaceSql(tableShard, valueObject, metaObject, boundSql);
} else { //单参数逻辑
//如果是基础类型抛出异常 if (isBaseType(parameterObject)) { throw new RuntimeException("单参数非法,请使用@Param注解"); }
if (parameterObject instanceof Map){ Map<String,Object> parameterMap = (Map<String,Object>)parameterObject; Object valueObject = parameterMap.get(value); //替换sql replaceSql(tableShard, valueObject, metaObject, boundSql); } else { //非基础类型对象 Class<?> parameterObjectClass = parameterObject.getClass(); Field declaredField = parameterObjectClass.getDeclaredField(value); declaredField.setAccessible(true); Object valueObject = declaredField.get(parameterObject); //替换sql replaceSql(tableShard, valueObject, metaObject, boundSql); } }
} else {//无需处理parameterField //替换sql replaceSql(tableShard, value, metaObject, boundSql); } //执行下一个插件逻辑 return invocation.proceed(); }
@Override public Object plugin(Object target) { // 当目标类是StatementHandler类型时,才包装目标类,否者直接返回目标本身, 减少目标被代理的次数 if (target instanceof StatementHandler) { return Plugin.wrap(target, this); } else { return target; } }
/** * @param object * @methodName: isBaseType * @author: 程序员阿星 * @description: 基本数据类型验证,true是,false否 * @date: 2021/5/9 * @return: boolean */ private boolean isBaseType(Object object) { if (object.getClass().isPrimitive() || object instanceof String || object instanceof Integer || object instanceof Double || object instanceof Float || object instanceof Long || object instanceof Boolean || object instanceof Byte || object instanceof Short) { return true; } else { return false; } }
/** * @param tableShard 分表注解 * @param value 值 * @param metaObject mybatis反射对象 * @param boundSql sql信息对象 * @author: 程序猿阿星 * @description: 替换sql * @date: 2021/5/9 * @return: void */ private void replaceSql(TableShard tableShard, Object value, MetaObject metaObject, BoundSql boundSql) { String tableNamePrefix = tableShard.tableNamePrefix(); //获取策略class Class<? extends ITableShardStrategy> strategyClazz = tableShard.shardStrategy(); //从spring ioc容器获取策略类
ITableShardStrategy tableShardStrategy = SpringUtil.getBean(strategyClazz); //生成分表名 String shardTableName = tableShardStrategy.generateTableName(tableNamePrefix, value); // 获取sql String sql = boundSql.getSql(); // 完成表名替换 metaObject.setValue("delegate.boundSql.sql", sql.replaceAll(tableNamePrefix, shardTableName)); }
/** * @param invocation * @author: 程序猿阿星 * @description: 获取MetaObject对象-mybatis里面提供的一个工具类,类似反射的效果 * @date: 2021/5/9 * @return: org.apache.ibatis.reflection.MetaObject */ private MetaObject getMetaObject(Invocation invocation) { StatementHandler statementHandler = (StatementHandler) invocation.getTarget(); // MetaObject是mybatis里面提供的一个工具类,类似反射的效果 MetaObject metaObject = MetaObject.forObject(statementHandler, SystemMetaObject.DEFAULT_OBJECT_FACTORY, SystemMetaObject.DEFAULT_OBJECT_WRAPPER_FACTORY, defaultReflectorFactory );
return metaObject; }
/** * @author: 程序猿阿星 * @description: 获取分表注解 * @param method * @param mappedStatement * @date: 2021/5/9 * @return: com.xing.shard.interceptor.TableShard */ private TableShard getTableShard(Method method, MappedStatement mappedStatement) throws ClassNotFoundException { String id = mappedStatement.getId(); //获取Class final String className = id.substring(0, id.lastIndexOf(".")); //分表注解 TableShard tableShard = null; //获取Mapper执行方法的TableShard注解 tableShard = method.getAnnotation(TableShard.class); //如果方法没有设置注解,从Mapper接口上面获取TableShard注解 if (tableShard == null) { // 获取TableShard注解 tableShard = Class.forName(className).getAnnotation(TableShard.class); } return tableShard; }
}
到了这里,其实分表功能就已经完成了,我们只需要把分表策略抽象接口、分表注解、分表拦截器抽成一个通用jar 包,需要使用的项目引入这个jar ,然后注册分表拦截器,自己根据业务需求实现分表策略,在给对应的Mpaaer 加上分表注解就好了。这里阿星单独写了一套demo ,场景是有两个分表策略,表也提前建立好了预警:后面都是代码实操环节,请各位读者大大耐心看完(非Java开发除外)。TableShardStrategy定义 /** * @Author wx * @Description 分表策略日期 * @Date 2021/5/9 */ @Component public class TableShardStrategyDate implements ITableShardStrategy {
private static final String DATE_PATTERN = "yyyyMM";
@Override public String generateTableName(String tableNamePrefix, Object value) { verificationTableNamePrefix(tableNamePrefix); if (value == null || StrUtil.isBlank(value.toString())) { return tableNamePrefix + "_" +DateUtil.format(new Date(), DATE_PATTERN); } else { return tableNamePrefix + "_" +DateUtil.format(new Date(), value.toString()); } } }
** * @Author 程序猿阿星 * @Description 分表策略id * @Date 2021/5/9 */ @Component public class TableShardStrategyId implements ITableShardStrategy { @Override public String generateTableName(String tableNamePrefix, Object value) { verificationTableNamePrefix(tableNamePrefix); if (value == null || StrUtil.isBlank(value.toString())) { throw new RuntimeException("value is null"); } long id = Long.parseLong(value.toString()); //可以加入本地缓存优化 return tableNamePrefix + "_" + (id % 2); } }
Mapper定义/** * @Author 程序猿阿星 * @Description * @Date 2021/5/8 */ @TableShard(tableNamePrefix = "tb_log_date",shardStrategy = TableShardStrategyDate.class) public interface LogDateMapper {
/** * 查询列表-根据日期分表 */ List<LogDate> queryList();
/** * 单插入-根据日期分表 */ void save(LogDate logDate);
}
-------------------------------------------------------------------------------------------------
/** * @Author 程序猿阿星 * @Description * @Date 2021/5/8 */ @TableShard(tableNamePrefix = "tb_log_id",value = "id",fieldFlag = true,shardStrategy = TableShardStrategyId.class) public interface LogIdMapper {
/** * 根据id查询-根据id分片 */ LogId queryOne(@Param("id") long id);
/** * 单插入-根据id分片 */ void save(LogId logId);
}
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-////DTD Mapper 3.0//EN" "http:///dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.xing.shard.mapper.LogDateMapper"> //对应LogDateMapper#queryList函数 <select id="queryList" resultType="com.xing.shard.entity.LogDate"> select id as id, comment as comment, create_date as createDate from tb_log_date </select> //对应LogDateMapper#save函数 <insert id="save" > insert into tb_log_date(id, comment,create_date) values (#{id}, #{comment},#{createDate}) </insert> </mapper>
-------------------------------------------------------------------------------------------------
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-////DTD Mapper 3.0//EN" "http:///dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.xing.shard.mapper.LogIdMapper"> //对应LogIdMapper#queryOne函数 <select id="queryOne" resultType="com.xing.shard.entity.LogId"> select id as id, comment as comment, create_date as createDate from tb_log_id where id = #{id} </select> //对应save函数 <insert id="save" > insert into tb_log_id(id, comment,create_date) values (#{id}, #{comment},#{createDate}) </insert>
</mapper>
执行下单元测试 @Test void test() { LogDate logDate = new LogDate(); logDate.setId(snowflake.nextId()); logDate.setComment("测试内容"); logDate.setCreateDate(new Date()); //插入 logDateMapper.save(logDate); //查询 List<LogDate> logDates = logDateMapper.queryList(); System.out.println(JSONUtil.toJsonPrettyStr(logDates)); }
@Test void test() { LogId logId = new LogId(); long id = snowflake.nextId(); logId.setId(id); logId.setComment("测试"); logId.setCreateDate(new Date()); //插入 logIdMapper.save(logId); //查询 LogId logIdObject = logIdMapper.queryOne(id); System.out.println(JSONUtil.toJsonPrettyStr(logIdObject)); }
本文可以当做对Mybatis 进阶的使用教程,通过Mybatis 拦截器实现分表的功能,满足基本的业务需求,虽然比较简陋,但是Mybatis 这种扩展机制与设计值得学习思考。有兴趣的读者也可以自己写一个,或基于阿星的做改造,毕竟是简陋版本,还是有很多场景没有考虑到。另外分表的demo 项目,阿星放到了Gitee ,大家按需自取Gitee地址: https:///jxncwx/shard
一键三连「分享」、「点赞」和「在看」 技术干货与你天天见~
|