MyBatis攔截器分表實踐分享
背景
部門內(nèi)有一些億級別核心業(yè)務(wù)表增速非???,增量日均100W,但線上業(yè)務(wù)只依賴近一周的數(shù)據(jù)。隨著數(shù)據(jù)量的迅速增長,慢SQL頻發(fā),數(shù)據(jù)庫性能下降,系統(tǒng)穩(wěn)定性受到嚴重影響。本篇文章,將分享如何使用MyBatis攔截器低成本的提升數(shù)據(jù)庫穩(wěn)定性。
業(yè)界常見方案
針對冷數(shù)據(jù)多的大表,常用的策略有以2種:
1. 刪除/歸檔舊數(shù)據(jù)。
2. 分表。
歸檔/刪除舊數(shù)據(jù)
定期將冷數(shù)據(jù)移動到歸檔表或者冷存儲中,或定期對表進行刪除,以減少表的大小。此策略邏輯簡單,只需要編寫一個JOB定期執(zhí)行SQL刪除數(shù)據(jù)。我們開始也是用這種方案,但此方案也有一些副作用:
1.數(shù)據(jù)刪除會影響數(shù)據(jù)庫性能,引發(fā)慢sql,多張表并行刪除,數(shù)據(jù)庫壓力會更大。
2.頻繁刪除數(shù)據(jù),會產(chǎn)生數(shù)據(jù)庫碎片,影響數(shù)據(jù)庫性能,引發(fā)慢SQL。
綜上,此方案有一定風險,為了規(guī)避這種風險,我們決定采用另一種方案:分表。
分表
我們決定按日期對表進行橫向拆分,實現(xiàn)讓系統(tǒng)每周生成一張周期表,表內(nèi)只存近一周的數(shù)據(jù),規(guī)避單表過大帶來的風險。
分表方案選型
經(jīng)調(diào)研,考慮2種分表方案:Sharding-JDBC、利用Mybatis自帶的攔截器特性。
經(jīng)過對比后,決定采用Mybatis攔截器來實現(xiàn)分表,原因如下:
1.JAVA生態(tài)中很常用的分表框架是Sharding-JDBC,雖然功能強大,但需要一定的接入成本,并且很多功能暫時用不上。
2.系統(tǒng)本身已經(jīng)在使用Mybatis了,只需要添加一個mybaits攔截器,把SQL表名替換為新的周期表就可以了,沒有接入新框架的成本,開發(fā)成本也不高。
分表具體實現(xiàn)代碼
分表配置對象
import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Date; @Data @AllArgsConstructor @NoArgsConstructor public class ShardingProperty { // 分表周期天數(shù),配置7,就是一周一分 private Integer days; // 分表開始日期,需要用這個日期計算周期表名 private Date beginDate; // 需要分表的表名 private String tableName; }
分表配置類
import java.util.concurrent.ConcurrentHashMap; public class ShardingPropertyConfig { public static final ConcurrentHashMap<String, ShardingProperty> SHARDING_TABLE = new ConcurrentHashMap<>(); static { ShardingProperty orderInfoShardingConfig = new ShardingProperty(15, DateUtils.string2Date("20231117"), "order_info"); ShardingProperty userInfoShardingConfig = new ShardingProperty(7, DateUtils.string2Date("20231117"), "user_info"); SHARDING_TABLE.put(orderInfoShardingConfig.getTableName(), orderInfoShardingConfig); SHARDING_TABLE.put(userInfoShardingConfig.getTableName(), userInfoShardingConfig); } }
攔截器
import lombok.extern.slf4j.Slf4j; import o2o.aspect.platform.function.template.service.TemplateMatchService; import org.apache.commons.lang3.StringUtils; import org.apache.ibatis.executor.statement.StatementHandler; import org.apache.ibatis.mapping.BoundSql; import org.apache.ibatis.mapping.MappedStatement; import org.apache.ibatis.plugin.*; import org.apache.ibatis.reflection.DefaultReflectorFactory; import org.apache.ibatis.reflection.MetaObject; import org.apache.ibatis.reflection.ReflectorFactory; import org.apache.ibatis.reflection.factory.DefaultObjectFactory; import org.apache.ibatis.reflection.factory.ObjectFactory; import org.apache.ibatis.reflection.wrapper.DefaultObjectWrapperFactory; import org.apache.ibatis.reflection.wrapper.ObjectWrapperFactory; import org.springframework.stereotype.Component; import java.sql.Connection; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Date; import java.util.Properties; @Slf4j @Component @Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})}) public class ShardingTableInterceptor implements Interceptor { private static final ObjectFactory DEFAULT_OBJECT_FACTORY = new DefaultObjectFactory(); private static final ObjectWrapperFactory DEFAULT_OBJECT_WRAPPER_FACTORY = new DefaultObjectWrapperFactory(); private static final ReflectorFactory DEFAULT_REFLECTOR_FACTORY = new DefaultReflectorFactory(); private static final String MAPPED_STATEMENT = "delegate.mappedStatement"; private static final String BOUND_SQL = "delegate.boundSql"; private static final String ORIGIN_BOUND_SQL = "delegate.boundSql.sql"; private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); private static final String SHARDING_MAPPER = "com.jd.o2o.inviter.promote.mapper.ShardingMapper"; private ConfigUtils configUtils = SpringContextHolder.getBean(ConfigUtils.class); @Override public Object intercept(Invocation invocation) throws Throwable { boolean shardingSwitch = configUtils.getBool("sharding_switch", false); // 沒開啟分表 直接返回老數(shù)據(jù) if (!shardingSwitch) { return invocation.proceed(); } StatementHandler statementHandler = (StatementHandler) invocation.getTarget(); MetaObject metaStatementHandler = MetaObject.forObject(statementHandler, DEFAULT_OBJECT_FACTORY, DEFAULT_OBJECT_WRAPPER_FACTORY, DEFAULT_REFLECTOR_FACTORY); MappedStatement mappedStatement = (MappedStatement) metaStatementHandler.getValue(MAPPED_STATEMENT); BoundSql boundSql = (BoundSql) metaStatementHandler.getValue(BOUND_SQL); String originSql = (String) metaStatementHandler.getValue(ORIGIN_BOUND_SQL); if (StringUtils.isBlank(originSql)) { return invocation.proceed(); } // 獲取表名 String tableName = TemplateMatchService.matchTableName(boundSql.getSql().trim()); ShardingProperty shardingProperty = ShardingPropertyConfig.SHARDING_TABLE.get(tableName); if (shardingProperty == null) { return invocation.proceed(); } // 新表 String shardingTable = getCurrentShardingTable(shardingProperty, new Date()); String rebuildSql = boundSql.getSql().replace(shardingProperty.getTableName(), shardingTable); metaStatementHandler.setValue(ORIGIN_BOUND_SQL, rebuildSql); if (log.isDebugEnabled()) { log.info("rebuildSQL -> {}", rebuildSql); } return invocation.proceed(); } @Override public Object plugin(Object target) { if (target instanceof StatementHandler) { return Plugin.wrap(target, this); } return target; } @Override public void setProperties(Properties properties) {} public static String getCurrentShardingTable(ShardingProperty shardingProperty, Date createTime) { String tableName = shardingProperty.getTableName(); Integer days = shardingProperty.getDays(); Date beginDate = shardingProperty.getBeginDate(); Date date; if (createTime == null) { date = new Date(); } else { date = createTime; } if (date.before(beginDate)) { return null; } LocalDateTime targetDate = SimpleDateFormatUtils.convertDateToLocalDateTime(date); LocalDateTime startDate = SimpleDateFormatUtils.convertDateToLocalDateTime(beginDate); LocalDateTime intervalStartDate = DateIntervalChecker.getIntervalStartDate(targetDate, startDate, days); LocalDateTime intervalEndDate = intervalStartDate.plusDays(days - 1); return tableName + "_" + intervalStartDate.format(FORMATTER) + "_" + intervalEndDate.format(FORMATTER); } }
臨界點數(shù)據(jù)不連續(xù)問題
分表方案有1個難點需要解決:周期臨界點數(shù)據(jù)不連續(xù)。舉例:假設(shè)要對operate_log(操作日志表)大表進行橫向分表,每周一張表,分表明細可看下面表格。
| 第一周(operate_log_20240107_20240108) | 第二周(operate_log_20240108_20240114) | 第三周(operate_log_20240115_20240121) | | 1月1號 ~ 1月7號的數(shù)據(jù) | 1月8號 ~ 1月14號的數(shù)據(jù) | 1月15號 ~ 1月21號的數(shù)據(jù) |
1月8號就是分表臨界點,8號需要切換到第二周的表,但8號0點剛切換的時候,表內(nèi)沒有任何數(shù)據(jù),這時如果業(yè)務(wù)需要查近一周的操作日志是查不到的,這樣就會引發(fā)線上問題。
我決定采用數(shù)據(jù)冗余的方式來解決這個痛點。每個周期表都冗余一份上個周期的數(shù)據(jù),用雙倍數(shù)據(jù)量實現(xiàn)數(shù)據(jù)滑動的效果,效果見下面表格。
| 第一周(operate_log_20240107_20240108) | 第二周(operate_log_20240108_20240114) | 第三周(operate_log_20240115_20240121) | | 12月25號 ~ 12月31號的數(shù)據(jù) | 1月1號 ~ 1月7號的數(shù)據(jù) | 1月8號 ~ 1月14號的數(shù)據(jù) | | 1月1號 ~ 1月7號的數(shù)據(jù) | 1月8號 ~ 1月14號的數(shù)據(jù) | 1月15號 ~ 1月21號的數(shù)據(jù) |
注:表格內(nèi)第一行數(shù)據(jù)就是冗余的上個周期表的數(shù)據(jù)。
思路有了,接下來就要考慮怎么實現(xiàn)雙寫(數(shù)據(jù)冗余到下個周期表),有2種方案:
1.在SQL執(zhí)行完成返回結(jié)果前添加邏輯(可以用AspectJ 或 mybatis攔截器),如果SQL內(nèi)的表名是當前周期表,就把表名替換為下個周期表,然后再次執(zhí)行SQL。此方案對業(yè)務(wù)影響大,相當于串行執(zhí)行了2次SQL,有性能損耗。
2.監(jiān)聽增量binlog,京東內(nèi)部有現(xiàn)成的數(shù)據(jù)訂閱中間件DRC,讀者也可以使用cannal等開源中間件來代替DRC,原理大同小異,此方案對業(yè)務(wù)無影響。
方案對比后,選擇了對業(yè)務(wù)性能損耗小的方案二。
監(jiān)聽binlog并雙寫流程圖
監(jiān)聽binlog數(shù)據(jù)雙寫注意點
1.提前上線監(jiān)聽程序,提前把老表數(shù)據(jù)同步到新的周期表。分表前只監(jiān)聽老表binlog就可以,分表前只需要把老表數(shù)據(jù)同步到新表。
2.切換到新表的臨界點,為了避免丟失積壓的老表binlog,需要同時處理新表binlog和老表binlog,這樣會出現(xiàn)死循環(huán)同步的問題,因為老表需要同步新表,新表又需要雙寫老表。為了打破循環(huán),需要先把雙寫老表消費堵上讓消息暫時積壓,切換新表成功后,再打開雙寫消費。
監(jiān)聽binlog數(shù)據(jù)雙寫代碼
注:下面代碼不能直接用,只提供基本思路
/** * 監(jiān)聽binlog ,分表雙寫,解決數(shù)據(jù)臨界問題 */ @Slf4j @Component public class BinLogConsumer implements MessageListener { private MessageDeserialize deserialize = new JMQMessageDeserialize(); private static final String TABLE_PLACEHOLDER = "%TABLE%"; @Value("${mq.doubleWriteTopic.topic}") private String doubleWriteTopic; @Autowired private JmqProducerService jmqProducerService; @Override public void onMessage(List<Message> messages) throws Exception { if (messages == null || messages.isEmpty()) { return; } List<EntryMessage> entryMessages = deserialize.deserialize(messages); for (EntryMessage entryMessage : entryMessages) { try { syncData(entryMessage); } catch (Exception e) { log.error("sharding sync data error", e); throw e; } } } private void syncData(EntryMessage entryMessage) throws JMQException { // 根據(jù)binlog內(nèi)的表名,獲取需要同步的表 // 3種情況: // 1、老表:需要同步當前周期表,和下個周期表。 // 2、當前周期表:需要同步下個周期表,和老表。 // 3、下個周期表:不需要同步。 List<String> syncTables = getSyncTables(entryMessage.tableName, entryMessage.createTime); if (CollectionUtils.isEmpty(syncTables)) { log.info("table {} is not need sync", tableName); return; } if (entryMessage.getHeader().getEventType() == WaveEntry.EventType.INSERT) { String insertTableSqlTemplate = parseSqlForInsert(rowData); for (String syncTable : syncTables) { String insertSql = insertTableSqlTemplate.replaceAll(TABLE_PLACEHOLDER, syncTable); // 雙寫老表發(fā)Q,為了避免出現(xiàn)同步死循環(huán)問題 if (ShardingPropertyConfig.SHARDING_TABLE.containsKey(syncTable)) { Long primaryKey = getPrimaryKey(rowData.getAfterColumnsList()); sendDoubleWriteMsg(insertSql, primaryKey); continue; } mysqlConnection.executeSql(insertSql); } continue; } }
數(shù)據(jù)對比
為了保證新表和老表數(shù)據(jù)一致,需要編寫對比程序,在上線前進行數(shù)據(jù)對比,保證binlog同步無問題。
具體實現(xiàn)代碼不做展示,思路:新表查詢一定量級數(shù)據(jù),老表查詢相同量級數(shù)據(jù),都轉(zhuǎn)換成JSON,equals對比。
以上就是MyBatis攔截器分表實踐分享的詳細內(nèi)容,更多關(guān)于MyBatis攔截器分表的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Spring Cache @Cacheable 緩存在部分Service中不生效的解決辦法
這篇文章主要介紹了Spring Cache @Cacheable 緩存在部分Service中不生效的解決辦法2023-10-10深入探究SpringBoot中的Elasticsearch自動配置原理及用法
SpringBoot中的Elasticsearch自動配置為我們提供了一種快速集成Elasticsearch的方式,使我們可以在SpringBoot應(yīng)用程序中輕松地使用Elasticsearch,本文將介紹Spring Boot中的Elasticsearch自動配置的作用、原理和使用方法2023-07-07Java面試題 從源碼角度分析HashSet實現(xiàn)原理
這篇文章主要介紹了Java面試題 從源碼角度分析HashSet實現(xiàn)原理?,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友可以參考下2019-07-07