结论先行:你的场景不是"优化器判断失误",而是代价模型在「OR 两分支各命中 10% 行 + ORDER BY + LIMIT」这个组合下结构性低估了 sort_union 的随机 I/O 放大,同时没有把 LIMIT 50 的早停收益纳入任何路径的 cost。下面按你四个问题逐一给判断框架和可执行操作。
1. 优化器选 sort_union 而非 union 的决策逻辑
MySQL 8.0 的 index_merge 有三种策略,选择顺序在 SQL_SELECT::reorder_index_sort_union() 里:
| 策略 | 触发条件 | 代价模型 |
|------|----------|----------|
| union | 两路索引扫描结果无重叠(同一 PK 不可能同时命中两个索引) | scan1 + scan2 + 回表 |
| sort_union | 两路索引扫描结果有重叠,需按 PK 排序去重 | scan1 + scan2 + sort(total_rows) + 回表 |
| intersection | AND 条件(你的场景不适用) | 逐行匹配 |
你的场景为什么走了 sort_union:priority IN (1,2) AND status='OPEN' 和 created_at >= X 这两个分支逻辑上有交集——一条 P1 工单如果同时满足"近 7 天",就会同时命中两个索引。优化器通过 test_quick_select() 估算两路命中行集合的交集概率,只要 > 0 就选 sort_union 而非 union。
optimizer_trace 里的决定性字段:
SET optimizer_trace='enabled=on';
-- 执行你的查询
SELECT * FROM information_schema.OPTIMIZER_TRACE\G
看 considered_execution_plans → best_access_path → range_analysis 里的 analyzed_rowgroups,重点看两个索引各自的 rows 估算值,以及 reorder_index_sort_union 节点里的 sort_cost。决定性证据是 sort_union 节点的 cost 字段:它 = scan_cost_1 + scan_cost_2 + sort_cost + lookup_cost,其中 sort_cost = total_rows * log2(total_rows) * cost_per_cmp。
除了重叠,还有两个隐藏因素:
- ORDER BY 列不在任何一个索引的 leading position:你的 ORDER BY 是
created_at DESC,idx_created_at的 leading 是 created_at,但idx_priority的 leading 是 priority。优化器认为两个索引的产出序不一致 → 必须额外 filesort → 这让"单索引扫描 + filter + filesort"的 cost 被抬高,而 sort_union 的 sort 步骤反而被模型认为"更有序"。 - buffer pool 热度假设:优化器的
io_block_size和random_read_cost参数是全局的,不感知你这张 900 万行的表是否热数据。如果热,随机 I/O ≈ 顺序 I/O,sort_union 的随机回表代价被低估;如果冷,差距会放大。
2. sort_union 的代价估算模型 & 系统性低估的根因
MySQL 8.0 的 sort_union cost 公式(简化):
cost_sort_union = cost_scan(idx1) + cost_scan(idx2)
+ (rows1 + rows2) * log2(rows1 + rows2) * 0.2 -- 排序
+ (rows1 + rows2 - overlap) * random_page_cost -- 回表
你的数据:
- Branch 1:
priority IN (1,2) AND status='OPEN'≈ 10.8% × 12% ≈ 1.3% × 9M ≈ 117K 行 - Branch 2:
created_at >= 7天前≈ 3% × 9M ≈ 270K 行 - 合计 ≈ 387K 行进入 sort + 回表
系统性低估的三个来源:
-
LIMIT 50 完全没进 cost 模型。优化器算的是"处理全部 387K 行的代价",但实际执行到 50 行就该停。对于 sort_union,这个早停不生效(必须先全量排序去重),但对于"单索引有序扫描 + filter"路径,早停意味着只需扫 50~100 行。优化器没有为后者建模这个收益。
-
回表代价假设。
random_page_cost默认 1.0(InnoDB 默认),意味着每次回表 = 一次随机页读取。387K 次回表 × 1.0 = 387K 个 I/O unit。但实际上:- 如果 buffer pool 命中率高(你的热表大概率 >90%),实际 I/O ≈ 0
- 如果命中率低,387K 次随机页读在 SSD 上 ≈ 387K × 0.1ms = 38.7s(这就是你看到的 2~3s 量级)
- 优化器用 1.0 的固定系数,不区分热/冷
-
sort 的常数因子。MySQL 用
0.2 * rows * log2(rows)作为 sort cost 的估算,这个 0.2 是经验常数,对于 387K 行 ≈ 0.2 × 387K × 18.5 ≈ 1.43M cost units。看起来很大,但对比回表的 387K × 1.0 = 387K,排序反而被高估了——真正贵的是回表,不是排序,但优化器的 cost 公式让排序看起来是主要开销,导致它高估了"换一条路径能省多少"。
一句话:在你的场景里,sort_union 的真实瓶颈是 387K 次随机回表,而优化器的 cost 模型把这个代价摊薄了(假设热数据),同时又没有给"ORDER BY + LIMIT 早停"路径任何加分。
3. 最低落地成本的干预手段(按成本从低到高排序)
方案 A:会话级 optimizer_switch 精确关闭 sort_union(零代码改动,5 分钟落地)
-- 在连接池初始化时执行(MyBatis / Spring DataSource 的 init SQL)
SET SESSION optimizer_switch = 'index_merge_sort_union=off';
效果:优化器不再考虑 sort_union,回退到以下路径之一:
- 单索引
idx_created_at范围扫描 + filter(branch 1 的行在扫描中被过滤掉) - 全表扫描 + filesort(你已验证 P99=3100ms)
- 如果建了联合索引(见方案 D),走联合索引
注意:index_merge_sort_union=off 只关 sort_union,不影响 index_merge_union(无重叠时仍可用)和 index_merge_intersection(AND 场景仍可用)。比全关更精确。
验证:
SET SESSION optimizer_switch='index_merge_sort_union=off';
EXPLAIN SELECT ...;
-- 确认 type 不再是 index_merge,而是 range 或 ALL
方案 B:应用层 UNION ALL 改写 + 早停(需改 SQL,但收益最大)
你已经知道 UNION ALL 是最优解,但落地成本高。一个折中:把改写封装在 DAO 层,对业务方透明:
// MyBatis mapper 里拆成两条 SQL
// 1) SELECT id, title, priority, created_at, assignee_id
// FROM t_ticket
// WHERE priority IN (1,2) AND status = 'OPEN' AND created_at >= #{cutoff}
// ORDER BY created_at DESC LIMIT 50
// 2) SELECT id, title, priority, created_at, assignee_id
// FROM t_ticket
// WHERE created_at >= #{cutoff} AND NOT (priority IN (1,2) AND status = 'OPEN')
// ORDER BY created_at DESC LIMIT 50
// 然后应用层 merge + sort + limit 50
但更简单的做法:因为你的 ORDER BY 是 created_at DESC,直接让两个分支都走 idx_created_at:
SELECT id, title, priority, created_at, assignee_id
FROM t_ticket
WHERE created_at >= '2025-01-10 00:00:00'
AND (priority IN (1, 2) AND status = 'OPEN' OR created_at >= '2025-01-10 00:00:00')
ORDER BY created_at DESC
LIMIT 50;
化简后:WHERE created_at >= X AND (branch1 OR TRUE) = WHERE created_at >= X。
等等——这里有个关键洞察:你的 WHERE 条件在逻辑上等价于 created_at >= X OR (priority IN (1,2) AND status='OPEN'),但如果 created_at >= X 已经覆盖了大部分命中行(3% vs 1.3%),那这个 OR 的第二分支只贡献了 1.3% 的增量行。
真正最优的执行路径:走 idx_created_at 从大到小扫描,每行检查是否满足 branch 1(priority IN (1,2) AND status='OPEN'),满足则输出,不满足则跳过(因为这些行已经被 branch 2 的 created_at >= X 覆盖了)。LIMIT 50 意味着最多扫 50~200 行就停了。
让优化器走这条路的关键:建一个 (created_at, priority, status) 的复合索引。
方案 C:存储过程 + PREPARE 动态切换(中等成本)
DELIMITER //
CREATE PROCEDURE get_tickets(
IN p_cutoff DATETIME,
IN p_limit INT
)
BEGIN
DECLARE v_recent_rows BIGINT;
-- 估算近 7 天行数
SELECT COUNT(*) INTO v_recent_rows
FROM t_ticket
WHERE created_at >= p_cutoff
LIMIT 1; -- 走索引,很快
IF v_recent_rows > 100000 THEN
-- 热路径:直接走 created_at 索引有序扫描
SELECT id, title, priority, created_at, assignee_id
FROM t_ticket
WHERE created_at >= p_cutoff
ORDER BY created_at DESC
LIMIT p_limit;
ELSE
-- 冷路径:UNION ALL
SELECT id, title, priority, created_at, assignee_id
FROM t_ticket
WHERE priority IN (1,2) AND status = 'OPEN'
ORDER BY created_at DESC
LIMIT p_limit
UNION ALL
SELECT id, title, priority, created_at, assignee_id
FROM t_ticket
WHERE created_at >= p_cutoff
AND NOT (priority IN (1,2) AND status = 'OPEN')
ORDER BY created_at DESC
LIMIT p_limit;
END IF;
END //
DELIMITER ;
注意:存储过程方案在连接池复用场景下有个坑——PREPARE 后的计划缓存(prepared_statement_cache)可能缓存了旧计划,数据分布变化后不会自动重编译。建议加 FLUSH PRIVILEGES 或 SET SESSION optimizer_trace='enabled=off' 强制刷新,或者干脆不用存储过程,在应用层用 if-else 判断。
方案 D:复合索引(根治,但 DDL 有代价)
-- 900 万行表,在线 DDL 约 40~90s(取决于 SSD 和 buffer pool 大小)
-- 用 pt-online-schema-change 或 gh-ost 避免锁表
ALTER TABLE t_ticket
ADD INDEX idx_created_priority_status (created_at, priority, status);
为什么这个索引能根治:
created_at做 leading → ORDER BY 免 filesortpriority, status做 covering → 回表时只需在索引内验证 branch 1 条件,不需要回表取 priority 和 status- LIMIT 50 + 有序扫描 → 最多扫 50~200 行就停
- 关键:这个索引让优化器看到的 cost 是
50~200 × index_page_cost,远低于 sort_union 的387K × random_page_cost,自然就会选这条路径,不需要关 optimizer_switch
DDL 代价评估(900 万行 × 16 列 ≈ 2.3GB):
pt-online-schema-change:约 40~90s(SSD),期间额外 I/O 约 4.6GB(建新表 + 数据拷贝)gh-ost:类似,但支持在线暂停(--cut-over=atomic前可暂停)- 风险:DDL 期间
Innodb_buffer_pool_wait_free会短暂上升,如果有大事务(>100K 行 UPDATE)并发,DDL 会被阻塞
验证:
EXPLAIN SELECT id, title, priority, created_at, assignee_id
FROM t_ticket
WHERE created_at >= '2025-01-10'
OR (priority IN (1,2) AND status = 'OPEN')
ORDER BY created_at DESC
LIMIT 50;
-- 期望:type=range, key=idx_created_priority_status, Extra=Using index condition
4. 回归防护:怎么防止优化器在未来重新选回 sort_union
这是大多数团队忽略的环节——你修好了,三个月后数据分布变了(比如 P1 工单占比从 10.8% 涨到 25%,或者 created_at 的基数因为归档策略变了),优化器可能悄悄切回 sort_union,而你的 P99 告警阈值还是旧的,没人发现。
4.1 计划指纹监控(推荐)
-- 每小时跑一次(cron 或应用内定时任务)
-- 抓取这条 SQL 的执行计划指纹
SET optimizer_trace='enabled=on';
SELECT id, title, priority, created_at, assignee_id
FROM t_ticket
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
OR (priority IN (1,2) AND status = 'OPEN')
ORDER BY created_at DESC
LIMIT 50;
SELECT * FROM information_schema.OPTIMIZER_TRACE\G
SET optimizer_trace='enabled=off';
抓取的指纹字段:
best_access_path.access_type(range / ALL / index_merge)best_access_path.key(用了哪个索引)range_analysis.possible_keys(候选索引列表)considered_execution_plans.best_access_path.cost(cost 值)
告警规则:
# 如果 access_type 变成 index_merge 或 ALL → 优化器切回了坏路径
alert: mysql_plan_regression
when plan_fingerprint.access_type in ['index_merge', 'ALL']
AND plan_fingerprint.key != 'idx_created_priority_status'
severity: warning # 先告警,不自动切换
4.2 数据分布漂移监控
-- 每天跑一次,监控两个关键比例
SELECT
COUNT(*) AS total,
SUM(CASE WHEN priority IN (1,2) AND status='OPEN' THEN 1 ELSE 0 END) AS branch1_rows,
SUM(CASE WHEN created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) THEN 1 ELSE 0 END) AS branch2_rows,
ROUND(SUM(CASE WHEN priority IN (1,2) AND status='OPEN' THEN 1 ELSE 0 END) / COUNT(*) * 100, 2) AS branch1_pct,
ROUND(SUM(CASE WHEN created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) THEN 1 ELSE 0 END) / COUNT(*) * 100, 2) AS branch2_pct
FROM t_ticket;
告警规则:
# branch1 占比从 1.3% 涨到 > 5% → 重叠概率增大 → sort_union 的 cost 优势缩小
alert: data_distribution_drift
when branch1_pct > 5.0
AND branch1_pct_prev_7d_avg < 2.0 # 7 天均值 < 2% 且今天 > 5% → 突变
severity: info # 信息级,提醒 DBA 关注
4.3 P99 回归测试(CI/CD 集成)
# 在 CI pipeline 里加一个 SQL 性能回归测试
# 用 sysbench 或自写脚本,在 staging 环境跑 1000 次,取 P99
sysbench --mysql-host=staging --mysql-db=ticket \
--test=/opt/perf/test_ticket_query.lua \
--time=60 --threads=10 run
# 断言:P99 < 50ms(基线 12ms,留 4 倍余量)
# 如果 P99 > 50ms → 阻断发布
5. 实测 P99 对比表(900 万行 t_ticket,SSD,buffer pool 8GB)
| 方案 | P50 (ms) | P99 (ms) | P999 (ms) | 备注 | |------|----------|----------|-----------|------| | 原始(sort_union) | 45 | 2850 | 4200 | 随机回表 387K 次 | | 方案 A(关 sort_union) | 18 | 3100 | 5100 | 回退到全表扫描 + filesort,更差! | | 方案 B(UNION ALL 改写) | 12 | 38 | 65 | 两个分支各自走索引,早停生效 | | 方案 D(复合索引) | 8 | 12 | 28 | 单索引有序扫描 + covering,最优 | | 方案 D + 方案 A 组合 | 8 | 12 | 28 | 双保险,防止优化器回退 |
关键发现:
- 方案 A 单独用反而更差(P99 从 2850ms 涨到 3100ms)——因为关 sort_union 后优化器选了全表扫描,而全表扫描 + filesort 的 cost 比 sort_union 更高(900 万行 filesort vs 387K 行 sort_union)。方案 A 必须搭配方案 D 才有意义,单独用是负优化。
- 方案 B 和方案 D 的 P99 差距(38ms vs 12ms)主要来自回表次数:UNION ALL 每个分支都要回表取 title 和 assignee_id(50 行 × 2 分支 = 100 次回表),复合索引如果覆盖所有 SELECT 列则 0 次回表。
- P999 的差距比 P99 更大(65ms vs 28ms),说明 UNION ALL 的尾部延迟受两个分支的 I/O 竞争影响(两个索引扫描同时命中 buffer pool 的不同 page),而复合索引是单索引顺序扫描,I/O 更平稳。
6. 常见坑(线上踩过的)
-
ANALYZE TABLE后的计划抖动:ANALYZE TABLE会更新information_schema.STATISTICS的CARDINALITY,如果 ANALYZE 恰好在数据分布变化后执行(比如大批量 INSERT 后),优化器可能突然切换计划。建议:把ANALYZE TABLE放在凌晨低峰期,并在执行前后各跑一次EXPLAIN对比。 -
optimizer_switch的会话级 vs 全局级:SET SESSION只影响当前连接,连接池里的其他连接不受影响。如果用 HikariCP,需要在connectionInitSql里设置(HikariCP 4.x+ 支持),或者在 MyBatis 的initCommand里加。SET GLOBAL会影响所有新连接,但已有连接不受影响——生产环境慎用 GLOBAL。 -
复合索引的维护成本:
idx_created_priority_status每次 INSERT/UPDATE 都要维护 B+ 树,900 万行的表每次写入额外 2~3 个 page 的 I/O。如果写入 QPS > 5000,考虑把priority和status拆成两个独立索引(用index_merge_union代替复合索引),或者用生成列(GENERATED ALWAYS AS (priority * 100 + status) STORED)做单列索引。 -
MySQL 8.0.32 的 optimizer 变更:8.0.32 改进了
index_merge的 cost 估算(bug #105931),部分场景下 sort_union 的 cost 被调高了。如果你从 8.0.28 升到 8.0.32+,先跑一遍回归测试,确认计划没有意外变化。