(二十三)MySQL分表篇:该如何将月增上亿条数据的单表处理方案优雅落地?

您所在的位置:网站首页 mysql单表一亿条数据 (二十三)MySQL分表篇:该如何将月增上亿条数据的单表处理方案优雅落地?

(二十三)MySQL分表篇:该如何将月增上亿条数据的单表处理方案优雅落地?

2024-07-11 06:31| 来源: 网络整理| 查看: 265

引言

作者:竹子爱熊猫

前面[《分库分表的正确姿势》][《分库分表的后患问题》]两篇中,对数据库的分库分表技术进行了全面阐述,但前两篇大多属于方法论,并不存在具体的实战实操,而只有理论没有实践的技术永远都属纸上谈兵,所以接下来会再开几个单章对分库分表各类方案进行落地。

分库分表实战内容基本上很少有人去分享,在网上能够搜出来的也大多属于一些方法论,但大部分技术开发真正缺少的恰恰是这些实操经验,所以后续的内容多以实践为主,携手诸位真正彻底悟透分库分表相关的技术。

尤其是对于库内分表这块的分享,当你去搜索单表数据增长过快该如何处理时,一般都会推荐你做分表处理,但你几乎找不到较为全面的实操教学,网上讲述分表技术更多是停留在表面的理论概念层次做阐述,而本章中则会结合自身之前接触的一个项目业务,再对库内分表技术进行全面阐述~

PS:虽然当时负责的项目并未达到月增上亿条数据的规模,但处理这种单表数据过大的方案都是一致的,将本文看完最后,无论单月数据增长多少,几百万条、几千万条、甚至几亿条....,相信诸位都能具备处理这类业务的能力!

一、源自于软硬结合的特殊业务

在讲本次主题之前,先来聊聊之前碰到的这个业务,这个业务比较特殊,相信很多小伙伴从未碰到过,这种业务本身用户量不大,甚至可以说用户量非常非常少,因为业务的起源来自于一款硬件设备,但具体的设备类型由于某些缘故就不透露了,可以理解成是下面这个东东:

虽然当时的硬件设备并不是这个,但也和它很类似,相信大家但凡在超市购过物都认识它,也就是超市收银台的收银机,当时我们是对外提供了一千台设备,这种设备通常一台只有一个用户,所以当时整个系统上线后所有的用户加起来,涵盖后台管理员、超级管理员账号在内,也不过1200个用户,这个用户规模相较于常见业务而言属实不多。

而当时我们需要负责的就是:为这些设备开发一个操作系统,这里不是指Windows、Linux、Mac这类嵌入式的底层系统,而是给机器的操作员开发一个操作界面,就类似于诸位在超市购物时,超市收银员用手操作的那个界面。

因为这些机器本身会安装一个带UI的系统,里面也支持安装一些软件,我们的软件会以GUI的形式嵌入这些设备,当时我要干的就是直接开发API接口,然后提供给GUI界面界面调用。本质上就属一个前后端分离的项目,只不过前端从原本的Web界面变成了GUI界面。

大家听起来这个项目是不是特别容易完成,用户量又少代表不需要考虑并发,也不会存在太大的流量冲击,性能要求也不会太高,似乎就是一个简简单单的单体增删改查项目呀?但事情远没有表面这么简单,诸位请接着往下看。

1.1、项目的难点

起初当我收到通知要负责这个需求时,从表面浅显的想了一下,似乎发现也不是太难,就是一个单体项目的CRUD工作,以我这手出神入化的CV大法,Hlod住它简直轻轻松松,因此当时也没想太多就直接接手了,项目初期由于团队每位成员经验都很丰富,各自凭借着个人的Copy神功,项目的开发进度可谓是一骑千里,但慢慢的问题来了,而且这个问题还不小!

当时大概对外预计分发1000台机器,每台机器正式投入运营后,预估单日会产生500~600条数据的产出,套到前面的举例中,也就是大概会向几百个超市投放共计1000台收银机,每个收银台平均下来之后,大概单日内会有500~600个顾客结账!

这里咱们做个数学题:现在有1000台机器,每台机器单日就算产生500条数据:1000 * 500 = 500000,这也就意味着单日的账单表中会新增50W条流水数据,单月整个账单表的数据增长量为:50W * 30 = 1500W!

单月数据增长1500W的概念不言而喻,这也就代表着一年的数据增长量为1500W * 12 = 1.8E,这批机器投入后预估最少会运行三年起步,甚至十年乃至更久,同时第一批次就要投入1000台,后面可能还会有第二批次、第三批次.....的投入。

50W只是最低的账单流水数据量,后续正式运营后可能数据量更大,此时架构的设计就成了难题!

1.2、方案的探讨

基本上当时团队的成员中,没人在此之前碰过这类需求,因此开了一个研讨会,去决定该如何将具体的方案落地,这里有人也许会说,数据量这么大,快上分布式/微服务啊!但实则解决不了这个问题,Why?因为项目整体的用户量并不大,最多同一时刻也才1000并发请求,就算这个并发量再增大几个级别,这里用单体架构优化好了也能够抗住,所以问题并不在业务系统的架构上面,而是在数据落库这方面。

这里直接用分库可以吗?答案是也不行,Why?因为整个项目中只有账单表才有这么大的数据量,其他的用户表、系统表、功能菜单表、后台表......,基本上不会有太大的数据量,所以直接做分库也没必要,属实有些浪费资源。

有小伙伴可能想到了!在之前的文章中好像聊过《MySQL的表分区技术》[1],这里可以按月份对流水表做分区呀!乍一听似乎像那么一回事,但依旧不行,因为第一批机器投入后,单月预计就会产生1500W条数据,后续可能会增加机器数量,因此单月的数据量达到2000W、3000W.....都有可能,如果按月做表分区,每个分区里面都有几千万条数据,一张账单表的流水随着时间推移,数据量甚至会达到几十亿!

一张表中存储几十亿条数据,这基本上不现实,虽然InnoDB在数据页为16KB尺寸下,单表最多能存储64TB数据,有可能这几十亿条数据真的能存下去,但查询时的性能简直令人头大,并且最关键的是不方便后续对数据做维护、管理、备份和迁移工作。

因此经过一番探讨后,最后决定选择了表分区技术的进阶版实现,即单库内做水平分表,按月份对数据做分表,也就是将账单表分为month_bills_202210、month_bills_202211、month_bills_202212.......以月份结尾的多张表,每个月的账单流水数据最终都会插入到各自的月份表中。

最终架构定型为:业务系统使用单体架构 + 数据库使用单库 + 流水表按月份做水平分表。

二、按月分表方案的落地实践

在上一阶段中已经决定好了具体的方案,但又该如何将方案落地呢?首先咱们先把方案落地的思路捋清楚:

①能够自动按月创建一张月份账单表,从而将每月的流水数据写入进去。

②写入数据时,能够根据当前的日期,选择对应的月份账单表并插入数据。

实现了上面两个需求后,整个方案近乎落地了一半,但接下来该如何去实现相应功能呢?咱们一点点来动手实现。

2.1、利用存储过程实现按月动态创建表

创建表的SQL语句大家都不陌生,按月份创建表之前,自然也需要一份原生创建表的DDL语句,如下:

CREATE TABLE `month_bills_202211`  (     `month_bills_id` int(8) NOT NULL AUTO_INCREMENT COMMENT '账单ID',     `serial_number` varchar(50) NOT NULL COMMENT '流水号',     `bills_info` text NOT NULL COMMENT '账单详情',     `pay_money` decimal(10,3) NOT NULL COMMENT '支付金额',     `machine_serial_no` varchar(20) NOT NULL COMMENT '收银机器',     `bill_date` timestamp NOT NULL COMMENT '账单日期',     `bill_comment` varchar(100) NULL DEFAULT '无' COMMENT '账单备注',     PRIMARY KEY (`month_bills_id`) USING BTREE,  UNIQUE `serial_number` (`serial_number`),  KEY `bill_date` (`bill_date`) ) ENGINE = InnoDB  CHARACTER SET = utf8  COLLATE = utf8_general_ci  ROW_FORMAT = Compact;

上述的语句会创建一张月份账单表,这张表主要包含七个字段,如下:

字段简介描述month_bills_id月份账单ID主要作为月份账单表的主键字段serial_number流水号所有账单流水数据的唯一流水号bills_info账单详情顾客本次订单中,购买的所有商品详情数据pay_money支付金额本次顾客共计消费的总金额machine_serial_no收银机器负责结算顾客订单的收银机器bill_date账单日期本次账单的结算日期bill_comment账单备注账单的额外备注

其中注意的几个小细节:

①日期字段使用的是timestamp类型,而并非datetime,因为前者更省空间。

②账单详情字段用的是text类型,因为这个字段可能会出现很多的信息。

③定义了一个和表没有关系的自增字段作为主键,用于维护聚簇索引树的结构。

除开有上述七个字段外,还有三个索引:

索引字段索引类型索引作用month_bills_id主键索引主要作用就是用来维护聚簇索引树serial_number唯一索引当需要根据流水号查询数据时使用bill_date唯一联合索引当需要根据日期查询数据时使用

到这里就有了最基本的建表语句,主要是用来创建第一张月份账单表,如果想要实现动态按照每月建表的话,还需要用到存储过程来实现,接着来写一个存储过程,但如若对于存储过程语法还不了解的各位小伙伴,这里就不再做基础讲解,可自行阅读之前的《全解MySQL存储过程》[2]。

最终撰写出的存储过程如下:

DELIMITER //  DROP PROCEDURE IF EXISTS create_table_by_month // CREATE PROCEDURE `create_table_by_month`() BEGIN          DECLARE nextMonth varchar(20);          DECLARE createTableSQL varchar(5210);          DECLARE tableCount int;          DECLARE tableName varchar(20);          DECLARE table_prefix varchar(20);      SELECT SUBSTR(     replace(         DATE_ADD(CURDATE(), INTERVAL 1 MONTH),     '-', ''),   1, 6) INTO @nextMonth;      set @table_prefix = 'month_bills_';      SET @tableName = CONCAT(@table_prefix, @nextMonth);      set @createTableSQL=concat("create table if not exists ",@tableName,"(     `month_bills_id` int(8) NOT NULL AUTO_INCREMENT COMMENT '账单ID',     `serial_number` varchar(50) NOT NULL COMMENT '流水号',     `bills_info` text NOT NULL COMMENT '账单详情',     `pay_money` decimal(10,3) NOT NULL COMMENT '支付金额',     `machine_serial_no` varchar(20) NOT NULL COMMENT '收银机器',     `bill_date` timestamp NOT NULL DEFAULT now() COMMENT '账单日期',     `bill_comment` varchar(100) NULL DEFAULT '无' COMMENT '账单备注',    PRIMARY KEY (`month_bills_id`) USING BTREE,             UNIQUE `serial_number` (`serial_number`),             KEY `bill_date` (`bill_date`)    ) ENGINE = InnoDB     CHARACTER SET = utf8     COLLATE = utf8_general_ci     ROW_FORMAT = Compact;");         PREPARE create_stmt from @createTableSQL;       EXECUTE create_stmt;      DEALLOCATE PREPARE create_stmt;      SELECT      COUNT(1) INTO @tableCount    FROM      information_schema.`TABLES`    WHERE TABLE_NAME = @tableName;         SELECT @tableCount 'tableCount'; END // delimiter ;

上述这个存储过程比较长,但基本上都写好了注释,所以阅读起来应该还是比较轻松的,也包括该存储过程在MySQL5.1、8.0版本中都测试过,所以大家也可以直接用,主要拆解一下里面较为难理解的一句SQL,如下:

SELECT SUBSTR(     replace(         DATE_ADD(CURDATE(), INTERVAL 1 MONTH),     '-', ''), 1, 6) INTO @nextMonth;

这条语句执行之后会生成一个202212这样的月份数字,主要用来作为表名的后缀,以此来区分不同的表,但里面用了几个函数组合出了该效果,下面做一下拆解,如下:

select DATE_ADD(CURDATE(), INTERVAL 1 MONTH); select replace('2022-12-12 23:46:11', '-', ''); select SUBSTR("20221212 23:46:11",1,6);

经过上述拆解之后大家应该能看的很清楚,最终每次调用该存储过程时,都会基于当前数据库的时间,然后向后增加一个月,同时将格式转化为YYYYMM格式,接下来调用该存储过程,如下:

call create_table_by_month(); + | tableCount | + |        1   | +

当返回的值为1而并非0时,就表示已经在数据库中查到了前面通过存储过程创建的表,即表示动态创建表的存储过程可以生效!接着为了能够每月定时触发,可以在MySQL中注册一个每月执行一次的定时事件,如下:

create EVENT      `create_table_by_month_event`   ON SCHEDULE EVERY      1 MONTH  STARTS     '2022-11-28 00:00:00'  ON COMPLETION     PRESERVE ENABLE   DO      call create_table_by_month(); 

MySQL5.1版本中除开引入了存储过程/函数、触发器的支持外,还引入了定时器的技术,也就是支持定时执行一条SQL,此时咱们可借助MySQL自带的定时器来定时调用之前的存储过程,最终实现按月定时创建表的需求!

但定时器在使用之前,需要先查看定时器是否开启,如下:show variables like 'event_scheduler'; 如果是OFF关闭状态,需要通过set global event_scheduler = 1 | on;命令开启。 如果想要永久生效,MySQL8.0以下的版本可找到my.ini/my.conf文件,然后找到[mysqld]的区域,再里面多加入一行event_scheduler = ON的配置即可。

这里再附上一些管理定时器的命令:

show events; select * from mysql.event; select * from information_schema.EVENTS; drop event 定时器名称; alter event 定时器名称 on COMPLETION PRESERVE DISABLE;   alter event 定时器名称 on COMPLETION PRESERVE ENABLE; 

经过上述几步后,就能够让MySQL自己按月创建表了,但为啥我会将定时器的时间设置为2022-11-28 00:00:00这个时间后开始呢?因为202211这张表我已经手动建立了,不将建立表的工作放在月初一号执行,这是因为前面的存储过程是创建下月表,而不是创建当月表,同时月底提前创建下月表,还能提高容错率,在MySQL定时器故障的情况下,能预留人工介入的时间。

2.2、写入数据时能够根据月份插入对应表

作为一个后端项目,必然还需要搭建客户端,这里用SpringBoot+MyBatis来快速构建一个单体项目(最后会给出完整源码),这里需要注意,月份账单表对应的实体类中要多出一个targetTable字段,如下:

public class MonthBills {          private Integer monthBillsId;          private String serialNumber;          private BigDecimal payMoney;          private String machineSerialNo;          private Date billDate;          private String billsInfo;          private String billComment;               private String targetTable;           }

上述的实体类与之前的表字段结构几乎完全相同,但会多出一个targetTable属性,后续会用来记录要操作的目标表,接着再撰写一个工具类,如下:

public class TableTimeUtils {           * 使用ThreadLocal来确保线程安全,或者可以使用Java8新引入的DateTimeFormatter类:      *      monthTL:负责将一个日期处理成 YYYYMM 格式     */     private static ThreadLocal monthTL =             ThreadLocal.withInitial(() ->                     new SimpleDateFormat("YYYYMM"));               private static String tablePrefix = "month_bills_";          public static String getYearMonth(Date date) {         return monthTL.get().format(date);     }          public static void getDataByTable(MonthBills monthBills){                  Date billDate = monthBills.getBillDate();                  String yearMonth = getYearMonth(billDate);                  monthBills.setTargetTable(tablePrefix + yearMonth);     } }

这个工具类主要负责处理日期的时间格式,以及用来定位要操作的目标表名,对于日期格式化类:SimpleDateFormat由于是线程不安全的,所以使用ThreadLocal来确保线程安全!上述工具类中主要提供了两个基础方法:

getYearMonth():将一个日期格式化成YYYYMM格式。

getDataByTable():获取单条数据操作时的表名。

有了工具类后,接着来撰写Dao、Mapper层的代码,如下:

@Mapper @Repository public interface MonthBillsMapper {     int deleteByPrimaryKey(Integer monthBillsId);     int insertSelective(MonthBills record);     MonthBills selectByPrimaryKey(Integer monthBillsId);     int updateByPrimaryKeySelective(MonthBills record); }

上述是月份账单表对应的Dao/Mapper接口,因为我这里是通过MyBatis的逆向工程文件自动生成的,所以名字就是上面那样,我这边未成更改,接着来看看对应的xml文件,如下:

insert into ${targetTable}        month_bills_id,           serial_number,           pay_money,           machine_serial_no,           bill_date,           bill_comment,           bills_info,           #{monthBillsId,jdbcType=INTEGER},           #{serialNumber,jdbcType=VARCHAR},           #{payMoney,jdbcType=DECIMAL},           #{machineSerialNo,jdbcType=VARCHAR},           #{billDate,jdbcType=TIMESTAMP},           #{billComment,jdbcType=VARCHAR},           #{billsInfo,jdbcType=LONGVARCHAR},   

上述这么大一长串,其实也不是俺手敲的,依旧是MyBatis逆向工程生成的代码,但我对其中的一处稍微做了改动,如下:

insert into month_bills_202211 insert into ${targetTable}

还记得最开始的实体类中,咱们多添加的那个targetTable属性嘛?在这里会根据该字段的值动态的去操作不同月份的表,接着来写一下Service层的接口和实现类,如下:

public interface IMonthBillsService {     int insert(MonthBills monthBills); } @Service public class MonthBillsServiceImpl implements IMonthBillsService {     @Autowired     private MonthBillsMapper billsMapper;     @Override     public int insert(MonthBills monthBills) {                  TableTimeUtils.getDataByTable(monthBills);                  return billsMapper.insertSelective(monthBills);     } }

在service层目前仅实现了一个插入数据的方法,其中的逻辑也非常简单,仅仅在调用Dao层的插入方法之前,获取了一下当前这条数据要插入的表名,最后来看看Controller/API层,如下:

@RestController @RequestMapping("/bills") public class MonthBillsAPI {     @Autowired     private IMonthBillsService billsService;               @RequestMapping("/settleUp")     public String settleUp(MonthBills monthBills){                  monthBills.setBillDate(new Date(System.currentTimeMillis()));                  monthBills.setSerialNumber(monthBills.getMachineSerialNo()              + System.currentTimeMillis());                  if (billsService.insert(monthBills) > 0){             return ">>>>账单结算成功账单结算失败>>>未查询到流水号对应的数据 '")                         .append(startTime)                         .append("' union all ");             }                          else if (table.equals(tables.get(tables.size()-1))){                 sql.append("select * from ")                         .append(table)                         .append(" where bill_date 


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3