Mybatis實現分表插件
背景
事情是醬紫的,阿星的上級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"); } } }
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); } }
傳入進來的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
分表攔截器流程圖如下
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
,場景是有兩個分表策略,表也提前建立好瞭
- 根據
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); } }
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); }
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>
執行下單元測試
日期分表單元測試執行
@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)); }
輸出結果
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)); }
輸出結果
小結一下
本文可以當做對Mybatis
進階的使用教程,通過Mybatis
攔截器實現分表的功能,滿足基本的業務需求,雖然比較簡陋,但是Mybatis
這種擴展機制與設計值得學習思考。
有興趣的讀者也可以自己寫一個,或基於阿星的做改造,畢竟是簡陋版本,還是有很多場景沒有考慮到。
另外分表的demo
項目,阿星放到瞭Gitee
,大傢按需自取
Gitee地址: https://gitee.com/jxncwx/shard
項目結構:
到此這篇關於Mybatis實現分表插件的文章就介紹到這瞭,更多相關Mybatis 分表插件內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!
推薦閱讀:
- mybatis輸出SQL格式化方式
- MyBatis攔截器的實現原理
- Java mybatis 開發自定義插件
- Springboot自定義mybatis攔截器實現擴展
- 使用mybatis的interceptor修改執行sql以及傳入參數方式