MySQL索引优化实战:从原理到调优

为什么加了索引还是慢?"

这个问题我被问过无数次。索引不是万能药,用不好反而是负担。这篇从原理讲起,说说索引优化的实战经验。


索引的本质:B+树

MySQL的InnoDB索引用的是B+树,理解这个结构才能理解索引的行为。

css 复制代码
[根节点: 50]
                   /            \
          [20, 35]              [70, 85]
         /    |    \           /    |    \
      [数据] [数据] [数据]   [数据] [数据] [数据]
       ↓      ↓      ↓        ↓      ↓      ↓
      叶子节点包含完整数据行(聚簇索引)
      或主键值(二级索引)

关键特点:

  • 叶子节点存数据,非叶子节点只存索引
  • 叶子节点有序且双向链接,范围查询很快
  • 树高度通常3-4层,千万级数据也只需3-4次IO

聚簇索引 vs 二级索引

聚簇索引(主键索引)

数据按主键顺序存储,主键索引的叶子节点就是数据本身。

sql 复制代码
-- 主键查询,直接定位到数据
SELECT * FROM users WHERE id = 100;
-- 只需要查聚簇索引,一次搞定

二级索引(普通索引)

叶子节点存的是主键值,查到后还要回表查聚簇索引。

sql 复制代码
-- 假设name上有索引
SELECT * FROM users WHERE name = '张三';

-- 执行过程:
-- 1. 在name索引上找到name='张三'对应的主键id
-- 2. 拿着id去聚簇索引找完整数据
-- 这个过程叫"回表"

回表是性能杀手。能避免就避免。


覆盖索引:干掉回表

如果查询的列都在索引里,就不用回表了。

sql 复制代码
-- 原SQL,需要回表
SELECT id, name, age FROM users WHERE name = '张三';

-- 如果只有name索引,要回表取age

-- 优化:建联合索引
CREATE INDEX idx_name_age ON users(name, age);

-- 现在查询的列(id, name, age)都在索引里了
-- id是主键,二级索引叶子节点自带
-- name, age在联合索引里
-- 不用回表,直接返回

EXPLAIN看到Using index就是覆盖索引:

sql 复制代码
EXPLAIN SELECT id, name, age FROM users WHERE name = '张三';
-- Extra: Using index  ← 覆盖索引,没回表

联合索引的最左前缀原则

联合索引(a, b, c)的结构:

css 复制代码
先按a排序
a相同的按b排序
b相同的按c排序

所以:

css 复制代码
-- 能用上索引
WHERE a = 1
WHERE a = 1 AND b = 2
WHERE a = 1 AND b = 2 AND c = 3
WHERE a = 1 AND c = 3        -- 只用到a(c用不上,因为跳过了b)

-- 用不上索引
WHERE b = 2                  -- 跳过了a
WHERE c = 3                  -- 跳过了a和b
WHERE b = 2 AND c = 3        -- 跳过了a

范围查询会截断

css 复制代码
-- 索引 (a, b, c)
WHERE a = 1 AND b > 10 AND c = 3
-- a用等值查询 ✓
-- b用范围查询 ✓
-- c用不上!因为b是范围查询,后面的列无法使用索引

所以等值查询的列放前面,范围查询的列放后面

lua 复制代码
-- 差:(status, create_time, user_id)
WHERE status = 1 AND create_time > '2024-01-01' AND user_id = 100
-- create_time是范围,user_id用不上

-- 好:(status, user_id, create_time)
WHERE status = 1 AND user_id = 100 AND create_time > '2024-01-01'
-- 三个列都能用上

索引失效的常见场景

1. 对索引列做运算

sql 复制代码
-- 失效
SELECT * FROM orders WHERE YEAR(create_time) = 2024;

-- 优化
SELECT * FROM orders 
WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01';

2. 隐式类型转换

sql 复制代码
-- phone是varchar类型
-- 失效:数字会转成字符串,导致全表扫描
SELECT * FROM users WHERE phone = 13800138000;

-- 正确
SELECT * FROM users WHERE phone = '13800138000';

3. LIKE以%开头

sql 复制代码
-- 失效
SELECT * FROM users WHERE name LIKE '%张';

-- 能用索引
SELECT * FROM users WHERE name LIKE '张%';

4. OR连接的条件

sql 复制代码
-- 如果name没索引,整个查询都不走索引
SELECT * FROM users WHERE id = 1 OR name = '张三';

-- 优化1:给name加索引
-- 优化2:改成UNION
SELECT * FROM users WHERE id = 1
UNION
SELECT * FROM users WHERE name = '张三';

5. NOT IN、NOT EXISTS、!=

sql 复制代码
-- 可能不走索引(优化器判断)
SELECT * FROM users WHERE status != 0;
SELECT * FROM users WHERE id NOT IN (1, 2, 3);

-- 如果status大部分是0,可以改成
SELECT * FROM users WHERE status IN (1, 2, 3);

6. IS NULL / IS NOT NULL

sql 复制代码
-- 看数据分布,NULL值多可能不走索引
SELECT * FROM users WHERE deleted_at IS NULL;

索引设计原则

1. 选择区分度高的列

diff 复制代码
-- 区分度 = COUNT(DISTINCT col) / COUNT(*)

-- 性别:区分度约0.5,不适合单独建索引
-- 手机号:区分度接近1,适合建索引
-- 状态:区分度低,但如果经常查某个状态的少量数据,也可以建

2. 联合索引顺序

sql 复制代码
1. 等值查询的列放前面
2. 区分度高的列放前面
3. 排序的列考虑放进去
-- 常见查询
SELECT * FROM orders WHERE user_id = ? AND status = ? ORDER BY create_time DESC;

-- 索引设计
CREATE INDEX idx_user_status_time ON orders(user_id, status, create_time);
-- user_id区分度高,放前面
-- status等值查询
-- create_time用于排序,放最后

3. 避免冗余索引

css 复制代码
-- 已有 (a, b, c)
-- 不需要再建 (a) 或 (a, b),联合索引已经覆盖
-- 但可能需要 (b) 或 (c),如果单独查询这些列

4. 控制索引数量

索引不是越多越好:

  • 占用磁盘空间
  • 插入/更新/删除时要维护索引,影响写性能
  • 一般一张表不超过5-6个索引

实战案例

案例1:订单列表查询

sql 复制代码
-- 需求:查某用户某状态的订单,按时间倒序
SELECT * FROM orders 
WHERE user_id = 123 AND status = 1 
ORDER BY create_time DESC 
LIMIT 20;

方案1:单列索引

scss 复制代码
CREATE INDEX idx_user_id ON orders(user_id);
-- 能用上,但要回表过滤status,再排序

方案2:联合索引

lua 复制代码
CREATE INDEX idx_user_status_time ON orders(user_id, status, create_time);
-- 完美:
-- 1. user_id和status用于过滤
-- 2. create_time已经有序,不需要额外排序
-- 3. 如果只查id,还是覆盖索引

案例2:分页深度优化

sql 复制代码
-- 原SQL:深分页很慢
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;
-- 要扫描100万+20行

-- 优化:用上一页最后的ID
SELECT * FROM orders WHERE id > 1000000 ORDER BY id LIMIT 20;
-- 直接定位到id>1000000,只扫描20行

案例3:统计查询优化

sql 复制代码
-- 原SQL
SELECT COUNT(*) FROM orders WHERE status = 1;
-- 如果status区分度低,可能全表扫描

-- 优化1:建索引
CREATE INDEX idx_status ON orders(status);

-- 优化2:如果经常统计,用汇总表
-- 定时任务更新
CREATE TABLE order_stats (
    status INT,
    cnt INT,
    updated_at DATETIME
);

EXPLAIN怎么看

ini 复制代码
EXPLAIN SELECT * FROM orders WHERE user_id = 123;

关键字段:

字段 含义 关注点
type 访问类型 ALL=全表扫描(差),ref/range=索引扫描(好)
key 实际用的索引 NULL说明没用索引
rows 预估扫描行数 越小越好
Extra 额外信息 Using index=覆盖索引,Using filesort=额外排序

type从好到差:

sql 复制代码
system > const > eq_ref > ref > range > index > ALL

总结

索引优化的核心:

  1. 理解B+树,知道索引怎么存、怎么查
  2. 善用覆盖索引,避免回表
  3. 遵循最左前缀,注意联合索引顺序
  4. 避免索引失效,函数、类型转换、%开头的LIKE
  5. 用EXPLAIN分析,看type、key、rows、Extra

记住:索引是空间换时间。写多读少的场景,索引可能是负担;读多写少的场景,索引是救命稻草。

相关推荐
梁大虎1 小时前
Electrobun 开发必看:CEF 依赖下载失败?手动解压一招搞定!
前端·javascript·后端
狂奔小菜鸡1 小时前
Day41 | Java中的锁分类
java·后端·java ee
神奇小汤圆2 小时前
Redis缓存三大问题实战:穿透、雪崩、击穿怎么解决
后端
晚星star2 小时前
震惊!这个GitHub项目竟然能让你拥有专属域名邮箱!
后端
倚栏听风雨2 小时前
AI Agent 核心原理解析:一文看懂 ReAct 规划框架(附手搓代码)
后端
神奇小汤圆2 小时前
高并发写入场景:MySQL 事务隔离级别与行锁策略设计
后端
武子康2 小时前
大数据-243 离线数仓 - 实战电商核心交易增量导入(DataX - HDFS - Hive 分区
大数据·后端·apache hive
得物技术2 小时前
搜索 C++ 引擎回归能力建设:从自测到工程化准出|得物技术
c++·后端·测试
工边页字2 小时前
AI 开发必懂:Context Window(上下文窗口)到底是什么?
前端·人工智能·后端