21_MySQL 优化案例集 ------ 慢 SQL 优化、索引重构与参数调整收益对比
系列定位 :《MySQL 数据库全栈运维实战系列》第 21 篇,与《Oracle 数据库全栈运维实战系列(VMware 版)》同构对标。适用版本 :MySQL 8.0(5.7 差异随文标注)。官方文档:MySQL 8.0 Reference Manual、Optimization / The InnoDB Buffer Pool / Indexes 章节。
一、优化方法论(总图)
MySQL 没有 Oracle 的 AWR,但 performance_schema.events_statements_summary_by_digest + sys schema + 慢日志三件套完全够用来定位 Top SQL。优化的铁律是"没有量化对比就不算优化"------每一次变更都要留下前后基线。

图 1
| 维度 | MySQL 定位手段 | 类比 Oracle |
|---|---|---|
| Top SQL | performance_schema.events_statements_summary_by_digest |
AWR Top SQL |
| 执行计划 | EXPLAIN / EXPLAIN ANALYZE(8.0.18+) |
DBMS_XPLAN |
| 索引使用 | sys.schema_unused_indexes / performance_schema.table_io_waits_summary_by_index_usage |
v$object_usage |
| 等待事件 | performance_schema.events_waits_summary_global_by_event_name |
v$system_event |
VMware 虚机环境注意事项 :优化压测必须在与生产同规格的虚机 上做(同 vCPU、同内存、同磁盘置盘方式),否则收益数字不可信。虚机做基准测试前先固定
vmx里的cpuid.coresPerSocket、关闭宿主机上其他大负载 VM、确认磁盘是 SSD/thick,避免 noisy neighbor 把测试数据污染。每次压测前清 Buffer Pool:SET GLOBAL innodb_buffer_pool_dump_now=OFF;冷/热数据要分开标注。
二、案例 1:全表扫描慢 SQL(订单列表查询)

图 2
2.1 问题定位
业务反馈:后台订单列表页打开要 8 秒。
-- 慢日志抓到的 SQL(解释:典型的多条件查询没走索引)
SELECT * FROM t_order
WHERE status = 2
AND create_time >= '2026-09-01'
AND create_time < '2026-10-01'
ORDER BY create_time DESC
LIMIT 20;
-- EXPLAIN 看执行计划(解释:type=ALL 就是全表扫描,rows 接近全表)
EXPLAIN SELECT * FROM t_order
WHERE status = 2
AND create_time >= '2026-09-01'
AND create_time < '2026-10-01'
ORDER BY create_time DESC
LIMIT 20\G
-- 输出关键行:
-- type: ALL
-- key: NULL
-- rows: 8300000
-- Extra: Using where; Using filesort
-- 8.0.18+ 直接看真实执行成本(解释:EXPLAIN ANALYZE 实际跑一遍,给真实行数和耗时)
EXPLAIN ANALYZE
SELECT * FROM t_order WHERE status = 2 AND create_time >= '2026-09-01' LIMIT 20\G
2.2 优化与收益
-- 优化:建复合索引(解释:等值列在前,范围列在后,顺序匹配 WHERE + ORDER BY)
CREATE INDEX idx_order_status_ctime ON t_order(status, create_time);
-- 验证(解释:type 变成 ref/range,rows 从 830 万降到几千,filesort 消失)
EXPLAIN SELECT * FROM t_order
WHERE status = 2 AND create_time >= '2026-09-01' AND create_time < '2026-10-01'
ORDER BY create_time DESC LIMIT 20\G
-- type: range
-- key: idx_order_status_ctime
-- rows: 4500
-- Extra: Using index condition
| 指标 | 优化前 | 优化后 |
|---|---|---|
| SQL 耗时 | 8.2s | 0.03s |
| 扫描行数 | 830 万 | 4500 |
| filesort | 有 | 无 |
| 高峰期 DB CPU | 95% | 40% |
最左前缀原则 :索引
(status, create_time)能支撑WHERE status=?、WHERE status=? AND create_time>=?,但不能 支撑WHERE create_time>=?(跳过了 status 这个最左列)。这是 MySQL B+Tree 索引与 Oracle 索引共用的核心规则。
三、案例 2:索引失效(函数包裹列 / 隐式类型转换)
3.1 问题定位
-- 现象:这 SQL 明明有 idx_user_id,却还是全表扫描
SELECT * FROM t_order WHERE DATE(create_time) = '2026-10-09';
EXPLAIN SELECT * FROM t_order WHERE DATE(create_time) = '2026-10-09'\G
-- type: ALL,key: NULL
第二个坑:
-- phone 字段是 VARCHAR,SQL 里却传数字(解释:隐式类型转换导致索引失效)
SELECT * FROM t_user WHERE phone = 13800138000; -- phone 是 varchar
EXPLAIN SELECT * FROM t_user WHERE phone = 13800138000\G
-- type: ALL(对每行做 CAST(phone AS SIGNED),索引用不上)
3.2 优化
-- 优化 1:不要在索引列上包函数,改成范围查询
SELECT * FROM t_order
WHERE create_time >= '2026-10-09 00:00:00'
AND create_time < '2026-10-10 00:00:00';
-- 优化 2:类型对齐,SQL 传字符串
SELECT * FROM t_user WHERE phone = '13800138000';
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 订单按天查询耗时 | 6s | 0.02s |
| 用户按手机号查询 | 2s(全表 500 万) | 0.001s |
四、案例 3:深分页优化(LIMIT 1000000, 20)

图 3
4.1 问题定位
后台报表翻到第 5 万页,SQL 跑 3 秒。
-- 慢 SQL(解释:MySQL 要先扫描并丢弃前 100 万行,再取 20 行)
SELECT * FROM t_order
ORDER BY id DESC
LIMIT 1000000, 20;
4.2 优化
-- 优化 1:游标/keyset 翻页(解释:记住上一页最后一个 id,避免 OFFSET)
SELECT * FROM t_order
WHERE id < 987654321 -- 上一页最后一条 id
ORDER BY id DESC
LIMIT 20;
-- 优化 2:延迟关联(解释:先用覆盖索引查出 20 个主键,再回表)
SELECT o.* FROM t_order o
INNER JOIN (
SELECT id FROM t_order ORDER BY id DESC LIMIT 1000000, 20
) t ON o.id = t.id;
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 第 5 万页耗时 | 3.2s | 0.05s |
| 回表次数 | 100 万次 | 20 次 |
五、案例 4:索引重构(碎片 + 冗余 + 未使用索引)
5.1 问题定位
DML 写入变慢,一张 2000 万行的表,INSERT ... SELECT 导入比上周慢了 40%。
-- 找未使用的索引(解释:8.0 用 sys schema,跑一周后看)
SELECT * FROM sys.schema_unused_indexes
WHERE object_schema = 'app'\G
-- 看索引体积与冗余(解释:重复索引浪费写性能)
SELECT * FROM sys.schema_redundant_indexes\G
-- 看 InnoDB 表碎片(解释:information_schema.TABLES 的 DATA_FREE)
SELECT table_name,
ROUND(data_length/1024/1024,1) AS data_mb,
ROUND(index_length/1024/1024,1) AS index_mb,
ROUND(data_free/1024/1024,1) AS free_mb
FROM information_schema.tables
WHERE table_schema='app' AND table_name='t_order';
5.2 优化
-- 1. 删掉确认无业务使用的冗余/未用索引(解释:每个索引都拖慢写)
DROP INDEX idx_order_status_old ON app.t_order; -- 已被 idx_order_status_ctime 覆盖
-- 2. 碎片整理(解释:8.0 InnoDB 在线 DDL,不锁表;5.7 大表建议 pt-online-schema-change)
ALTER TABLE app.t_order ENGINE=InnoDB;
-- 或 OPTIMIZE TABLE app.t_order; -- 本质也是重建,会锁表片刻,建议低峰
| 指标 | 优化前 | 优化后 |
|---|---|---|
| t_order 索引数量 | 11 个(含 3 个冗余) | 8 个 |
| 表碎片率 | 35% | < 5% |
| 批量导入速度 | 100%(基准) | -40% 耗时 |
| 写放大(redo 量) | 100% | -25% |
类比 Oracle :Oracle 重建索引用
ALTER INDEX ... REBUILD ONLINE,MySQL InnoDB 没有原生"重建索引"命令,用ALTER TABLE ... ENGINE=InnoDB或OPTIMIZE TABLE整表重建;大表线上操作推荐 Percona Toolkit 的pt-online-schema-change,避免长时间锁表。
六、案例 5:JOIN 优化(被驱动表没走索引)
6.1 问题定位
-- 慢 SQL(解释:订单 JOIN 用户表,用户表没走索引)
SELECT o.order_id, u.user_name, o.amount
FROM t_order o
JOIN t_user u ON o.user_id = u.id
WHERE o.status = 2;
EXPLAIN ... \G
-- o: type=ref(用了 idx_status),rows=50 万
-- u: type=ALL(每行订单都全表扫一次用户表),rows=500 万
-- 总扫描 = 50 万 × 500 万 = 灾难
6.2 优化
-- 确保被驱动表 t_user.id 是主键(本来就该是),
-- 且小表驱动大表;MySQL 8.0 优化器一般自动处理,但要确认:
EXPLAIN FORMAT=JSON ...\G
-- 看 "query_cost" 和 nested_loop_loop 结构
-- 另一个常见场景:JOIN 字段类型不一致导致索引失效
-- 检查两表 JOIN 列类型是否完全一致(字符集、排序规则、int/bigint)
SHOW FULL COLUMNS FROM t_order LIKE 'user_id';
SHOW FULL COLUMNS FROM t_user LIKE 'id';
| 指标 | 优化前 | 优化后 |
|---|---|---|
| JOIN 查询耗时 | 25s | 0.2s |
| 嵌套循环次数 | 50 万 × 500 万 | 50 万 × 主键命中 |
七、案例 6:参数调整收益对比(Buffer Pool / redo / 临时表)
7.1 背景
一台 32G 内存的 VMware 虚机跑 MySQL 8.0,业务上线半年,参数还是初始化默认值。用 sysbench(详见番外附-01)跑基线后做了三组调整,量化对比如下。
7.2 参数调整项
# my.cnf 调整前后对比(32G 虚机)
[mysqld]
-- 调整前(默认/保守)
innodb_buffer_pool_size = 134217728 -- 128M!
innodb_log_file_size = 50331648 -- 48M(5.7 默认)
innodb_flush_log_at_trx_commit = 1
sync_binlog = 1
tmp_table_size = 16777216 -- 16M
max_heap_table_size = 16777216
innodb_io_capacity = 200
innodb_io_capacity_max = 400
-- 调整后(32G 虚机合理基线)
innodb_buffer_pool_size = 21474836480 -- 20G(物理内存 60~70%)
innodb_redo_log_capacity = 2147483648 -- 2G(8.0.30+ 统一参数,取代 innodb_log_file_size)
innodb_flush_log_at_trx_commit = 1 -- 不建议改,双 1 保数据安全
sync_binlog = 1 -- 不建议改
tmp_table_size = 134217728 -- 128M
max_heap_table_size = 134217728
innodb_io_capacity = 2000 -- SSD/SSD 直通盘可调高
innodb_io_capacity_max = 4000
innodb_flush_method = O_DIRECT
-- 在线验证(解释:8.0 大部分参数可动态改,不用重启;buffer pool 也支持在线 resize)
SET GLOBAL innodb_buffer_pool_size = 21474836480;
SHOW STATUS LIKE 'Innodb_buffer_pool_resize_status';
7.3 收益对比(sysbench oltp_read_write,10 表 × 100 万行)
| 参数组 | TPS | QPS | 平均延迟 | 备注 |
|---|---|---|---|---|
| 默认基线(BP=128M) | 210 | 4200 | 480ms | 数据全落盘,IO 打满 |
| BP=20G | 1850 | 37000 | 55ms | 热数据进内存,IO 降 80% |
| redo=2G + IO_CAPACITY=2000 | 2350 | 47000 | 42ms | 刷盘更顺,checkpoint 抖动消失 |
| tmp_table_size=128M | 2400 | 48000 | 40ms | 排序不再落临时磁盘表 |
| 最终组合 | 2400 | 48000 | 40ms | 综合收益约 11 倍 |
双 1 原则不动 :
innodb_flush_log_at_trx_commit=1+sync_binlog=1是 MySQL 不丢数据的底线(类比 Oracle LGWR + ARCH 同步写)。很多文章教你改成 2/0 换性能------那是拿数据安全换 TPS,金融/交易业务禁止。
八、案例 7:慢日志捕获与常态化治理

图 4
-- 开启慢日志(解释:线上长期开,long_query_time 别设 0,会写爆)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1; -- 超过 1s 记下来
SET GLOBAL log_queries_not_using_indexes = OFF; -- 高峰别开,会刷大量全表扫描
SET GLOBAL slow_query_log_file = '/data/mysql-slow/slow.log';
-- 用 performance_schema 看 Top SQL(解释:比解析慢日志更实时)
SELECT DIGEST_TEXT,
COUNT_STAR AS exec_count,
ROUND(SUM_TIMER_WAIT/1e12,2) AS total_sec,
ROUND(AVG_TIMER_WAIT/1e9,2) AS avg_ms,
SUM_ROWS_EXAMINED,
SUM_ROWS_SEND
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10\G
| 治理动作 | 频率 | 产出 |
|---|---|---|
| 慢日志 Top 10 SQL 评审 | 每周 | 优化清单 |
schema_unused_indexes 巡检 |
每月 | 删冗余索引 |
| 关键 SQL 执行计划基线 | 上线前 | 防计划漂移 |
九、优化收益闭环(验证纪律)

图 5

图 6
| 纪律 | 说明 |
|---|---|
| 同口径对比 | 同一 SQL、同一时间窗、同一数据量 |
| 多轮取样 | 跑 3 次取中位数,排除缓存/抖动 |
| 留基线 | 变更前后都存 EXPLAIN 和耗时截图 |
| 测试库先行 | 大表加索引在从库/预发先验证耗时 |
十、踩坑记录(实测汇总)
| # | 坑 | 现象 | 解决 |
|---|---|---|---|
| 1 | 加了索引 SQL 没变快 | 优化器没选新索引 | ANALYZE TABLE 或删旧索引,确认统计信息 |
| 2 | 索引建了反而写更慢 | 索引过多拖慢 DML | 用 schema_unused_indexes 每月清冗余 |
| 3 | WHERE 列包了函数 | 索引失效全表扫 | 改范围查询,别在索引列上做函数 |
| 4 | 字符串列传数字 | 隐式转换索引失效 | 应用侧类型严格对齐 |
| 5 | 深分页用大 OFFSET | 越翻越慢 | keyset 翻页 / 延迟关联 |
| 6 | 大表 OPTIMIZE TABLE 锁表 | 业务中断 | 低峰做或用 pt-online-schema-change |
| 7 | 盲目改双 1 换 TPS | 宕机丢数据 | 双 1 不动,从架构上扩展 |
| 8 | Buffer Pool 开太大 | OS OOM | 60~70% 物理内存,留够连接和 OS |
| 9 | 压测在 noisy neighbor 虚机上做 | 收益数字忽高忽低 | 同规格虚机、固定资源、清缓存 |
| 10 | EXPLAIN 看 type=ref 就放心 | 没看 rows 估算偏差 | 用 EXPLAIN ANALYZE 看真实行数 |
十一、类比 Oracle 速查
| MySQL 优化动作 | 类比 Oracle | 说明 |
|---|---|---|
EXPLAIN / EXPLAIN ANALYZE |
DBMS_XPLAN.DISPLAY_CURSOR |
8.0.18+ ANALYZE 等价真实执行 |
events_statements_summary_by_digest |
AWR Top SQL | 按 digest 聚合,避免硬解析干扰 |
CREATE INDEX |
CREATE INDEX |
都要注意 ONLINE 与锁 |
ALTER TABLE ENGINE=InnoDB |
MOVE TABLE / REBUILD |
MySQL 整表重建碎片 |
innodb_buffer_pool_size |
db_cache_size / SGA |
都是数据缓存核心 |
| 双 1(flush_log_at_trx_commit+sync_binlog) | LGWR+ARCH 同步写 | 不可为性能牺牲持久性 |
十二、本文要点速记
- 定位靠 performance_schema digest:比解析慢日志更实时,按总耗时排序找 Top SQL。
- EXPLAIN 看三样 :
type(别 ALL)、key(真用上索引)、rows(估算行数)。 - 复合索引最左前缀:等值在前范围在后,排序列尽量走索引避免 filesort。
- 索引要定期瘦身:未用索引和冗余索引每个月清一次,写性能白捡。
- 参数调整要量化:Buffer Pool 是第一收益点,但双 1 底线不动。
- 优化必须前后对比:没有基线数字的优化都是玄学。
十三、参考资料(官方正式文档)
| 主题 | 官方链接 |
|---|---|
| MySQL 8.0 Optimization 总览 | https://dev.mysql.com/doc/refman/8.0/en/optimization.html |
| EXPLAIN 输出格式 | https://dev.mysql.com/doc/refman/8.0/en/explain-output.html |
| EXPLAIN ANALYZE(8.0.18+) | https://dev.mysql.com/doc/refman/8.0/en/explain-analyze.html |
| InnoDB Buffer Pool 调优 | https://dev.mysql.com/doc/refman/8.0/en/innodb-buffer-pool.html |
| 优化语句(慢查询日志) | https://dev.mysql.com/doc/refman/8.0/en/slow-query-log.html |
| performance_schema 语句摘要表 | https://dev.mysql.com/doc/refman/8.0/en/performance-schema-statement-summary-tables.html |
| sys schema(未用索引/冗余索引) | https://dev.mysql.com/doc/refman/8.0/en/sys-schema.html |
| Percona Toolkit(pt-online-schema-change) | https://www.percona.com/doc/percona-toolkit/LATEST/pt-online-schema-change.html |
本文章属于《MySQL 数据库全栈运维实战系列》实战与方案篇。欢迎收藏、交流。