直接以博文正文开始:
平时跟同事聊 MySQL 索引,聊得最多的就是“这条 SQL 为什么没走索引”“那个表加了联合索引为什么查询还这么慢”。很多人背过八股文,知道主键索引和联合索引的存储结构,但一落到真实业务和 SQL 优化上就开始凭感觉操作。这篇文章我不打算抄书,而是把主键索引和联合索引的原理掰开来讲:它们底层到底怎么存数据、回表是怎么回事、联合索引最左前缀原则背后的原因是什么,以及实战中最容易踩的坑和排查方法。适合正在学习 MySQL 底层机制的人,也适合写了好几年 SQL 但从没系统梳理过索引原理的开发同学。
我默认你用的是 InnoDB 引擎,这是 MySQL 5.5 之后默认的存储引擎,也是绝大多数业务系统的选择。搞清楚 InnoDB 里的索引模型,比死记索引规则有用得多。
1. 先搞懂 InnoDB 的索引模型:B+ Tree 是怎么存储数据的
1.1 为什么索引选择了 B+ Tree,而不是二叉查找树或哈希表
很多人一开始会想,查找最快的不就是哈希表吗?O(1) 的复杂度,为什么 MySQL 不直接用哈希索引做主索引?这个问题想明白了,B+ Tree 的设计意图也就清楚了一大半。
哈希索引确实能单条等值查询做到接近 O(1),但它解决不了范围查询和排序。比如 WHERE id > 100 AND id < 1000,哈希结构只能全表遍历,因为哈希值的排列顺序和数据本身没有单调关系。而 B+ Tree 是排好序的多叉平衡树,叶子节点之间通过双向指针串联,既能走等值查询,也能高效走范围扫描。
二叉查找树同理,理论上每个节点的查找是 O(logN),但树的高度受数据量影响。当数据量达到千万级时,二叉树的高度动辄二十几层,而 InnoDB 的页大小是 16KB,一个节点能放下成百上千个键值。MySQL 使用 B+ Tree 一个核心考量就是把树高控制在 2 到 4 层,极端情况下三层 B+ Tree 就能支撑千万级数据,意味着查询最多只需要几次磁盘 I/O。
1.2 聚簇索引:主键就是整行数据存在的位置
InnoDB 中,主键索引也叫聚簇索引。聚簇这个词听起来抽象,但你记住一句话就够用了:聚簇索引的叶子节点存的是完整的一整行记录。
也就是说,表里的数据行并不是独立存放在某个文件里,而是直接按照主键的顺序组织在 B+ Tree 的叶子节点上。主键是 1、2、3,数据在磁盘上物理排布也基本按 1、2、3 的顺序。这个设计带来一个特点:按主键查数据,只要从 B+ Tree 根节点往下走到叶子节点,读到的就是目标行的完整字段,不需要再做任何二次查询。
这里有个自然的推论——InnoDB 表必须有主键。如果你建表时没有显式指定主键,InnoDB 会先找第一个非空的唯一索引作为主键;如果连唯一索引都没有,它就会自动生成一个 6 字节的隐式主键 ROWID,但这个 ROWID 对应用层完全透明,你也无法利用它做查询优化。所以建表时务必要指定主键,否则你可能白白付出额外的存储和 I/O 代价。
还有一个很多人在意的概念叫索引组织表。因为数据按照主键排序存储,插入新记录时 InnoDB 会找到主键排序后的正确位置,再插入。如果主键值是单调递增的,那么新记录永远追加在末尾,代价最小。如果主键值是随机的,插入时可能会造成页分裂和页重排,产生大量随机 I/O 和碎片,写入性能明显下降。这也是为什么业界普遍推荐用自增主键或雪花算法生成的趋势递增主键,而不是直接用 UUID 字符串。
1.3 非聚簇索引的落叶归根指向主键
除了主键索引之外,其他索引都叫二级索引,也叫非聚簇索引。你手动加的普通索引、唯一索引,以及下面要讲的联合索引,都属于二级索引。
二级索引的 B+ Tree 结构和主键索引大致相同,但有一个关键差异:二级索引叶子节点存放的不是完整行数据,而是当前索引列的值加上对应的主键值。
举个例子,假设你在一张 user 表的 name 字段上建了一个普通索引,那么这颗二级索引树的结构大致是:每个叶子节点存了 (name, id) 这样的二元组,并按 name 排序。当你执行 SELECT * FROM user WHERE name = '张三' 时,MySQL 先在二级索引树上查到 name 等于张山的记录,发现叶子节点里只有 name 和主键 id,并没有你要的完整字段,于是拿到主键 id 再去主键索引树查一次,把整行数据读出来。这个“拿到主键再回主键索引查询”的过程就叫回表。
回表本身不是 bug,是 InnoDB 为了节省存储空间做出的均衡设计。如果每个二级索引叶子节点都存一份完整行数据,那每建一个索引就等于把整张表复制一遍,磁盘占用和写入开销将无法接受。而只存主键值,既能让索引体积小,也能保证所有二级索引都能通过主键关联回完整数据。
理解这个模型后,你自然就明白为什么 InnoDB 强烈建议主键越短越好。因为主键值会被复制到每一个二级索引的叶子节点里,主键越长,所有二级索引的体积就越大,占用的内存和磁盘也就越多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 联合索引的本质:排序规则与最左前缀的真正逻辑
2.1 联合索引的内部结构是一棵单树,而不是多个独立索引
很多初学者会把联合索引理解成“在多个列上分别建索引”,这是最大的误区。ALTER TABLE user ADD INDEX idx_name_age (name, age) 建立的是一个联合索引,而不是 name 上的索引加上 age 上的索引。它底层只用一棵 B+ Tree,这棵树先按第一个字段 name 排序,在 name 相同的情况下再按第二个字段 age 排序,如果有第三个字段,就接着按第三个字段排序。
你可以类比成查英文字典的过程。字典先按首字母排序,首字母相同再看第二个字母,以此类推。你查一个词的时候,如果你只知道第三个字母,是无法通过字典的目录结构减少查找范围的,只能从头翻到尾。
联合索引内部数据排列大概是这样,我用极简示例表示一下:
- ('Alice', 21, id=1)
- ('Alice', 25, id=6)
- ('Bob', 20, id=3)
- ('Bob', 24, id=8)
- ('Bob', 30, id=10)
- ('Carol', 22, id=2)
能看到 name 有序,而 age 只有在 name 相同的前提下才保持有序。如果跳过 name 直接查 age,引擎面对的是一堆乱序的 age 值,那就只能全量扫描这棵索引树。这就是为什么联合索引查询必须遵守最左前缀原则。本质不是 MySQL 定了一条死规则,而是 B+ Tree 的排序结构天然决定了:只有从最左列开始匹配,才能充分利用索引的有序性做快速定位。
2.2 最左前缀原则:哪些查询能走索引
基于上面的排序规则,我们来看实际查询条件对索引 idx_name_age 的利用情况。我用一张表直接对比:
| 查询条件 | 是否走索引 | 原因分析 |
|---|---|---|
WHERE name = 'Alice' |
走索引 | 直接使用联合索引第一个字段 |
WHERE name = 'Alice' AND age = 25 |
走索引 | 先按 name 定位,再按 age 精确过滤 |
WHERE age = 25 |
不走索引 | 没有 name 作为前缀,age 在索引中不是全局有序 |
WHERE name > 'Alice' AND age = 25 |
部分走索引 | name 范围查询后的 age 无法利用索引过滤 |
这四类是最典型的场景。其中第一类和第二类最好理解,第三类也是大家最容易出错的,看到 age 上建了索引就以为条件里有 age 就能走,其实如果查询条件是 age = 25,无论你联合索引的第一个字段是什么,只要没带上前缀列,优化器只能选择扫全表。
- 如果联合索引是
(a, b, c),查询条件里只有 b 和 c,通常索引无效。 - 如果条件里只有 a 和 c,那么 a 能利用索引定位,c 则无法利用索引过滤。
- 如果条件里只有 a 和 b,这两个条件都能高效利用索引。
值得注意的是,前缀列的匹配可以是不等值操作。WHERE name LIKE 'Ali%' 也能走索引,因为索引按字典序存储,Ali 前缀可以把区间缩小到一个连续范围。但 WHERE name LIKE '%li%' 这种通配符开头的写法由于无法确定起始位置,走不了索引。
2.3 为什么说联合索引可以起到“多个单列索引”的作用
联合索引另一个常见的用法是通过调整字段顺序,让一个索引覆盖多个查询场景。比如 (a, b) 联合索引既能加速 WHERE a = ?,也能加速 WHERE a = ? AND b = ?,实际上等于同时建立了 a 单列索引和 ab 联合索引这两个效果,且只付出了一棵索引树的成本。
但是这里必须注意一个条件,WHERE b = ? 单独作为查询条件时,这个联合索引完全不生效。如果你发现业务上高频出现只按 b 查询的场景,那么要么再建立 b 单列索引,要么把联合索引设计成 (b, a),必须根据你的真实查询模式决定。
这引出了一个在索引设计里常见且很有用的优化策略:把高频查询列放在联合索引最左侧,把用于精确过滤的列放在范围条件列的前面。举个例子,如果始终先按 user_id 查,再按 status 和 create_time 筛选,那么 (user_id, status, create_time) 就比 (status, user_id, create_time) 更合理,因为省去了每次查询都额外回表或额外过滤的麻烦。
3. 覆盖索引、回表和索引下推:联合索引隐藏的成本博弈
3.1 覆盖索引为什么能避免回表
很多人知道回表慢,但对“覆盖索引”这个概念的把握并不准确。覆盖索引并不是一种特殊的索引类型,它描述的是一个查询效果:当你要查询的所有字段都包含在某个二级索引树里时,MySQL 只需要扫描这棵二级索引树,不需要再回到主键索引去取数据行。
举个例子,表里有联合索引 idx_name_age (name, age),你执行 SELECT name, age FROM user WHERE name = 'Alice'。在二级索引叶子节点上,name 和 age 都已经存在,那么 MySQL 直接就能返回结果,连回表都不需要做。
但是如果改成 SELECT name, age, phone FROM user WHERE name = 'Alice',phone 字段不在二级索引里,必须要回表去主键索引取 phone,这个时候就不是覆盖索引了。
在真实业务里,覆盖索引的收益通常体现在高频查询上。比如分页列表只展示 id、title、status,如果建一个 (status, create_time, id, title) 联合索引,查询时就有可能完全避免回表,把这个列表接口的查询耗时压低一半以上。当然,字段越多索引越大,写入成本也越高,所以覆盖索引需要在空间和查询收益之间做取舍,不要试图把所有字段都塞进去。
对于查询中出现的 SELECT *,几乎不可能做到覆盖。过宽的行数据会导致回表次数直线上升。一个有效的手段是在写完 SQL 后,把不必要的 * 换成明确字段列表,这既能让优化器更容易利用覆盖索引,也能显著减少网络传输的数据量。
3.2 联合索引与排序:filesort 到底是哪里来的
除了过滤,联合索引还能优化排序。因为索引本身是有序的,如果 ORDER BY 的字段正好满足索引字段的顺序,MySQL 就能直接利用索引顺序输出结果,不需要额外的文件排序。
比如索引 idx_user_time (user_id, create_time),执行 SELECT * FROM user WHERE user_id = 123 ORDER BY create_time DESC 时,由于 user_id 等值匹配定位到一批数据后,这些数据的 create_time 已经天然按序排列,直接反向扫描即可,完全避免 filesort。如果执行的是 WHERE user_id = 123 ORDER BY name,那么排序字段 name 不在索引中,MySQL 只能先把符合条件的数据读取出来,再在内存中或磁盘上做排序,也就是 explain 结果里的 Using filesort。
文件排序并不一定使用了磁盘文件,数据量小时在内存的 sort buffer 中就能完成,但总体上它仍然会带来额外消耗。一次完整的排序:先把数据读出来,逐行放入排序缓冲区,按照排序字段进行排列,如果缓冲区不够还要分块排序再合并,最后再返回数据。
有两个方向可以优化排序:
- 让 ORDER BY 字段尽量匹配上联合索引的字段顺序,把排序提前到索引层完成。
- 减少排序行宽,不要 SELECT 大字段(如 text、长 varchar)来参与排序,尽量先用索引和主键完成排序,最后再回表取详细内容。
如果从原理层面理解这两条,你就能解释很多调优案例里看起来费解的现象。
3.3 索引下推:联合索引里被忽略的“隐性优化”
MySQL 5.6 引入的索引下推,是联合索引体系下一个非常关键的优化机制,但日常开发中很多人没意识到它的存在。它解决的问题是:在联合索引查询中,对于没有完全利用到的后缀字段,能否提前在索引层过滤掉,而不是把整行数据回表再过滤。
拿联合索引 (age, name) 来说,执行 WHERE age > 20 AND name LIKE '%张%' 时,age 能用索引范围定位,但 name 因为左模糊匹配无法使用索引定位,只能作为普通过滤条件。在没有索引下推的年代,MySQL 的行为是:用 age 范围从索引上找到所有符合条件的记录,拿到主键回表,取出整行后再在服务层判断 name 是否匹配。
有了索引下推后,InnoDB 在读取二级索引记录时,如果发现 name 条件中的列也在索引里,就率先在存储引擎层对 name 做过滤,过滤掉明显不满足条件的记录,只有真正通过判断的记录才回表。这样大大减少了回表次数。
用你能感知到的对比来说:假设年龄大于 20 的记录有 10000 条,其中名字带“张”的有 100 条。不开下推时,要回表 10000 次;开启下推后,只回表 100 次。虽然 name 的 % 开头查询无法用于树定位,但利用索引下推可以让它至少承担“索引内过滤”的角色,整体效率天壤之别。
如果用 EXPLAIN 查看执行计划,出现 Using index condition 就表示引擎层使用了索引下推。它对联合索引优化意义很大,尤其是查询条件同时包含可用前缀和不可作为前缀条件的字段时,你要尽量把可过滤字段也建到联合索引中,就是为了让下推能生效。
4. 主键索引和联合索引在写入时的性能博弈
4.1 每多一个二级索引,一次 INSERT 就要多写一棵 B+ Tree
开发阶段建索引往往很随意,但随着数据量增长和写入并发升高,索引对写入性能的影响就会逐渐暴露。一次 INSERT 不仅要往主键索引插入新记录,还要往每个二级索引各插入一条新的索引数据。也就是说:如果一张表有 1 个主键和 4 个二级索引,一次 INSERT 本质上是同时在 5 棵 B+ Tree 上执行插入操作。
如果表中还有唯一性约束的二级索引,那么写操作前还需要额外的唯一性检查,读取可能涉及的索引页进行冲突判断。这个开销在并发量高的时候会被明显放大。很多时候数据库写入慢,并不是磁盘不行,而是表上的索引过多、每个插入都需要维护多棵索引树,导致大量随机 I/O 与锁等待。
这也是我建议“索引够用就好”的原因。不要想着把所有查询条件都建上索引。正常单表二级索引控制在 5 个以内比较稳妥,如果超过这个数量,建议重新审视一下业务查询模式,看能否通过合并联合索引来减少重复索引。
4.2 页分裂是怎么发生的:慢写入的隐形凶手
往 B+ Tree 插入新数据时,如果目标页已经写满了,就需要申请一个新的页,然后把原来页中一半的数据移动到新页,这个过程叫页分裂。页分裂本身并不可怕,可怕的是在高并发写入时频繁发生,它会带来额外的写放大、锁竞争以及数据碎片。
前面讲过,主键自增时新记录总是插入到最后一个页,基本不触发页分裂。如果主键是随机字符串,比如 UUID,那么新记录可能插入到 B+ Tree 的中间位置,导致频繁页分裂和页重排。很多团队在生产环境用过 UUID 主键后都发现,写入性能下降不是 10%、20% 的事,而是数量级级别的退化。
如果你的主键是 varchar 类型但非 UUID,比如业务订单号,可以考虑两种优化路径:一是额外增加一个自增 id 作为主键,把业务编号放到唯一索引上;二是保持业务主键但尽量保证其前缀具有趋势递增特性。否则,随着数据量上涨,页分裂造成的性能损耗会越来越明显。
4.3 更新操作与索引的联动成本
UPDATE 操作对索引的影响也容易被低估。如果更新的是普通字段,那么只需要回表修改数据行本身。如果更新的字段正好是联合索引中的某个列,InnoDB 除了要更新数据行外,还需要把该行在联合索引树中的位置进行调整:先删除旧的索引条目,再插入新的索引条目。
看这条常用 SQL:
UPDATE user SET age = 26 WHERE name = 'Alice'
如果 age 在 idx_name_age 里面,更新后索引顺序可能变化,因为 name 相同的情况下,age 的排序位置需要和别的记录重新比较。所以本质上,这条更新至少涉及二级索引里一次删除和一次插入。如果你的 UPDATE 同时涉及多个索引列,成本还会叠加。对应到索引设计上,实际线上的更新频率比查询频率更高的列,不太适合放在联合索引前列。反之,查询频率高但更新很少的列,才适合进入索引。
这一点也是很多架构师在评审表结构时最关心的:请先把表字段拆分清楚——哪些是只读列,哪些是频繁更新列,然后决定你到底要为哪些列建立联合索引。
5. 通过执行计划看懂索引是否真正生效
5.1 EXPLAIN 输出中几个必须关注的列
理论讲了这么多,实际业务验证索引效果最快的方式还是 EXPLAIN。MySQL 8.0 中执行 EXPLAIN SELECT ...,结果里有很多列,日常调优至少要关注这几个字段。
- type:访问类型。从好到坏大致为:system、const、eq_ref、ref、range、index、ALL。如果看到 ALL,说明是全文扫描,基本意味着索引失效或没有可用索引。
- key:实际选择的索引名。如果为 NULL,说明没有走任何索引。
- key_len:使用索引的长度,能够反映联合索引到底用了哪几个字段。
- rows:预估扫描的行数,数值越小通常越好。
- Extra:额外信息,常见值有 Using index、Using where、Using index condition、Using filesort、Using temporary。
一个很容易被忽略的技巧是 key_len。比如联合索引 idx_name_age,name 是 varchar(50) 且字符集为 utf8mb4,那么 name 字段最大占用是 50*4+ 变长字段 2 个字节,也就是 202 字节左右(还要看是否允许 NULL,如果允许 NULL 则再加 1 个字节)。当你的查询只用了 name 条件时,key_len 大约等于 202;如果同时用了 name 和 age,age 如果是 int 类型且非空,key_len 会再增加 4。观察 key_len 你就能知道联合索引到底生效到了哪一列,比凭感觉猜要准确得多。
5.2 一条典型 SQL 的执行计划全解析
假设表结构如下:
sql复制CREATE TABLE user_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
order_no VARCHAR(64) NOT NULL,
status TINYINT NOT NULL,
create_time DATETIME NOT NULL,
amount DECIMAL(10,2),
KEY idx_user_status_time (user_id, status, create_time),
UNIQUE KEY uk_order_no (order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
执行查询:
sql复制SELECT id, order_no, amount
FROM user_order
WHERE user_id = 1001
AND status = 1
AND create_time > '2024-01-01 00:00:00'
ORDER BY create_time DESC;
上面的 WHERE 条件完整覆盖了联合索引 (user_id, status, create_time) 的前缀列。user_id 等值匹配定位用户,status 等值过滤一层,create_time 由于是范围条件,可以在索引中继续缩小范围。ORDER BY create_time 也和联合索引顺序一致,所以很可能不需要文件排序。你可以用 EXPLAIN 验证,预期 Extra 不会出现 Using filesort,key 为 idx_user_status_time。
再看另一个容易走偏的查询:
sql复制SELECT id, order_no, amount
FROM user_order
WHERE status = 1
AND create_time > '2024-01-01 00:00:00';
查询条件里的第一个可过滤列变成了 status,而联合索引的第一个 column 是 user_id,无法满足最左前缀。最坏情况下执行计划是全表扫描,type=ALL。虽然表里确实存在包含 status 和 create_time 的索引,但由于它们不在联合索引的最左侧,这个查询依然无法有效利用该联合索引。如果这个查询是核心高频查询,那么就要考虑额外创建 (status, create_time) 的联合索引,或者调整已有索引的字段顺序。
5.3 你可能会遇到的一个反直觉现象:索引明明存在却没有使用
有时候我们用 EXPLAIN 诊断 SQL,发现一个查询实际上有条件字段也建立了索引,但执行计划就是显示全表扫描。这是因为优化器会基于统计信息估算成本,如果它判定“走索引回表的成本比全表扫描还高”,就会放弃索引。
最常见的情形就是查询条件命中了表中绝大多数数据。比如 status 字段只有两个值,其中 90% 行都是 1,你执行 WHERE status = 1,优化器算了一下不如直接全表扫又快又省,于是它就不走索引。这并非索引失效,只是优化器的理性选择。
另一个常见原因是发生了隐式类型转换。字段是 varchar,传入参数是数字,例如 WHERE order_no = 123456,MySQL 可能会放弃索引或产生无法命中索引的转换。同理,在索引列上使用函数,例如 WHERE DATE(create_time) = '2024-01-01',同样会让优化器无法直接使用索引来定位,因为索引里保存的原始值,而非函数运算后的结果。针对这种日期查询,更好的写法是等值或范围区间:
sql复制WHERE create_time >= '2024-01-01 00:00:00'
AND create_time < '2024-01-02 00:00:00'
养成这样写日期范围条件的习惯后,能少踩很多索引失效的坑。
6. 结合实战的联合索引设计取舍
6.1 索引字段顺序怎么排,我给出一个可操作的排序逻辑
面试或实际评审里经常讨论联合索引到底把哪个字段放前面。其实没有万能答案,但有一套非常实用的判断顺序:
- 先看等值查询字段,把它们尽量放在最前面。等值条件可以精确定位,让索引过滤效能最大化。等值字段中,区分度高的放前面还是后面,需要结合查询频率决定,没有绝对标准。
- 再看范围查询字段,放到等值字段之后。范围查询会截断后续索引列的使用,所以把范围字段放在前面会浪费后续字段的过滤能力。
- 排序字段如果可以,应该放在联合索引中靠后的位置,让排序直接利用索引顺序。
- 如果一个字段经常做分组或去重,也可以考虑把它放进联合索引,因为它能减少临时表和文件排序。
举个例子,常见订单表高频查询是:WHERE merchant_id = ? AND status IN (...)? AND create_time > ? ORDER BY create_time DESC。由于 status 是范围(IN 本质上是多个等值组合,不算纯前缀截断),你按“等值在最前、范围在后、排序字段靠后”的思路排,适合考虑索引是 (merchant_id, create_time) 或者 (merchant_id, status, create_time)。具体选哪个要看 status 的过滤效果和查询组合,不能凭感觉实现一刀切。
6.2 冗余索引和无效索引的清理
日常开发中最容易出现的索引浪费是重复索引。比如先建了 idx_user_id (user_id),后来又建了 idx_user_id_status (user_id, status)。这两个索引在前者场景下是完全冗余的,因为所有走 idx_user_id 的查询都可以用 idx_user_id_status 替代,单列索引能做的,联合索引也能做。保留两个不仅浪费空间,还拖慢写入。
同理,如果表里已有 (a, b, c) 联合索引,再建 (a, b) 联合索引也属于冗余索引。但 (b, a, c) 并不是冗余,因为两棵树的排列顺序不同,它们服务于不同的查询前缀。
清理索引时,不要只看字段,要结合实际 SQL 日志、慢查询日志和 information_schema 中统计的索引使用次数。比如可以通过查询 sys.schema_unused_indexes 找到长期未被使用的索引,然后再决定是否删除。我见过不少表里累计了三四个从来没被优化器选中的索引,删除后写入性能明显改善。
6.3 分页查询和大数据量场景下的联合索引设计
大数据量常见查询还有一个容易被忽略的问题:深分页。执行 LIMIT 100000, 20 时,MySQL 仍然需要先扫描并丢弃前面的 100000 行,再返回最后 20 行。即使走了索引,随着页码加深,消耗也会越来越大。
一个和联合索引结合的惯用优化方法是先通过辅助索引查出当前页需要的主键范围,再通过主键回表取完整数据,或者直接用延迟关联把主键查出来后再 join 原表。但这种优化方式对 WHERE 条件有要求,比如执行计划能利用 (user_id, create_time) 联合索引定位候选 id,再 join 回表:
sql复制SELECT t.*
FROM user_order t
INNER JOIN (
SELECT id
FROM user_order
WHERE user_id = 1001
ORDER BY create_time DESC
LIMIT 100000, 20
) tmp ON t.id = tmp.id
上面子查询中只 select id,如果 (user_id, create_time) 索引存在,可以在索引树上完成排序和分页,避免回表 100000 行。之后再用 20 个主键回表,速度自然快很多。这是把“索引覆盖尽可能少的列”和“回表延后到数据量已经收敛后”两个思想结合起来。
7. 一个综合案例:从慢查询定位到索引设计的完整思路
为了把这些内容串起来,我模拟一个很常见的慢查询案例。假设你的业务有一张交易流水表,已经有联合索引 idx_user_created (user_id, create_time)。某天收到一条慢查询:
sql复制SELECT *
FROM trade_flow
WHERE user_id = 12345
AND status = 1
AND create_time >= '2024-06-01'
ORDER BY create_time DESC
LIMIT 10;
看到这个 SQL 后,先不要急着加索引,按下面的步骤排查。
第一步,执行 EXPLAIN,观察 key 和 rows。如果当前走的是 idx_user_created,说明用户维度定位已经生效。但因为查询还带了 status = 1,执行计划必须在回表后再过滤 status,导致 rows 可能就是该用户某个时间段内的所有流水量,如果数量很大,回表次数会很多。
第二步,查看 Extra 是否有 Using filesort。由于索引顺序是 (user_id, create_time),而 user_id 使用了等值,create_time 的范围条件后仍然能保持有序,所以这个查询的排序直接可以利用索引,一般不会有 filesort。
第三步,分析性能瓶颈在回表过滤。解决方向是考虑将 status 也加入联合索引,构成 (user_id, status, create_time)。这样查询执行过程会变成:在索引里先精确定位该用户和 status=1 的记录,再在 create_time 上做范围过滤,而且叶子节点只需要回表少数符合条件的记录,效率自然提升明显。
改完之后再次用 EXPLAIN 验证,可以关注 key_len 是否比原来多了 1 字节(status 是 tinyint 且非空时多 1 个字节),就说明联合索引已经覆盖到了 status 这一列。
最后,再检查 SELECT 出的列。如果业务只需要 id、amount、create_time、status 这些字段,而你不小心写了 SELECT *,每次回表都需要取所有列值,查出的行宽一大,性能又会下降。如果改成明确需要的字段列表,甚至可以考虑把 amount 加入联合索引形成覆盖,这种把慢查询优化到极致的方法在做高并发交易系统时非常有价值。
这个案例里没有高深莫测的操作,但每一步都建立在理解 B+ Tree 存储结构和联合索引排序方式的基础上。
在我看来,MySQL 索引原理最核心的知识点就两类:一类是每个索引底层其实是一棵独立的 B+ Tree,另一类是叶子节点上到底存了什么。主键索引存整行,二级索引存索引字段和主键。把这两句话说透,后面所有与索引相关的优化都不会走偏。面试时你能从 B+ Tree 的存储结构推导出回表和最左前缀原则,就比单纯背诵概念好得多。
在实际开发中,建议每写一条查询时都顺手跑一下 EXPLAIN,尤其看看 key_len 和 Extra,搞清楚联合索引真正用到了哪一层。长时间下来,你会对索引行为形成直觉,而不是等到线上慢查询暴雷才来找原因。
