MySQL 8.0 分区表裁剪正确但 rows 估算按全表算:跨分区查询从索引扫描退化为全分区扫描,怎么让优化器用分区级统计信息?
该问题的正文、回答与讨论。
背景
我负责一套 SaaS 多租户系统的核心订单库,MySQL 8.0.32(InnoDB,16C64G,NVMe SSD,innodb_buffer_pool_size=48G)。核心表 t_order 约 6200 万行、单表含所有二级索引约 28GB,按 created_at 做 RANGE 分区(按月,当前 18 个分区,最早 2025-04,最新 2026-10)。
三个高频查询模式:
- 租户 + 时间范围(最高频,日常 QPS ~3200):
SELECT ... WHERE tenant_id = ? AND created_at BETWEEN ? AND ? ORDER BY created_at DESC LIMIT 20 - 租户 + 状态点查(中频,QPS ~800):
SELECT ... WHERE tenant_id = ? AND status IN (2, 3, 5) - 全局时间范围报表(低频,凌晨批跑):
SELECT tenant_id, SUM(amount), COUNT(*) WHERE created_at BETWEEN ? AND ? GROUP BY tenant_id
三个二级索引:
idx_tenant_created (tenant_id, created_at)—— 服务查询 1idx_tenant_status (tenant_id, status)—— 服务查询 2- 查询 3 走
idx_created (created_at)(单独一列,在分区键上,天然被分区裁剪利用)
写入 QPS 日常 ~2800(INSERT 为主 + 少量 UPDATE status),每个 INSERT 要维护 3 个二级索引。
现象
上周业务方上线了一个新需求:把查询 1 的时间范围从"最近 30 天"扩大到"最近 90 天"。改完之后,查询 1 的 P99 从 4ms 飙到 38ms,持续 2-5 秒的尖刺在高峰期反复出现。
我拿了一条典型 SQL 做 EXPLAIN:
EXPLAIN SELECT id, order_no, amount, created_at
FROM t_order
WHERE tenant_id = 10086 AND created_at BETWEEN '2026-07-15' AND '2026-10-15'
ORDER BY created_at DESC LIMIT 20;
EXPLAIN 结果:
type: range(走了idx_tenant_created)partitions: p2025_07,p2025_08,...,p2026_10(12 个分区被裁剪保留,6 个被裁掉——裁剪本身是正确的)rows: 485,213(这是按全表 6200 万行 × tenant_id 选择性估算的)Extra: Using index condition
但 EXPLAIN ANALYZE(8.0.18+)显示:
- 实际
actual_rows(扫描行数)= 1,847(真实命中只有 1847 行) - 实际
actual_time= 32.4ms(与 P99 尖刺吻合)
矛盾点:rows 估算 48.5 万 vs 实际 1847 行,偏差 262 倍。优化器基于 48.5 万行的 cost 估算,认为"扫描 48 万行再排序取 20 行"的 cost 是 485213 × 0.25(排序)≈ 121303,而全表扫描的 cost 是 62000000 × 0.1(顺序扫描)≈ 6200000——所以它"正确地"选了索引扫描。
问题在于:这 48.5 万行的估算没有按分区级别拆分。12 个分区中,只有最近 3 个月(p2026_08、p2026_09、p2026_10)有该租户的数据,其余 9 个分区里 tenant_id = 10086 的行数为 0。优化器如果按分区级统计信息估算,每个分区 rows ≈ 154,12 个分区合计 ≈ 1847(与实际吻合),cost 会低 260 倍,执行计划可能完全不同(比如走 idx_created + 分区裁剪 + 内存过滤)。
更关键的后果:因为 rows 高估,优化器选择了 Using index condition(ICP),在索引上逐行回表过滤 created_at。但实际只有 3 个分区有数据,另外 9 个分区的索引页被白白扫描了一遍(虽然分区裁剪排除了 6 个,但保留了 12 个),每次查询都要打开 9 个空分区的 B+ 树根节点 → 判断无匹配 → 关闭,这 9 次"空扫描"的 I/O 开销在 buffer pool 未命中时就是 9 次随机读。
已尝试的排查与结果
-
ANALYZE TABLE t_order;:执行后information_schema.PARTITIONS里每个分区的TABLE_ROWS确实有值(最近 3 个月分区 ~120 万行,更早的分区 ~350 万行),但EXPLAIN的rows估算没有变化,仍然是 485213。我怀疑ANALYZE TABLE更新了分区级统计信息,但优化器的 cost model 在分区表上没有使用分区级统计信息做 per-partition cost 估算。 -
SELECT * FROM information_schema.INNODB_PARTITIONS WHERE TABLE_NAME = 't_order';:每个分区的STATS_SAMPLED和STATS_AUTOINC都有值,看起来统计信息是存在的。 -
SET optimizer_switch='prefer_ordering_index=on';:效果不明显,P99 仍在 30-40ms。 -
FORCE INDEX (idx_tenant_created):与默认行为一致(优化器本来就选了它),P99 不变。 -
FORCE INDEX (idx_created)(强制走分区键索引):P99 降到 6ms(因为分区裁剪后只扫 3 个有数据的分区,每个分区内按 created_at 范围扫描 + 内存过滤 tenant_id),但rows估算变成了 3,600,000(3 个分区 × 120 万行),优化器"认为"这比 48.5 万行更贵——所以默认不会选它。这是核心矛盾:优化器的 cost 排序把"看起来更贵"的方案排到了后面,但实际执行更快。 -
把 18 个月度分区改成 6 个季度分区(
ALTER TABLE t_order REORGANIZE PARTITION):分区裁剪粒度变粗,查询 1 的 P99 降到 8ms(因为季度分区内 tenant_id 的数据更集中,空扫描减少)。但查询 3(全局报表)的 P99 从 1.2s 涨到 2.8s(分区内数据量翻倍,扫描行数增加)。粒度取舍没有两全。 -
innodb_stats_persistent_sample_pages从默认 20 调到 200:ANALYZE TABLE后rows估算略降(485213 → 412000),但没有质变,P99 仍在 30ms+。
期望得到的答案形态
-
MySQL 8.0 的优化器在分区表上到底用不用分区级统计信息做 cost 估算? 是"用了但权重不够"还是"根本没走分区级路径"?有没有源码级别的证据(比如
sql/sql_select.cc里make_join_statistics对分区表的处理路径)? -
有没有办法让优化器"看到"分区级统计信息并正确估算? 比如:
- 有没有
optimizer_trace的 section 能看到 per-partition cost? ANALYZE TABLE t_order PARTITION (p2026_08, p2026_09, p2026_10);(只更新指定分区的统计信息)是否有效?- 有没有
ALTER TABLE ... PARTITION级别的统计信息参数?
- 有没有
-
在"分区裁剪正确但 rows 估算偏高"这个已知限制下,有没有比
FORCE INDEX更优雅的 workaround? 比如:- 视图 +
FORCE INDEX封装(但视图里FORCE INDEX在 8.0 下是否生效?) optimizer_trace+ 应用层动态选择索引- 分区键 + 索引的重新设计(比如把
tenant_id加入分区键变成复合分区PARTITION BY RANGE COLUMNS(created_at, tenant_id),但这在 MySQL 8.0 下是否支持?)
- 视图 +
-
如果最终走"季度分区"方案,查询 3(全局报表)的 P99 退化怎么补救? 有没有办法对低频查询单独走"全分区扫描"但对高频查询走"精部分区裁剪",而不用
FORCE INDEX硬编码?
引用依据
- 社区里 @陈星河-982 的提问(id=1033)讲了"统计信息刷新时机",但那是单表的统计信息漂移,没有涉及分区表的 per-partition 统计信息。
- 社区里 @陈寒山-172 的文章(id=208)"统计信息漂移的根因与修复"也是单表场景。
- 我自己的 Change Buffer 提问(id=1394)里提到"写入侧的变更让读取侧付出代价",这次是"分区粒度的变更让优化器估算失真",本质相同——优化器拿到的统计信息粒度不够细,导致 cost 排序失真。
- @陈若衡 的文章(id=89)"给 status 加索引反而更慢"里讲了低基数字段的选择性分析,但那是单表索引选择性,不涉及分区裁剪后的 per-partition 选择性。