一、常见原因概览
即使使用了索引,查询仍可能很慢,主要原因包括:
- 回表次数过多
- 索引选择错误
- 索引区分度不够
- 数据量过大或深度分页
- 未使用覆盖索引
- 索引列有函数或计算
- 隐式类型转换
- 统计信息不准确
- 锁等待或死锁
- 磁盘IO瓶颈
二、详细原因分析
原因1:回表次数过多
问题描述
-- 表:1000万行,索引:INDEX(status)
-- status分布:active (900万), inactive (100万)
SELECT * FROM users WHERE status='active';
执行过程:
1. 扫描索引 idx_status,找到900万条记录的主键
2. 根据主键回表900万次,获取完整行数据
3. 返回结果
耗时分析:
- 索引扫描:0.1秒
- 回表操作:8秒(900万次随机IO)
- 总耗时:8.1秒
原因分析
回表成本 = 回表次数 × 单次回表成本
单次回表成本:
- 数据在内存:0.001ms
- 数据在磁盘:10ms(随机IO)
900万次回表:
- 全在内存:9秒
- 部分在磁盘:更长时间
解决方案
方案1:使用覆盖索引
-- 原查询
SELECT id, name, email FROM users WHERE status='active';
-- 创建覆盖索引
CREATE INDEX idx_status_id_name_email ON users(status, id, name, email);
-- 执行计划
EXPLAIN SELECT id, name, email FROM users WHERE status='active';
-- Extra: Using index ← 无需回表
-- 耗时:0.5秒(减少94%)
方案2:延迟关联(分页场景)
-- 不好:深度分页需要回表10万次
SELECT * FROM users
WHERE status='active'
ORDER BY create_time
LIMIT 100000, 20;
-- 好:先走覆盖索引,再延迟关联
SELECT u.* FROM users u
INNER JOIN (
SELECT id FROM users
WHERE status='active'
ORDER BY create_time
LIMIT 100000, 20
) t ON u.id = t.id;
-- 耗时减少80%
方案3:调整查询条件
-- 原查询:返回90%的数据
SELECT * FROM users WHERE status='active';
-- 优化:增加过滤条件
SELECT * FROM users
WHERE status='active' AND create_time >= '2025-01-01';
-- 减少回表次数
原因2:索引选择错误
问题描述
-- 有两个索引
INDEX idx_status(status)
INDEX idx_create_time(create_time)
-- 查询
SELECT * FROM orders
WHERE status='shipped' AND create_time >= '2025-11-01';
-- 优化器错误选择 idx_status
-- 实际应该选择 idx_create_time(过滤效果更好)
验证方法
-- 查看实际使用的索引
EXPLAIN SELECT * FROM orders
WHERE status='shipped' AND create_time >= '2025-11-01';
-- 对比不同索引的效果
EXPLAIN SELECT * FROM orders FORCE INDEX(idx_status)
WHERE status='shipped' AND create_time >= '2025-11-01';
-- rows: 500000
EXPLAIN SELECT * FROM orders FORCE INDEX(idx_create_time)
WHERE status='shipped' AND create_time >= '2025-11-01';
-- rows: 10000 ← 更优
解决方案
方案1:强制使用正确索引
SELECT * FROM orders FORCE INDEX(idx_create_time)
WHERE status='shipped' AND create_time >= '2025-11-01';
方案2:创建联合索引
-- 根据查询模式创建最优索引
CREATE INDEX idx_status_time ON orders(status, create_time);
-- 或根据过滤效果
CREATE INDEX idx_time_status ON orders(create_time, status);
方案3:更新统计信息
-- 统计信息过时可能导致错误选择
ANALYZE TABLE orders;
-- 再次检查执行计划
EXPLAIN SELECT * FROM orders
WHERE status='shipped' AND create_time >= '2025-11-01';
原因3:索引区分度不够
问题描述
-- 索引:INDEX(gender)
-- 数据分布:M (500万), F (500万)
SELECT * FROM users WHERE gender='M';
-- 虽然走了索引,但需要回表500万次
-- 效果不佳
判断标准
-- 计算索引选择性
SELECT
COUNT(DISTINCT gender) / COUNT(*) AS selectivity,
COUNT(*) AS total_rows,
COUNT(*) / COUNT(DISTINCT gender) AS avg_rows_per_value
FROM users;
-- 结果:
-- selectivity: 0.0000002(极低)
-- total_rows: 10000000
-- avg_rows_per_value: 5000000(平均每个值500万行)
解决方案
方案1:创建联合索引
-- 单独的 gender 索引效果差
-- 与其他列组合提升区分度
CREATE INDEX idx_gender_age_city ON users(gender, age, city);
-- 查询
SELECT * FROM users
WHERE gender='M' AND age BETWEEN 20 AND 30 AND city='Beijing';
-- 过滤效果大幅提升
方案2:调整查询策略
-- 如果确实需要查询大量数据(如50%以上)
-- 考虑全表扫描 + 缓存
-- 全表扫描可能更快(顺序IO)
SELECT * FROM users WHERE gender='M';
-- 配合应用层缓存
原因4:数据量过大或深度分页
深度分页问题
-- 典型场景:翻到第5000页
SELECT * FROM posts
ORDER BY create_time DESC
LIMIT 100000, 20;
执行过程:
1. 从索引扫描100020行
2. 回表100020次
3. 返回最后20行
4. 前面100000行的回表全部浪费
耗时:
- 扫描100020行:0.5秒
- 回表100020次:3秒
- 总计:3.5秒
解决方案
方案1:延迟关联
SELECT p.* FROM posts p
INNER JOIN (
SELECT id FROM posts
ORDER BY create_time DESC
LIMIT 100000, 20
) t ON p.id = t.id;
-- 子查询走覆盖索引,只回表20次
-- 耗时:0.6秒
方案2:游标分页
-- 不使用OFFSET,使用WHERE条件
-- 第一页
SELECT * FROM posts
ORDER BY create_time DESC
LIMIT 20;
-- 第二页(记录上一页最后一条的create_time)
SELECT * FROM posts
WHERE create_time < '上一页最后一条的时间'
ORDER BY create_time DESC
LIMIT 20;
-- 优势:
-- 1. 不需要扫描前N页
-- 2. 直接定位到目标位置
-- 3. 性能稳定,不随页数增加而下降
方案3:搜索引擎
-- 对于超深分页(10000页+)
-- 使用 Elasticsearch 等搜索引擎
-- MySQL 只存储近期数据
原因5:索引列有函数或计算
问题示例
-- 索引:INDEX(create_time)
-- ❌ 索引失效
SELECT * FROM orders
WHERE DATE(create_time) = '2025-11-02';
-- ❌ 索引失效
SELECT * FROM orders
WHERE YEAR(create_time) = 2025;
-- ❌ 索引失效
SELECT * FROM products
WHERE price * discount > 100;
原因:函数或计算导致索引无法使用B+树的有序性。
解决方案
方案1:改写查询条件
-- 改写为范围查询
SELECT * FROM orders
WHERE create_time >= '2025-11-02 00:00:00'
AND create_time < '2025-11-03 00:00:00';
-- 改写年份查询
SELECT * FROM orders
WHERE create_time >= '2025-01-01'
AND create_time < '2026-01-01';
-- 改写计算条件
-- 假设 final_price = price * discount
CREATE INDEX idx_final_price ON products((price * discount));
-- MySQL 8.0+ 支持函数索引
方案2:添加冗余字段
-- 添加日期字段
ALTER TABLE orders ADD COLUMN create_date DATE;
UPDATE orders SET create_date = DATE(create_time);
CREATE INDEX idx_create_date ON orders(create_date);
-- 查询
SELECT * FROM orders WHERE create_date = '2025-11-02';
原因6:隐式类型转换
问题示例
-- 字段:phone VARCHAR(20), INDEX(phone)
-- ❌ 索引失效(隐式转换)
SELECT * FROM users WHERE phone = 13800138000;
-- phone 是字符串,传入数字,MySQL转换为:
-- WHERE CAST(phone AS UNSIGNED) = 13800138000
-- ✅ 正确写法
SELECT * FROM users WHERE phone = '13800138000';
类型转换规则
-- 字符串字段 vs 数字条件 → 索引失效
WHERE varchar_col = 123 -- ❌
-- 数字字段 vs 字符串条件 → 索引有效
WHERE int_col = '123' -- ✅(MySQL自动转换条件)
-- 字符集不同 → 可能失效
WHERE utf8_col = utf8mb4_value
解决方案
-- 1. 确保类型匹配
SELECT * FROM users WHERE phone = '13800138000';
-- 2. 查看是否有隐式转换
EXPLAIN FORMAT=TREE SELECT * FROM users WHERE phone = 13800138000;
-- 查看是否有 CAST 或类型转换
-- 3. 修改字段类型
-- 如果phone总是数字,改为 BIGINT
ALTER TABLE users MODIFY phone BIGINT;
原因7:统计信息不准确
问题描述
-- 实际情况:status='active' 只有1000行
-- 统计信息:优化器认为有100万行(数据已删除但未更新统计)
EXPLAIN SELECT * FROM orders WHERE status='active';
-- rows: 1000000 ← 错误估计
-- 优化器选择全表扫描(认为成本更低)
-- 实际:走索引只需扫描1000行,但优化器不知道
解决方案
-- 1. 更新统计信息
ANALYZE TABLE orders;
-- 2. 验证统计信息
SHOW INDEX FROM orders;
-- 查看 Cardinality 列
-- 3. 强制使用索引(临时)
SELECT * FROM orders FORCE INDEX(idx_status)
WHERE status='active';
-- 4. 配置自动更新(MySQL 8.0+)
SET GLOBAL innodb_stats_auto_recalc = ON;
原因8:锁等待
问题描述
-- 查询本身很快,但因为锁等待而慢
SELECT * FROM orders WHERE id=12345;
-- SHOW PROCESSLIST 显示:
-- State: Waiting for table metadata lock
-- 或:Waiting for row lock
排查方法
-- 1. 查看锁等待
SELECT * FROM sys.innodb_lock_waits;
-- 2. 查看当前事务
SELECT * FROM information_schema.innodb_trx;
-- 3. 查看锁信息
SHOW ENGINE INNODB STATUS\G
-- 4. 查看阻塞会话
SELECT
waiting_pid AS waiting_thread,
waiting_query,
blocking_pid AS blocking_thread,
blocking_query
FROM sys.innodb_lock_waits;
解决方案
-- 1. 优化事务(减少持锁时间)
BEGIN;
SELECT ... FOR UPDATE;
-- 业务逻辑(尽量快)
UPDATE ...;
COMMIT;
-- 2. 调整隔离级别(如果业务允许)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 3. 使用 NOWAIT 或 SKIP LOCKED
SELECT * FROM orders WHERE status='pending'
FOR UPDATE SKIP LOCKED
LIMIT 10;
-- 4. 杀死阻塞会话(谨慎)
KILL <blocking_thread_id>;
原因9:磁盘IO瓶颈
问题描述
-- Buffer Pool 太小,大量数据需要从磁盘读取
-- 机械硬盘随机IO慢(5-10ms/次)
排查方法
-- 1. 查看Buffer Pool命中率
SHOW STATUS LIKE 'Innodb_buffer_pool_read%';
-- Innodb_buffer_pool_read_requests: 10000000(总请求)
-- Innodb_buffer_pool_reads: 1000000(磁盘读)
-- 命中率:90%(理想应 > 99%)
-- 2. 查看磁盘IO
-- Linux
iostat -x 1
-- 3. 查看慢查询的IO等待
SHOW PROFILE FOR QUERY 1;
-- 关注 Sending data 阶段的耗时
解决方案
-- 1. 增加 Buffer Pool
SET GLOBAL innodb_buffer_pool_size = 8G;
-- 2. 优化查询(减少数据量)
-- 使用覆盖索引、限制返回行数等
-- 3. 升级硬件
-- 机械硬盘 → SSD(随机IO提升100倍)
-- 4. 分离冷热数据
-- 热数据(近期):高性能存储
-- 冷数据(历史):归档存储
三、综合排查流程
Step 1:确认使用索引
EXPLAIN SELECT ...;
-- 检查关键字段:
-- type: ALL(全表扫描) < index(索引扫描) < range(范围) < ref(等值)
-- key: NULL(未使用索引)vs idx_xxx(使用了索引)
-- rows: 实际扫描行数
-- Extra: Using filesort, Using temporary(需要优化)
Step 2:分析慢查询日志
-- 开启慢查询日志
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1; -- 1秒以上记录
-- 分析日志
-- Linux: pt-query-digest /var/log/mysql/slow.log
-- 关注字段:
-- Query_time: 总耗时
-- Lock_time: 锁等待时间
-- Rows_sent: 返回行数
-- Rows_examined: 扫描行数
Step 3:使用EXPLAIN ANALYZE
-- MySQL 8.0.18+
EXPLAIN ANALYZE SELECT ...;
-- 输出实际执行时间和行数
-- 对比预估(EXPLAIN)和实际(ANALYZE)的差异
Step 4:性能分析
-- 开启profiling
SET profiling = 1;
-- 执行查询
SELECT ...;
-- 查看详细耗时
SHOW PROFILES;
SHOW PROFILE FOR QUERY 1;
-- 分析各阶段耗时:
-- Sending data: 数据读取和传输
-- Sorting result: 排序
-- Creating sort index: 创建排序索引
Step 5:系统资源检查
# CPU使用率
top
# 内存使用
free -h
# 磁盘IO
iostat -x 1
# MySQL连接数
mysql> SHOW STATUS LIKE 'Threads_connected';
mysql> SHOW VARIABLES LIKE 'max_connections';
四、常见误区
误区1:认为有索引就一定快
-- 反例:索引选择性差
INDEX(gender) -- 只有2个值
SELECT * FROM users WHERE gender='M'; -- 返回50%的数据
-- 可能比全表扫描还慢(随机IO)
误区2:忽略回表成本
-- 反例:覆盖不全的索引
INDEX(user_id)
SELECT * FROM orders WHERE user_id=?; -- 需要回表获取所有列
-- 如果user_id的订单很多,回表成本高
误区3:过度依赖EXPLAIN
-- EXPLAIN 只是预估,不是实际执行
-- 需要结合 EXPLAIN ANALYZE 或实际执行时间
五、优化建议清单
1. 索引优化
- 创建覆盖索引,避免回表
- 使用联合索引,提升区分度
- 定期ANALYZE TABLE,更新统计信息
- 删除未使用的索引
2. 查询优化
- 避免SELECT *,只查询必要列
- 深度分页使用延迟关联或游标
- 不在索引列使用函数
- 确保类型匹配,避免隐式转换
3. 事务优化
- 缩短事务执行时间
- 避免长事务持有锁
- 合理使用隔离级别
4. 硬件优化
- 增加Buffer Pool大小
- 使用SSD替代机械硬盘
- 增加内存
六、面试总结
使用索引仍然很慢的常见原因:
数据层面:
- 回表次数过多(过滤后数据量大)
- 索引区分度不够(低选择性)
- 数据量过大或深度分页
索引层面:
- 索引选择错误(优化器判断失误)
- 未使用覆盖索引(额外回表开销)
- 统计信息不准确
查询层面:
- 索引列有函数或计算
- 隐式类型转换导致索引失效
系统层面:
- 锁等待或死锁
- 磁盘IO瓶颈(Buffer Pool命中率低)
排查方法:
- EXPLAIN/EXPLAIN ANALYZE 分析执行计划
- 慢查询日志分析
- SHOW PROFILE 详细耗时
- 系统资源监控
优化方向:
- 索引:覆盖索引、联合索引、更新统计
- 查询:避免函数、类型匹配、限制返回
- 分页:延迟关联、游标分页
- 系统:增加Buffer Pool、使用SSD
这道题考查对MySQL性能问题的全面排查和优化能力,是实战经验的体现。