0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
会员中心
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

一种轻量分表方案-MyBatis拦截器分表实践

京东云 来源:京东零售 张均杰 作者:京东零售 张均杰 2024-12-03 11:19 次阅读

作者:京东零售 张均杰

背景

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

业界常见方案

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

删除/归档旧数据。

分表。

归档/删除旧数据

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

1.数据删除会影响数据库性能,引发慢sql,多张表并行删除,数据库压力会更大。

2.频繁删除数据,会产生数据库碎片,影响数据库性能,引发慢SQL。

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

分表

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

分表方案选型

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

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

1.JAVA生态中很常用的分表框架是Sharding-JDBC,虽然功能强大,但需要一定的接入成本,并且很多功能暂时用不上。

2.系统本身已经在使用Mybatis了,只需要添加一个mybaits拦截器,把SQL表名替换为新的周期表就可以了,没有接入新框架的成本,开发成本也不高。

分表具体实现代码

分表配置对象

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Date;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ShardingProperty {
    // 分表周期天数,配置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);
        // 没开启分表 直接返回老数据
        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);
    }
}

临界点数据不连续问题

分表方案有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数据双写注意点

1.提前上线监听程序,提前把老表数据同步到新的周期表。分表前只监听老表binlog就可以,分表前只需要把老表数据同步到新表。

2.切换到新表的临界点,为了避免丢失积压的老表binlog,需要同时处理新表binlog和老表binlog,这样会出现死循环同步的问题,因为老表需要同步新表,新表又需要双写老表。为了打破循环,需要先把双写老表消费堵上让消息暂时积压,切换新表成功后,再打开双写消费。

监听binlog数据双写代码

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

/**
 * 监听binlog ,分表双写,解决数据临界问题
*/
@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 {
        // 根据binlog内的表名,获取需要同步的表
        // 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);
                // 双写老表发Q,为了避免出现同步死循环问题
                if (ShardingPropertyConfig.SHARDING_TABLE.containsKey(syncTable)) {
                    Long primaryKey = getPrimaryKey(rowData.getAfterColumnsList());
                    sendDoubleWriteMsg(insertSql, primaryKey);
                    continue;
                }
                mysqlConnection.executeSql(insertSql);
            }
            continue;
        }
    }

数据对比

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

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

审核编辑 黄宇

声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
  • SQL
    SQL
    +关注

    关注

    1

    文章

    766

    浏览量

    44165
  • binlog
    +关注

    关注

    0

    文章

    7

    浏览量

    1251
  • mybatis
    +关注

    关注

    0

    文章

    61

    浏览量

    6716
收藏 人收藏

    评论

    相关推荐

    介绍一种OpenAtom OpenHarmony系统适配方案

    本文在不改变原有系统基础框架的基础上, 介绍了一种OpenAtom OpenHarmony(以下简称“OpenHarmony”)系统适配方案
    的头像 发表于 03-05 09:24 1205次阅读
    介绍<b class='flag-5'>一种</b>OpenAtom OpenHarmony<b class='flag-5'>轻</b><b class='flag-5'>量</b>系统适配<b class='flag-5'>方案</b>

    三丰千分表通讯方案

    套能够让电脑每隔0.1秒读取次千分表读数的方案,有意者电联***
    发表于 06-19 14:11

    分库是什么?怎么实现?

    数据库分库、读写分离的原理实现,使用场景
    发表于 10-25 17:24

    mybatis支持数据库兼容的方案

    方案, 令mybatis支持数据库兼容
    发表于 04-09 17:44

    原理及实际应用

    覆盖率。  1.1功原理  功全称功率分配器,是一种路输入信号能量分成两路或多路输出
    发表于 12-03 15:00

    介绍一种串行接口方案

    折叠式手机面临哪些问题?一种满足手机高速图像数据传输的差串行接口方案
    发表于 06-01 06:51

    动能拦截器六自由度仿真建模研究

    仿真建模技术是动能拦截器制导律研究中的重要技术,文中主要建立动能拦截器的轨道运动动力学以及姿态运动动力学模型,并建立完整的制导控制系统数学模型。文末,以某型
    发表于 08-07 08:50 14次下载

    利用Mycat实现MySQL读写分离、分库最佳实践

    利用Mycat实现MySQL读写分离、分库最佳实践
    发表于 09-08 10:20 14次下载
    利用Mycat实现MySQL读写分离、分库<b class='flag-5'>分</b><b class='flag-5'>表</b>最佳<b class='flag-5'>实践</b>

    springmvc 自定义拦截器实现未登录用户的拦截

    springmvc自定义拦截器实现未登录用户的拦截
    发表于 11-25 14:44 2521次阅读
    springmvc 自定义<b class='flag-5'>拦截器</b>实现未登录用户的<b class='flag-5'>拦截</b>

    分表和千分表的区别是什么

    分表:百分表是利用精密齿条齿轮机构制成的式通用长度测量工具。通常由测头、杆、防震弹簧、齿条、齿轮、游丝、圆表盘及指针等组成。
    发表于 01-02 18:16 8.9w次阅读
    百<b class='flag-5'>分表</b>和千<b class='flag-5'>分表</b>的区别是什么

    数据库分库基础和实践

    访问,实现了读写分离和分库。   图1:典型的读写分离和分库   数据分片的实现方案   数据分片的实现
    发表于 09-05 16:40 259次阅读

    什么是分库?为什么分库?什么情况下会用分库呢?

    分库是由分库和这两个独立概念组成的,只不过通常分库与的操作会同时进行,以至于我们习惯
    的头像 发表于 11-30 09:37 7558次阅读

    springboot过滤器和拦截器哪个先执行

    的概念、用途、执行顺序以及实际使用中的注意事项。 、过滤器和拦截器的概念和用途 过滤器(Filter) 过滤器是Java Web应用程序中的一种组件,它用于拦截客户端请求并对其进行预
    的头像 发表于 12-03 15:00 2573次阅读

    使用go语言实现个grpc拦截器

    在开发grpc服务时,我们经常会遇到些通用的需求,比如:日志、链路追踪、鉴权等。这些需求可以通过grpc拦截器来实现。本文使用go语言来实现个 grpc元模式(Unary)
    的头像 发表于 12-18 10:13 704次阅读
    使用go语言实现<b class='flag-5'>一</b>个grpc<b class='flag-5'>拦截器</b>

    分库后复杂查询的应对之道:基于DTS实时性ES宽构建技术实践

    1 问题域 业务发展的初期,我们的数据库架构往往是单库单,外加读写分离来快速的支撑业务,随着用户和订单的增加,数据库的计算和存储往往会成为我们系统的瓶颈,业界的实践多数采用分而治
    的头像 发表于 06-25 18:30 876次阅读
    分库<b class='flag-5'>分</b><b class='flag-5'>表</b>后复杂查询的应对之道:基于DTS实时性ES宽<b class='flag-5'>表</b>构建技术<b class='flag-5'>实践</b>