
引言:一个凌晨三点的报警
凌晨3点14分,运维群炸了。
“订单查询接口超时率飙升到45%!”“数据库cpu跑满了!”“应用线程池快被耗尽!”
你睡眼惺忪地打开监控面板——数据库的活跃连接数从平时的20暴涨到300,cpu使用率长时间维持在98%以上。show processlist里塞满了同一个查询的副本:
select * from orders where user_id = 123456 order by order_date desc limit 10;
这个查询平时不到50毫秒,现在却要跑3到8秒。你的第一反应是什么?“加索引”?但user_id上明明已经有索引了。
接下来我要带你走完从发现 → 诊断 → 修复 → 验证的完整过程。这不是一次"运气好蒙对了"的调优,而是一套可以肌肉记忆的排查流程。
一、前置知识:诊断工具包
在动手之前,先确认你的工具箱里有这三样东西:
1.1 慢查询日志——你的第一道防线
慢查询日志记录所有执行时间超过long_query_time阈值的sql。先确认它是否开启:
show variables like 'slow_query_log%'; show variables like 'long_query_time';
如果没开,在my.cnf中加入:
[mysqld] slow_query_log = 1 slow_query_log_file = /var/log/mysql/slow.log long_query_time = 1 log_queries_not_using_indexes = 1 log_slow_admin_statements = 1
环境依赖:mysql 5.6+,需有super或process权限查看processlist,需有file权限操作慢日志文件。
1.2 explain
explain select * from orders where user_id = 123456 order by order_date desc limit 10;
1.3 explain analyze(mysql 8.0.18+)——真正的"测谎仪"
传统explain的rows是估算值。explain analyze会真正执行查询,输出每个步骤的实际耗时、循环次数和真实行数。
explain analyze select * from orders where user_id = 123456 order by order_date desc limit 10;
警告:explain analyze会真实执行sql,绝对不要在压测中的生产库上直接跑。请在从库或测试环境执行。
二、核心剖析:读懂explain这张"天书"
拿到explain输出后,不要被那十几列吓到。真正决定生死的关键字段只有5个:
2.1 type——性能的"红绿灯"
| type | 含义 | 判断 |
|---|---|---|
system/const | 主键或唯一索引等值查询,最多一行 | ✅ 最优 |
eq_ref | 唯一索引关联(join连接条件是主键) | ✅ 优秀 |
ref | 普通索引等值查询 | ✅ 合格 |
range | 索引范围扫描(between、>、<、in) | ⚠️ 及格线 |
index | 全索引扫描 | ❌ 较差 |
all | 全表扫描 | ❌ 灾难 |
铁律:生产环境查询的type至少要达到 range 级别。看到all或index,立刻拉响警报。
2.2 key——到底用没用索引?
possible_keys:mysql认为可能用到的索引key:mysql实际使用的索引key_len:索引使用的字节数,值越大说明索引利用得越充分(联合索引中实际用了多少列)
关键判断:如果possible_keys有值但key为null,说明优化器判断走索引比全表扫描还慢——这通常发生在数据分布极不均匀时。
2.3 rows——扫描行数估算
rows是估算需要扫描的行数,数字越大越慢。
重要:优化器的rows估算依赖于统计信息。统计信息过旧时,rows可能与真实情况差一个数量级。这正是optimizer_trace可以揭示的秘密。
2.4 filtered——回表代价的"放大镜"
表示存储引擎层返回的数据经过where条件过滤后的剩余比例。rows=10000、filtered=1.00意味着最终只返回约100行——存储引擎扫了1万行,在server层又过滤掉了99%,是巨大的性能浪费。
2.5 extra——藏着魔鬼的细节
| extra信息 | 含义 | 严重程度 |
|---|---|---|
using index | 覆盖索引,无需回表 | 🟢 好事 |
using index condition | 索引下推(icp) | 🟢 较好 |
using where | 需要回表过滤 | 🟡 正常 |
using filesort | 需要额外排序 | 🔴 严重 |
using temporary | 使用临时表 | 🔴 严重 |
using filesort是最常见的性能杀手——意味着mysql无法利用索引完成排序,必须在内存或磁盘中额外排序。
三、手把手实操:从慢日志到根治
现在回到凌晨三点的报警现场。
step 1:从慢日志中揪出"头号罪犯"
用pt-query-digest分析慢日志:
# 安装percona toolkit # ubuntu/debian: apt-get install percona-toolkit # centos/rhel: yum install percona-toolkit pt-query-digest /var/log/mysql/slow.log --limit 10
输出报告的核心指标:
- response time:总响应时间占比——占比最高的就是头号罪犯
- calls:执行次数
- r/call:平均每次耗时
- rows examined:平均扫描行数
在我们的案例中,报告显示那个订单查询的rows examined高达50万,而表总共才100万行。
step 2:用explain看清执行计划
explain select * from orders where user_id = 123456 order by order_date desc limit 10\g
输出:
*************************** 1. row ***************************
id: 1
select_type: simple
table: orders
type: ref
possible_keys: idx_user_id
key: idx_user_id
key_len: 4
ref: const
rows: 52341
extra: using filesort
诊断结论:
type=ref,走了idx_user_id索引——✅ 索引在用rows=52341,该用户有5.2万条订单——⚠️ 扫描行数大extra=using filesort——🔴 病根在这里!order_date没有进入索引,mysql要把5.2万条记录全部取出,在内存中排序后再取前10条
step 3:数据压测——三种索引方案在不同数据量下的真实表现
为了验证不同方案的优劣,我在同等硬件环境下(4c16g、ssd、mysql 8.0.32)用sysbench构造了一张订单表,分别在100万、500万、1000万三个数据量级下进行压测。每次测试前重启数据库、清空buffer pool,确保结果可复现。
测试sql固定为:
select * from orders where user_id = ? order by order_date desc limit 10;
方案a:单列索引(现状)
create index idx_user_id on orders(user_id);
| 数据量 | 平均耗时 | 扫描行数(explain) | 实际扫描行数(explain analyze) |
|---|---|---|---|
| 100万行(该用户约5万条) | ~820ms | ~52,000 | ~52,000 |
| 500万行(该用户约25万条) | ~4.2s | ~250,000 | ~250,000 |
| 1000万行(该用户约50万条) | ~8.5s | ~500,000 | ~500,000 |
观察:扫描行数≈该用户的订单总数,filesort排序是整个操作的瓶颈,耗时随数据量线性增长。
方案b:联合索引(解决排序)
create index idx_user_date on orders(user_id, order_date);
| 数据量 | 平均耗时 | 扫描行数(explain) | 实际扫描行数(explain analyze) |
|---|---|---|---|
| 100万行 | ~45ms | ~52,000 | 10 |
| 500万行 | ~48ms | ~250,000 | 10 |
| 1000万行 | ~52ms | ~500,000 | 10 |
为什么explain估算rows还是几十万,但实际只扫描了10行?
这是新手最容易困惑的地方。explain的rows是优化器在生成执行计划之前的代价估算,它只统计了索引的基数(cardinality),估算出该用户大约有50万条记录。但优化器忽略了limit 10——它估算的是"该用户总共多少条",而不是"为了取前10条实际扫描多少行"。
真正的执行过程是:mysql在(user_id, order_date)联合索引中,用b+tree定位到user_id=123456的第一条记录(该用户的最新订单,因为索引内已按order_date降序排列),然后连续读取10条索引记录就结束了。explain analyze显示的实际扫描行数只有10行。
方案c:覆盖索引(彻底消除回表)
create index idx_user_date_covering on orders(user_id, order_date, status, amount);
| 数据量 | 平均耗时 | 扫描行数(explain) | 实际扫描行数(explain analyze) |
|---|---|---|---|
| 100万行 | ~5ms | ~52,000 | 10 |
| 500万行 | ~8ms | ~250,000 | 10 |
| 1000万行 | ~12ms | ~500,000 | 10 |
方案c比方案b快了约5-6倍,原因在于:
- 方案b:索引中只有
(user_id, order_date),select*需要的status、amount等字段必须回表(根据主键去聚簇索引读取完整行),额外消耗了随机io - 方案c:索引中包含查询所需的所有列(
user_id, order_date, status, amount),mysql直接从索引中返回数据,完全跳过回表步骤。extra列显示using index
三方案耗时对比图(1000万行数据)
耗时 (ms)
8500 | ████████████████████████████████████████████████████████ 方案a
52 | ██▌ 方案b
12 | █▌ 方案c
+-------------------------------------------------------
方案a 方案b 方案c
结论:方案c(覆盖索引)在千万级数据下依然能稳定在12ms以内,性能提升超过700倍。
step 4:用optimizer_trace看透优化器的"内心戏"
上面我们看到了方案b和方案c的explain输出,但你有没有想过:优化器是怎么决定用哪个索引的?它为什么认为方案c更好?
mysql的optimizer_trace可以把优化器的完整决策过程以json格式输出。这是比explain更底层的诊断工具。
-- 开启trace(仅对当前会话生效,安全) set optimizer_trace="enabled=on"; set optimizer_trace_max_mem_size=1000000; -- 执行要分析的查询 select * from orders where user_id = 123456 order by order_date desc limit 10; -- 获取trace结果 select * from information_schema.optimizer_trace\g
输出的json非常庞大,但真正有价值的关键节点只有三个:
关键节点1:rows_estimation(行数估算)
"rows_estimation": [
{
"table": "orders",
"range_analysis": {
"table_scan": { "rows": 1000000, "cost": 202431 },
"potential_range_indexes": [
{ "index": "idx_user_id", "usable": true, "chosen": true },
{ "index": "idx_user_date_covering", "usable": true, "chosen": true }
],
"analyzing_range_alternatives": {
"range_scan_alternatives": [
{
"index": "idx_user_id",
"ranges": ["123456 <= user_id <= 123456"],
"index_dives_for_eq_ranges": true,
"rowid_ordered": false,
"using_mrr": false,
"index_only": false,
"rows": 52341,
"cost": 62812
},
{
"index": "idx_user_date_covering",
"ranges": ["123456 <= user_id <= 123456"],
"rowid_ordered": false,
"using_mrr": false,
"index_only": true, -- ✅ 覆盖索引标记!
"rows": 52341,
"cost": 10469 -- ✅ 代价远低于idx_user_id
}
]
}
}
}
]
解读:优化器对两个可用索引都做了代价估算——idx_user_id的代价是62,812,而idx_user_date_covering的代价是10,469。关键差异在于index_only: true(覆盖索引无需回表),使得io代价大幅降低。
关键节点2:considered_execution_plans(执行计划选择)
"considered_execution_plans": [
{
"plan_prefix": [],
"table": "orders",
"best_access_path": {
"considered_access_paths": [
{
"access_type": "ref",
"index": "idx_user_date_covering",
"cost": 10469,
"chosen": true,
"cause": "cost"
},
{
"access_type": "ref",
"index": "idx_user_id",
"cost": 62812,
"chosen": false
}
]
},
"cost_for_plan": 10469,
"rows_for_plan": 52341,
"chosen": true
}
]
这里记录了优化器遍历了所有可能的执行路径,最终根据代价(cost)最小原则选择了idx_user_date_covering。
关键节点3:join_optimization(最终优化结果)
"join_optimization": {
"select#": 1,
"steps": [
{
"join_type": "ref",
"table": "orders",
"ref_columns": ["user_id"],
"used_index": "idx_user_date_covering",
"output_order": "order by order_date", -- 索引保证排序,无需filesort
"limit": 10,
"using_join_buffer": false
}
]
}
这里确认了优化器的最终决策:使用覆盖索引,且order by order_date由索引直接提供有序性,无需filesort。
optimizer_trace的价值:当你的查询在执行计划中表现异常(如优化器选择了错误的索引)时,optimizer_trace能告诉你为什么——是统计信息偏差导致估算行数不准?还是代价计算中的某个因素被高估/低估了?这比单纯看explain深刻得多。
step 5:验证并上线
测试环境验证方案c:
create index idx_user_date_covering on orders(user_id, order_date, status, amount); explain select * from orders where user_id = 123456 order by order_date desc limit 10\g
预期输出:
type: ref
key: idx_user_date_covering
rows: 52341 -- 仍为估算值,实际执行只扫描10行(见explain analyze)
extra: using index
上线步骤:
-- 1. 在从库先创建索引,观察复制延迟 -- 2. 业务低峰期,在主库创建(使用inplace算法避免长时间锁表) alter table orders add index idx_user_date_covering (user_id, order_date, status, amount), algorithm=inplace, lock=none; -- 3. 确认索引生效后,可考虑删除旧索引(先观察几天,确认不影响其他查询) -- drop index idx_user_id on orders;
step 6:常见错误与调试
| 错误现象 | 可能原因 | 排查方法 |
|---|---|---|
创建索引后key还是旧索引 | 统计信息未更新 | analyze table orders; |
rows估算值没下降 | 优化器基于统计信息估算 | 用explain analyze看实际扫描行数 |
| 创建索引时业务阻塞 | 大表ddl默认锁表 | 使用pt-online-schema-change |
| 覆盖索引占用空间暴增 | 包含了过多大字段 | 精简索引列,只包含select的必要字段 |
四、进阶思考:三个"看不见的坑"
坑一:最左前缀原则——联合索引不是万能的
联合索引(a, b, c)遵循最左前缀原则:查询必须从索引的最左列开始,且不能跳过中间的列。
-- ✅ 能用到索引 (a, b, c) where a = 1 and b = 2 and c = 3 where a = 1 and b = 2 -- ⚠️ 部分用到(只用a,b跳过,c的排序/过滤失效) where a = 1 and c = 3 -- ❌ 完全用不到(跳过了a) where b = 2 and c = 3
验证索引到底用到了哪几列:
explain select * from orders where user_id = 1 and order_date > '2025-01-01'\g -- 看key_len字段:如果索引是(user_id, order_date),user_id=4字节,order_date=3字节 -- key_len=4表示只用了user_id,key_len=7表示两列都用到了
坑二:隐式类型转换——索引失效的"隐形杀手"
当字段类型和查询值类型不匹配时,mysql会做隐式转换:
-- 假设表结构:id int -- ❌ 索引失效!id被转换成字符串再比较 select * from orders where id = '10086'; -- 假设 phone是varchar -- ❌ 索引失效!phone字段被转换成数字再比较 select * from users where phone = 13800138000;
验证索引是否真的失效:用explain看key列是否为null,或用show status like 'handler_read%'观察读取行为。
坑三:索引下推(icp)——mysql 5.6+的福音
没有icp:存储引擎根据索引找到主键 → 回表 → server层再过滤其他条件。
有icp:部分where条件下推到存储引擎,在索引层就完成过滤。
-- 假设有联合索引(name, age) select * from tuser where name like '张%' and age = 20;
如何确认icp生效?看extra列是否有using index condition。
坑四(新增):大表创建索引的"时间窗口陷阱"
在1000万行的表上创建覆盖索引,可能耗时数十分钟甚至数小时。如果直接在生产库执行,可能导致:
- 业务写入被阻塞(即使使用
algorithm=inplace,ddl过程中仍需要短暂的元数据锁(mdl),会阻塞所有dml) - 主从复制延迟飙升(ddl在从库重放时同样耗时)
解决方案:
# 使用pt-online-schema-change,在业务不中断的情况下在线变更 pt-online-schema-change \ --alter "add index idx_user_date_covering (user_id, order_date, status, amount)" \ --execute \ --alter-foreign-keys-method=auto \ --no-drop-old-table \ --max-lag=1 \ --check-interval=1 \ h=localhost,d=myapp,t=orders,u=root,p='password'
该工具通过创建影子表、触发器同步增量数据的方式实现在线变更,对业务影响最小。
五、总结:从"会用"到"会诊断"
回到凌晨三点的报警。现在你知道完整的排查路径了:
慢查询报警
↓
pt-query-digest 分析慢日志 → 定位问题sql
↓
explain 查看执行计划 → 发现 type、rows、extra 的异常
↓
(疑难杂症时)optimizer_trace 查看优化器决策全过程
↓
explain analyze 验证实际执行中的真实扫描行数
↓
诊断病根(filesort / 回表过多 / 索引失效)
↓
设计索引方案(单列 → 联合 → 覆盖,逐级优化,附压测验证)
↓
使用 pt-osc 在大表上安全上线
↓
监控对比(关注 qps、p99 延迟、cpu 使用率)
↓
复盘沉淀:更新索引设计规范 + 慢查询监控阈值
这次你真正带走的东西:
- 一套完整的排查工具链:
pt-query-digest→explain→explain analyze→optimizer_trace,从粗筛到精确定位,每层工具有明确的适用场景 - 读懂执行计划的关键判断力:看到
type=all知道要全表扫描,看到extra=using filesort知道排序是瓶颈,看到key_len判断联合索引用了多少列 - 优化器决策的"读心术":通过
optimizer_trace理解优化器为什么选择/放弃某个索引,而不是只能被动接受 - 索引设计的科学方法:覆盖索引为什么能把5.2万行降到10行,如何用压测数据验证方案而不是凭感觉,以及大表索引上线的安全姿势
最后送你一句话:“写sql是本能,读执行计划是基本功,看optimizer_trace是进阶,设计索引是手艺,而压测验证才是真正的底气。”
现在,去跑一遍你生产环境中最慢的那条sql的explain吧。再看看optimizer_trace,理解优化器为什么做出了那个选择——答案往往就在那里。
以上就是sql数据库性能故障排查与索引设计实战指南的详细内容,更多关于sql性能优化与索引设计的资料请关注代码网其它相关文章!
发表评论