目录
- 一、前言:为什么你的SQL不走索引?
- 二、联合索引到底是什么?
- 三、B+Tree与联合索引的深度原理
- 四、最左前缀原则详解
- 五、索引下推优化(ICP)
- 六、实战代码演示
- 七、常见踩坑与优化建议
- 八、总结
一、前言:为什么你的SQL不走索引?
大家好,今天来聊一个面试高频问题:联合索引的底层实现原理。
很多同学遇到过这种情况:明明建了索引,SQL却不走索引;或者建立了idx(a,b,c)索引,查询WHERE a=1 AND b=2能用索引,但WHERE b=2就不行了。
要搞清楚这个问题,我们需要深入到B+Tree的底层结构,理解联合索引是如何存储和检索数据的。
二、联合索引到底是什么?
2.1 定义
联合索引 :指在多个列上同时创建的索引,本质上是对这些列的组合值建立索引。
sql
-- 创建联合索引
CREATE INDEX idx_name_age_dept ON employee(name, age, dept_id);
这相当于对 (name, age, dept_id) 这个组合值建立B+Tree索引。
2.2 与单列索引的本质区别
| 特性 | 单列索引 | 联合索引 |
|---|---|---|
| 索引数量 | 每个字段独立创建 | 多字段组合成一个 |
| 存储方式 | 单独的B+Tree | 拼接key的B+Tree |
| 排序依据 | 单列值 | 多列值按顺序拼接 |
核心区别 :联合索引的key是由多个列的值拼接而成的,排序时按照创建顺序依次比较。
ini
索引 key 构成:key = name + age + dept_id
例如:('张三', 25, 10) 存储为 key = '张三_25_10'
三、B+Tree与联合索引的深度原理
3.1 InnoDB B+Tree结构
ini
[非叶子节点层]
┌─────────────┐
│ name='D' │
│ name='L' │
│ name='W' │
└─────────────┘
/ | \
┌─────────┐ ┌─────────┐ ┌─────────┐
│ D-H │ │ L-Q │ │ W-Z │
│ 索引页 │ │ 索引页 │ │ 索引页 │
└─────────┘ └─────────┘ └─────────┘
↓ ↓ ↓
┌─────────────────────────────────────┐
│ 叶子节点(双向链表) │
│ (name,age,dept) → (name,age,dept) │
│ ... → ... │
└─────────────────────────────────────┘
关键特性:
- 非叶子节点:只存储索引列,不存储完整数据
- 叶子节点:存储完整索引数据和指向主键的指针
- 双向链表:叶子节点间用双向链表连接,支持高效范围查询
3.2 联合索引的排序逻辑
联合索引 (name, age, dept_id) 的排序规则:
- 先按
name排序 name相同的情况下,按age排序name和age都相同的情况下,按dept_id排序
示例数据排序:
| name | age | dept_id | 索引key |
|---|---|---|---|
| 张三 | 20 | 10 | '张三_20_10' |
| 张三 | 25 | 5 | '张三_25_5' |
| 张三 | 25 | 10 | '张三_25_10' |
| 李四 | 22 | 8 | '李四_22_8' |
| 王五 | 30 | 15 | '王五_30_15' |
注意 :'张三_25_5' 排在 '张三_25_10' 前面,因为 dept_id=5 < 10
四、最左前缀原则详解
4.1 原则定义
联合索引要生效,必须从最左边的列开始使用 ,并且不能跳过中间列。
4.2 实战验证
sql
CREATE TABLE employee (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT,
dept_id INT,
INDEX idx_name_age_dept (name, age, dept_id)
);
-- ✅ 情况1:从最左列开始,使用索引
SELECT * FROM employee WHERE name = '张三';
SELECT * FROM employee WHERE name = '张三' AND age = 25;
SELECT * FROM employee WHERE name = '张三' AND age > 24 AND dept_id = 10;
-- ❌ 情况2:跳过最左列,不使用索引
SELECT * FROM employee WHERE age = 25;
SELECT * FROM employee WHERE dept_id = 10;
-- ⚠️ 情况3:跳过中间列,只使用部分索引
SELECT * FROM employee WHERE name = '张三' AND dept_id = 10;
-- 结果:只使用了 name 列的索引,age 和 dept_id 无法利用
4.3 执行计划分析
sql
EXPLAIN SELECT * FROM employee WHERE name = '张三' AND age = 25;
-- 关键字段解读
+----+-------------+----------+------------+-------+------------------------+
| id | select_type| type | key | rows | Extra |
+----+-------------+----------+------------+-------+------------------------+
| 1 | SIMPLE | ref | idx_name.. | 5 | Using index condition |
+----+-------------+----------+------------+-------+------------------------+
-- 字段含义
key = idx_name_age_dept -- 实际使用的索引
type = ref -- 索引等值查询,性能优秀
rows = 5 -- 预计扫描5行
Extra = Using index condition -- 索引条件下推
4.4 范围查询导致索引断链
sql
-- age 是范围查询,导致其后的 dept_id 无法使用索引
EXPLAIN SELECT * FROM employee
WHERE name = '张三' AND age > 25 AND dept_id = 10;
-- 结果:name 使用索引,age 使用索引(范围),dept_id 不使用
优化建议 :范围查询列尽量放在联合索引的最后面。
五、索引下推优化(ICP)
5.1 什么是ICP?
索引下推(Index Condition Pushdown) :MySQL 5.6+引入的优化,在存储引擎层直接过滤数据,减少回表次数。
5.2 ICP vs 非ICP
sql
SELECT * FROM employee WHERE name = '张三' AND age > 24 AND dept_id = 10;
关闭ICP的执行流程:
ini
1. 存储引擎:根据 name='张三' 筛选,找到所有匹配行
2. 回表:每行都回表获取完整数据
3. Server层:过滤 age > 24 AND dept_id = 10
开启ICP的执行流程(默认):
ini
1. 存储引擎:根据 name='张三' AND age > 24 筛选
2. 回表:只对 age > 24 的行回表
3. Server层:过滤 dept_id = 10
性能差异 :ICP显著减少了不必要的回表操作,特别是当 age > 24 条件能过滤掉大量数据时。
5.3 ICP适用场景
| 场景 | ICP效果 |
|---|---|
| 联合索引中间列为范围查询 | ✅ 有效减少回表 |
| 索引覆盖查询 | ❌ 无需回表,ICP无效果 |
| 存储引擎层可下推的条件 | ✅ 等值、范围、IN列表 |
六、实战代码演示
6.1 创建测试表
sql
CREATE TABLE `orders` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`order_no` VARCHAR(32) NOT NULL,
`user_id` BIGINT NOT NULL,
`status` TINYINT DEFAULT 0,
`amount` DECIMAL(10,2),
`create_time` DATETIME,
INDEX `idx_user_status_time` (`user_id`, `status`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
6.2 索引使用分析
sql
-- ✅ 使用完整索引
EXPLAIN SELECT * FROM orders
WHERE user_id = 1001 AND status = 1 AND create_time > '2024-01-01';
-- ✅ 使用前两列索引
EXPLAIN SELECT * FROM orders
WHERE user_id = 1001 AND status = 1;
-- ⚠️ 只使用第一列索引
EXPLAIN SELECT * FROM orders
WHERE user_id = 1001 AND create_time > '2024-01-01';
-- 结果:只使用 user_id 索引,status 和 create_time 无法利用
6.3 覆盖索引优化
sql
-- ❌ 需要回表:gender 不在索引中
SELECT user_id, status, create_time, gender
FROM orders WHERE user_id = 1001;
-- ✅ 覆盖索引:无需回表
CREATE INDEX idx_covering ON orders(user_id, status, create_time);
SELECT user_id, status, create_time FROM orders WHERE user_id = 1001;
6.4 深度分页优化
sql
-- ❌ 低效:OFFSET大时性能极差
SELECT * FROM orders ORDER BY id LIMIT 1000000, 10;
-- ✅ 优化:书签式分页,利用主键索引
SELECT * FROM orders
WHERE id > 1000000 ORDER BY id LIMIT 10;
-- ✅ 优化:子查询延迟关联
SELECT t.* FROM orders t
INNER JOIN (SELECT id FROM orders ORDER BY id LIMIT 1000000, 10) AS tmp
ON t.id = tmp.id;
七、常见踩坑与优化建议
7.1 索引失效的常见场景
| 误区 | 说明 | 正确做法 |
|---|---|---|
| 跳过最左列 | WHERE b = 2 | 从最左列开始使用 |
| OR连接 | WHERE a = 1 OR b = 2 | 改用 UNION 或 IN |
| 函数运算 | WHERE YEAR(create_time) = 2024 | 改写为范围查询 |
| 类型转换 | WHERE name = 123 (name是VARCHAR) | 使用正确类型 |
| LIKE前缀通配 | WHERE name LIKE '%三' | 避免前缀通配符 |
7.2 联合索引设计原则
1. 区分度高的列放前面
sql
-- ✅ 优化:区分度高的 user_id 在前
INDEX idx_1 (user_id, status, create_time)
-- ❌ 低效:区分度低的 status 在前
INDEX idx_2 (status, user_id, create_time)
2. 范围查询列放最后
sql
-- ✅ 优化:范围查询列放最后
INDEX idx_range (user_id, status, create_time)
-- ❌ 低效:范围查询列在中间导致后续列无法使用
INDEX idx_bad (user_id, create_time, status)
3. 覆盖索引减少回表
sql
-- 频繁查询字段
SELECT user_id, status, create_time FROM orders WHERE user_id = ?
-- 建立覆盖索引
INDEX idx_covering (user_id, status, create_time)
7.3 索引维护成本
| 操作 | 单列索引 | 联合索引 |
|---|---|---|
| INSERT | 维护1个索引 | 维护1个索引(但key更大) |
| UPDATE | 维护1个索引 | 维护1个索引 |
| DELETE | 维护1个索引 | 维护1个索引 |
| 空间占用 | 分散 | 集中 |
注意:联合索引不是越多越好,过多索引会增加写入开销和存储空间。
八、总结
核心要点
- 联合索引本质:将多列值拼接为key,建立B+Tree索引
- 排序规则:从左到右依次比较,前面的列值相同才比较后面的列
- 最左前缀原则:必须从最左列开始且不能跳过中间列
- 索引下推(ICP):在存储引擎层过滤数据,减少回表
- 覆盖索引:查询字段全在索引中时,无需回表
面试高频回答
联合索引底层使用B+Tree结构存储,key由多个列的值按顺序拼接而成。查询必须从最左列开始且不跳过中间列才能使用索引。索引按创建顺序从左到右排序,前面的列值相同才比较后面的列。此外,MySQL 5.6+的索引下推优化会在存储引擎层直接过滤数据,减少回表次数。