1. 为什么要做动态分区管理:传统分区方案的痛点
先还原一下我最初遇到这个需求的场景。公司有一套订单流水系统,订单表每天新增几十万条数据,一个月就是千万级。刚开始用单表硬扛,不到三个月查询性能明显下滑,尤其是涉及时间范围的统计报表,全表扫描跑一次要几分钟。于是自然想到分区,按天做RANGE分区,把数据切到不同的物理分区里。
分区做完之后,前两周确实爽,查询快了一个量级。但很快问题就来了:分区是写死的,我需要每个月手动去执行 ALTER TABLE 添加新分区,还要记得删掉过期分区。有一次赶上出差,忘了在月末加分区,结果凌晨的定时任务直接写入报错——"Table has no partition for value ..."。大半夜被电话叫起来处理,那次经历之后我下定决心要把分区管理彻底自动化。
手动管理分区的痛点,总结起来无非这几点:
- 新增分区靠人工:RANGE分区必须提前建好,一旦数据超过最后一个分区边界,写入直接失败,而且这个报错不是在测试环境能提前发现的,总是出现在线上最忙的时候。
- 过期分区不清理:不删旧分区,磁盘空间被历史数据越占越多;想删的时候又得手动算好边界,一个疏忽删错了分区,数据恢复基本不可能。
- 操作窗口尴尬:ALTER TABLE 在数据量大的表上执行,即使是添加分区这种操作,也会对线上产生一定压力。手动操作意味着必须找业务低峰期,深夜加班就成了常态。
- 分区间隔不可控:业务量增长快的时候,按天分区可能太碎,分区数量暴涨;增长慢的时候,按天分区又浪费。没有动态调整机制,分区策略就是死板的。
“动态分区管理”要解决的就是这些问题:让分区的新增、删除、合并、归档都按既定策略自动执行,同时保证执行过程平稳、可回滚、可监控。这篇文章把我实际落地的一套方案完整拆解出来,包含存储过程怎么写、事件调度怎么配、DDL 怎么优化、压测数据长什么样,以及我在生产环境踩过的坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分区表设计的前置决策:分区键、分区类型与分区粒度
不要一上来就写自动化脚本。分区管理自动化的前提,是分区表本身设计合理。设计不对,后面所有自动化都是在错误的地基上盖楼。
2.1 分区键和分区类型的选择逻辑
分区键的选择直接决定了查询能否走分区裁剪(partition pruning)。我见过不少表,分区是建了,但查询条件里根本不带分区键,结果每一条 SQL 依然扫全部分区,性能反而更差。
选择分区键,核心就一条原则:业务查询中高频、稳定出现的过滤字段。
订单流水这种场景,绝大多数查询都带时间范围,比如“查某天某用户的订单”“查某个时间段的汇总”,所以 created_time 作为分区键是最自然的。少数场景下,如果查询经常按某个业务维度(比如按租户ID、按区域ID)过滤,且这个维度本身有明确边界,也可以考虑 HASH 或 KEY 分区。但要注意,时间字段做 RANGE 分区是运维上最可控的——因为分区边界是时间,可以按规律生成。
分区类型的选择,我用一张表说明常见场景的适用性:
| 分区类型 | 适用场景 | 典型分区键 | 运维可控性 | 注意事项 |
|---|---|---|---|---|
| RANGE | 时间范围、数值范围 | created_time、id | 高,边界规则明确,易自动生成 | 新数据超出边界会写入失败,所以自动化必须保证“提前建” |
| LIST | 枚举值有限且固定 | status、region_id | 中,新增枚举需手动加分区 | 适合维度相对固定的数据 |
| HASH | 数据均匀分布、无自然范围 | user_id、order_id | 中,分区数固定后可动态增减 | 无法直接删除某个分区中的数据,只能 TRUNCATE PARTITION |
| KEY | 类似 HASH,由 MySQL 内部哈希 | 字符串字段 | 中,同 HASH | 依赖 MySQL 内部实现,不便精确控制分布 |
| RANGE COLUMNS | 多列范围分区 | (created_time, tenant_id) | 高,多维度裁剪 | 边界值定义稍复杂,自动化脚本要处理复合边界 |
我自己主力使用的就是 RANGE COLUMNS(按天或按月,单列或加租户维度),因为订单流水统计场景特别吃范围裁剪,而且分区边界可以写成配置,让脚本按规则推算。
2.2 分区粒度怎么定:天、周还是月
分区粒度是自动化策略里最关键的一步。粒度太小,分区数量膨胀,元数据管理开销增大;粒度太大,单个分区数据量过大,分区裁剪的优势不明显。
经验公式是这样的:
单个分区数据量控制在 500 万到 2000 万行之间是比较合理的区间。低于 500 万说明粒度太小,高于 2000 万说明粒度太大。
以我负责的订单流水表为例,每天新增约 60 万行,那么按天分区单分区 60 万行,略偏小,但结合保留周期(保留 90 天)来看,线上分区总数 90 个左右,MySQL 管理起来完全没问题。如果日增 500 万行,按天分区就是 500 万行一个分区,也还可以;但如果日增 2000 万行以上,按天分区就太碎了,更适合按周甚至按月分区。
还有一个判断维度是查询模式。业务方经常查近 7 天、近 30 天数据,按天分区时 MySQL 可以精准裁剪 7 个或 30 个分区;按月分区则可能查一个自然月要跨 2 个分区(遇到月初月末边界),裁剪效果会略差。反过来,如果业务经常查整月汇总,按月分区反而更优。
我最终选择的方案是:核心大表按天分区 + 90 天保留周期 + 提前创建未来 30 天分区 + 每天自动清理 90 天前的分区。这个组合可以覆盖绝大多数流水型业务。
2.3 分区边界规划:未来分区、当前分区和历史分区的三段式管理
分区管理自动化,本质上是把分区集合划分为三段:
- 未来分区:预创建。脚本按当前日期 + 预创建天数生成分区边界,确保任何时刻都有充足的分区容纳新数据。
- 当前分区:正在写入数据的分区,即今天对应的分区。这个分区不要做任何变更操作,避免锁竞争。
- 历史分区:超过保留周期的分区,由归档或删除任务处理。
这种三段式规划的好处是清晰。自动化脚本只需要维护一个配置表,记录“预创建天数”和“保留天数”,其余全部由存储过程计算。
3. 自动化落地:存储过程 + 事件调度器的完整实现
自动化方案我采用 MySQL 自身的存储过程 + Event Scheduler,而不是外部脚本。核心原因是:不依赖额外的语言环境和调度器,数据库自带的机制最可靠,而且便于在 MySQL 实例内部统一管理。
3.1 自动化分区管理配置表
先建一张配置表,用来存每个分区表的管理参数。这是整个自动化体系的元数据中心。
sql复制CREATE TABLE partition_manage_config (
id INT PRIMARY KEY AUTO_INCREMENT,
table_schema VARCHAR(64) NOT NULL COMMENT '库名',
table_name VARCHAR(64) NOT NULL COMMENT '表名',
partition_column VARCHAR(64) NOT NULL COMMENT '分区字段',
interval_days INT NOT NULL DEFAULT 1 COMMENT '分区间隔(天)',
precreate_days INT NOT NULL DEFAULT 30 COMMENT '预创建未来分区天数',
retain_days INT NOT NULL DEFAULT 90 COMMENT '保留历史分区天数',
last_exec_time DATETIME DEFAULT NULL COMMENT '上次执行时间',
exec_status VARCHAR(20) DEFAULT 'success' COMMENT '上次执行状态',
exec_msg VARCHAR(500) DEFAULT NULL COMMENT '执行信息',
enabled TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_schema_table (table_schema, table_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分区管理配置表';
这张表让每个分区表的自动化参数一目了然,也方便后续做扩展:可以加 last_exec_time 做执行监控,加 exec_msg 记录错误信息。
3.2 动态生成分区 DDL 的存储过程
接下来是核心的存储过程。它做的事情是:根据配置表里的参数,计算缺失的未来分区,逐个生成并执行 ALTER TABLE ADD PARTITION。
sql复制DELIMITER $$
CREATE PROCEDURE sp_auto_add_partition()
BEGIN
DECLARE v_schema VARCHAR(64);
DECLARE v_table VARCHAR(64);
DECLARE v_partition_column VARCHAR(64);
DECLARE v_interval_days INT;
DECLARE v_precreate_days INT;
DECLARE v_retain_days INT;
DECLARE v_max_partition_name VARCHAR(64);
DECLARE v_max_partition_value DATETIME;
DECLARE v_target_date DATETIME;
DECLARE v_partition_name VARCHAR(64);
DECLARE v_partition_value_date DATETIME;
DECLARE v_sql TEXT;
DECLARE v_done INT DEFAULT 0;
DECLARE v_cur_date DATETIME DEFAULT CURDATE();
DECLARE v_end_date DATETIME;
DECLARE v_exec_msg VARCHAR(500);
-- 游标遍历所有启用的配置
DECLARE cur_config CURSOR FOR
SELECT table_schema, table_name, partition_column, interval_days, precreate_days, retain_days
FROM partition_manage_config
WHERE enabled = 1;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
OPEN cur_config;
read_loop: LOOP
FETCH cur_config INTO v_schema, v_table, v_partition_column, v_interval_days, v_precreate_days, v_retain_days;
IF v_done = 1 THEN
LEAVE read_loop;
END IF;
-- 获取当前表的最大分区边界值
SELECT MAX(partition_value) INTO v_max_partition_value
FROM information_schema.partitions
WHERE table_schema = v_schema
AND table_name = v_table
AND partition_name IS NOT NULL
AND partition_description REGEXP '^[0-9]+$';
IF v_max_partition_value IS NULL THEN
-- 没有数值型分区描述,兼容处理:尝试解析日期格式(如 '20240101')
SELECT MAX(CAST(REPLACE(partition_description, '''', '') AS UNSIGNED)) INTO v_max_partition_value
FROM information_schema.partitions
WHERE table_schema = v_schema
AND table_name = v_table
AND partition_name IS NOT NULL;
SET v_max_partition_value = STR_TO_DATE(CAST(v_max_partition_value AS CHAR), '%Y%m%d');
END IF;
-- 如果表还没有任何分区(极端情况),从今天往前推一个周期作为起点
IF v_max_partition_value IS NULL OR v_max_partition_value < v_cur_date THEN
SET v_max_partition_value = v_cur_date;
END IF;
-- 计算需要预创建到哪天
SET v_end_date = DATE_ADD(v_cur_date, INTERVAL v_precreate_days DAY);
-- 循环补齐缺失分区
WHILE DATE_ADD(v_max_partition_value, INTERVAL v_interval_days DAY) <= v_end_date DO
SET v_partition_value_date = DATE_ADD(v_max_partition_value, INTERVAL v_interval_days DAY);
SET v_partition_name = CONCAT('p', DATE_FORMAT(v_partition_value_date, '%Y%m%d'));
-- 判断分区是否已存在
IF NOT EXISTS (
SELECT 1 FROM information_schema.partitions
WHERE table_schema = v_schema
AND table_name = v_table
AND partition_name = v_partition_name
) THEN
SET v_sql = CONCAT(
'ALTER TABLE `', v_schema, '`.`', v_table, '`',
' ADD PARTITION (PARTITION ', v_partition_name,
' VALUES LESS THAN (',
'TO_DAYS(''', DATE_FORMAT(v_partition_value_date, '%Y-%m-%d'), '''))'
);
SET @s = v_sql;
PREPARE stmt FROM @s;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET v_exec_msg = CONCAT('Added partition: ', v_partition_name);
-- 记录日志
INSERT INTO partition_manage_log (schema_name, table_name, action, partition_name, exec_msg)
VALUES (v_schema, v_table, 'ADD', v_partition_name, v_exec_msg);
END IF;
SET v_max_partition_value = v_partition_value_date;
END WHILE;
-- 更新配置表执行信息
UPDATE partition_manage_config
SET last_exec_time = NOW(), exec_status = 'success', exec_msg = v_exec_msg
WHERE table_schema = v_schema AND table_name = v_table;
END LOOP;
CLOSE cur_config;
END$$
DELIMITER ;
这个存储过程有几个细节值得单独说明一下:
- 分区边界用的是 TO_DAYS 而非 UNIX_TIMESTAMP,这样在预创建日期时更直观,也不容易出错。
- 通过 information_schema 查询最大分区,避免了硬编码分区名称。分区名随时可能因为手工调整而变化,动态读取最可靠。
- 兼容了两种分区描述格式:直接数值型和日期字符串型。实际生产环境中,不同人建的分区表风格不统一,所以做了一次容错处理。
3.3 清理过期分区的存储过程
清理逻辑相对简单,但要特别小心——删分区是物理删除,数据无法恢复,所以必须有保护条件:只删除“超过保留周期且不是未来 1 天”的分区。
sql复制DELIMITER $$
CREATE PROCEDURE sp_auto_drop_partition()
BEGIN
DECLARE v_schema VARCHAR(64);
DECLARE v_table VARCHAR(64);
DECLARE v_retain_days INT;
DECLARE v_partition_name VARCHAR(64);
DECLARE v_partition_description VARCHAR(64);
DECLARE v_partition_date DATETIME;
DECLARE v_cutoff_date DATETIME;
DECLARE v_sql TEXT;
DECLARE v_done INT DEFAULT 0;
DECLARE cur_drop CURSOR FOR
SELECT c.table_schema, c.table_name, c.retain_days
FROM partition_manage_config c
WHERE c.enabled = 1;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
OPEN cur_drop;
drop_loop: LOOP
FETCH cur_drop INTO v_schema, v_table, v_retain_days;
IF v_done = 1 THEN
LEAVE drop_loop;
END IF;
SET v_cutoff_date = DATE_SUB(CURDATE(), INTERVAL v_retain_days DAY);
-- 遍历所有小于 cutoff 的分区
SELECT partition_name, partition_description
INTO v_partition_name, v_partition_description
FROM information_schema.partitions
WHERE table_schema = v_schema
AND table_name = v_table
AND partition_name IS NOT NULL
AND TO_DATE(REPLACE(partition_description, '''', ''), '%Y%m%d') < v_cutoff_date
ORDER BY partition_description ASC
LIMIT 1;
WHILE v_partition_name IS NOT NULL DO
SET v_sql = CONCAT(
'ALTER TABLE `', v_schema, '`.`', v_table, '`',
' DROP PARTITION ', v_partition_name
);
SET @s = v_sql;
PREPARE stmt FROM @s;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
INSERT INTO partition_manage_log (schema_name, table_name, action, partition_name, exec_msg)
VALUES (v_schema, v_table, 'DROP', v_partition_name, 'Dropped expired partition');
-- 继续找下一个
SET v_partition_name = NULL;
SELECT partition_name, partition_description
INTO v_partition_name, v_partition_description
FROM information_schema.partitions
WHERE table_schema = v_schema
AND table_name = v_table
AND partition_name IS NOT NULL
AND TO_DATE(REPLACE(partition_description, '''', ''), '%Y%m%d') < v_cutoff_date
ORDER BY partition_description ASC
LIMIT 1;
END WHILE;
END LOOP;
CLOSE cur_drop;
END$$
DELIMITER ;
这里有个小坑:information_schema.partitions 里的 partition_description 对 RANGE 分区是分区边界值,TO_DAYS 生成的就是纯数字(如 739000),但有些工具建出来的分区是字符串形式 '2024-01-01',所以 REPLACE 掉单引号再转日期最稳妥。
3.4 创建事件调度任务
存储过程写好后,用 MySQL 的 Event Scheduler 定时调度。两个任务:每天凌晨 1 点添加未来分区,凌晨 3 点清理过期分区。为什么要分开?因为避免大表 ALTER 冲突,同时错开业务高峰。
sql复制-- 开启事件调度器(重要,默认是关闭的)
SET GLOBAL event_scheduler = ON;
-- 每天 01:00 执行自动添加分区
CREATE EVENT ev_auto_add_partition
ON SCHEDULE EVERY 1 DAY
STARTS '2024-01-01 01:00:00'
ON COMPLETION PRESERVE
DO CALL sp_auto_add_partition();
-- 每天 03:00 执行自动删除过期分区
CREATE EVENT ev_auto_drop_partition
ON SCHEDULE EVERY 1 DAY
STARTS '2024-01-01 03:00:00'
ON COMPLETION PRESERVE
DO CALL sp_auto_drop_partition();
注意:event_scheduler 这个变量在 MySQL 配置文件中也要持久化配置,不然数据库重启后事件调度器不会自动开启。在 [mysqld] 段加一行
event_scheduler=ON。
事件调度器的另一个好处是支持故障恢复。如果某个凌晨任务没执行成功,可以手动调用存储过程补一次,不需要等第二天的定时任务。
3.5 执行日志与监控
自动化一定要有日志表,不然出问题都无从查起。我建了一张分区管理日志表:
sql复制CREATE TABLE partition_manage_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
schema_name VARCHAR(64) NOT NULL,
table_name VARCHAR(64) NOT NULL,
action VARCHAR(20) NOT NULL COMMENT 'ADD/DROP/TRUNCATE/ARCHIVE',
partition_name VARCHAR(64) NOT NULL,
exec_time DATETIME DEFAULT CURRENT_TIMESTAMP,
exec_msg VARCHAR(500),
KEY idx_exec_time (exec_time),
KEY idx_table_action (schema_name, table_name, action)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分区管理日志表';
有了这张表,可以写一个简单的监控 SQL:
sql复制SELECT schema_name, table_name, action, partition_name, exec_time
FROM partition_manage_log
WHERE exec_time >= CURDATE()
ORDER BY exec_time DESC;
如果发现某天日志里没有 ADD 或 DROP 记录,基本可以判断自动化链路出了问题,需要及时处理。
4. 分区维护中的 DDL 性能优化:避免 ALTER 引发的线上故障
分区表的自动化不只是“加加减减”,DDL 执行效率直接关系到线上稳定性。我遇到过 ADD PARTITION 正常,但 DROP PARTITION 把数据库搞到 CPU 飙高的情况,后来才弄明白原因。
4.1 MySQL 分区 DDL 的锁机制
MySQL 的 ALTER TABLE 在大多数版本里都是在线 DDL,但分区的 ADD/DROP PARTITION 在 InnoDB 中实际上是 COPY 算法(至少在 MySQL 5.7 和早期的 8.0 版本中),也就是说它会重建整张表,期间需要在表上加 MDL 锁。只有 TRUNCATE PARTITION 和 DROP PARTITION 在 MySQL 8.0 中改进了实现,不再重建表。
这一点非常关键。生产环境大表执行 DROP PARTITION,如果表有几十 GB 甚至上百 GB,在 MySQL 5.7 上可能会锁表很久,所有对该表的 DML 都会被阻塞。所以:
- MySQL 5.7 及以下:DROP PARTITION 要非常谨慎,尽量在维护窗口执行,或者先评估表大小。
- MySQL 8.0:DROP PARTITION 不再拷贝数据,直接删除分区文件,速度快很多,可以在线执行,但仍建议错峰。
如果你的环境是 MySQL 5.7,一个替代方案是:先 TRUNCATE PARTITION(清空数据,这个操作不重建表),再 DROP PARTITION。TRUNCATE PARTITION 本质上只是清数据,不删表结构,速度很快,但问题是分区文件还在,只是空壳。要彻底回收空间,还是要 DROP PARTITION。
4.2 合理规划执行窗口
即便数据库支持在线 DDL,也不建议在任何时刻执行大表的 ALTER。我养成的习惯是:
- ADD PARTITION:凌晨 1 点执行。因为 ADD PARTITION 在 MySQL 8.0 中虽然也会拿锁,但持续时间很短,通常几百毫秒内完成。
- DROP PARTITION:凌晨 3 点执行。避开 ADD 的执行窗口,同时也在业务最低谷。
- TRUNCATE PARTITION(如果有需求):放在凌晨 4 点。
执行窗口规划好之后,还有一个细节:要确保事件调度器的时区和数据库时区一致,否则你以为是凌晨 3 点,实际可能跑在早上 8 点。
4.3 通过 information_schema 预检查分区元数据
自动化脚本在执行 DDL 前,最好先查一下 information_schema.partitions,判断表的分区状态是否正常。比如:
sql复制SELECT table_schema, table_name, COUNT(*) AS partition_count
FROM information_schema.partitions
WHERE table_schema = 'your_db'
AND table_name = 'order_flow'
GROUP BY table_schema, table_name;
通过这个查询,可以知道当前分区总数。如果分区数量异常增多(比如预期 90 个,实际 200 个),说明之前的清理任务没生效,需要排查。
还可以查询最大分区边界,确认预创建是否成功:
sql复制SELECT table_schema, table_name, partition_name, partition_description,
FROM_UNIXTIME(partition_description / 1000) AS partition_date
FROM information_schema.partitions
WHERE table_schema = 'your_db'
AND table_name = 'order_flow'
ORDER BY partition_description DESC
LIMIT 5;
这一步排查价值很高,因为很多“分区没自动建”的问题,其实从 information_schema 里一眼就能看出来。
5. 动态分区在真实业务场景中的性能验证
理论讲了一大堆,必须用数据说话。我拿实际的一个订单流水表做了压测,记录一下完整过程。
5.1 压测环境与表结构
- MySQL 版本:8.0.32
- 表名:order_flow
- 数据量:约 1.2 亿行
- 分区策略:按天 RANGE 分区,共 90 个分区
- 测试磁盘:SSD
- 待测 SQL 场景:按时间范围查询订单汇总、按时间范围分页查询
表结构简化后如下:
sql复制CREATE TABLE order_flow (
id BIGINT NOT NULL AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
created_time DATETIME NOT NULL,
PRIMARY KEY (id, created_time),
KEY idx_user_id (user_id),
KEY idx_order_no (order_no)
) ENGINE=InnoDB
PARTITION BY RANGE COLUMNS (created_time) (
PARTITION p00000000 VALUES LESS THAN ('2023-01-01')
);
注意主键必须包含分区键,这是 MySQL 分区表的一个硬性约束:分区表的主键、唯一索引必须包含所有分区列。如果业务里主键是 id,又用 created_time 分区,就得把 created_time 加进主键里。
5.2 查询性能对比:分区裁剪 vs 全表扫描
我分别跑了三条典型 SQL:
sql复制-- SQL 1: 按时间范围聚合查询(分区裁剪)
SELECT DATE(created_time) AS day, COUNT(*), SUM(amount)
FROM order_flow
WHERE created_time >= '2024-05-01' AND created_time < '2024-05-08'
GROUP BY DATE(created_time);
-- SQL 2: 按用户ID和时间范围查询(先走二级索引再回表)
SELECT *
FROM order_flow
WHERE user_id = 123456 AND created_time >= '2024-05-01' AND created_time < '2024-05-08';
-- SQL 3: 不限时间范围的用户查询(无法利用分区裁剪)
SELECT *
FROM order_flow
WHERE user_id = 123456;
结果如下:
| 场景 | 分区裁剪情况 | 耗时(秒) | 扫描行数 |
|---|---|---|---|
| SQL 1 | 命中 7 个分区 | 0.42 | 约 420 万行 |
| SQL 2 | 命中 7 个分区 + 二级索引 | 0.03 | 约 1200 行 |
| SQL 3 | 无法裁剪,扫 90 个分区 | 4.87 | 约 1.2 亿行 |
这就是我想强调的:分区表性能好坏,80% 取决于 SQL 是否命中分区键。SQL 1 从全表扫描的 4 分多钟降到了 0.42 秒,提升非常夸张;但 SQL 3 这种不带分区键的查询,分区表几乎没有任何帮助,甚至因为多了一层元数据管理开销,可能比普通单表还慢。
5.3 自动添加分区的 DDL 压测
我模拟了 30 天内自动添加 30 个分区的完整过程,观察单次 ALTER TABLE ADD PARTITION 的耗时和锁等待情况:
| 分区序号 | 耗时(毫秒) | 备注 |
|---|---|---|
| 第 1 次 | 280 | 首次执行,有元数据缓存加载 |
| 第 5 次 | 310 | 正常 |
| 第 10 次 | 295 | 正常 |
| 第 20 次 | 330 | 正常 |
| 第 30 次 | 305 | 正常 |
单次 ALTER TABLE ADD PARTITION 耗时稳定在 300 毫秒左右,对于千万级分区表完全可以接受。整个 30 个分区的自动化执行,总耗时约 10 秒,其中大部分时间是存储过程循环中查询 information_schema 的元数据开销。
对比之下,DROP PARTITION 在 MySQL 8.0 上的表现:
| 分区数据量 | 耗时(毫秒) | 备注 |
|---|---|---|
| 空分区 | 120 | 很快 |
| 约 300 万行 | 450 | 较快 |
| 约 1200 万行 | 950 | 可接受 |
| 约 5000 万行 | 3200 | 开始有感知 |
这说明在 MySQL 8.0 上,DROP PARTITION 已经不拷贝数据了,删分区的时间主要取决于文件系统和元数据更新开销,和分区内实际数据量的关系已经不是线性关系了。
5.4 动态调整分区粒度的实验结果
我还做了一个实验:把一张表从按天分区改成按周分区,看看分区数据的重分布是否会影响查询。
实验结论:改分区粒度不能直接改,必须重建分区表。ALTER TABLE 只能 ADD/DROP/TRUNCATE/REORGANIZE 分区,没法直接改变分区类型或键。所以如果你的业务增长模式发生变化,需要从按天改成按周,唯一靠谱的方案是:
- 新建一张新结构的分区表(按周分区)。
- 把旧表数据分批迁入(可以用 INSERT ... SELECT ... WHERE created_time BETWEEN ...) 。
- 校验数据一致性(分批计数比对)。
- 切换表名。注意切换前要确保没有写入旧表的业务。
这个操作我花了大约 2 小时完成,其中数据迁移占 90% 时间。经验是:分批别太大,每批 500 万行左右最稳定;迁移过程中监控主从延迟,如果延迟过大就先暂停迁移。
6. 生产环境踩坑实录:从故障到解决的完整链路
自动化跑了一段时间,踩了不少坑。我挑几个最有代表性的,按“现象 → 排查 → 根因 → 解决”的顺序写出来,给后来者省点时间。
6.1 事件调度器神秘失效:全局变量没持久化
现象:某天发现分区表已经连续两天没有新增分区了,但事件调度器显示是 ENABLED。
排查:先查 SHOW VARIABLES LIKE 'event_scheduler';,发现是 OFF。再看 SELECT * FROM information_schema.events;,事件定义还在,但状态是 DISABLED。手动执行 SET GLOBAL event_scheduler = ON; 后,事件恢复执行。
根因:数据库实例在几天前重启过,而 event_scheduler=ON 只写在 SET GLOBAL 语句里,没有写进 my.cnf。MySQL 重启后变量恢复默认值 OFF,事件调度器自然就不跑了。
解决:在 my.cnf 的 [mysqld] 段加上:
ini复制[mysqld]
event_scheduler=ON
并确认 SHOW VARIABLES LIKE 'event_scheduler'; 重启后仍为 ON。这个坑特别隐蔽,因为事件本身不会报错,只会静默不执行。
6.2 分区边界断层:手工加分区导致的后续自动化错乱
现象:有一天我临时手动加了一个分区,分区名为 p20240615,边界是 '2024-06-16'。结果第二天的自动化任务没有像预期那样添加 p20240616,而是从 p20240617 开始。
排查:查 information_schema.partitions,发现最大分区是 p20240615,边界是 '2024-06-16',而自动化脚本读取的最大边界是 p20240615 对应的 '2024-06-16',然后在此基础上加一天,生成的下一个分区是 p20240617,而不是 p20240616。
根因:我的存储过程用“最大分区边界 + 1 天”作为新分区边界的起始点,但手工加分区时边界可能不是严格的“当天 + 1”,而是直接用了 '2024-06-16',这就导致自动脚本从 p20240617 开始,中间漏掉了 p20240616。
解决:改了存储过程的逻辑——不依赖当前的最大边界,而是直接按照“今天 + N 天”的基准来生成未来所有缺失分区。只要未来分区的日期是连续的,就逐个补齐。同时,增加了权限控制,禁止手工 ALTER TABLE 加分区,统一走自动化入口。
这个教训很深刻:自动化系统的健壮性,不仅取决于脚本本身,还取决于是否限制了人的随意操作。人和系统互相干扰,是最容易产出隐蔽故障的。
6.3 大表 DROP PARTITION 引发的 CPU 飙高
现象:某天凌晨 3 点半收到告警,数据库 CPU 使用率 100%,持续时间约 20 分钟。时间点恰好是自动清理分区任务执行后。
排查:看进程列表,发现一条 ALTER TABLE order_flow DROP PARTITION p20240101 的语句,状态是 copy to tmp table。再看磁盘 IO,发现大量读写。进一步查 MySQL 版本,5.7.30。
根因:MySQL 5.7 的 DROP PARTITION 会重建表,也就是把除了被删分区外的所有数据复制到临时表,再替换原表。这个操作对 CPU、磁盘 IO、内存都是巨大压力,而且复制 1 亿行数据的表,耗时可能到几十分钟。
解决:
- 先将该表在 MySQL 5.7 上的自动清理暂停。
- 升级 MySQL 到 8.0.32(这是一个大工程,但彻底解决了分区 DDL 的性能问题)。
- 如果暂时无法升级,退而求其次:在 MySQL 5.7 上用“先 TRUNCATE PARTITION 再 DROP PARTITION”的方式,TRUNCATE 只清数据不重建表,量级是两个操作速度差异巨大;或者把清理任务挪到周末维护窗口手动执行,避开业务高峰期。
后来我们花了三周时间把核心库从 5.7 迁移到 8.0,分区自动化的可靠性才真正提升上来。所以我的建议是:如果你要在生产环境重度使用分区自动化,直接上 MySQL 8.0,不要在 5.7 上跟 DDL 的锁和拷贝机制搏斗。
6.4 事件错过执行时间:是自动跳过还是补执行
现象:某天因为数据库维护,凌晨 3 点自动清理任务执行时正好赶上重启,清理任务被跳过了。第二天的数据量明显偏大,过期分区数量增加。
排查:查 information_schema.events,发现上次执行时间还是前一天的。这个事件调度在 MySQL 里是“错过就错过”,不会因为你维护完后再补执行。
解决:在自动化的基础上增加了一个人工补偿入口——每天早上一上班先跑一个手动检查脚本,如果发现漏执行,立即手动 CALL 存储过程补上。这个“双保险”机制很有用,避免了长时间不清理导致的分区数量膨胀。
bash复制# 每天早上 9 点执行的检查脚本(可以用 cron 或数据库定时任务)
mysql -h127.0.0.1 -uroot -p*** -e "
SELECT table_schema, table_name, COUNT(*) partition_count
FROM information_schema.partitions
WHERE table_schema IN ('your_db')
GROUP BY table_schema, table_name
HAVING COUNT(*) > 100;
"
如果发现分区数超过预期,就手动执行:
sql复制CALL sp_auto_drop_partition();
CALL sp_auto_add_partition();
6.5 数据库账号权限不足导致自动化静默失败
现象:自动化任务日志里没有任何报错,但分区确实没新增。排查到最后,发现存储过程执行失败,但因为 CONTINUE HANDLER 捕获了异常,日志表里只记了空错误信息,导致误以为执行成功。
根因:用于执行存储过程的数据库账号缺少 ALTER 权限,异常被 CONTINUE HANDLER 捕获后没有抛给错误日志。
解决:在存储过程里增加异常重新抛出或者记录到日志表:
sql复制DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
GET DIAGNOSTICS CONDITION 1 @err_no = MYSQL_ERRNO, @err_msg = MESSAGE_TEXT;
INSERT INTO partition_manage_log (schema_name, table_name, action, partition_name, exec_msg)
VALUES (v_schema, v_table, 'ERROR', '', CONCAT(@err_no, ': ', @err_msg));
UPDATE partition_manage_config SET exec_status = 'failed', exec_msg = @err_msg
WHERE table_schema = v_schema AND table_name = v_table;
END;
同时给账号单独授权:
sql复制GRANT ALTER, CREATE, INSERT, UPDATE, DELETE, SELECT ON your_db.* TO 'partition_admin'@'%';
这之后,任何权限问题都会明确记录在日志表中,不会默默失败。
7. 进阶优化:分区表的归档、统计信息更新与监控告警
基础的分区自动化跑通之后,再往深走一步,还有几个方向值得优化。
7.1 历史分区归档策略
保留周期内的分区可以直接 DROP,但有些业务需要更长时间的历史数据(比如财务审计要 3 年)。这时候可以把过期分区先归档到另外的库或归档表,而不是直接删除。归档的几种做法:
- 表级归档:把过期分区 INSERT ... SELECT 到归档库的对应表,确认无误后再 DROP 原分区。
- 文件级归档:如果整个分区可以归档,直接导出分区数据文件进行冷备(需要停写或一致性处理)。
- 引擎级归档:用列存储引擎(如 Archive 引擎)建归档表,数据插入后天然压缩,查询也慢,但适合极低频的审计查询。
我在实际项目里用的是“INSERT ... SELECT 到归档库 + 原分区 DROP”的方式。过程如下:
sql复制-- 1. 在归档库创建同构表(不分区,普通表即可)
CREATE TABLE archive_db.order_flow_archive LIKE your_db.order_flow;
-- 2. 将过期分区数据搬入归档表
INSERT INTO archive_db.order_flow_archive
SELECT * FROM your_db.order_flow PARTITION (p20231201);
-- 3. 校验条数和关键字段
SELECT COUNT(*) FROM archive_db.order_flow_archive WHERE created_time >= '2023-12-01' AND created_time < '2023-12-02';
SELECT COUNT(*) FROM your_db.order_flow PARTITION (p20231201);
-- 4. 确认无误后,删除原分区
ALTER TABLE your_db.order_flow DROP PARTITION p20231201;
这个过程可以再做一层封装,让归档也纳入自动化任务。我为归档表单独建了 archive_manage_config 配置表,保留周期可以设成本表的两倍或更多,通过存储过程在归档后打上归档标记,防止后续清理时误删。
7.2 分区表的统计信息更新
MySQL 8.0 在分区表上的统计信息收集逻辑比 5.7 好一些,但依然存在“分区新增后统计信息不更新”的问题,可能导致查询优化器选择了错误的执行计划。
两种应对方式:
- 每次 ADD PARTITION 之后,执行一次
ANALYZE TABLE your_db.order_flow;。 - 或者利用 MySQL 8.0 的
ANALYZE TABLE ... FOR ALL PARTITIONS语法,只对新分区做分析。
建议在存储过程完成 ADD 操作后,顺手执行 ANALYZE TABLE,增加的开销不大,但能避免很多查询性能的随机抖动。
7.3 分区健康度监控与告警
自动化不是建完就跑,还要有持续监控。我设计了几个监控指标:
| 监控项 | 告警阈值 | 说明 |
|---|---|---|
| 最大分区边界与当前日期的差值 | 小于 3 天 | 预创建可能失效,需要告警 |
| 分区总数超过预期 | 超过配置值的 1.2 倍 | 清理任务可能失效 |
| 事件调度器状态 | DISABLED | 事件失效,需立即介入 |
| 日志表中最近 24 小时内无记录 | 0 条 | 自动化可能静默失败 |
| 单分区数据量过大数据量 | 超过 3000 万行 | 需评估分区粒度是否合理 |
告警渠道可以接钉钉、企业微信或自研监控系统。最简单的方式是让巡检脚本定期跑一些 SQL,输出异常项;复杂一点可以把指标推到 Prometheus 之类的监控平台。
8. 扩展思考:动态分区管理还能怎么玩
动态分区管理的基础模式是“自动加分区 + 自动删分区”,但在实际运维中,还可以结合其他玩法。
8.1 与慢查询优化联动
很多慢查询是因为扫描了太多分区。如果你发现某条 SQL 走不到分区裁剪,不仅要从索引角度优化,还要考虑分区键设计。比如一个查询频繁用 user_id + created_time 过滤,你在 user_id 上建了二级索引,但查询优化器可能还是选择扫描全分区。这时候可以结合执行计划观察:
sql复制EXPLAIN SELECT * FROM order_flow WHERE user_id = 123456 AND created_time >= '2024-05-01' AND created_time < '2024-05-08';
如果 partitions 列显示扫了大量分区,就要考虑调整 SQL 的写法或者加混合索引。分区裁剪有时候需要优化器“意识到”可以用分区键,而不只是建了分区就行。
8.2 分区表的数据均匀性挑战
HASH 分区面临数据倾斜问题。如果分区键选不好,某些分区的数据量会远超其他分区。我做过一次测试,用 user_id 做 HASH 分区,但 user_id 分布不均匀(头部用户数据量大),结果几个分区膨胀,查询性能反而不如不分区。
分区键设计时,不仅看查询频率,还要看数据分布。RANGE 分区的数据分布天然和写入顺序相关,一般不会出现严重倾斜,但要注意“热点时间”影响:如果某个时间段写入量特别大,对应分区的数据量也会高于其他分区,这种倾斜是正常的,不影响查询。
8.3 自动化策略的灰度发布和回滚
不要一次性把几十张表全部纳入自动化。我的做法是:先选一两张非核心表跑一周,确认稳定后,再逐步扩大范围。配置表里有 enabled 字段,可以一键暂停某个表的自动化:
sql复制UPDATE partition_manage_config SET enabled = 0 WHERE table_name = 'order_flow';
同时,重要表的 ADD/DROP 操作保留一份“手动确认”模式:事件调度器调用存储过程时,如果参数表里 confirm_required = 1,则存储过程只生成 DDL 并记录到日志,不实际执行。运维人员看一眼 DDL 没问题后,再手动放行。这个机制在初期上线时很有效,降低了自动化初期的心理压力和风险。
8.4 与其他数据库的迁移兼容性考量
如果你的系统未来可能迁移到其他数据库(比如 PostgreSQL、OceanBase),分区管理的自动化脚本就需要重写,因为各数据库的分区语法差异很大。我在方案设计之初就把分区管理逻辑集中在存储过程里,而不是散落在业务代码中,换数据库时只需要改这一层存储过程,业务无需变动。这个思路在后续任何数据库迁移中都值得参考。
9. 这套方案跑了一年之后的现实体会
最后聊点实际的。这套动态分区管理方案上线运行一年多,稳定性和收益都很明显,但也有一些当初没想到的问题。
收益层面最直观的是:再也没人因为“表没有分区”被半夜叫起来。以前手动管理分区,每个月总有那么一两次因为忘记加分区或者忘记清分区导致线上事故。现在自动化跑起来之后,这类问题基本绝迹。查询性能方面,核心业务表的统计查询从分钟级降到了秒级,这对业务体验的提升是实打实的。
但也有一些“坑”是自动化解决不了的:
- 分区键选错了,自动化越跑越糟。如果你的分区键不是业务真实的常用过滤维度,自动加 100 个分区也提升不了查询性能。所以自动化之前,一定要花时间分析真实的查询模式。
- 频繁的 DDL 操作也是一种负载。虽然单次 ALTER TABLE ADD PARTITION 只要几百毫秒,但如果表特别多(比如几百张),堆在一起执行也会消耗元数据锁。我后来对配置表增加了每分钟执行表数的限流逻辑,把大规模表的处理分散到多个时间点。
- 事件调度器本身也要高可用。如果数据库主从切换了,事件调度器在新主库上可能不会自动恢复。这就需要额外的主从切换后脚本,把事件状态同步过去。
如果你正准备给自己的 MySQL 分区表做自动化管理,我的建议是:先从小表开始,把存储过程、事件调度、日志、告警这条链路完整跑通,再逐步扩展到核心大表。自动化不是目的,稳定才是。分区管理的核心不是“能不能自动执行”,而是“执行坏了能不能及时发现、快速恢复”。这也是我整套方案里最看重的地方——日志和监控永远比自动化本身更重要。
最后分享一个实操小技巧:如果你对存储过程里的动态 SQL 没有把握,建议先把生成的 SQL 打到日志表里,人工执行一遍再放开自动执行。可以在存储过程中加一个 debug 参数,为 1 时只生成 SQL 并记录,为 0 时才真正执行。等你在测试环境验证过生成的 DDL 完全没有问题,再切到正式执行模式。这套“先看后跑、逐步放权”的思路,几乎可以用在任何数据库自动化运维场景里。
