一、常见原因概览

即使使用了索引,查询仍可能很慢,主要原因包括:

  1. 回表次数过多
  2. 索引选择错误
  3. 索引区分度不够
  4. 数据量过大或深度分页
  5. 未使用覆盖索引
  6. 索引列有函数或计算
  7. 隐式类型转换
  8. 统计信息不准确
  9. 锁等待或死锁
  10. 磁盘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替代机械硬盘
  • 增加内存

六、面试总结

使用索引仍然很慢的常见原因:

数据层面:

  1. 回表次数过多(过滤后数据量大)
  2. 索引区分度不够(低选择性)
  3. 数据量过大或深度分页

索引层面:

  1. 索引选择错误(优化器判断失误)
  2. 未使用覆盖索引(额外回表开销)
  3. 统计信息不准确

查询层面:

  1. 索引列有函数或计算
  2. 隐式类型转换导致索引失效

系统层面:

  1. 锁等待或死锁
  2. 磁盘IO瓶颈(Buffer Pool命中率低)

排查方法:

  1. EXPLAIN/EXPLAIN ANALYZE 分析执行计划
  2. 慢查询日志分析
  3. SHOW PROFILE 详细耗时
  4. 系统资源监控

优化方向:

  • 索引:覆盖索引、联合索引、更新统计
  • 查询:避免函数、类型匹配、限制返回
  • 分页:延迟关联、游标分页
  • 系统:增加Buffer Pool、使用SSD

这道题考查对MySQL性能问题的全面排查和优化能力,是实战经验的体现。