基于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和公眾號,大家按需自取