基于 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?{@Overridepublic?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();@Overridepublic?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));}//替換sqlreplaceSql(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);//替換sqlreplaceSql(tableShard,?valueObject,?metaObject,?boundSql);}?else?{//非基礎類型對象Class<?>?parameterObjectClass?=?parameterObject.getClass();Field?declaredField?=?parameterObjectClass.getDeclaredField(value);declaredField.setAccessible(true);Object?valueObject?=?declaredField.get(parameterObject);//替換sqlreplaceSql(tableShard,?valueObject,?metaObject,?boundSql);}}}?else?{//無需處理parameterField//替換sqlreplaceSql(tableShard,?value,?metaObject,?boundSql);}//執行下一個插件邏輯return?invocation.proceed();}@Overridepublic?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();//獲取策略classClass<??extends?ITableShardStrategy>?strategyClazz?=?tableShard.shardStrategy();//從spring?ioc容器獲取策略類ITableShardStrategy?tableShardStrategy?=?SpringUtil.getBean(strategyClazz);//生成分表名String?shardTableName?=?tableShardStrategy.generateTableName(tableNamePrefix,?value);//?獲取sqlString?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();//獲取Classfinal?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";@Overridepublic?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?{@Overridepublic?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?mapperPUBLIC?"-//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">selectid?as?id,comment?as?comment,create_date?as?createDatefromtb_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?mapperPUBLIC?"-//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">selectid?as?id,comment?as?comment,create_date?as?createDatefromtb_log_idwhereid?=?#{id}</select>//對應save函數<insert?id="save"?>insert?into?tb_log_id(id,?comment,create_date)values?(#{id},?#{comment},#{createDate})</insert></mapper>執行下單元測試
日期分表單元測試執行
@Testvoid?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分表單元測試執行
@Testvoid?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 中為什么不建議使用 where 1=1?
SpringBoot 使用注解實現消息廣播功能
聊聊接口性能優化的11個小技巧
總結
以上是生活随笔為你收集整理的基于 MyBatis 手撸一个分表插件的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: observable_Java Obse
- 下一篇: 为什么创建线程池一定要用ThreadPo