做完 MySQL 8.0 迁移,业务压测通过、切流上线,第二天早高峰就接到告警:一批原本几十毫秒的 SQL 突然跑到几秒,CPU 与磁盘 IO 同步飙高。更让人困惑的是,DBA 逐条比对新旧环境的执行计划,发现索引还是那几个索引,表结构也一行没改。
大多数人第一反应是加索引、改写 SQL、扩 buffer pool,甚至怀疑 8.0 有 bug。问题是:8.0 的基于成本的优化器(CBO)换了成本模型、换了统计信息口径、还换了连接算子,同一份物理数据在两套成本下会走出完全不同的路径,你调的那些参数根本不在病灶上。
本文分享一套迁移后的计划劣化排查与治理方法:成本常量对齐 + 统计信息重建 + 计划 diff 工具链,全部可直接落地。

一、诊断起点:别急着加索引,先比三类成本
先给结论:8.0 的计划变慢,八成出在下面这张表里被忽略的三处差异上。
| 对比项 | 5.7 的口径 | 8.0 的口径 |
|---|---|---|
| 页读取成本 | 内存页与磁盘页都记 1.0 | 磁盘页 1.0,内存页降到 0.2 |
| 统计信息 | 高频值 + 高低值选择性 | 纳入 histogram_json 直方图参与基数估计 |
| 连接算子 | 嵌套循环 + Block Nested Loop | 无索引可用时优先 Hash Join |
| 默认排序规则 | utf8mb4_general_ci 居多 | utf8mb4_0900_ai_ci,逐位比较、pad_space 语义变化 |
排查顺序建议固定为:先看成本模型导致的选路差异,再看统计信息导致的行数估算偏差,最后看新算子带来的成本转移。三类问题的修复手段完全不同,混在一起查只会越查越乱。

**核心结论:**计划变了不等于索引坏了,先确认优化器的成本账本换了版本。
二、成本常量:内存页变便宜,选路悄悄偏移
内存页成本从 1.0 降到 0.2,是 8.0 最常被忽略的一处改动,它会双向扭曲选路。
- 随机读被低估 :优化器认为在 buffer pool 里捞一页只要 0.2 个成本单位,于是大范围区间扫描 + 逐行回表的路径被整体抬高,原本 5.7 会掐断的宽范围查询在 8.0 被放行。
- 全表扫相对更划算 :页几乎都命中内存时,一次顺序扫的成本被稀释,某些点查场景反而选了全表扫,表现为数据越热、计划越离谱。
在新旧两套环境分别执行下面这段,对比成本估算与真实读取量:
sql
-- 5.7 与 8.0 各跑一次,对比估算行数、真实扫描行数
EXPLAIN FORMAT=TREE
SELECT o.id, o.amount, c.name
FROM orders o JOIN customers c ON c.id = o.cust_id
WHERE o.created_at >= '2024-01-01';
SHOW SESSION STATUS LIKE 'Handler_read%';
估算行数与 Handler_read_next 量级差 10 倍以上,基本可判定是成本模型选路问题。
修复优先级:把热查询做成覆盖索引 (消除回表,成本差异自然消失),必要时用 FORCE INDEX 临时锁死计划,同时把扫描范围收敛到能走 ref 的粒度。
三、统计信息:直方图与采样让估算整体偏移
第二类差异藏在统计信息里,8.0 把直方图纳入基数估计后,同样的数据会算出不同的 rows。
- 直方图默认缺席 :新建表不会自动生成直方图,但优化器的估算逻辑已经按有直方图的口径实现,选择性估计落到默认档位,等值条件的估算行数可能虚高或虚低一个数量级。
- 采样页数放大偏差 :
innodb_stats_persistent_sample_pages调小能提速统计收集,代价是分布信息失真,长尾条件的执行计划会抖动。 - 迁移期间数据形态变了:批量导入后统计信息仍是导入前的状态,估算口径与真实数据严重脱节。
迁移完成后立即重建统计与直方图,这是成本最低的一步:
sql
-- 切流后立即执行,按实际过滤条件补直方图
ANALYZE TABLE orders UPDATE HISTOGRAM ON cust_id, created_at
WITH 64 BUCKETS;
SELECT table_name, column_name, JSON_EXTRACT(histogram, '$.num_buckets')
FROM information_schema.column_statistics;
先让估算回到真实分布,再判断计划是否真的错了。
四、执行算子:新算子把成本挪到了别处
第三类差异是算子层的:8.0 引入的新算子会改变同一条 SQL 的成本结构。
- Hash Join 抢走连接路径 :8.0.18 起,无索引可用时优化器会选 Hash Join 替代 Block Nested Loop,内存占用与临时结构开销变大,大结果集连接的耗时从 IO 型变成内存型。
- 排序规则变更导致索引失效 :库默认改成 utf8mb4_0900_ai_ci 后,字符串比较是逐位的且 pad_space 语义不同,旧表列的隐式比较可能无法命中索引,计划从 ref 退化为全表扫。
- Descending Index 与 Skip Scan 改变可用路径:新增路径不一定更快,但会挤掉原本稳定的索引顺序扫描。
| 信号 | 8.0 现象 | 处置 |
|---|---|---|
| 计划里出现 Hash Join | 内存峰值抬高 | 为连接键补索引,强制回到 nested loop |
| 字符串条件走全表扫 | 排序规则不匹配 | 统一列与常量的 collation,或改写为显式 CAST |
| 出现 Skip Scan | 索引前缀基数过高 | 调整索引列顺序或加组合索引 |
**核心结论:**算子换了,成本就换了账本,别用旧版本的经验读新计划。
五、复现与对比:把两套环境的计划 diff 出来
靠肉眼比对几十条 SQL 的计划不现实,用脚本把两套环境的执行计划拉成结构化 JSON 后做 diff,定位效率高一个量级。
下面这段 Python 脚本用 PyMySQL 连接两套环境,逐条执行 EXPLAIN FORMAT=JSON 并输出差异:
python
import json, pymysql
SQLS = ["SELECT * FROM orders WHERE cust_id = 12345",
"SELECT * FROM orders WHERE created_at >= '2024-01-01'"]
def plans(host):
conn = pymysql.connect(host=host, user='dba',
password='***', database='shop')
with conn.cursor() as cur:
out = []
for s in SQLS:
cur.execute("EXPLAIN FORMAT=JSON " + s)
out.append(json.loads(cur.fetchone()[0]))
return out
old, new = plans('mysql57.example.com'), plans('mysql80.example.com')
for sql, a, b in zip(SQLS, old, new):
print('=' * 60, sql)
print('5.7 :', a['query_block'].get('cost_info', a['query_block']))
print('8.0 :', b['query_block'].get('cost_info', b['query_block']))
先 diff 估算行数与访问类型,再 diff 成本数字,三步内就能锁定是哪一类差异。
诊断链路固定为:EXPLAIN FORMAT=JSON 采集 → cost_info / rows 差异定位 → 回查统计信息与 collation。

六、治理清单:按三类差异逐项收口
切流前把这张清单过一遍,能挡掉大部分计划劣化:
| 差异类型 | 排查动作 | 治理手段 |
|---|---|---|
| 成本模型 | 比对 cost_info 与 Handler_read_next | 补覆盖索引、收敛扫描范围、临时 FORCE INDEX |
| 统计信息 | 查 column_statistics 是否为空 | ANALYZE TABLE + UPDATE HISTOGRAM、调高采样页 |
| 执行算子 | 看计划是否出现 Hash Join / 全表扫 | 补连接键索引、统一 collation、复核索引列序 |
落地时注意两点:迁移窗口内先跑 ANALSZIZE 再切流 ,避免旧统计带病上线;监控计划漂移 ,对 EXPLAIN FORMAT=JSON 做定时采集,一旦 cost_info 变化就报警。
**核心结论:**把成本模型、统计信息、执行算子三类差异写进迁移 checklist,8.0 升级才真正可控。
结语
8.0 迁移后的 SQL 变慢,很少是引擎退步,更多是成本账本换版后没人对账。内存页成本降到 0.2、直方图进入基数估计、Hash Join 取代 BNL,这三处改动各自都会让计划悄悄偏移,叠加起来就是早高峰的那条红线。
把本文的诊断链路固化成迁移流水线的一环:切流前 diff 两套环境的计划、重建统计与直方图,切流后持续采集 cost_info 做漂移告警,同类问题就不会再来第二次。
计划劣化的根因不在索引,而在优化器算账的口径变了------对上账本,SQL 自然归位。