接手一个线上订单系统的时候,我有过一段印象很深的经历:订单列表页越翻越慢,用户反馈加载要两三秒,业务方天天在后面催。后来翻慢查询日志,定位到一条按用户ID查订单的SQL,执行计划里清清楚楚写着ALL,全表扫描。几张核心表加起来上千万行,不慢才怪。当时干的第一件事就是在order表的user_id字段上建了一个单列索引,再跑同样的查询,执行时间从一秒多直接掉到几十毫秒。一个CREATE INDEX的功夫,前后差了二十多倍。也就是从那次之后,我开始认真对待MySQL的索引调优,尤其是单列索引——它不像复合索引那样听着高级,但恰恰是最基础、也最容易被忽视的优化手段。
这篇内容主要围绕单列索引来展开,包括它的底层结构、适用场景、创建前的分析思路、EXPLAIN怎么看、常见的失效场景,以及索引数量的权衡。适合正在接触索引优化、想搞清楚“为什么加了索引还是慢”的MySQL使用者,也适合看完官方文档但总觉得差一步的开发者。不会讲太深奥的源码内容,但会把B+树结构、索引选择性、回表这些关键概念用大白话讲明白,看完能直接拿自己的表实验。
1. 为什么说单列索引是所有索引调优的地基
1.1 先搞清楚B+树到底给你省了什么
很多人建索引就是一句“这个字段常在WHERE里出现,加个索引”,但加完索引为什么快,心里其实是模糊的。这里得把InnoDB的索引结构说白了。
MySQL的InnoDB存储引擎用的是B+树,索引数据按顺序组织成一棵树,从根节点出发到叶子节点,每一层之间的节点都通过指针关联。B+树最核心的特点是:数据都存放在叶子节点,非叶子节点只存索引键和指向子节点的指针。这样做的好处是树的高度非常稳定,通常三层就能撑住千万级别的数据量。
我习惯用一个简单的估算方式来帮助理解:假设InnoDB一个数据页默认16KB,一行数据大约占1KB,那么一个叶子页大约能放16条数据;非叶子节点上的索引键加指针大约占12字节,一个页大约能放1170个键值。三层B+树大约能存储1170乘以1170乘以16,接近2200万行。也就是说,对于千万级表,走聚簇索引查询最多只需要三次磁盘I/O就能定位到目标行。而全表扫描要一个页一个页地读,机械硬盘或高负载场景下,这个差距就是秒级和毫秒级的区别。
回到单列索引上来。单列索引就是只针对一个字段建的普通B+树,叶子节点存放的是索引列的值和主键值。查询时先通过这棵“小树”找到对应的主键,再通过主键回表去聚簇索引里拿完整行。这个过程就是俗称的“回表”。加了单列索引,等于给查询条件建了一条“快速通道”,不用再把整张表翻一遍。
1.2 什么时候该用单列索引,什么时候别急着上复合索引
单列索引最舒服的场景就是:查询条件里只涉及一个字段,而且这个字段的区分度高。比如用户登录场景,账号字段唯一性很强,一个账号对应一条用户记录,这时候在account字段上建一个唯一索引或普通索引,查询效率和直查主键几乎没差别。
但如果业务查询里经常同时出现多个等值条件,比如“status + type + city”三个字段一起过滤,很多人第一反应是给每个字段各建一个单列索引。这种做法其实有坑。MySQL虽然理论上可以做索引合并(index merge),但实际执行计划里那种理想合并出现得并不频繁,更多时候优化器只会从中挑一个它觉得性价比最高的索引,剩下条件还是靠回表后逐行过滤。结果是每个单列索引都建了,但SQL的响应时间并没有明显改善。
真遇到多个字段高频组合过滤时,应该考虑的是复合索引,把最常一起出现的查询字段做成一个索引。但这不代表单列索引没价值——复合索引的左边前缀原则,本质上依赖了最左侧字段可以独立作为一个索引来使用。换句话说,复合索引的最左字段,就是一个“带额外属性”的单列索引。把单列索引吃透了,你自然就能理解复合索引的匹配规则,也知道什么时候该拆、什么时候该合。
1.3 哪些字段根本不值得建索引
经常有同学拿着表就问:“我这张表几十个字段,要不要把出现过的条件全建上索引?”答案是千万别。建索引不是零成本的,单列索引也是真实存储结构,每一条数据的插入、更新、删除都要同步维护这棵树。字段越多、索引越多,写入链路上的代价就越大。
不适合建单列索引的典型字段有这么几类:
- 区分度低的字段。比如性别、状态这类取值只有几个枚举值的列。搜索引擎对这类字段的过滤效果很差,可能一次扫描还是得捞出一大片数据,优化器稍微算算就发现全表扫比走索引更快。
- 极少出现在WHERE条件或JOIN条件里的字段。索引建了基本就是吃灰,还白占空间。
- 超长文本字段。比如长VARCHAR或TEXT类型的字段,如果只是偶尔查询,直接建索引会让索引树变得巨大臃肿,利用率还低。
- 频繁更新的字段。每次更新都会伴随索引维护,热度高的更新字段加索引,容易加剧锁竞争和页面分裂。
我自己的经验是,先看慢查询日志,再决定加不加索引,而不是对着表结构脑补。索引是为查询服务的,没有明确查询压力,就别动手。
2. 创建单列索引之前,先把数据本身摸清楚
2.1 一张示例表与基础索引语句
为了后面讲起来方便,我建一张常见的用户订单表来做示例。表结构大概长这样:
CREATE TABLE `user_order` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `user_id` bigint NOT NULL COMMENT '用户ID', `order_no` varchar(64) NOT NULL COMMENT '订单号', `pay_amount` decimal(10,2) DEFAULT '0.00' COMMENT '支付金额', `order_status` tinyint NOT NULL DEFAULT '0' COMMENT '订单状态 0待支付 1已支付 2已取消', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户订单表';假设这张表已经积累了大约五百万行数据,业务里最典型的查询是根据user_id查某个用户最近的订单。这时候建一个单列索引:
ALTER TABLE `user_order` ADD INDEX idx_user_id (`user_id`);这就是最标准的单列索引创建方式。如果确定业务里user_id在同一时刻只会对应一条有效记录,也可以考虑唯一索引,但大多数订单场景下用户是有多笔订单的,普通索引更合适。
2.2 用区分度判断索引值不值得建
索引的核心价值是帮你缩小扫描范围。判断一个字段有没有这个能力,最直接的办法就是算区分度,也就是字段的离散程度。SQL可以这样写:
SELECT COUNT(DISTINCT user_id) / COUNT(*) AS selectivity FROM user_order;这个比例越接近1,说明每个值对应的记录越少,索引过滤效果越好。比如一个字段一共有300个不同的值,表里有500万行,那平均每个值对应上万行记录,这种索引就算建了,过滤完还是剩一大堆,意义不大。
除了区分度,还要看字段的活跃度。一个字段天生就固定只有“0、1、2”三个状态值,区分度再高也就那样,得配合其他字段一起用。反过来,像user_id、order_no这种每个用户、每笔订单几乎都不一样的字段,区分度天然好,非常适合建单列索引。
我习惯在正式建索引前跑一遍这样的统计,把候选字段的区分度数据列出来,再结合慢查询里的实际SQL频率综合判断。这样做的好处是避免“拍了脑袋建索引,回头一看执行计划根本不走”。
2.3 前缀索引和排序规则的影响
还有一种常见情况:字段是长字符串,比如一串很长的业务编码。这种字段整体建索引会占用不少空间,导致索引页能放下的记录变少,B+树变高,查询性能反而受影响。此时可以只对字段的前N个字符建前缀索引:
ALTER TABLE `user_order` ADD INDEX idx_order_no_prefix (`order_no`(20));这里的关键是选择合适的长度。长度太短,区分度不够;长度太长,省空间的优势又没了。可以用不同前缀长度试算区分度:
SELECT COUNT(DISTINCT LEFT(order_no, 10)) / COUNT(*) AS sel10, COUNT(DISTINCT LEFT(order_no, 20)) / COUNT(*) AS sel20, COUNT(DISTINCT order_no) / COUNT(*) AS sel_full FROM user_order;当某个长度值对应的区分度已经与全字段区分度非常接近时,就可以直接采用该前缀长度。前缀索引最大的代价是它不能用于覆盖索引,也不能用于排序和GROUP BY操作,因为索引里存的不是完整字段值。所以,前缀索引只适合那种“减少扫描范围”的场景,不适合“直接返回字段值”或“按这个字段排序”的场景。
字符集和排序规则也常被人忽略。utf8mb4下字段的索引比较规则默认跟随表定义。如果业务对大小写敏感,但表使用的是不区分大小写的排序规则,那么索引也无法加速区分大小写的查询。这种细节问题在排查“为什么建了索引就是不走”的疑问时经常碰到。
3. 单列索引实操:看懂EXPLAIN,拒绝盲人摸象
3.1 EXPLAIN各列到底怎么看
建完索引第一件事不是急着上线,而是先看执行计划。EXPLAIN是MySQL调优最基础的工具,它不会真正执行SQL,只是告诉你优化器打算怎么干。
EXPLAIN SELECT * FROM user_order WHERE user_id = 123456;执行计划里重点关注这几列:
| 列名 | 含义 | 对索引调优的意义 |
|---|---|---|
| type | 访问类型 | ref、range优于index,ALL是全表扫描,是重要警报 |
| possible_keys | 优化器候选索引 | 如果为空,说明SQL没命中任何可用索引 |
| key | 优化器实际选择的索引 | 判断最终是否走了你建的索引 |
| rows | 预估需要扫描的行数 | 数值越小通常越好,但也是估算值 |
| Extra | 附加信息 | 出现Using index代表覆盖索引,Using filesort代表需要额外排序 |
type字段的常见取值从好到差依次是:system > const > eq_ref > ref > range > index > ALL。单列等值查询如果走索引,通常会落在const或者ref;范围查询则会落在range;如果type是ALL,说明这条SQL还是全表扫描。
3.2 等值查询、范围查询和排序的实际执行计划
以之前建了idx_user_id的user_order表为例,来看几种常见场景。
场景一:等值查询。
EXPLAIN SELECT * FROM user_order WHERE user_id = 1001;执行计划里type是ref,key是idx_user_id,rows显示预估扫描的行数。这个结果就说明索引生效了。
场景二:范围查询。
EXPLAIN SELECT * FROM user_order WHERE user_id > 100000 AND user_id < 200000;同样走idx_user_id,type变成range。范围查询能利用索引,是因为B+树的叶子节点是有序的,可以顺着指针连续扫描目标区间,不需要逐行碰运气。
场景三:排序。
EXPLAIN SELECT * FROM user_order ORDER BY create_time DESC LIMIT 10;如果create_time上还没有索引,Extra里大概率会出现Using filesort。这说明MySQL需要把结果先放进排序缓冲区做一次额外排序。给create_time加上单列索引之后,由于B+树叶子节点天然有序,ORDER BY可以直接按索引顺序扫描来获取结果,Using filesort就会消失。
3.3 回表是怎么影响单列索引效率的
走了索引也不代表一切OK,还要留意回表层数。单列索引的叶子节点只存索引字段和主键值,查询需要SELECT出来而不在索引里的字段,仍要靠主键回到聚簇索引里去读取整行数据。
我做过一个实验:从同样的表里查询数据,一个SQL只返回主键和user_id,另一个SQL返回整行所有字段,两者的耗时差距在数据量大时非常明显。前者如果只查user_id,那么光靠索引本身就能拿到数据,Extra里会显示Using index,这就是覆盖索引。后者必须回表,每行记录都要额外做一次主键查找。
要减少回表带来的损耗,最直接的办法就是尽量让SQL只查询必要字段,不要动不动SELECT *。但业务上经常确实需要完整行数据,这时候单列索引回表是正常代价,只要扫描行数控制得够小,性能依然可以接受。
提示:如果发现某个查询经常要按单列索引过滤然后回表取大量字段,同时又需要多次排序或分组,这时候可以考虑在单列索引的基础上扩展成复合索引,把高频返回字段或排序字段一起挂进索引,从而做到更彻底的覆盖。单列索引是起点,但不是终点。
4. 单列索引的经典失效场景,很多坑我也踩过
4.1 类型转换和函数包裹让索引直接作废
这是出现频率最高的坑。建了索引却没走,十次里有八次是SQL写法上的问题。
最常见的就是隐式类型转换。字段在表里定义是varchar,SQL传参却传了数字;或者字段定义是int,SQL里却把参数写成了字符串。MySQL为了比较,会把字段值做一次转换再匹配,这样索引上的原始值无法直接参与比较,优化器会选择放弃索引。
-- order_no是varchar(64),下面这条SQL如果order_no列上建了索引也不会走 EXPLAIN SELECT * FROM user_order WHERE order_no = 123456;解决办法是保持字段类型和参数类型一致。如果确实改不了SQL,可以考虑在列上做修正,但这通常不是好方案,因为对列做类型转换会导致索引失效。
另一个常见的失效原因是函数包裹。SQL里对索引列做了函数处理,比如YEAR(create_time)、DATE_FORMAT等,索引树的排序结构是基于原始值的,函数处理后的结果和原始值顺序不再对应,所以没办法走索引。对这类查询,能改成范围条件就尽量改,比如把YEAR(create_time) = 2024改写成create_time >= '2024-01-01' AND create_time < '2025-01-01',这样就能正常走索引。
4.2 LIKE模糊查询和OR条件的影响
LIKE查询的失效规则很多人知道,但细节说不太清。以name字段为例,'abc%'这种前缀匹配是可以走索引的,因为B+树的有序性允许按前缀定位。但'%abc'这种后缀匹配,无法利用索引的顺序定位,只能把所有记录都过一遍,索引自然就失效了。
OR条件也值得注意。如果OR两边都是索引字段,MySQL有可能对两个索引分别索引合并;如果有一边没有索引,整个逻辑就变成全表扫描。我曾经排查过一个案例,查询条件里一个字段能走索引,另一个字段没有索引,SQL加上OR之后执行计划直接变成ALL,建索引的那个条件也白搭了。遇到这种场景,合情合理的做法是想办法把OR改写成UNION,或者给OR两边涉及的字段都配上索引。
4.3 优化器“看不上”索引的几种情形
有时候SQL写法没问题,索引也建了,但执行计划里还是ALL。这可能不是索引失效,而是优化器算完账以后觉得走索引还不如全表扫描划算。
最常见的情况是数据量很小。一张表只有几百条记录,全表扫描可能只要一个数据页就读完,走索引还需要先查索引页再回表,反而多一步。这种时候优化器放弃索引是合理行为,不需要焦虑。另一种情况是统计信息滞后。MySQL优化器依赖表统计信息来估算行数,如果表数据发生了大规模变化,而统计信息没有及时更新,估算就会偏。平时可以定期用ANALYZE TABLE来刷新统计信息,尤其是大批量导入数据之后。
还有一种情况是字段区分度确实太差,优化器预判走索引后过滤不掉多少数据,最终选择全表扫。这时候加上索引也没用,得从查询条件或表结构设计上想办法。
4.4 单列索引与覆盖索引的取舍
虽然覆盖索引不是单列索引的主场,但在某些简单场景里它可以发挥作用。比如:
SELECT user_id, COUNT(*) FROM user_order GROUP BY user_id;如果user_id上有单列索引,这个语句的查询字段只有user_id,执行计划里就可能出现Using index,直接从索引树统计分组结果,不需要回表,速度极快。这就是单列索引聊胜于无的“覆盖”能力——只要查询涉及的列都在索引里,InnoDB就不需要回聚簇索引读行。
实际业务中遇到“SQL卡在回表”的场景,我一般会在单列索引基础上加入需要返回的字段做成复合索引,而不是执着于只用单列索引。比如经常按user_id查order_no和create_time,就可以建idx_user_id_time(user_id, create_time),这样既能过滤又能排重查询字段,回表压力会小很多。
5. 单列索引的数量、维护,以及调优的先后顺序
5.1 不是给所有热字段都建索引就叫调优
早年间我对索引的态度就是“谁用得多就建谁”,一张表上堆了十几个单列索引,看起来每个字段都有覆盖,实际线上跑起来问题一大堆。最直接的问题是写入变慢。每次INSERT或UPDATE,所有索引树都要同步更新,索引越多,更新链路上的开销就越大。
如果一张表本身写入频率高,比如订单流水、日志表,那么多索引带来的延迟会被成倍放大。更麻烦的是,多个单列索引之间没法互相配合,复杂的组合条件查询只能挑一个索引走,其余条件照样得逐行过滤。这导致明明建了很多索引,查询却没快多少。
我现在的习惯是:单表索引数量尽量控制在5个以内,核心原则是让每个索引都有明确的“服务对象”,要么服务一个高频等值查询,要么服务一个排序场景。如果发现多个单列索引经常被同一个SQL用到,就果断把他们合并成复合索引。
5.2 用SHOW INDEX和慢查询日志持续跟踪索引状态
索引建好不是一劳永逸的,MySQL的统计信息、数据的增删改,都会影响执行计划的走向。我每次调试索引后都会做这么几件事:
第一,用SHOW INDEX查看索引状态。重点关注Cardinality列,这个值代表索引中不同值的估算数量。如果这个值和表行数相差过大,说明索引区分度不好,或者统计信息老化。
SHOW INDEX FROM user_order;第二,重新收集统计信息。大批量数据变更后,执行计划有时候会变得反常,最稳妥的办法是跑一次ANALYZE TABLE让优化器重新认识表。
ANALYZE TABLE user_order;第三,继续盯慢查询日志。索引优化是个反复的过程,上线后要看真正的生产查询是否稳定走到了预期索引,有异常就马上回查和调整。
5.3 调优的顺序:先找出慢SQL,再谈建索引
很多刚接触调优的人容易倒过来,先看表结构,再脑补应该建什么索引。但真实的调优流程应该是:从慢查询日志或业务反馈里找到具体的慢SQL,分析它的表访问方式,然后针对这个SQL去设计索引。
一条SQL对应一个索引方案,多条件查询考虑复合索引,单条件高频查询就上单列索引。索引不是越多越好,也不是越少越好,而是刚好够用。我踩过很多次“加了索引但没效果”的坑,最后发现原因几乎都是没搞清楚SQL真实的访问路径就动手建索引。
注意:生产环境的大表加索引一定要选在业务低峰期操作。MySQL 8.0里可以用ALGORITHM=INPLACE、LOCK=NONE来尽量做到在线加索引,但大表操作依然可能产生额外负载。稳妥起见,宁可在维护窗口执行,也不要赌它在高峰期能一路绿灯。
6. 一个小实验:验证单列索引在不同数据量下的表现
为了更直观地展示单列索引的作用,我做过一个简单的对照实验。向一张空表里插入不同规模的数据,然后分别在没有索引和有索引的情况下执行同一条等值查询。
数据量在几万行以内的时候,有没有索引差距不算大,因为表本身不大,全表扫描也能很快完成。等数据量到了几百万行,差别就开始显露了。没有索引时,每次查询都要扫描大量数据页;有单列索引时,定位目标行只需要少量索引页访问加一次回表。实测同一台机器上,500万行的表,无索引查询耗时经常超过几百毫秒到一秒,加索引后稳定在十几毫秒以内。
但我也遇到过一个反例,某张表刚好只有几万行,给一个区分度很低的字段建了索引,执行计划反而选择全表扫描。这正是因为优化器认为“这点数据量直接扫也很快,走索引还要额外读索引页”。所以,调优一定要基于当前表实际的数据规模来判断,不能光看理论。
通过这种实验也能建立一种直觉:单列索引是为“大表 + 高区分度条件 + 高频查询”准备的工具,而不是任何条件下都能立竿见影的银弹。
7. 我自己的实操习惯与建议收尾
如果你也正在调自己的单列索引,我分享几个平时沉淀下来的小习惯。首先是建索引之前一定先跑EXPLAIN,让数据库告诉你现在的SQL是什么状态,有时候连索引都不用加,只是改写一下SQL就能解决。其次是区分度计算,一个区分度只有百分之几的字段,就算建了索引也过滤不了多少数据,不如想想怎么调整查询条件。最后是多关注执行计划里的Extra列,Using filesort、Using temporary这些隐藏信息往往是性能瓶颈的真正来源。
踩过几次坑之后,我现在面对“慢查询”的第一反应不再是急着加索引,而是先问几个问题:这个字段的区分度怎么样?查询条件能不能覆盖到索引?SQL写法有没有类型转换或函数包裹?是不是多个条件共同过滤却没有复合索引?把这些问题过一遍,索引方案基本就清晰了。
单列索引看起来简单,但它考验的是你对索引原理、查询模式和数据特征的组合理解。把单列索引玩明白了,后面的复合索引、覆盖索引、索引下推这些进阶优化,都会顺畅很多。希望这篇内容能帮你少走一些弯路。