月增千万的数据,我用单体+单库扛下了所有~

您所在的位置:网站首页 mysql按月分区表 月增千万的数据,我用单体+单库扛下了所有~

月增千万的数据,我用单体+单库扛下了所有~

2023-04-07 00:11| 来源: 网络整理| 查看: 265

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

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

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

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

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

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

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

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

1.2、方案的探讨

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 类型,因为这个字段可能会出现很多的信息。

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

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

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

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

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

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

到这里就有了最基本的建表语句,主要是用来创建第一张月份账单表,如果想要实现动态按照每月建表的话,还需要用到存储过程来实现,接着来写一个存储过程。

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

DELIMITER //

DROP PROCEDURE IF EXISTS create_table_by_month //

CREATE PROCEDURE `create_table_by_month`

BEGIN

-- 用于记录下一个月份是多久

DECLARE nextMonth varchar(20);

-- 用于记录创建表的SQL语句

DECLARE createTableSQL varchar(5210);

-- 执行创建表的SQL语句后,获取表的数量

DECLARE tableCount int;

-- 用于记录要生成的表名

DECLARE tableName varchar(20);

-- 用于记录表的前缀

DECLARE table_prefix varchar(20);

-- 获取下个月的日期并赋值给nextMonth变量

SELECT SUBSTR(

replace(

DATE_ADD(CURDATE, INTERVAL 1 MONTH),

'-', ''),

1, 6) INTO @nextMonth;

-- 设置表前缀变量值为td_user_banks_log_

set@table_prefix = 'month_bills_';

-- 定义表的名称=表前缀+月份,即 month_bills_2022112 这个格式

SET @tableName = CONCAT(@table_prefix, @nextMonth);

-- 定义创建表的SQL语句

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 关键字来创建一个预备执行的SQL体

PREPARE create_stmt from @createTableSQL;

-- 使用 EXECUTE 关键字来执行上面的预备SQL体:create_stmt

EXECUTE create_stmt;

-- 释放掉前面创建的SQL体(减少内存占用)

DEALLOCATE PREPARE create_stmt;

-- 执行完建表语句后,查询表数量并保存再 tableCount 变量中

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;

-- 在当前日期的基础上增加一个月,如2022-11-12 23:46:11,会得到2022-12-12 23:46:11

select DATE_ADD(CURDATE, INTERVAL 1 MONTH);

-- 使用空字符代替日期中的 - 符号,得到 20221212 23:46:11 这样的效果

select replace( '2022-12-12 23:46:11', '-', '');

-- 对字符串做截取,获取第一位到第六位,得到 202212 这样的效果

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'-- 从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;

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

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

public class MonthBills {

// 月份账单表ID

private Integer monthBillsId;

// 账单流水号

private String serialNumber;

// 支付金额

private BigDecimal payMoney;

// 收银机器

private String machineSerialNo;

// 账单日期

private Date billDate;

// 账单详情

private String billsInfo;

// 账单备注

private String billComment;

// 要操作的目标表

private String targetTable;

// 省略构造方法和Get/Set方法.....

}

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

public class TableTimeUtils {

/*

* 使用ThreadLocal来确保线程安全,或者可以使用Java8新引入的DateTimeFormatter类:

* monthTL:负责将一个日期处理成 YYYYMM 格式

*/

private static ThreadLocal monthTL =

ThreadLocal.withInitial( ->

new SimpleDateFormat( "YYYYMM"));

// 表的前缀

private static String tablePrefix = "month_bills_";

// 将一个日期格式化为YYYYMM格式

public static String getYearMonth(Date date) {

returnmonthTL.get.format(date);

}

// 获取目标数据的表名(操作单条数据公用的方法:增删改查)

public static void getDataByTable(MonthBills monthBills){

// 获取传入对象的时间

Date billDate = monthBills.getBillDate;

// 根据该对象中的时间,计算出要操作的表名后缀

String yearMonth = getYearMonth(billDate);

// 将表前缀和后缀拼接,得到完整的表名,如:month_bills_202211

monthBills.setTargetTable(tablePrefix + yearMonth);

}

}

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

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

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

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}

< iftest= "monthBillsId != null">

month_bills_id,

< iftest= "serialNumber != null">

serial_number,

< iftest= "payMoney != null">

pay_money,

< iftest= "machineSerialNo != null">

machine_serial_no,

< iftest= "billDate != null">

bill_date,

< iftest= "billComment != null">

bill_comment,

< iftest= "billsInfo != null">

bills_info,

< iftest= "monthBillsId != null">

#{monthBillsId,jdbcType=INTEGER},

< iftest= "serialNumber != null">

#{serialNumber,jdbcType=VARCHAR},

< iftest= "payMoney != null">

#{payMoney,jdbcType=DECIMAL},

< iftest= "machineSerialNo != null">

#{machineSerialNo,jdbcType=VARCHAR},

< iftest= "billDate != null">

#{billDate,jdbcType=TIMESTAMP},

< iftest= "billComment != null">

#{billComment,jdbcType=VARCHAR},

< iftest= "billsInfo != null">

#{billsInfo,jdbcType=LONGVARCHAR},

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

-- 原本生成的代码是:

insert into month_bills_202211

-- 然后被我改成了:

insert into ${targetTable}

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

// Service接口(目前里面只有一个方法)

public interface IMonthBillsService {

int insert(MonthBills monthBills);

}

// Service实现类

@Service

public class MonthBillsServiceImpl implements IMonthBillsService {

@Autowired

private MonthBillsMapper billsMapper;

@Override

public int insert(MonthBills monthBills) {

// 获取要插入数据的表名

TableTimeUtils.getDataByTable(monthBills);

// 返回插入数据的状态

returnbillsMapper.insertSelective(monthBills);

}

}

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

@RestController

@RequestMapping( "/bills")

public class MonthBillsAPI {

@Autowired

private IMonthBillsService billsService;

// 账单结算的API

@RequestMapping( "/settleUp")

public String settleUp(MonthBills monthBills){

// 设置账单交易时间为当前时间

monthBills.setBillDate(new Date(System.currentTimeMillis));

// 使用UUID随机生成一个流水号

monthBills.setSerialNumber(monthBills.getMachineSerialNo

+ System.currentTimeMillis);

// 调用新增账单数据的service方法

if(billsService.insert(monthBills) > 0){

return">>>>账单结算成功>>>账单结算失败



【本文地址】


今日新闻


推荐新闻


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