背景

部门内有一些亿级别核心业务表增速非常快,增量日均100W,但线上业务只依赖近一周的数据。随着数据量的迅速增长,慢SQL频发,数据库性能下降,系统稳定性受到严重影响。本篇文章,将分享如何使用MyBatis拦截器低成本的提升数据库稳定性。

业界常见方案

针对冷数据多的大表,常用的策略有以2种:

1. 删除/归档旧数据。

2. 分表。

归档/删除旧数据

定期将冷数据移动到归档表或者冷存储中,或定期对表进行删除,以减少表的大小。此策略逻辑简单,只需要编写一个JOB定期执行SQL删除数据。我们开始也是用这种方案,但此方案也有一些副作用:

1.数据删除会影响数据库性能,引发慢sql,多张表并行删除,数据库压力会更大。
2.频繁删除数据,会产生数据库碎片,影响数据库性能,引发慢SQL。

综上,此方案有一定风险,为了规避这种风险,我们决定采用另一种方案:分表。

分表

我们决定按日期对表进行横向拆分,实现让系统每周生成一张周期表,表内只存近一周的数据,规避单表过大带来的风险。

分表方案选型

经调研,考虑2种分表方案:Sharding-JDBC、利用Mybatis自带的拦截器特性。

经过对比后,决定采用Mybatis拦截器来实现分表,原因如下:

1.JAVA生态中很常用的分表框架是Sharding-JDBC,虽然功能强大,但需要一定的接入成本,并且很多功能暂时用不上。
2.系统本身已经在使用Mybatis了,只需要添加一个mybaits拦截器,把SQL表名替换为新的周期表就可以了,没有接入新框架的成本,开发成本也不高。

分表具体实现代码

分表配置对象

  1. import lombok.AllArgsConstructor;
  2. import lombok.Data;
  3. import lombok.NoArgsConstructor;
  4. import java.util.Date;
  5. @Data
  6. @AllArgsConstructor
  7. @NoArgsConstructor
  8. public class ShardingProperty {
  9. // 分表周期天数,配置7,就是一周一分
  10. private Integer days;
  11. // 分表开始日期,需要用这个日期计算周期表名
  12. private Date beginDate;
  13. // 需要分表的表名
  14. private String tableName;
  15. }

分表配置类

  1. import java.util.concurrent.ConcurrentHashMap;
  2. public class ShardingPropertyConfig {
  3. public static final ConcurrentHashMap<String, ShardingProperty> SHARDING_TABLE ();
  4. static {
  5. ShardingProperty orderInfoShardingConfig = new ShardingProperty(15, DateUtils.string2Date("20231117"), "order_info");
  6. ShardingProperty userInfoShardingConfig = new ShardingProperty(7, DateUtils.string2Date("20231117"), "user_info");
  7. SHARDING_TABLE.put(orderInfoShardingConfig.getTableName(), orderInfoShardingConfig);
  8. SHARDING_TABLE.put(userInfoShardingConfig.getTableName(), userInfoShardingConfig);
  9. }
  10. }

拦截器

  1. import lombok.extern.slf4j.Slf4j;
  2. import o2o.aspect.platform.function.template.service.TemplateMatchService;
  3. import org.apache.commons.lang3.StringUtils;
  4. import org.apache.ibatis.executor.statement.StatementHandler;
  5. import org.apache.ibatis.mapping.BoundSql;
  6. import org.apache.ibatis.mapping.MappedStatement;
  7. import org.apache.ibatis.plugin.*;
  8. import org.apache.ibatis.reflection.DefaultReflectorFactory;
  9. import org.apache.ibatis.reflection.MetaObject;
  10. import org.apache.ibatis.reflection.ReflectorFactory;
  11. import org.apache.ibatis.reflection.factory.DefaultObjectFactory;
  12. import org.apache.ibatis.reflection.factory.ObjectFactory;
  13. import org.apache.ibatis.reflection.wrapper.DefaultObjectWrapperFactory;
  14. import org.apache.ibatis.reflection.wrapper.ObjectWrapperFactory;
  15. import org.springframework.stereotype.Component;
  16. import java.sql.Connection;
  17. import java.time.LocalDateTime;
  18. import java.time.format.DateTimeFormatter;
  19. import java.util.Date;
  20. import java.util.Properties;
  21. @Slf4j
  22. @Component
  23. @Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
  24. public class ShardingTableInterceptor implements Interceptor {
  25. private static final ObjectFactory DEFAULT_OBJECT_FACTORY = new DefaultObjectFactory();
  26. private static final ObjectWrapperFactory DEFAULT_OBJECT_WRAPPER_FACTORY = new DefaultObjectWrapperFactory();
  27. private static final ReflectorFactory DEFAULT_REFLECTOR_FACTORY = new DefaultReflectorFactory();
  28. private static final String MAPPED_STATEMENT = "delegate.mappedStatement";
  29. private static final String BOUND_SQL = "delegate.boundSql";
  30. private static final String ORIGIN_BOUND_SQL = "delegate.boundSql.sql";
  31. private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
  32. private static final String SHARDING_MAPPER = "com.jd.o2o.inviter.promote.mapper.ShardingMapper";
  33. private ConfigUtils configUtils = SpringContextHolder.getBean(ConfigUtils.class);
  34. @Override
  35. public Object intercept(Invocation invocation) throws Throwable {
  36. boolean shardingSwitch = configUtils.getBool("sharding_switch", false);
  37. // 没开启分表 直接返回老数据
  38. if (!shardingSwitch) {
  39. return invocation.proceed();
  40. }
  41. StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
  42. MetaObject metaStatementHandler = MetaObject.forObject(statementHandler, DEFAULT_OBJECT_FACTORY, DEFAULT_OBJECT_WRAPPER_FACTORY, DEFAULT_REFLECTOR_FACTORY);
  43. MappedStatement mappedStatement = (MappedStatement) metaStatementHandler.getValue(MAPPED_STATEMENT);
  44. BoundSql boundSql = (BoundSql) metaStatementHandler.getValue(BOUND_SQL);
  45. String originSql = (String) metaStatementHandler.getValue(ORIGIN_BOUND_SQL);
  46. if (StringUtils.isBlank(originSql)) {
  47. return invocation.proceed();
  48. }
  49. // 获取表名
  50. String tableName = TemplateMatchService.matchTableName(boundSql.getSql().trim());
  51. ShardingProperty shardingProperty = ShardingPropertyConfig.SHARDING_TABLE.get(tableName);
  52. if (shardingProperty == null) {
  53. return invocation.proceed();
  54. }
  55. // 新表
  56. String shardingTable = getCurrentShardingTable(shardingProperty, new Date());
  57. String rebuildSql = boundSql.getSql().replace(shardingProperty.getTableName(), shardingTable);
  58. metaStatementHandler.setValue(ORIGIN_BOUND_SQL, rebuildSql);
  59. if (log.isDebugEnabled()) {
  60. log.info("rebuildSQL -> {}", rebuildSql);
  61. }
  62. return invocation.proceed();
  63. }
  64. @Override
  65. public Object plugin(Object target) {
  66. if (target instanceof StatementHandler) {
  67. return Plugin.wrap(target, this);
  68. }
  69. return target;
  70. }
  71. @Override
  72. public void setProperties(Properties properties) {}
  73. public static String getCurrentShardingTable(ShardingProperty shardingProperty, Date createTime) {
  74. String tableName = shardingProperty.getTableName();
  75. Integer days = shardingProperty.getDays();
  76. Date beginDate = shardingProperty.getBeginDate();
  77. Date date;
  78. if (createTime == null) {
  79. date = new Date();
  80. } else {
  81. date = createTime;
  82. }
  83. if (date.before(beginDate)) {
  84. return null;
  85. }
  86. LocalDateTime targetDate = SimpleDateFormatUtils.convertDateToLocalDateTime(date);
  87. LocalDateTime startDate = SimpleDateFormatUtils.convertDateToLocalDateTime(beginDate);
  88. LocalDateTime intervalStartDate = DateIntervalChecker.getIntervalStartDate(targetDate, startDate, days);
  89. LocalDateTime intervalEndDate = intervalStartDate.plusDays(days - 1);
  90. return tableName + "_" + intervalStartDate.format(FORMATTER) + "_" + intervalEndDate.format(FORMATTER);
  91. }
  92. }

临界点数据不连续问题

分表方案有1个难点需要解决:周期临界点数据不连续。举例:假设要对operate_log(操作日志表)大表进行横向分表,每周一张表,分表明细可看下面表格。

第一周(operate_log_20240107_20240108) 第二周(operate_log_20240108_20240114) 第三周(operate_log_20240115_20240121)
1月1号 ~ 1月7号的数据 1月8号 ~ 1月14号的数据 1月15号 ~ 1月21号的数据

1月8号就是分表临界点,8号需要切换到第二周的表,但8号0点刚切换的时候,表内没有任何数据,这时如果业务需要查近一周的操作日志是查不到的,这样就会引发线上问题。

我决定采用数据冗余的方式来解决这个痛点。每个周期表都冗余一份上个周期的数据,用双倍数据量实现数据滑动的效果,效果见下面表格。

第一周(operate_log_20240107_20240108) 第二周(operate_log_20240108_20240114) 第三周(operate_log_20240115_20240121)
12月25号 ~ 12月31号的数据 1月1号 ~ 1月7号的数据 1月8号 ~ 1月14号的数据
1月1号 ~ 1月7号的数据 1月8号 ~ 1月14号的数据 1月15号 ~ 1月21号的数据

注:表格内第一行数据就是冗余的上个周期表的数据。

思路有了,接下来就要考虑怎么实现双写(数据冗余到下个周期表),有2种方案:

1.在SQL执行完成返回结果前添加逻辑(可以用AspectJ 或 mybatis拦截器),如果SQL内的表名是当前周期表,就把表名替换为下个周期表,然后再次执行SQL。此方案对业务影响大,相当于串行执行了2次SQL,有性能损耗。
2.监听增量binlog,京东内部有现成的数据订阅中间件DRC,读者也可以使用cannal等开源中间件来代替DRC,原理大同小异,此方案对业务无影响。

方案对比后,选择了对业务性能损耗小的方案二。

监听binlog并双写流程图

监听binlog数据双写注意点

1.提前上线监听程序,提前把老表数据同步到新的周期表。分表前只监听老表binlog就可以,分表前只需要把老表数据同步到新表。
2.切换到新表的临界点,为了避免丢失积压的老表binlog,需要同时处理新表binlog和老表binlog,这样会出现死循环同步的问题,因为老表需要同步新表,新表又需要双写老表。为了打破循环,需要先把双写老表消费堵上让消息暂时积压,切换新表成功后,再打开双写消费。

监听binlog数据双写代码

注:下面代码不能直接用,只提供基本思路

  1. /**
  2. * 监听binlog ,分表双写,解决数据临界问题
  3. */
  4. @Slf4j
  5. @Component
  6. public class BinLogConsumer implements MessageListener {
  7. private MessageDeserialize deserialize = new JMQMessageDeserialize();
  8. private static final String TABLE_PLACEHOLDER = "%TABLE%";
  9. @Value("${mq.doubleWriteTopic.topic}")
  10. private String doubleWriteTopic;
  11. @Autowired
  12. private JmqProducerService jmqProducerService;
  13. @Override
  14. public void onMessage(List<Message> messages) throws Exception {
  15. if (messages == null || messages.isEmpty()) {
  16. return;
  17. }
  18. List<EntryMessage> entryMessages = deserialize.deserialize(messages);
  19. for (EntryMessage entryMessage : entryMessages) {
  20. try {
  21. syncData(entryMessage);
  22. } catch (Exception e) {
  23. log.error("sharding sync data error", e);
  24. throw e;
  25. }
  26. }
  27. }
  28. private void syncData(EntryMessage entryMessage) throws JMQException {
  29. // 根据binlog内的表名,获取需要同步的表
  30. // 3种情况:
  31. // 1、老表:需要同步当前周期表,和下个周期表。
  32. // 2、当前周期表:需要同步下个周期表,和老表。
  33. // 3、下个周期表:不需要同步。
  34. List<String> syncTables = getSyncTables(entryMessage.tableName, entryMessage.createTime);
  35. if (CollectionUtils.isEmpty(syncTables)) {
  36. log.info("table {} is not need sync", tableName);
  37. return;
  38. }
  39. if (entryMessage.getHeader().getEventType() == WaveEntry.EventType.INSERT) {
  40. String insertTableSqlTemplate = parseSqlForInsert(rowData);
  41. for (String syncTable : syncTables) {
  42. String insertSql = insertTableSqlTemplate.replaceAll(TABLE_PLACEHOLDER, syncTable);
  43. // 双写老表发Q,为了避免出现同步死循环问题
  44. if (ShardingPropertyConfig.SHARDING_TABLE.containsKey(syncTable)) {
  45. Long primaryKey = getPrimaryKey(rowData.getAfterColumnsList());
  46. sendDoubleWriteMsg(insertSql, primaryKey);
  47. continue;
  48. }
  49. mysqlConnection.executeSql(insertSql);
  50. }
  51. continue;
  52. }
  53. }

数据对比

为了保证新表和老表数据一致,需要编写对比程序,在上线前进行数据对比,保证binlog同步无问题。

具体实现代码不做展示,思路:新表查询一定量级数据,老表查询相同量级数据,都转换成JSON,equals对比。

作者:京东零售 张均杰

来源:京东云开发者社区 转载请注明来源

一种轻量分表方案-MyBatis拦截器分表实践的更多相关文章

  1. 基于mybatis拦截器分表实现

    1.拦截器简介 MyBatis提供了一种插件(plugin)的功能,但其实这是拦截器功能.基于这个拦截器我们可以选择在这些被拦截的方法执行前后加上某些逻辑或者在执行这些被拦截的方法时执行自己的逻辑. ...

  2. 玩转SpringBoot之整合Mybatis拦截器对数据库水平分表

    利用Mybatis拦截器对数据库水平分表 需求描述 当数据量比较多时,放在一个表中的时候会影响查询效率:或者数据的时效性只是当月有效的时候:这时我们就会涉及到数据库的分表操作了.当然,你也可以使用比较 ...

  3. PintJS – 轻量,并发的 GruntJS 运行器

    PintJS 是一个小型.异步的 GruntJS 运行器,试图解决大规模构建流程中的一些问题. 典型的Gruntfile 会包括 jsHint,jasmine,LESS,handlebars, ugl ...

  4. Mybatis拦截器介绍

    拦截器的一个作用就是我们可以拦截某些方法的调用,我们可以选择在这些被拦截的方法执行前后加上某些逻辑,也可以在执行这些被拦截的方法时执行自己的逻辑而不再执行被拦截的方法.Mybatis拦截器设计的一个初 ...

  5. Mybatis拦截器介绍及分页插件

    1.1    目录 1.1 目录 1.2 前言 1.3 Interceptor接口 1.4 注册拦截器 1.5 Mybatis可拦截的方法 1.6 利用拦截器进行分页 1.2     前言 拦截器的一 ...

  6. Mybatis拦截器执行过程解析

    上一篇文章 Mybatis拦截器之数据加密解密 介绍了 Mybatis 拦截器的简单使用,这篇文章将透彻的分析 Mybatis 是怎样发现拦截器以及调用拦截器的 intercept 方法的 小伙伴先按 ...

  7. "犯罪心理"解读Mybatis拦截器

    原文链接:"犯罪心理"解读Mybatis拦截器 Mybatis拦截器执行过程解析 文章写过之后,我觉得 "Mybatis 拦截器案件"背后一定还隐藏着某种设计动 ...

  8. 关于mybatis拦截器,对结果集进行拦截

    因业务需要,需将结果集序列化为json返回,于是,网上找了好久资料,都是关于拦截参数的处理,拦截Sql语法构建的处理,就是很少关于对拦截结果集的处理,于是自己简单的写了一个对结果集的处理, 记录下. ...

  9. 详解Mybatis拦截器(从使用到源码)

    详解Mybatis拦截器(从使用到源码) MyBatis提供了一种插件(plugin)的功能,虽然叫做插件,但其实这是拦截器功能. 本文从配置到源码进行分析. 一.拦截器介绍 MyBatis 允许你在 ...

  10. Mybatis拦截器,修改Date类型数据。设置毫秒为0

    1:背景 Mysql自动将datetime类型的毫秒数四舍五入,比如代码中传入的Date类型的数据值为  2021.03.31 23:59:59.700     到数据库   2021.04.01 0 ...

随机推荐

  1. 深度克隆从C#/C/Java漫谈到JavaScript真复制

    如果只想看js,直接从JavaScript标题开始. 在C#里面,深度clone有System.ICloneable.创建现有实例相同的值创建类的新实例 克隆原理 值类型变量与引用类型变量 如果我们有 ...

  2. ElasticSearch 实现分词全文检索 - Java SpringBoot ES 索引操作

    目录 ElasticSearch 实现分词全文检索 - 概述 ElasticSearch 实现分词全文检索 - ES.Kibana.IK安装 ElasticSearch 实现分词全文检索 - Rest ...

  3. 【辅助工具】IDEA使用

    IDEA使用 快捷键 快捷键 alt+enter:代码错误智能提示 alt+up:上个方法 alt+down:下个方法 alt+1:快速定位到项目窗口,还可边按键盘输文件名查找文件 alt+F7:定位 ...

  4. 在 Ubuntu 20.04 上安装 Visual Studio Code

    Visual Studio Code 是一个由微软开发的强大的开源代码编辑器.它包含内建的调试支持,嵌入的 Git 版本控制,语法高亮,代码自动完成,集成终端,代码重构以及代码片段功能. Visual ...

  5. C++ 的两种换行符区别

    当我们在C++执行一个输出语句时,在输出语句最后可以使用 std::endl 或 \n 建立一个新行. 但这两种换行方式对程序有不同的影响. std::endl 它在建立一个新的行的同时,还会自动刷新 ...

  6. 基于 HTML5 WebGL + WebVR 的 3D 虚拟现实可视化培训系统

    前言 2019 年 VR, AR, XR, 5G, 工业互联网等名词频繁出现在我们的视野中,信息的分享与虚实的结合已经成为大势所趋,5G 是新一代信息通信技术升级的重要方向,工业互联网是制造业转型升级 ...

  7. 引入阿里在线图标(微信小程序)

    https://www.bilibili.com/video/BV1WJ41197sD?p=49

  8. C++跨DLL内存所有权问题探幽(三)导致堆问题的可能性

    0xC0000374: 堆已损坏. (参数: 0x00007FFA1E9787F0). _Mem 是 nullptr 这里提供一个可能性,不一定是内存所属地址冲突的问题,除了MT和 MD编译,还有可能 ...

  9. 银行个人住房贷款LPR办理流程-建行app

    8月底之前即将需完成银行的个人住房贷款定价基准利率的转换.选择"LPR+浮动利率"或者"固定利率". 以下举例建行app上办理方法给大家参考下. 办理方案: 一 ...

  10. kafka Linux环境搭建安装及命令创建队列生产消费消息

    本文为博主原创,未经允许不得转载: 1. 安装JDK 由于Kafka是用Scala语言开发的,运行在JVM上,因此在安装Kafka之前需要先安装JDK. yum install java‐1.8.0‐ ...