☰
MySQL分区表自动加分区:存储过程+事件调度器全攻略
2026/9/25 23:41:07 网站建设 项目流程

做MySQL分区表维护的同学,十有八九都经历过手动加分区的痛。业务表数据量一大,按天做RANGE分区是最常见的选择,但分区不会自己长出来,你得在每天零点之前把下一个分区提前建好。偶尔手一抖漏加一次,凌晨业务写入直接报错,大半夜爬起来救火。这篇文章想分享的是这个场景的通用解法:写一个自动添加分区表的函数(落地时用存储过程),配合MySQL的事件调度器,让分区在后台自己长出来。标题里写"函数",实际建库时却不建议用CREATE FUNCTION,这里面的弯在第2节细说。

这套方案我们线上用了很长时间,从单表到几十张分区表都是同一套逻辑在管,基本做到无人值守。接下来直接讲思路、贴代码、聊踩坑,都是可以直接抄作业的干货。

1. 为什么要写一个自动加分区的存储过程

1.1 分区表解决的可不是"查询变快"这么简单

MySQL的RANGE分区,尤其是按日期做的TO_DAYS()分区,是互联网业务里处理订单、流水、日志这类时间序数据最常用的手段。很多人一听到分区表,第一反应就是"查询快",其实分区裁剪只是收益之一,而且只有当SQL条件能落到分区键上时才有用。真正让我离不开分区表的,是另外两个价值。

第一个是数据生命周期管理。线上订单表数据量起来之后,合规和成本要求我们只能保留最近一年或者两年的数据。如果是普通单表,清理一亿行历史数据要跑几个小时甚至更久,期间还会产生巨大的redo log和undo压力,弄不好就把磁盘打满。而分区表只需要一条ALTER TABLE ... DROP PARTITION,秒级释放磁盘空间,本质上是改元数据,不是逐行删除,对业务的影响小得多。

第二个是索引和统计信息的维护成本。单表几千万行时,每天晚上的统计信息更新和OPTIMIZE操作都会拖累跑批。分区表可以把这些任务按时间维度拆到单个分区上做,哪个分区要清理就处理哪个,不用每次动全表。所以分区表不是为了炫技,它是为了给维护环节省时间、降低操作风险。这也是我为什么坚持让线上大表统一走按天分区的根本原因。

有了分区表之后,紧接着就会出现一个操作层面的问题:分区不会自动出现,得有人把它建好,而且必须在数据写入到达之前建好,这就引出了最折磨人的"手动加分区"环节。

1.2 手动加分区为什么一定会掉链子

手动加分区这件事,难点从来不在SQL怎么写——不就是一条ALTER TABLE ADD PARTITION吗?真正的难点在于"别忘",而且"别算错提前量"。

假设我们按天分区,最大分区边界是2025-02-10,那么当业务写入create_time为2025-02-11的数据时,MySQL会直接抛错:Table has no partition for value from "2025-02-11"。这个错误一旦在线上出现,受影响的不是一条SQL,而是所有往这张表写的请求,支付、下单、日志采集全部中断。我见过凌晨两点被叫起来加分区的同事,也见过因为活动流量暴涨导致分区提前用完、然后DBA抱着电脑蹲在机房里手动补分区的场景。

更麻烦的是多环境问题。测试环境、预发环境、生产环境的分区表结构经常不完全一样,你在测试环境写好了一套加分区语句,拿到生产环境可能因为现有最大分区不同而报错。每个环境都要单独判断一次现状,手工操作量成倍增加。

这些事情反复出现之后,结论就很明确了:必须让"加分区"变成一段固定的逻辑,输入参数只要表名和需要预留的天数,剩下的从读分区现状到拼接DDL再到执行,全部自动化。这也正是本文要写的东西。

1.3 标题写"函数",落地为什么用存储过程

这里有个新手特别容易踩的坑:需求叫"MySQL自动添加分区表的函数",很多人就用CREATE FUNCTION去写,结果发现怎么都建不了,或者建了之后一执行就报错。

原因是MySQL的存储函数(FUNCTION)有严格限制:函数体内不允许执行PREPARE、EXECUTE这类动态SQL语句,否则报ERROR 1336: Dynamic SQL is not allowed in stored function。而加分区的核心恰恰是运行时动态拼接SQL:分区名和边界值都要先查出来、算出来,然后拼成ALTER TABLE语句再执行,这必须用动态SQL。

MySQL里允许执行动态SQL的存储程序是存储过程(PROCEDURE)。所以哪怕你平时习惯把这段逻辑叫作"函数",建库时也一定要用CREATE PROCEDURE。结论顺手记一下:名字按习惯叫没问题,落地姿势按PROCEDURE走。

2. 核心实现:一条存储过程搞定自动加分区

2.1 设计思路:先查最大分区边界,再往后推N天

写自动加分区之前,先想清楚它到底要做什么。给定一张按RANGE(TO_DAYS)分区的表,我要告诉它"从当前最大分区往后补齐N个分区",它就能自动完成三件事。

第一步,查出这张表当前最大分区的边界。这个信息从information_schema.PARTITIONS里拿:对于RANGE + TO_DAYS分区,每一行分区信息里都有一个PARTITION_DESCRIPTION字段,存的是边界日期的TO_DAYS整数值,比如边界2025-02-11对应的是739950这样的一个数字。既然是整数,直接MAX(PARTITION_DESCRIPTION)就能取到最大边界,非常可靠。

第二步,以这个边界日期为起点,按天往后推,生成N天的分区定义。每天生成一个分区,命名为pYYYYMMDD,边界是下一天的TO_DAYS值。

第三步,把这些分区定义拼成ALTER TABLE ADD PARTITION语句,用PREPARE/EXECUTE动态执行。为什么非得动态执行?因为SQL语句里的表名、分区名、日期全部是运行时变量,静态SQL根本写不出来。

为什么要从information_schema取数据,而不是直接解析SHOW CREATE TABLE?因为information_schema是一张可查询的关系表,可以直接聚合、排序、过滤;SHOW CREATE TABLE返回的是大段文本,解析它又慢又容易出错,完全没有必要。这里补充一点:如果你用的是MySQL 8.0,还可以从performance_schema或更细粒度的字典表取数,但information_schema依然是各版本通用、零配置成本的选择。

2.2 完整代码:实测可用的自动加分区存储过程

下面是完整代码,直接贴出来,已经做了空值判断和重复分区判断,比网上很多粗糙版本要稳一些。以MySQL 8.0为例,5.7也通用。

DELIMITER $$ DROP PROCEDURE IF EXISTS sp_auto_add_partition$$ CREATE PROCEDURE sp_auto_add_partition( IN p_schema_name VARCHAR(64), IN p_table_name VARCHAR(64), IN p_advance_days INT ) BEGIN DECLARE v_max_desc INT DEFAULT 0; DECLARE v_start_date DATE; DECLARE v_next_date DATE; DECLARE v_part_name VARCHAR(16); DECLARE v_sql VARCHAR(4096) DEFAULT ''; DECLARE v_has_max INT DEFAULT 0; DECLARE v_part_count INT DEFAULT 0; -- 检查是否存在MAXVALUE分区,存在则先拆分,否则后续ADD PARTITION会报错 SELECT COUNT(*) INTO v_has_max FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = p_schema_name AND TABLE_NAME = p_table_name AND PARTITION_DESCRIPTION IS NULL; IF v_has_max > 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '存在MAXVALUE分区,请先拆分MAXVALUE分区再执行自动加分区'; END IF; -- 取当前最大分区的边界描述 SELECT MAX(PARTITION_DESCRIPTION) INTO v_max_desc FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = p_schema_name AND TABLE_NAME = p_table_name; IF v_max_desc IS NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '未找到分区信息,请确认该表是RANGE分区表且有初始分区'; END IF; -- 将最大边界整数转成日期 SET v_start_date = FROM_DAYS(v_max_desc); -- 循环生成新分区 WHILE p_advance_days > 0 DO SET v_next_date = DATE_ADD(v_start_date, INTERVAL 1 DAY); SET v_part_name = CONCAT('p', DATE_FORMAT(v_next_date, '%Y%m%d')); -- 分区名已存在则跳过,避免重复添加报错 SELECT COUNT(*) INTO v_part_count FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = p_schema_name AND TABLE_NAME = p_table_name AND PARTITION_NAME = v_part_name; IF v_part_count = 0 THEN SET v_sql = CONCAT( 'ALTER TABLE `', p_schema_name, '`.`', p_table_name, '`', ' ADD PARTITION (PARTITION ', v_part_name, ' VALUES LESS THAN (TO_DAYS(\'', DATE_FORMAT(v_next_date, '%Y-%m-%d'), '\')))' ); SET @dyn_sql = v_sql; PREPARE stmt FROM @dyn_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; END IF; SET v_start_date = v_next_date; SET p_advance_days = p_advance_days - 1; END WHILE; END$$ DELIMITER ;

接下来说说代码里容易被忽略的几个点。

首先是两个SIGNAL判断。第一个判断MAXVALUE分区,是最容易被网上版本漏掉的。如果表里最后一个分区的边界是MAXVALUE,那么MAX(PARTITION_DESCRIPTION)得到的是NULL,整个逻辑直接走偏。而且直接执行ADD PARTITION会报"MAXVALUE can only be used in last partition definition"。所以必须先把这个问题拦下来。

第二个判断是v_max_desc IS NULL,这种情况说明这张表压根不是分区表,或者是一个没有任何初始分区的RANGE分区表,直接报错比执行一条奇怪的ALTER TABLE要友好得多。

然后是FROM_DAYS函数。PARTITION_DESCRIPTION里存的是TO_DAYS整数,FROM_DAYS能把它还原成DATE,这个日期就是当前最大分区的"不含当天"边界。举个例子:分区p20250210的边界是TO_DAYS('2025-02-11'),那说明p20250210存的是2025-02-10以及之前的数据,FROM_DAYS返回2025-02-11。我们从这里加一天,得到2025-02-12作为新分区p20250211的边界,逻辑正好扣上。

最后说说动态SQL的写法。MySQL里PREPARE/EXECUTE要求SQL语句保存在一个用户变量里,所以SET @dyn_sql = v_sql这一步不能省。PREPARE之后必须EXECUTE,最后DEALLOCATE PREPARE释放,否则短时间大量调用会累积预处理语句。我在代码里每次循环都做了释放,这样即使一次要补30个分区,也不会把会话资源拖垮。

2.3 边界日期和分区名的对应关系,千万别搞反

加分区有个非常容易出错的细节:分区边界和分区名之间的日期错位。VALUES LESS THAN (TO_DAYS('2025-02-11'))的数据含义是:所有create_time小于2025-02-11的数据,也就是最多存到2025-02-10 23:59:59。所以这个分区实际上装的是2025-02-10这一天的数据。

我见过有同事把分区名写成p20250211,然后对着数据排查了半天,总觉得分区和数据对不上。我的习惯是"分区名对应数据日期,边界值等于数据日期加一天"。也就是说存2月10日数据的分区叫p20250210,边界写TO_DAYS('2025-02-11')。上面代码里,v_start_date是"不含边界"的那一天,分区名用的是v_next_date也就是新边界的前一天,正好符合这个习惯。

调用方式也很简单。比如订单表orders在mydb库里,要往后补齐未来7个分区:

CALL sp_auto_add_partition('mydb', 'orders', 7);

执行完再查一下最大分区,如果边界已经推进到8天后,说明成功了。手动验证的SQL我放到第4节统一讲。

3. 部署与调度:让分区在后台自己长出来

3.1 新表初始化与存量表补救

自动加分区的前提是表里至少有一个初始分区。对于新建表,建表DDL里就要带上一段初始分区,比如:

CREATE TABLE orders ( id BIGINT NOT NULL, user_id BIGINT NOT NULL, order_status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL, PRIMARY KEY (id, create_time) ) ENGINE=InnoDB PARTITION BY RANGE (TO_DAYS(create_time)) ( PARTITION p20250201 VALUES LESS THAN (TO_DAYS('2025-02-02')), PARTITION p20250202 VALUES LESS THAN (TO_DAYS('2025-02-03')), PARTITION p20250203 VALUES LESS THAN (TO_DAYS('2025-02-04')) );

注意这里有个硬性规则:如果表上有主键或唯一索引,分区键必须是这些索引的一部分,否则MySQL会拒绝分区。所以上面我把主键写成了(id, create_time),这个细节在建表时就要想清楚,不然后期想改分区特别痛苦。

对于已经跑了一段时间的存量分区表,补救方式更简单,直接多调用几次存储过程。比如现在最大分区只到2025-02-10,想一口气补到3月底,可以直接CALL sp_auto_add_partition('mydb', 'orders', 60),过程是幂等的,重复执行也不会报错——每个分区在拼接前都会查一次是否存在。

3.2 用Event Scheduler做定时调度

存储过程写完只是第一步,真正让它"自动"起来,要挂在MySQL的事件调度器(Event Scheduler)上。先确保调度器是开启状态:

SET GLOBAL event_scheduler = ON;

如果想永久生效,还要在my.cnf的[mysqld]段里加上event_scheduler=ON,否则MySQL重启后又回到OFF状态。

然后创建一个每天执行一次的事件,比如每天凌晨3点调用存储过程,补齐未来7天的分区:

CREATE EVENT ev_auto_add_partition_daily ON SCHEDULE EVERY 1 DAY STARTS '2025-01-01 03:00:00' ON COMPLETION PRESERVE ENABLE DO CALL sp_auto_add_partition('mydb', 'orders', 7);

为什么定在凌晨3点而不是零点?两点考虑。第一,零点经常是业务跑批、日切、对账的高峰,DDL操作尽量避开;第二,我们要求分区提前量足够,既然提前7天,那凌晨1点还是3点执行都无所谓,选一个业务最闲的时间窗口就行。

事件创建之后,用SHOW EVENTS可以查看状态,或者查information_schema.EVENTS确认LAST_EXECUTED字段。只要看到LAST_EXECUTED一直在更新时间,说明调度链路是通的。

还有一个点要提醒:创建事件的用户需要有EVENT权限,调用存储过程的用户需要有ALTER权限。如果线上账号体系比较严格,为这个事件单独建一个运维账号,只给最小权限,能减少误操作面。

3.3 管理几十张分区表:配置表加日志表

单表用上面那个事件就够了,但如果线上有几十张分区表,一张表建一个event会管理得很痛苦。我的做法是加一层配置表和日志表,把"跑哪一个存储过程"变成"读一份维护清单"。

先建一张维护配置表:

CREATE TABLE partition_plan ( id INT PRIMARY KEY AUTO_INCREMENT, table_schema VARCHAR(64) NOT NULL, table_name VARCHAR(64) NOT NULL, advance_days INT NOT NULL DEFAULT 7, enabled TINYINT NOT NULL DEFAULT 1, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_schema_table (table_schema, table_name) );

再写一个总控存储过程,从这张表里读出所有enabled=1的记录,逐条调用上面那个sp_auto_add_partition。核心逻辑用游标循环就行,每次执行结束后把耗时、状态、影响分区数写进一张执行日志表。

这样日常维护就变成了一条SQL:哪张表要纳入自动管理,INSERT一条配置;哪张表暂停维护,把enabled改成0。不用再为了加一张表去改存储过程代码。分区计划的变更全部数据化,也方便做审计。

3.4 失败告警的几种实用做法

Event调度器不会因为你存储过程报错就给你发消息,执行失败它只是记录一下状态。所以监控不能只盯着"事件有没有跑",一定要盯着"分区的结果是否满足业务时间线"。

最省事的方案是写一个外部巡检脚本,每天调一次下面这条SQL:

SELECT TABLE_SCHEMA, TABLE_NAME, MAX(PARTITION_DESCRIPTION) FROM information_schema.PARTITIONS WHERE PARTITION_DESCRIPTION IS NOT NULL GROUP BY TABLE_SCHEMA, TABLE_NAME HAVING MAX(PARTITION_DESCRIPTION) < TO_DAYS(CURRENT_DATE + INTERVAL 3 DAY);

这条SQL的作用是找出所有"最大分区边界还没覆盖到三天后"的表。只要返回任意一行,就说明这些表的分区有断档风险,立即告警。脚本里调用方可以是Zabbix、Prometheus这类现成监控,也可以就是一个crontab加curl触发钉钉或企业微信机器人。我踩过太多次"凌晨才发现分区没加上"的坑,现在都是靠这条SQL每天主动巡检,效果比单纯看Event状态可靠得多。

4. 常见问题与排查技巧实录

4.1 已存在MAXVALUE分区,怎么救

这个问题在存量表改造时特别常见。以前为了图省事,有些人建表会在最后加一个VALUES LESS THAN MAXVALUE的分区兜底,防止数据没分区可写。但有了MAXVALUE分区之后,我们上面的存储过程会直接报错,而且即使不依赖存储过程,光执行ADD PARTITION也会报错。

MySQL规定,MAXVALUE只能用在最后一个分区的定义里。解决办法是先通过REORGANIZE把MAXVALUE分区拆开:比如把MAXVALUE分区拆成具体日期分区加新的MAXVALUE分区:

ALTER TABLE orders REORGANIZE PARTITION pmax INTO ( PARTITION p20250210 VALUES LESS THAN (TO_DAYS('2025-02-11')), PARTITION pmax VALUES LESS THAN MAXVALUE );

拆完之后,MAXVALUE分区前面就有了具体的日期分区,之后存储过程再往后面追加分区就不受影响了。这里要提醒一句:如果MAXVALUE分区里已经落了数据,REORGANIZE时会扫描和重写MAXVALUE分区里的数据,数据量大的话这个操作会有点重,最好在低峰期执行,并且提前确认MAXVALUE里面到底有没有数据、有多少。

说句题外话,MAXVALUE这种兜底方案我建议尽量别用。日常按天分区只要保证提前量足够,根本不需要MAXVALUE来兜底;真到兜底了,说明自动化已经失守了。而且数据一旦进了MAXVALUE,后续想按时间把它们拆到具体分区,操作成本和风险都不小。

4.2 重复执行、并发执行,分区名冲突怎么办

自动加分区脚本最常见的重复执行场景有两个:一个是DBA手动补分区之后忘了改调度时间,凌晨事件又跑了一次;另一个是多实例的监控脚本同时调用了存储过程。

如果存储过程里没有查重逻辑,第二次执行就会报ERROR 1564: Duplicate partition name。我们这个版本已经在每个分区拼接前都查了一次information_schema.PARTITIONS,分区存在就跳过,所以重复执行是安全的。这也是我把"是否存在MAXVALUE"判断放在最前面的原因之一——不要等到循环里拼接完SQL才发现问题。

并发执行的问题更隐蔽。两个会话同时查了一下发现分区不存在,然后都去执行ALTER TABLE,还是会有一个失败。不过实际运维中很少出现两个进程同时调度同一个自动化任务的情况,只要你把任务统一收敛到一套调度器(比如只用一个event,或者只用一个外部巡检任务),就不会频繁撞车。真要在高可用架构里做双机调度,我建议在配置表里加一个task_lock字段,用GET_LOCK或者UPDATE ... WHERE task_lock=0这类方式保证同一时刻只有一个执行者。

4.3 时区导致的日期边界偏移

时区问题在分区表场景里比想象中更容易踩。TO_DAYS()函数会受MySQL系统时区影响,如果服务器的time_zone设置和业务约定的北京时间不一致,同样的字符串日期经过TO_DAYS转换出来的整数可能就差了一天。

我处理这个问题有个原则:对分区而言,边界的基准要明确。如果业务表的create_time存的就是北京时间,那Event里最好显式SET time_zone = '+08:00',让整个会话统一到业务时区。如果公司服务器的系统时区是UTC,而你的分区脚本里用了NOW()或CURDATE()来产生起始边界,不加处理就会出现"该加2月11日的分区,结果加成了2月10日"这种错位。

好在我们这套存储过程的起始边界是来自已有分区的PARTITION_DESCRIPTION,不是取当前系统时间,所以时区对它的影响相对小。但如果你在外部巡检脚本里用CURRENT_DATE做判断,就一定要统一时区。最简单粗暴的办法:所有涉及日期比较的地方,显式拼接'2025-02-11'这种字符串,而不是依赖NOW(),从源头消除歧义。

4.4 ALTER TABLE ADD PARTITION到底锁不锁表

很多DBA一听ALTER TABLE就紧张,觉得会不会锁表锁半天。对InnoDB的RANGE分区表来说,ADD PARTITION本质上只是修改表的分区元数据,速度非常快,通常毫秒级到秒级就能完成,因为它不做数据搬运。但要注意,它仍然需要持有表的元数据锁(MDL)。如果在执行ADD PARTITION的时候,正好有一个长事务或者长查询持有这张表的MDL,加分区的操作会排队等待,而排在它后面的写请求也会一起被堵住。

所以我的调度原则是:把加分区的时间窗口放在业务低峰期,同时把提前量给足,不要让"加分区"变成紧急事件。提前7天和提前3天,对业务容错来说完全不是一个量级的风险。另外,REORGANIZE PARTITION这种操作和ADD PARTITION完全不是一回事,它涉及分区数据扫描和移动,遇到大分区一定要单独规划维护窗口,不能混进自动加分区的流程里。

4.5 常用验证SQL和巡检方法

最后把我日常用的几条验证SQL整理出来,顺便做成一个速查表,方便大家复制。

第一是查某张表最近几个分区的情况:

SELECT PARTITION_NAME, PARTITION_DESCRIPTION, FROM_DAYS(PARTITION_DESCRIPTION) AS boundary_date FROM information_schema.PARTITIONS WHERE TABLE_SCHEMA = 'mydb' AND TABLE_NAME = 'orders' ORDER BY PARTITION_ORDINAL_POSITION DESC LIMIT 5;

第二是全局巡检所有分区表,找分区断档:

SELECT TABLE_SCHEMA, TABLE_NAME, MAX(PARTITION_DESCRIPTION) FROM information_schema.PARTITIONS WHERE PARTITION_DESCRIPTION IS NOT NULL GROUP BY TABLE_SCHEMA, TABLE_NAME HAVING MAX(PARTITION_DESCRIPTION) < TO_DAYS(CURRENT_DATE + INTERVAL 3 DAY);

第三是查看Event调度器是否正常执行,查LAST_EXECUTED和STATUS:

SELECT EVENT_NAME, STATUS, LAST_EXECUTED, LAST_ALTERED FROM information_schema.EVENTS WHERE EVENT_SCHEMA = 'mydb';

把这些归纳成下表,对照排查:

现象可能原因处理方式
ADD PARTITION报MAXVALUE错误表结构里最后一个分区是MAXVALUE先REORGANIZE拆分MAXVALUE,拆完再跑
报Duplicate partition name分区已存在,重复执行存储过程里加存在性判断,已存在则跳过
凌晨没有新分区出现Event没开、账号权限不足、存储过程报错检查event_scheduler、SHOW EVENTS、查看错误日志
分区边界整体偏一天时区不一致统一time_zone,尽量用字符串日期
加分区时业务写阻塞MDL锁排队提前量给足,调度放在低峰期

我个人在实际操作中的体会是,自动加分区这件事,代码本身不难,难的是把"边界条件"想清楚。时区、MAXVALUE、重复执行、事件调度失效,这些零碎问题才是真正让你凌晨起床的元凶。最后再分享一个小习惯:我不管自动化脚本跑得多稳,每天还是会用上面那条全局巡检SQL扫一遍分区覆盖情况,发现"今天+3天"之内没有分区覆盖就直接告警。多一道旁路监控,心里踏实很多。这套存储过程思路,够覆盖大部分按天分区表的维护场景了,希望能帮你把凌晨的时间留给自己。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询