一、联合索引:不只是多个字段的简单组合

联合索引,也叫复合索引,说白了就是把多个字段揉在一起建一个索引。很多新手觉得这玩意儿就是把单列索引拼起来,其实完全不是那么回事。

我记得刚入行那会儿,有个同事在订单表上建了三个单列索引:user_idstatuscreate_time。结果查询还是慢得要命。我一看执行计划,好家伙,MySQL 只选了一个索引用,其他两个根本没用上。这就是典型的「索引各自为政」的问题。

1.1 联合索引的物理存储结构

联合索引在 B+ 树里是怎么存的?我画个图你就明白了。

联合索引 (user_id, status, create_time) 的 B+ 树结构 根节点: (101, 1, '2024-01-01') (100, 0, '2023-12-01') (102, 2, '2024-02-15') (99, 0, '2023-11-01') → 行记录指针 (100, 1, '2023-12-15') → 行记录指针 (102, 1, '2024-01-20') → 行记录指针 (103, 0, '2024-03-01') → 行记录指针 叶子节点按 (user_id, status, create_time) 字典序排序 先按 user_id 排序,相同则按 status,再相同则按 create_time 根/中间节点 叶子节点

看到没?联合索引的排序规则是:先按第一个字段排,第一个相同再按第二个排,以此类推。这就像查字典,先按拼音首字母,再按第二个字母...

二、最左前缀原则:为什么「跳列」就不行?

最左前缀原则,是联合索引的灵魂。我见过太多人在这上面栽跟头了。

2.1 原则的本质

假设我们有索引 (a, b, c),那么以下查询能用上索引:

  • WHERE a = 1 ✅ 用到了 a
  • WHERE a = 1 AND b = 2 ✅ 用到了 a, b
  • WHERE a = 1 AND b = 2 AND c = 3 ✅ 用到了 a, b, c
  • WHERE b = 2 ❌ 用不上!因为跳过了 a
  • WHERE a = 1 AND c = 3 ⚠️ 只用到了 a,c 用不上

为什么会这样?你想想看,B+ 树里数据是按 (a,b,c) 整体排序的。如果你只给 b 的值,树根本不知道从哪开始找——因为相同 b 值可能分散在不同 a 值下面。

核心结论:联合索引的匹配,必须从最左边开始连续匹配,不能跳过中间的列。

2.2 一个让我印象深刻的案例

我曾经帮一家电商公司优化订单查询。他们的表有 5000 万行,索引是 (order_id, user_id, status)。业务方经常跑这样的查询:

SELECT * FROM orders 
WHERE user_id = 12345 AND status = 'paid';

结果呢?全表扫描,每次查询 3 秒多。为什么?因为跳过了最左边的 order_id,索引根本用不上。

解决方案很简单:把索引改成 (user_id, status, order_id),查询立马降到 5 毫秒。你看,有时候就是字段顺序的问题。

三、联合索引的建立策略

建联合索引,我总结了几个实战经验:

3.1 字段选择五原则

原则 说明 举例
1. 等值条件放最左 经常用 = 的字段放前面 WHERE user_id = 1
2. 区分度高的放前面 唯一值多的字段优先 user_id 比 status 更适合放前面
3. 范围查询放后面 ><BETWEEN 的字段放右边 WHERE a = 1 AND b > 10
4. 频繁查询的字段优先 业务中最常用的查询条件 按日活用户查询习惯来定
5. 避免冗余索引 (a,b) 和 (a) 不要同时存在 (a,b) 已经覆盖了 (a) 的功能

我的小技巧:建索引前,先把业务中所有的查询 SQL 收集起来,统计每个字段出现在 WHERE 中的频率。频率最高的等值条件字段,放在最左边。这招我在多个项目里验证过,效果很好。

3.2 实战:如何设计一个高效的联合索引

假设我们要为订单表设计索引,常见的查询有:

  1. WHERE user_id = ? AND status = ?
  2. WHERE user_id = ? AND create_time > ?
  3. WHERE status = ? AND create_time BETWEEN ? AND ?

我的做法是:

-- 索引1:覆盖查询1和2
CREATE INDEX idx_user_status_time ON orders(user_id, status, create_time);

-- 索引2:覆盖查询3(注意这里status放前面)
CREATE INDEX idx_status_time ON orders(status, create_time);

为什么这么设计?因为查询1和2都带了 user_id,所以把它放最左边。查询3没有 user_id,需要单独建索引。

四、索引下推:MySQL 5.6 后的性能利器

索引下推(Index Condition Pushdown,ICP)是 MySQL 5.6 引入的优化。说实话,这个特性刚出来的时候我没太在意,直到有一次优化慢查询才真正体会到它的威力。

4.1 没有索引下推时发生了什么

假设索引是 (name, age),查询是:

SELECT * FROM users 
WHERE name LIKE '张%' AND age = 25;

没有 ICP 时,MySQL 的做法是:

  1. 用索引找到所有 name 以「张」开头的记录(可能 1000 条)
  2. 回表,把 1000 条完整记录都取出来
  3. 在 Server 层过滤 age = 25

这就浪费了!明明 age 也在索引里,为什么不直接在索引里过滤掉?

4.2 索引下推的优化原理

有了 ICP 后:

  1. 用索引找到 name 以「张」开头的记录
  2. 在存储引擎层,直接用索引里的 age 字段过滤,只保留 age = 25 的
  3. 只对过滤后的少量记录回表

说白了,就是把过滤操作「下推」到了存储引擎层,减少了回表次数。

-- 查看索引下推是否开启
SHOW VARIABLES LIKE 'optimizer_switch'\G
-- 输出中会包含 index_condition_pushdown=on

-- 通过执行计划确认是否用了ICP
EXPLAIN SELECT * FROM users 
WHERE name LIKE '张%' AND age = 25;
-- Extra 列会显示:Using index condition

注意:索引下推只适用于二级索引。对于聚簇索引(主键索引),因为数据就在索引上,不需要回表,所以也用不上 ICP。

4.3 索引下推的最佳实践

我在项目中总结了几点:

  • 尽量把查询条件涉及的字段都放进联合索引,这样 ICP 才能发挥作用
  • LIKE 模糊查询配合 ICP 效果明显,特别是前缀匹配的情况
  • 范围查询后的等值条件也能利用 ICP,比如 WHERE a > 10 AND b = 5

举个例子,我曾经优化过一个用户搜索功能:

-- 原索引
CREATE INDEX idx_name ON users(name);

-- 优化后(利用ICP)
CREATE INDEX idx_name_age_city ON users(name, age, city);

-- 查询
SELECT * FROM users 
WHERE name LIKE '王%' 
  AND age BETWEEN 20 AND 30 
  AND city = '北京';
-- 优化后,age 和 city 的过滤在索引层完成,回表次数减少 90% 以上

五、总结一下

联合索引和索引下推,说白了就是两件事:

  • 联合索引:把多个字段揉在一起排序,遵循最左前缀原则。设计时把等值条件、高区分度的字段放左边。
  • 索引下推:把过滤操作从 Server 层下推到存储引擎层,减少回表。MySQL 5.6 后默认开启,但需要合理的索引设计才能发挥效果。

嗯,这些就是我这些年和索引打交道的一些心得。下次建索引前,不妨先想想:这个查询到底能不能用上索引?最左前缀原则有没有被满足?能不能利用索引下推减少回表?想清楚了再动手,能少走很多弯路。