1. 为什么你该关注 MySQL 动态分区管理
做后端开发或者 DBA 的同学,手头上多多少少都有几张“无限增长”的表。订单流水、操作日志、埋点数据、接口调用记录……刚开始几百MB,业务跑几个月以后轻松突破几十GB。这时候最直观的两个变化,一是查询变慢,二是备份和维护越来越吃力。MySQL 分区表是老生常谈的解法,但真正落地的时候你会发现,分区本身也是一个需要持续维护的“实体”,不能指望建一张分区表就一劳永逸。
这里说的 MySQL 动态分区管理,指的是用自动化手段,让分区表在运行期间按预设策略自行创建新分区、清理过期分区,同时配合分区裁剪和索引优化,让查询性能始终保持在合理水位。技术栈其实不复杂:存储过程加事件调度器,最多再加一点管理日志。但整套逻辑设计得好不好,直接决定你未来一年要不要半夜起床处理“分区已满”的告警。
这篇内容适合谁?主要面向有一定 MySQL 基础、正在被大表和分区维护困扰的开发者、DBA,或者正准备把日志类数据模型做成“自动化分区”的架构设计者。我会把分区设计的关键参数、自动化脚本从零到一的实现过程,以及运行半年后遇到的典型问题全部摊开讲,都是一线实践里沉淀下来的东西。
1.1 分区表到底解决了什么问题
MySQL 分区表的核心思想是“分而治之”。一张两百GB的日志表,底层会按照你指定的分区键拆成几十个几十MB的小分区。查询时如果条件里带了分区键,优化器会做分区裁剪(Partition Pruning),只扫描命中的分区,IO 和 CPU 消耗都能明显降下来。
更爽的是清理场景。日志类数据通常有保留周期,比如只留30天。如果是普通表,清理一天的数据要发 DELETE FROM t WHERE create_date < xxx,这个操作在几十GB的表上可能跑几分钟甚至几十分钟,而且会产生大量 binlog 和 undo,稍不注意还能把主库拖垮。分区表就简单了,直接 ALTER TABLE t DROP PARTITION p20241201,删除的是物理级的数据文件,速度快,而且不会触发大量的行级锁和 undo 膨胀。
这一点在归档场景里价值非常大。很多公司的合规要求会规定业务数据保留时长,比如交易记录存三年、日志存一百八十天。用分区表配合动态删除,相当于把数据生命周期管理下沉到了数据库层面,比业务代码定时删数据靠谱得多。
1.2 纯手工维护分区能撑多久
如果你只有一张分区表,每天手动执行一两条 ALTER TABLE 完全没问题。但真实环境往往不止一张表,而且分区策略还会迭代——今天按天分区,明天想改成按周,后天又要调整保留周期。手工维护的问题一是在于容易漏,漏一次就可能出现新数据插不进去的线上事故;二是在于没人能保证半夜两点来执行删除任务,告警群响半天也没人操作。
我自己就经历过一次印象深刻的故障。某张订单日志表当时设了未来三天的预创建分区,结果上线前估算不足,业务量暴涨,第四天的分区根本没建出来,凌晨报表任务直接报错。因为生产环境不允许随手改表结构,最后用了将近两个小时才把分区补上,期间业务一直在重试。从那次之后我就下决心把分区管理完全自动化,并且把预创建天数从3天拉长到了7天。
所以,动态分区管理不是一个花哨的功能,而是大表运维的刚需。它解决的核心问题不是“能不能分区”,而是“分区能不能自动跟上业务节奏”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前先定好分区方案
很多人在“自动化”这一步栽跟头,其实根源不在脚本本身,而是前期分区设计没想清楚。分区类型怎么选、分区键怎么定、分区间隔给多大,这几个问题没有一个明确答案,后面写多少自动化代码都是歪的。
2.1 选对分区类型:RANGE 依然是日志表首选
MySQL 支持的分区类型主要有 RANGE、LIST、HASH 和 KEY 四种,选型不能拍脑袋,要跟数据特征匹配。
| 分区类型 | 划分逻辑 | 典型适用场景 | 注意事项 |
|---|---|---|---|
| RANGE | 按连续区间划分 | 时间范围、自增 ID 范围 | 分区值需递增,适合顺序写入 |
| LIST | 按离散值集合划分 | 省份、业务线、状态枚举 | 新增枚举值需要 ALTER 调整分区 |
| HASH | 对分区键做哈希取模 | 均匀打散热点数据 | 无法按范围快速清理 |
| KEY | MySQL 内部哈希函数 | 等值查询较多的场景 | 类似 HASH,支持多列 |
日志类数据我基本首选 RANGE,因为分区键如果选时间,数据写入天然落在最新分区,旧数据基本是只读状态,DROP 旧分区就能完成清理,整个生命周期非常顺。HASH 分区虽然能把 IO 打散,但你没法按时间快速淘汰数据,要清理过期数据还得定位行。LIST 分区更适合业务类型固定的枚举场景,但业务类型一变,ALTER 维护成本也不低。
2.2 分区键与主键的关系,一步错步步错
这是新手最容易踩的坑:MySQL 要求分区表的所有分区键必须是主键或唯一键的一部分。为什么有这条限制?因为 MySQL 需要靠唯一索引在全局范围内保证唯一性,如果分区键不在唯一键里,可能出现在两个不同分区有重复记录的情况。
所以建表的时候,如果主键是 id,你又想按 create_date 分区,那主键就得改成复合主键 (id, create_date)。这会让部分依赖单 id 的查询场景稍微变复杂,但这是使用分区表的必要代价。如果你的业务要求 id 单独唯一,那就只能放弃分区,或者改从业务层做分表,鱼和熊掌在 MySQL 里很难兼得。
注意:建表前一定要确认分区键是否在主键或唯一键中,否则建表直接报错。已经有大量数据的表可能要迁表,成本会高很多。
另外,分区键本身也要尽量是查询中出现频率很高的过滤字段。如果表按时间分区,业务查询却从来不按时间过滤,那分区裁剪永远用不上,每次查询还是全分区扫描,分区表就失去意义了。
2.3 分区间隔怎么定:按天、按周还是按月
分区间隔是分区方案里最需要权衡的参数。间隔太小,比如按小时分,分区数量会爆炸式增长,MySQL 打开文件数、元数据管理、ALTER 操作都会跟着变慢。间隔太大,比如按年分,单个分区又过大,清理和裁剪的效果都不明显。
我给一个通用判断标准:单个分区的数据量控制在千万行以下,单分区大小控制在几GB以内。日志流水类场景最常见的是按天分区,每天几百万甚至上千万写入量,一天一个分区刚刚好。如果业务量比较小,一天只有几万条,那按周甚至按月分区更合理,避免分区过碎。如果一天几亿条,那按天分区也不够,得考虑按小时分区或者分表了。
还有一点容易忽略:分区的数量上限。MySQL 8.0 中单表最多支持 8192 个分区,看起来很多,但实际操作中几百个分区就会明显感受到元数据操作的性能下降,建议控制在 500 个以内。按天分区、保留半年,大概 180 到 200 个分区,处于一个比较舒服的范围。
3. 自动化脚本是这样一步步实现的
设计做好了,接下来就是怎么把“建分区、删分区”这两件事变成定时任务。我的方案是存储过程封装逻辑,事件调度器定时触发,再加一张管理日志表做审计。这套组合不需要额外引入中间件,纯 MySQL 原生能力就能跑得很稳。
3.1 存储过程:把分区维护写成可复用函数
下面是一个按天分区的自动化存储过程示例。假设表名是 t_order_log,分区键是 create_date,分区命名规则是 pYYYYMMDD。这里用动态 SQL,因为 ALTER TABLE 的分区名和日期边界必须拼接成字符串才能执行。
sql复制DELIMITER //
CREATE PROCEDURE sp_manage_order_log_partitions(
IN p_pre_days INT, -- 预创建未来多少天的分区
IN p_keep_days INT -- 保留最近多少天的分区
)
BEGIN
DECLARE v_date DATE DEFAULT CURDATE();
DECLARE v_end_date DATE;
DECLARE v_part_name VARCHAR(16);
DECLARE v_next_value DATE;
DECLARE v_sql VARCHAR(1000);
DECLARE v_counter INT DEFAULT 0;
-- 预创建未来分区
SET v_end_date = DATE_ADD(CURDATE(), INTERVAL p_pre_days DAY);
WHILE v_date <= v_end_date DO
SET v_part_name = CONCAT('p', DATE_FORMAT(v_date, '%Y%m%d'));
SET v_next_value = DATE_ADD(v_date, INTERVAL 1 DAY);
-- 先检查分区是否已存在,避免重复执行时报错
IF NOT EXISTS (
SELECT 1 FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 't_order_log'
AND PARTITION_NAME = v_part_name
) THEN
SET v_sql = CONCAT(
'ALTER TABLE t_order_log ADD PARTITION (PARTITION ',
v_part_name,
' VALUES LESS THAN (TO_DAYS(''',
DATE_FORMAT(v_next_value, '%Y-%m-%d'),
''')))'
);
SET @sql = v_sql;
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET v_counter = v_counter + 1;
INSERT INTO t_partition_audit(table_name, action, partition_name, execute_time)
VALUES ('t_order_log', 'ADD', v_part_name, NOW());
END IF;
SET v_date = DATE_ADD(v_date, INTERVAL 1 DAY);
END WHILE;
-- 清理过期分区:保留 p_keep_days 天,一次最多往前追溯 60 个分区
SET v_date = DATE_SUB(CURDATE(), INTERVAL p_keep_days DAY);
SET v_end_date = DATE_SUB(v_date, INTERVAL 60 DAY);
WHILE v_date >= v_end_date DO
SET v_part_name = CONCAT('p', DATE_FORMAT(v_date, '%Y%m%d'));
IF EXISTS (
SELECT 1 FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 't_order_log'
AND PARTITION_NAME = v_part_name
) THEN
SET @sql = CONCAT('ALTER TABLE t_order_log DROP PARTITION ', v_part_name);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET v_counter = v_counter + 1;
INSERT INTO t_partition_audit(table_name, action, partition_name, execute_time)
VALUES ('t_order_log', 'DROP', v_part_name, NOW());
END IF;
SET v_date = DATE_SUB(v_date, INTERVAL 1 DAY);
END WHILE;
SELECT v_counter AS affected_partitions;
END //
DELIMITER ;
在这个存储过程里,有几个地方需要特别说明。
第一,分区是否存在的检查不能省。ALTER TABLE 动表结构是有成本的,如果事件调度器重复执行,或者上一次任务一半失败,没有这个检查就会直接报错,甚至打乱现有分区顺序。
第二,用 information_schema.PARTITIONS 判断分区元数据是标准做法,比 try-catch 更可控。注意这里用的是当前库 DATABASE(),如果你是跨库管理,可以把表名和库名字段都参数化。
第三,DROP 旧分区的循环范围设置成“保留期再往前多扫 60 天”,是为了防止一次任务删除过多个分区,避免长事务阻塞。如果任务中断很久恢复,多跑几轮就能追上来,不会在某一轮集中执行大量 DDL。
辅助审计表可以这样建:
sql复制CREATE TABLE t_partition_audit (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
table_name VARCHAR(64) NOT NULL,
action VARCHAR(10) NOT NULL,
partition_name VARCHAR(64) NOT NULL,
execute_time DATETIME NOT NULL,
INDEX idx_execute_time(execute_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这里我再补充一个 MySQL 8.0 用户的建议:如果你用的是 8.0 以上版本,可以考虑用 RANGE COLUMNS 分区,它比 TO_DAYS 更适合日期列,查询裁剪时可以直接用日期比较,而且插入时对数据类型的处理更直观,不用在 VALUES 里写 TO_DAYS 函数。
3.2 事件调度器:让 MySQL 自己干活
存储过程写好了,还需要一个定时调度的“闹钟”。MySQL 的事件调度器(Event Scheduler)可以做到这一点。默认情况下它是关闭的,需要手动打开:
sql复制SET GLOBAL event_scheduler = ON;
但要注意,这个参数不会在 MySQL 重启后自动保留。如果你用的是云数据库,可能在控制台参数组里改;如果是自建环境,建议在配置文件 [mysqld] 段加一行:
ini复制event_scheduler = ON
然后创建事件:
sql复制CREATE EVENT ev_auto_manage_order_log
ON SCHEDULE EVERY 1 DAY
STARTS '2025-01-01 02:00:00'
ON COMPLETION PRESERVE
DO
CALL sp_manage_order_log_partitions(7, 30);
调度时间选凌晨 2 点,主要是避开业务高峰。这里有两个细节:
ON COMPLETION PRESERVE:事件执行完毕不要自动删除,否则事件只在第一次执行时生效,后面就没了。- 创建事件的账号需要有
EVENT权限,执行存储过程的账号需要有ALTER、INSERT等权限。建议单独建一个管理账号,不要拿业务账号来跑定时任务。
提示:部署后记得确认
SHOW VARIABLES LIKE 'event_scheduler';返回 ON,否则事件不会执行。重启 MySQL 后这个参数容易丢,这也是线上分区没自动创建的高频原因。
事件调度器还有一个好处:它是 MySQL 内部的机制,不依赖外部 cron,也不依赖应用服务器。就算应用服务器挂了,只要数据库健康,分区维护就能正常继续。
3.3 预创建与延迟删除策略
我的实践经验是“多预创建、少删除”。新建分区的时候,提前量要留足,比方说业务预估一天增长 2GB,预创建 7 天就是 14GB 的余量,够你处理很多突发情况。上面的存储过程里 p_pre_days 建议按实际写入量来调,比如压测高峰期到了,可以直接临时调大到 10 天甚至更多。
删除策略要相对保守。数据确实超过保留期才删,而不是到了边界就立刻删。因为线上排查问题的时候,经常需要往前翻几天的数据,如果删除过于积极,等你要找问题时数据已经没了,那才是真正的灾难。我通常的做法是:保留 30 天的数据,但清理任务最早只删 30 天前的分区,而且一次最多删 60 个分区,避免一个 DDL 做太多事。
另外,强烈建议把 DROP 操作放到业务低峰期执行。DROP PARTITION 虽然比 DELETE 快得多,但 ALTER TABLE 仍可能持有表的元数据锁,如果正好赶上高峰期的事务,可能会引发锁等待甚至阻塞。
3.4 管理日志与告警
自动化不是“跑了就完”,必须能回溯、能告警。我在上面存储过程里加了一张 t_partition_audit 审计表,每次 ADD/DROP 分区都会插一条记录。对于几百张分区表的场景,可以再抽象一层,把表名也作为参数传进存储过程,做成一个通用的分区管理过程,所有表共用。
告警这块,我建议通过事件里调用存储过程,存储过程执行完如果发现异常,通过外部巡检来兜底。更简单的做法是,在监控系统里加一条 SQL,每天检查 t_partition_audit 里当天有没有成功执行记录,没有就报警。数据库本身不负责消息通知,但你要保证“有没有成功干活”这件事是可见的。换句话说,自动化让系统自己运行,但作为人,你依然要有一双眼睛盯着它。
4. 自动化之后的性能优化实践
分区自动建、自动删之后,数据库层面的运维节奏算是稳了,但查询性能不一定自动跟着好。分区表能不能跑得快,很大程度取决于你的 SQL 写法、索引设计,以及是否真正触发了分区裁剪。
4.1 分区裁剪:查询条件必须带上分区键
分区裁剪是分区表性能的核心机制。简单说,优化器在看到形如 create_date BETWEEN '2025-01-01' AND '2025-01-02' 的过滤条件时,会去元数据里判断哪些分区不需要扫描,然后只扫描命中的分区。如果你的查询条件里没有分区键,比如只写了 order_id = 'xxx',那 MySQL 只能全分区扫描,分区表反而比普通表更慢,因为要多一层分区元数据解释的开销。
我测试过一个真实的慢 SQL。当时查询语句是:
sql复制SELECT * FROM t_order_log
WHERE order_id = '20250101000123'
AND create_date >= '2025-01-01'
AND create_date < '2025-01-02';
用 EXPLAIN 查看执行计划,返回的 partitions 字段只命中了 p20250101,整个查询只扫一个分区,耗时从原来的 5 秒降到 150 毫秒。这就是分区裁剪的威力。
日常开发中,建议把 EXPLAIN 当成习惯。看到 partitions 列有多个分区或用 NULL,就要警惕是不是裁剪没生效。排查重点通常是:查询条件里到底带没带分区键、分区键的类型和分区定义的边界类型是否一致、SQL 里有没有对分区键做函数包裹导致无法裁剪。
提示:最常见的反例是
WHERE DATE(create_date) = '2025-01-01',这会让优化器没办法把条件映射到分区范围,直接放弃裁剪。改写成范围条件才是正解。
4.2 索引设计要与分区配合
很多人以为分区表就不用建索引了,这是误解。分区和索引解决的是不同层面的问题:分区减少扫描的数据量,索引在分区内部加速查找。两者配合起来才是最优解。
比如前面的 t_order_log,分区键是 create_date,业务上还有 order_id 查询,那就在 order_id 上建二级索引:
sql复制CREATE INDEX idx_order_id ON t_order_log(order_id);
这样当查询条件同时包含 create_date 和 order_id 时,先用分区裁剪缩小范围,再在对应分区里走 idx_order_id 定位行,性能最优。
有一个容易踩的细节:二级索引在分区表里的代价比普通表高,每个分区都有一份自己的索引副本。如果你建了多个二级索引,分区数量又多,写入时会同步维护很多索引,写入性能会明显下降。所以分区表上的索引宁缺毋滥,只保留真正高频的查询索引,能用联合索引覆盖的优先用联合索引。
4.3 慢 SQL 排查案例
有一个我印象很深的案例。某业务表按天分区,前端页面要查询某用户最近 30 天的操作记录,SQL 长这样:
sql复制SELECT * FROM t_user_operation
WHERE user_id = 1001
AND create_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY);
按说条件里带了 create_date,应该能裁剪,但 EXPLAIN 一看,partitions 列显示命中了全部分区。排查发现,问题出在 create_date 字段的类型是 DATETIME,而分区定义是 TO_DAYS(create_date),查询条件直接比较 create_date 和 DATE_SUB 的结果,MySQL 的优化器没法直接推导出这等价于 TO_DAYS 的范围。
解决方案有两种:一是把分区类型换成 RANGE COLUMNS (create_date),分区边界直接写日期值,这样查询条件里用日期比较就能正常裁剪;二是改 SQL,把条件改成 TO_DAYS(create_date) >= TO_DAYS(DATE_SUB(CURDATE(), INTERVAL 30 DAY)),但这样容易让索引失效,不推荐。
如果你的 MySQL 版本支持,优先用 RANGE COLUMNS 分区,这是我在 8.0 上最推荐的做法。至于 5.7 环境
