MySQL 分区裁剪排查:先核对日期边界,再看实际访问分区
查询已经按月份筛选,执行计划里却出现了好几个分区。先别急着给日期列再加一个索引:分区裁剪回答“哪些分区不必访问”,索引回答“进入剩余分区以后怎样找行”。两项判断可以同时成功,也可以各自失败。
下面用 MySQL 8.4 做一组可重复的查询对照。数据故意放在月初、月末最后一微秒、下月零点以及不同年份,让错误范围即使在小表上也能暴露。重点检查结果与执行计划中的 partitions,不拿八行数据的耗时评价线上收益。
让分区边界与样本位置都可见
在专用实验库中建立完整表。occurred_at 是业务已经统一好的时间轴字段,本例不做 TIMESTAMP 会话时区转换。所有唯一键包含分区列,租户加时间的二级索引用于支持常见查询。
CREATE DATABASE IF NOT EXISTS prune38_lab
CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE prune38_lab;
CREATE TABLE audit_event (
event_id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
occurred_at DATETIME(6) NOT NULL,
detail VARCHAR(100) NOT NULL,
PRIMARY KEY (event_id, occurred_at),
KEY idx_tenant_time (tenant_id, occurred_at)
) ENGINE=InnoDB
PARTITION BY RANGE COLUMNS (occurred_at) (
PARTITION p_before VALUES LESS THAN ('2025-01-01'),
PARTITION p202501 VALUES LESS THAN ('2025-02-01'),
PARTITION p202502 VALUES LESS THAN ('2025-03-01'),
PARTITION p202503 VALUES LESS THAN ('2025-04-01'),
PARTITION p_future VALUES LESS THAN (MAXVALUE)
);
INSERT INTO audit_event VALUES
(1,1,'2025-01-31 23:59:59.999999','January last microsecond'),
(2,1,'2025-02-01 00:00:00.000000','February start'),
(3,1,'2025-02-28 23:59:59.999999','February last microsecond'),
(4,1,'2025-03-01 00:00:00.000000','March start'),
(5,2,'2025-02-14 12:00:00.000000','other tenant'),
(6,1,'2026-02-01 00:00:00.000000','February next year'),
(7,1,'2024-02-01 00:00:00.000000','February prior year'),
(8,1,'2025-03-10 00:00:00.000000','March body');
SELECT PARTITION_NAME,PARTITION_DESCRIPTION
FROM INFORMATION_SCHEMA.PARTITIONS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='audit_event'
ORDER BY PARTITION_ORDINAL_POSITION;
p_before 并不是一个只有2024年二月的分区,它接收所有小于2025-01-01的值;p_future 也包含很多未来月份。先看边界,才能解释后面的 MONTH 查询为什么不能只凭月份编号确定一个物理分区。
先保住结果,再看访问范围
查询租户1在2025年二月的事件,使用左闭右开的范围。上界写下一月起点,不需要猜当前列到底保留几位小数。
SELECT event_id,occurred_at,detail
FROM audit_event
WHERE tenant_id=1
AND occurred_at >= '2025-02-01'
AND occurred_at < '2025-03-01'
ORDER BY occurred_at,event_id;
EXPLAIN FORMAT=TRADITIONAL
SELECT event_id,occurred_at,detail
FROM audit_event
WHERE tenant_id=1
AND occurred_at >= '2025-02-01'
AND occurred_at < '2025-03-01';
结果应为2、3,边界条件只需要 p202502。传统格式的 partitions 列用于核对这一点,key 和 type 再说明分区内采用哪种访问方式。样本很小时,优化器可能认为扫描便宜,不能要求它一定展示某个固定 key;但月份范围与分区边界是否相交,并不是同一项成本选择。
再执行一条故意写错的范围:
SELECT event_id,occurred_at
FROM audit_event
WHERE tenant_id=1
AND occurred_at BETWEEN '2025-02-01' AND '2025-02-28';
它把上界写成二月二十八日零点,不会包含第三条月末记录。就算计划成功裁剪成一个分区,这条查询仍然是错的。将上界改成 23:59:59 也不能完整覆盖 DATETIME(6) 的最后一秒。先验证边界结果,才能避免把性能检查当成正确性检查。
少到一个分区,并不是所有正确查询的目标
再查询跨越月界的短区间,让它同时覆盖一月最后一微秒和二月零点:
EXPLAIN FORMAT=TRADITIONAL
SELECT event_id,occurred_at
FROM audit_event
WHERE tenant_id=1
AND occurred_at >= '2025-01-31 23:59:59.999999'
AND occurred_at < '2025-02-01 00:00:00.000001';
范围虽然只有两微秒,却需要考虑 p202501 与 p202502。按相同条件直接 SELECT,应得到1和2。两个分区在这里是正确的候选集合,不能为了让计划只剩一个名字,把上界或下界偷偷移动。访问分区数量是查询含义的结果,不是一项越小越正确的考核指标。
租户条件也没有改变表按时间分区的事实。只写 tenant_id=1,二级索引可以在每个候选分区内定位该租户,却无法据此排除某个月份。若把它和时间范围一起使用,时间列负责收缩分区集合,索引列顺序再影响局部访问。一个计划同时显示多个分区和合适索引,并不矛盾。
反方向同样成立:计划已经裁剪到二月,但二月内部若仍有海量记录,且查询还需要筛选没有合适索引的业务字段,单个分区扫描仍可能很慢。此时继续修改分区边界,未必触及主要工作量。应结合剩余分区的行数、过滤比例、回表与排序判断下一步,而不是把所有慢查询都归结为裁剪失败。
“查询里有函数”不是充分诊断
把过滤条件改成格式化年月,结果逻辑看起来更接近页面筛选框:
EXPLAIN FORMAT=TRADITIONAL
SELECT event_id,occurred_at,detail
FROM audit_event
WHERE tenant_id=1
AND DATE_FORMAT(occurred_at,'%Y-%m')='2025-02';
SELECT event_id,occurred_at
FROM audit_event
WHERE tenant_id=1
AND DATE_FORMAT(occurred_at,'%Y-%m')='2025-02'
ORDER BY event_id;
结果仍应为2、3,但执行器需要解释函数与原始列边界之间的关系。把返回计划中的 partitions、key、type 与直接范围版本逐项记录,不能因为两份结果相同,就假定工作量相同。直接给原列明确上下界,是本例最容易核对的写法。
也不要把结论扩大成“函数一律让分区裁剪失效”。MySQL 对部分分区函数与条件有专门支持,例如 YEAR、TO_DAYS 等与特定分区定义的组合。当前表采用 RANGE COLUMNS 原始时间列,不能套用另一个按 YEAR 表达式建表的实验结果。版本、分区定义和谓词要作为一组检查。
接着去掉年份,仅保留月份:
SELECT event_id,occurred_at
FROM audit_event
WHERE tenant_id=1 AND MONTH(occurred_at)=2
ORDER BY occurred_at;
应看到7、2、3、6,跨了三个年份。这与“2025年二月”已经不是相同业务查询。把函数改写成一个2025年的范围,虽然可能让计划更好看,却会无声改变结果。只有接口本来同时给定年份,才可以按那个年份计算月初与下月初。
一个未限定时间的分支,会扩大可能位置
只按编号找事件时,分区定义没有说明编号与时间的函数关系。即便样本中编号4刚好位于三月,优化器也不能把这项偶然分布当成永久表约束。
EXPLAIN FORMAT=TRADITIONAL
SELECT * FROM audit_event WHERE event_id=4;
EXPLAIN FORMAT=TRADITIONAL
SELECT * FROM audit_event
WHERE tenant_id=1
AND ((occurred_at >= '2025-02-01' AND occurred_at < '2025-03-01')
OR event_id=4);
后一条的编号分支可能命中任意时间位置,因此不能只按前一个二月范围判断访问分区。给查询加了一条“人工补入指定事件”,也等于改变了裁剪条件。排查真实 SQL 时应看完整 WHERE,包括权限、兜底和动态拼接分支,而不只截取一段日期条件。
OR 并非天然不能裁剪。如果两个分支都给定明确时间范围,优化器可以据此考虑相应分区的并集。问题在于每个可满足分支还剩下多大候选范围,而不是看到某个逻辑运算符就宣布失败。
手工指定分区,会限制结果而不只是给优化器提示
下面两次按编号6查询,得到不同结果:
SELECT event_id,occurred_at
FROM audit_event WHERE event_id=6;
SELECT event_id,occurred_at
FROM audit_event PARTITION (p202502)
WHERE event_id=6;
第一条能找到2026年的记录,第二条为空,因为编号6在 p_future。PARTITION 子句明确限制允许访问的物理分区,不会为了保证全表语义而自动去别处补找。把分区名固定在应用 SQL 中,不是一种无需维护的性能提示。
分区维护以后名字和边界也可能改变。应用若把“2025年二月”永久翻译成某个名字,就同时承担维护同步责任。通常先让业务 SQL 清楚表达真实时间范围,再用执行计划核对自动裁剪,比把物理分区名散落到多个查询入口更容易保持语义。
还要区别统计估算与精确结果。INFORMATION_SCHEMA 中的分区行数可用于观察分布,但 InnoDB 的估计值不适合作为导出或清理验收总数。需要精确知道这份小样本在哪个分区,可以单独 SELECT COUNT(*) 并明确 PARTITION;需要业务月记录数,则仍按业务时间条件计数。
把一次排查留下来的证据写完整
实际记录至少包括服务器版本、SHOW CREATE TABLE、完整 SQL 与实际参数、结果边界样本和执行计划。参数绑定类型、日期上下界与线上不同,就算本机计划漂亮,也没有复现原查询。对照时先减少变量:保留同一表和同一参数,只改一种谓词表达。
需要运行观察时,可以对本实验的 SELECT 使用 EXPLAIN ANALYZE,核对实际循环和行数;它会真正执行查询,不能把它当作只打印估算的 EXPLAIN。小样本只验证计划解释能力,不能据此承诺线上会少多少毫秒,更不能虚构一个加索引前后的倍数。
修复完成后应留下那条月末微秒记录和下月零点记录作为回归输入。它们能抓住“分区变少了、结果也少了”的伪优化,比只观察 partitions 列是否变成一个名字更有价值。