本文转载自微信公众号「程序猿阿星」,作者程序猿阿星。转载本文请联系程序猿阿星公众号。
背景
事情是酱紫的,阿星的上级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。
分表策略
为了支持分表策略,我们需要先定义分表策略抽象接口,定义如下
/**
* @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");
}
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
generateTableName函数的任务就是生成分表名,入参有tableNamePrefix、value,tableNamePrefix为分表前缀,value作为生成分表名的逻辑参数。
verificationTableNamePrefix函数验证tableNamePrefix必填,提供给实现类使用。
为了方便理解,下面是id取模策略代码,取模两张表
/**
* @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);
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
传入进来的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();
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
注解的作用范围是类、接口、函数,运行时生效。
tableNamePrefix与shardStrategy属性都好理解,表前缀名和分表策略,剩下的value与fieldFlag要怎么理解,分表策略分两类,第一类依赖表中某个字段值,第二类则不依赖。
根据企业id取模,属于第一类,此处的value设置企业id入参字段名,fieldFlag为true,意味着,会去解析获取企业id字段名对应的值。
根据日期分表,属于第二类,直接在分表策略实现类里面写就行了,不依赖表字段值,value与fieldFlag无需填写,当然你value也可以设置时间格式,具体看分表策略实现类的逻辑。
通用性
抽象分表策略与分表注解都搞定了,最后一步就是根据分表注解信息,去执行分表策略得到分表名,再把分表名动态替换到sql中,同时具有通用性。
Mybatis框架中,有拦截器机制做扩展,我们只需要拦截StatementHandler#prepare函数,即StatementHandle创建Statement之前,先把sql里面的表名动态替换成分表名。
Mybatis分表拦截器流程图如下
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;
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
- 42.
- 43.
- 44.
- 45.
- 46.
- 47.
- 48.
- 49.
- 50.
- 51.
- 52.
- 53.
- 54.
- 55.
- 56.
- 57.
- 58.
- 59.
- 60.
- 61.
- 62.
- 63.
- 64.
- 65.
- 66.
- 67.
- 68.
- 69.
- 70.
- 71.
- 72.
- 73.
- 74.
- 75.
- 76.
- 77.
- 78.
- 79.
- 80.
- 81.
- 82.
- 83.
- 84.
- 85.
- 86.
- 87.
- 88.
- 89.
- 90.
- 91.
- 92.
- 93.
- 94.
- 95.
- 96.
- 97.
- 98.
- 99.
- 100.
- 101.
- 102.
- 103.
- 104.
- 105.
- 106.
- 107.
- 108.
- 109.
- 110.
- 111.
- 112.
- 113.
- 114.
- 115.
- 116.
- 117.
- 118.
- 119.
- 120.
- 121.
- 122.
- 123.
- 124.
- 125.
- 126.
- 127.
- 128.
- 129.
- 130.
- 131.
- 132.
- 133.
- 134.
- 135.
- 136.
- 137.
- 138.
- 139.
- 140.
- 141.
- 142.
- 143.
- 144.
- 145.
- 146.
- 147.
- 148.
- 149.
- 150.
- 151.
- 152.
- 153.
- 154.
- 155.
- 156.
- 157.
- 158.
- 159.
- 160.
- 161.
- 162.
- 163.
- 164.
- 165.
- 166.
- 167.
- 168.
- 169.
- 170.
- 171.
- 172.
- 173.
- 174.
- 175.
- 176.
- 177.
- 178.
- 179.
- 180.
- 181.
- 182.
- 183.
- 184.
- 185.
- 186.
- 187.
- 188.
- 189.
- 190.
- 191.
- 192.
到了这里,其实分表功能就已经完成了,我们只需要把分表策略抽象接口、分表注解、分表拦截器抽成一个通用jar包,需要使用的项目引入这个jar,然后注册分表拦截器,自己根据业务需求实现分表策略,在给对应的Mpaaer加上分表注解就好了。
实践跑起来
这里阿星单独写了一套demo,场景是有两个分表策略,表也提前建立好了
- 根据id分表
- tb_log_id_0
- tb_log_id_1
- 根据日期分表
- tb_log_date_202105
- tb_log_date_202106
预警:后面都是代码实操环节,请各位读者大大耐心看完(非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);
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
Mapper定义
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);
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
- 42.
- 43.
- 44.
Mapper.xml
<?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.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 "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/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>
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
- 42.
- 43.
- 44.
- 45.
- 46.
- 47.
- 48.
- 49.
- 50.
执行下单元测试
日期分表单元测试执行
@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));
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
输出结果
id分表单元测试执行
@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));
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
输出结果
小结一下
本文可以当做对Mybatis进阶的使用教程,通过Mybatis拦截器实现分表的功能,满足基本的业务需求,虽然比较简陋,但是Mybatis这种扩展机制与设计值得学习思考。
有兴趣的读者也可以自己写一个,或基于阿星的做改造,毕竟是简陋版本,还是有很多场景没有考虑到。
另外分表的demo项目,阿星放到了Gitee和公众号,大家按需自取