1. 先搞清楚索引是把"钥匙"还是"导航"
1.1 索引的本质:从"翻书找内容"说起
很多刚接触MySQL的朋友会把索引理解成一把"万能钥匙",认为只要有索引,查询就一定会快。但实际踩过几次坑之后你会发现,索引更像一本图书的"目录导航",它帮你快速定位到数据所在的物理位置,而不是让你直接拿到数据本身。这个理解上的偏差,直接决定了你后续优化思路是否正确。
我们拿一张真实的业务表来举例。假设有一张订单表,里面有两百万行数据,你要查某个用户在最近一个月内的所有订单。没有索引的时候,MySQL只能从头到尾把两百万行数据全部读一遍,逐个判断用户ID和下单时间是否满足条件,这就是全表扫描(full table scan)。全表扫描意味着两百万次判断,磁盘IO的消耗是实打实的,慢才是正常现象。
有索引之后,MySQL可以先通过索引这个"目录"找到满足条件的记录的物理地址,然后再回表(把主键拿回去查完整行数据)取出完整记录。这个过程就像你在一本几百页的书里查找某个关键词,有目录和没目录的体验完全是天壤之别。
不过这里有一个很关键的点:索引不是免费的午餐。每建立一个索引,写入数据的时候就要额外维护一份索引结构,插入、更新、删除操作的性能都会受影响。所以索引优化不是一个"越多越好"的问题,而是一个"怎么在查询性能和写入成本之间找平衡"的问题。
1.2 聚簇索引与非聚簇索引的存储差异
MySQL默认的InnoDB存储引擎下,索引的存储方式分为两大类:聚簇索引(clustered index)和二级索引(secondary index,也叫辅助索引、非聚簇索引)。
聚簇索引在InnoDB里就是主键索引。它的特点是:索引的叶子节点直接存储整行数据。InnoDB的表数据本身就是按照主键顺序组织的,所以每张InnoDB表有且只能有一个聚簇索引。你建表时如果没有显式定义主键,InnoDB会优先选一个非空的唯一列作为聚簇索引;如果连唯一列都没有,它会自动生成一个不可见的rowid来作为聚簇索引。这种"隐藏主键"表面上没影响,但如果你后续经常用其他字段做查询,就很容易出现回表次数过多的问题。
二级索引的叶子节点存储的是索引列值加上主键值。查询时如果索引列已经覆盖了需要的字段,就不用回表,这叫做"覆盖索引";如果还需要其他字段,就必须拿着主键回到聚簇索引里去取整行数据,这个动作就是"回表"。回表的次数直接决定了查询速度,所以优化索引时,一个高频操作就是想办法把回表次数降下来,甚至做到完全不回表。
我自己在实际项目里见过一个典型的例子:某系统加了索引之后查询反而更慢,排查下来发现是走了二级索引后每条记录都要回表,而普通列上建的索引选择性又不够高,结果回表次数几乎等于全表行数。这种时候索引还不如不用,优化器最终选择全表扫描反而是合理的。
理解了存储差异,你才能真正读懂后面要讲的联合索引设计、覆盖索引优化等等内容。索引不是简单的"加个索引"三个字,而是要先想清楚底层数据结构是怎么工作、查询路径是怎么走的。
2. 给一张具体的表设计索引时,先回答三个问题
2.1 你查得最多的到底是哪几条SQL
这是我在做索引优化时问自己的第一句话。很多人的习惯是一上来就对着表结构想"哪几个字段比较常用",然后每个字段都建一个单列索引。这样做往往会让事情变得更糟:索引数量膨胀,写入变慢,优化器在选择索引时也会纠结。
正确顺序应该是先捞慢查询日志,看看生产环境里真正拖后腿的SQL是哪些。每一类SQL都要拆开看它的WHERE条件、ORDER BY排序字段、GROUP BY分组字段、JOIN连接字段,以及SELECT要返回哪些列。
我举一个真实场景。一张订单表里有user_id、order_no、status、pay_time、amount这几个字段。业务上有两类高频查询:一是查某个用户最近30天的订单列表,二是根据订单号查订单详情。这两类查询需要的索引完全不同。第一类适合在user_id和pay_time上建联合索引,因为查询条件是用户加时间范围;第二类适合直接在order_no上建唯一索引,因为订单号本身就足够区分每一条记录。
这里面有一个反直觉的现象:如果你给status这种字段单独建索引,往往收益极低。因为status一般只有几个取值(待支付、已支付、已取消等),每个值对应的数据量占比都很高,MySQL优化器一算就知道索引选择性太低,走索引还不如直接全表扫描快。所以"建索引"之前先搞清楚业务查询模式,比任何技巧都重要。
2.2 区分度与选择性:为什么性别列不适合建索引
区分度(cardinality)这个指标值得认真理解。它表示索引列上不同值的个数。区分度越高,索引的筛选能力越强。比如订单号每一条都是唯一的,区分度很高;性别只有两个值,区分度极低。
一个很直观的计算方式是:
- 列的选择性 = 列中不同值的数量 / 表的总行数
- 选择性越接近1,说明这个列越适合建索引
- 选择性越接近0,说明这个列区分度越低,建索引的意义越小
比如一张十万行的用户表,城市列有300个不同值,选择性就是300/100000=0.003,这个值只能算一般。但如果是身份证号列,几乎每一行都不同,选择性接近1,就非常适合做唯一索引。
不过区分度也不是唯一标准。如果某个查询条件里经常用到低区分度字段,但你组织的联合索引把这个低区分度字段放在了最前面,那索引的过滤效果就会被严重拉低。最典型的反面案例就是联合索引(sex, age, name)这种设计,因为第一个字段就已经把可用的区分度浪费掉了。
我之前接手过一个项目,表里有个is_deleted字段,只有0和1两个值,结果开发同学在上面建了索引。每次查询都要带上is_deleted=0这个条件,MySQL也确实走了这个索引,但扫描的行数依然接近全表。后来把is_deleted从索引里去掉,让它作为普通过滤条件,查询速度反而加快了。这就是"索引不一定要包含所有查询字段"的教训。
2.3 字段长度与冗余:前缀索引的取舍
如果要在很长的字符串列上建索引(比如一个很长的备注字段、URL字段),直接对整个列建索引不仅浪费空间,还会让索引的B+树变得非常大。这时可以考虑前缀索引:只取字符串的前N个字符作为索引值。
前缀索引的核心思路是:用更小的存储空间换取可接受的区分度。假设某字段有十万条数据,你分别取前缀10、15、20个字符,前缀区分度会逐渐上升。实际操作时可以用这样一句SQL来验证:
SELECT COUNT(DISTINCT LEFT(comment, 10)) / COUNT(*) AS sel10, COUNT(DISTINCT LEFT(comment, 15)) / COUNT(*) AS sel15, COUNT(DISTINCT LEFT(comment, 20)) / COUNT(*) AS sel20 FROM activity_log;当某个N对应的选择性和全列选择性的差距可以接受时,就选那个N作为前缀长度。我一般会要求前缀索引的选择性尽量接近全列选择性的90%以上,太低的话查询时会扫出太多脏数据。
前缀索引也有代价:它不能让这条查询走覆盖索引,因为索引里存的是前缀而不是全值,回表是免不了的。所以如果某条查询对性能要求极严,且那个长字段又必须返回,就需要权衡是建完整列索引消耗空间,还是建前缀索引接受回表。没有绝对正确的答案,只有适合当前业务场景的取舍。
3. 联合索引的命中与失效边界
3.1 最左前缀原则的完整演示
联合索引是MySQL索引优化的核心武器。假设我们要对a、b、c三个字段建立联合索引(a, b, c),这个索引实际上会按照"先按a排序,a相同再按b排序,b相同再按c排序"的方式组织。
最左前缀原则意味着,可以命中该索引的查询条件是:
- WHERE a = ?
- WHERE a = ? AND b = ?
- WHERE a = ? AND b = ? AND c = ?
而不太容易命中的情况是:
- WHERE b = ?(跳过了a)
- WHERE c = ?(跳过了a和b)
- WHERE b = ? AND c = ?(跳过了a)
我经常用"字典的目录结构"来解释这件事:一本词典先按首字母排序,再按第二个字母排序,最后按第三个字母排序。你想查某个词,必须从首字母开始翻;直接找第二个字母是找不到页面的。联合索引也是同理,优化器需要从联合索引的第一个字段开始匹配,才能一步步利用索引的有序性。
需要特别留意的范围查询。如果WHERE条件里出现了范围查询(比如BETWEEN、>、<),那么范围查询后面的字段就享受不到索引的排序优势了。如下面的SQL:
SELECT * FROM order_detail WHERE user_id = 123 AND pay_time > '2024-01-01' AND status = 1;如果联合索引是(user_id, pay_time, status),那么status实际上用不到这个索引的排序过滤能力,MySQL只能在pay_time的范围结果里再做一次status过滤。这是"范围之后全失效"原则。所以设计联合索引时,要把等值查询的字段排在前面,范围查询的字段排在后面。
3.2 隐式类型转换、函数操作与字符集
索引失效还有一个高频陷阱:对索引列做了函数运算或隐式类型转换。最经典的案例就是字符串列和数字列比较。
-- phone 字段是 varchar 类型,但条件里直接传了数字 SELECT * FROM member WHERE phone = 13800138000;这条SQL看起来很正常,但MySQL会先把phone列转成数字再和13800138000比较,结果就是索引列上发生了隐式转换,索引失效,变成全表扫描。这也是为什么我总是建议查询条件里的参数类型一定要和表结构里的字段类型保持一致。如果是接口传参,要特别检查是不是有JS的数字类型把字符串自动转掉了、ORM框架有没有做类型映射这类问题。
函数操作则是另一种常见失效场景:
SELECT * FROM member WHERE DATE(create_time) = '2024-06-01';对create_time列用DATE()函数之后,索引列已经被加工过,B+树里存储的原始值和查询条件无法直接比较,所以索引废掉。解决办法有两个:一是把条件改成create_time >= '2024-06-01 00:00:00' AND create_time < '2024-06-02 00:00:00',二是考虑建函数索引(如果MySQL版本支持,比如8.0里的功能索引)。
字符集不一致的问题也很隐蔽。两张表关联查询时,一张表的字段是utf8mb4,另一张表的字段是utf8,MySQL在做比较时会对其中一个列做字符集隐式转换,同样导致索引使用失败。早期的跨表JOIN性能问题,很多就是出在字符集不统一上。
3.3 排序与分组场景下的索引利用
ORDER BY和GROUP BY操作是最容易被忽视的索引优化场景。很多人把索引只理解为WHERE过滤,但其实索引本身是有序的,如果ORDER BY的字段顺序和索引顺序一致,MySQL就可以直接利用索引顺序输出结果,省去文件排序(filesort)的开销。
举个例子:
SELECT user_id, pay_time FROM pay_record WHERE user_id = 10086 ORDER BY pay_time DESC;联合索引(user_id, pay_time)能同时完成过滤和排序。因为索引里user_id相同的情况下,pay_time已经天然有序,优化器直接反向扫描就可以拿到按时间倒序的数据,完全不需要filesort。
但如果你写成ORDER BY pay_time ASC, user_id DESC这种混合排序,方向和字段顺序都和索引不一致,排序优势就没法利用了。还有一个常见误区:联合索引是(a, b, c),你在WHERE里只用了a,ORDER BY却用了b和c,这时候索引能搞定过滤和排序,但如果你ORDER BY了c和b,顺序颠倒,索引又会失效。排序方向不一致、字段顺序不一致、中间有范围条件,都会让排序优化打折扣。
GROUP BY本质上也是一种排序操作,MySQL会对分组字段做排序再分组。如果分组字段能走联合索引,效率会明显提升。尤其是配合聚合函数(COUNT、SUM)使用时,通过覆盖索引能够大幅减少回表次数。
4. 实战案例:从500ms到8ms的优化全过程
4.1 表结构与慢查询现场
下面分享一个我实际处理过的案例,完整演示一次索引优化的排查链路。业务背景是一个电商后台的订单列表页,运营人员要按各种条件筛选订单。表结构简化后如下:
CREATE TABLE `trade_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL, `user_id` bigint NOT NULL, `shop_id` bigint NOT NULL, `status` tinyint NOT NULL, `order_amount` decimal(10,2) NOT NULL, `pay_time` datetime DEFAULT NULL, `create_time` datetime NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;表里大概有六百万行数据。运营后台的筛选条件常见的有:按用户ID查、按店铺ID查、按订单状态查、按支付时间范围查,并且要根据支付时间倒序排列。最初的慢SQL长这样:
SELECT order_no, user_id, status, order_amount FROM trade_order WHERE shop_id = 1001 AND status = 2 AND pay_time BETWEEN '2024-03-01 00:00:00' AND '2024-03-31 23:59:59' ORDER BY pay_time DESC LIMIT 20;这条SQL在测试环境跑还不觉得有什么问题,一到生产就原形毕露,单次查询要500ms左右。用户一多,数据库连接池直接被慢查询占满,整个后台都卡。
我做优化第一步不是直接建索引,而是先看执行计划,搞清楚MySQL到底是怎么执行这条SQL的。这里强调一下:index优化,一定要先explain,再看执行计划,再动索引。
4.2 explain读片:新手必须看懂的关键列
MySQL在执行一条SQL之前,优化器会生成一个执行计划,explain命令可以把执行计划展示出来。很多人知道explain,但不知道重点看哪些列。我自己最常看的五个关键列:
- type:访问类型,从好到坏大致是system > const > eq_ref > ref > range > index > ALL。能看到ref或者range就已经不错了,如果出现ALL就是全表扫描的警报。
- key:实际选中的索引名称。
- rows:预估扫描的行数,越小越好。
- filtered:表示返回的行数占扫描行数的百分比,100%最好。
- Extra:里面出现的"Using filesort""Using temporary"都是性能警示信号;出现"Using index condition"说明走了索引下推;出现"Using index"说明覆盖索引生效。
我执行explain看到的结果大概是:
- type: ALL
- key: NULL
- rows: 6000000
- Extra: Using where; Using filesort
type是ALL,等于全表扫描六百万行,还要做文件排序,不慢才有鬼。虽然status、shop_id、pay_time这些字段都各自有单列索引,但面对三条等值/范围条件组合,优化器判断走任何一个单列索引都扫不出足够精确的结果,还要大量回表,倒不如直接全表扫还省事。
这里就能看出"每个字段各建一个索引"的坏处了:单列索引之间是独立的,没法相互配合做交集过滤。MySQL虽然用索引合并(index merge)这个机制,但它的出场条件很苛刻,效果也不稳定,不能依赖它。
4.3 对症下药:覆盖索引与索引下推
分析清楚之后,我给这张表设计了一个联合索引:
ALTER TABLE trade_order ADD INDEX idx_shop_status_paytime (shop_id, status, pay_time);设计理由很简单:shop_id等值、status等值、pay_time是范围查询。按照之前说的"等值字段放前面,范围字段放后面"的原则,顺序就是shop_id、status、pay_time。
加上这个索引之后,我再跑explain:
- type: range
- key: idx_shop_status_paytime
- rows: 3050
- Extra: Using index condition
扫描行数从六百万降到了三千行左右,查询耗时降到了20ms以下。
但我还不满意,因为还有个问题:SELECT返回的列里有order_no、order_amount,这两个字段不在索引里,所以每次查到符合条件的记录后,还需要拿着主键id回到主索引取完整行,也就是回表。当结果集很大的时候,回表次数依然不少。
于是我又调整了索引,把查询要返回的字段也包含进来:
ALTER TABLE trade_order ADD INDEX idx_shop_status_paytime_cover (shop_id, status, pay_time, order_no, order_amount);这一步的目的就是尽量做到覆盖索引。但这条SQL里还有ORDER BY pay_time DESC,而索引里pay_time在中间位置,所以排序时需要费点劲。我分析了一下,其实因为筛选后的行数已经很少(三千行),filesort的代价已经可以接受了。最终这条慢查询的耗时稳定在8ms左右,运营后台的页面从"转圈圈"变成了"秒开"。
这个案例说明了一个道理:索引设计不是一步到位的。有时候你的第一版索引已经把查询从"全表扫描"救到了"范围扫描",但距离最优还差一步。如果你连返回字段都装进索引里,就能把回表也省掉。
关于MySQL 5.6以后引入的索引下推(Index Condition Pushdown,ICP),值得单独提一下。ICP允许MySQL在存储引擎层直接对索引中包含的字段做过滤,减少回表次数。它的关键标志就是Extra列里的"Using index condition"。比如上面的联合索引里pay_time是范围条件,status是等值条件,在没有ICP的时候,MySQL只把shop_id和status作为索引条件,其他字段过滤要回表后才能做。有了ICP,pay_time在存储引擎层就被过滤掉了,回表次数进一步减少,这也是为什么现在新版本MySQL里联合索引范围查询后的字段也不是完全没用。
5. 索引建完不是结束,还要体检与复盘
5.1 慢日志与performance_schema的组合用法
索引设计完并上线之后,接下来要做的是验证效果和持续监控。我见过太多人建完索引就撒手不管,结果过了几周索引基数漂移了、查询也变慢了,完全不知道。
第一步是开启慢查询日志。参数有两组:long_query_time用来定义"多慢算慢",一般业务我建议先设成1秒;slow_query_log用来开启日志记录。这种设置有两种做法,一是改配置文件my.cnf永久生效,二是用SET GLOBAL在线调整,适合临时排查。
开启了慢查询日志之后,还要配合performance_schema来定位高频慢SQL。有一个思路:如果慢日志里同一类SQL反复出现,ORMs的模板SQL大概率是固定的,只是参数值不同而已。你只需要把这些带参数的SQL统一归类,统计各模板出现的次数、平均耗时、总耗时,就能快速排查出最值得优化的SQL模板。
我在项目里常用的一种做法是:
SELECT digest_text, COUNT(*) AS exec_count, ROUND(SUM(timer_wait)/1000000000, 2) AS total_ms, ROUND(AVG(timer_wait)/1000000000, 2) AS avg_ms FROM performance_schema.events_statements_summary_by_digest WHERE SCHEMA_NAME = 'your_db' GROUP BY digest_text ORDER BY total_ms DESC LIMIT 20;这条SQL会把数据库里所有聚合后的SQL按总耗时排序。你会发现有趣的现象:有些SQL执行次数不高,但单次特别慢;有些SQL执行次数极高,平均耗时几毫秒,累加起来的总耗时却非常惊人。优化时不能只看单次速度,而是要看总消耗。
体检完要牢记一件事:慢查询日志只是一个入口,真正要判断"这条SQL有没有利用好索引",还是回到explain去验证。如果某个查询走了索引,但rows还是太大,可能需要重新审视索引的区分度和过滤条件。
5.2 冗余索引清理与索引统计信息更新
索引建多了之后,最直接的问题是写入变慢和磁盘占用变大。更隐蔽的问题是"冗余索引"——看似多个索引,实际职责重叠。比如已经有了联合索引(shop_id, status),再单独建一个shop_id的单列索引就是完全冗余的,因为联合索引的最左前缀已经能覆盖shop_id条件。冗余索引的存在还会干扰优化器的选择,让它计算代价时做出错误判断。
清理冗余索引时,先查一下当前有哪些索引,然后逐条分析它们的覆盖关系。我一般用这条SQL看表上的所有索引:
SHOW INDEX FROM trade_order;把结果列出来之后,按"左前缀原则"找重复:凡是某个索引的前缀字段集合,被另一个联合索引的前缀包含,这个索引就属于冗余索引。当然也要看具体value的区分度,如果某个单列索引有独特用途(比如唯一约束),那它有额外存在价值,不能一刀切。
另外一个容易被忽视的问题是索引统计信息过期。MySQL优化器判断走哪个索引,靠的是表的统计信息(cardinality)。如果统计信息不准,优化器可能做出错误的索引选择。常见做法是在数据量发生大幅变化后(比如大批量导入数据)执行ANALYZE TABLE:
ANALYZE TABLE trade_order;优化器拿到最新的统计信息后,索引选择的准确性会高很多。这也是"数据导入后查询反而变慢"的常见原因之一。
这里还要补充一个我不太推荐但偶尔有用的手段:force index。大多数时候我不会用它作为生产环境的长期方案,因为它是"硬编码"级别的干预,一旦数据分布变化,强制指定的索引可能反而不是最优。我更愿意把它当排查工具:用force index强制走某个索引,和正常执行做对比,判断优化器选错索引的原因到底出在统计信息还是索引结构上。
6. 索引设计经验清单
最后整理一份我在多个项目里沉淀下来的索引设计清单,每条都来自真实踩坑。供你对照自查。
- 先看业务SQL,再设计索引。没有慢查询日志就先开慢查询日志,拿数据说话,不要靠感觉猜。
- 联合索引的字段顺序:等值条件在前,范围条件在后,排序字段根据实际需要放在合适位置。
- 优先使用覆盖索引减少回表,但不要为了覆盖而盲目把很多字段塞进索引,索引宽度过大会导致B+树层级变深,IO次数反而增多。
- 低区分度字段(性别、状态码、is_deleted这类)尽量避免作为索引的第一列;如果业务必须带这种字段,要考虑它后面是否跟着高区分度字段。
- 字符串列太长时使用前缀索引,但一定要统计SELECT DISTINCT LEFT的效果,不能拍脑袋定前缀长度。
- 避免在索引列上做函数运算和隐式类型转换,这会让索引直接失效。参数类型和列类型保持一致是最基本的操作规范。
- 联合索引范围查询后方的字段并不会完全失效,在MySQL 5.6+的索引下推机制下仍能部分过滤,但排序优势会消失,设计时仍要遵循等值在前、范围在后的大原则。
- 频繁更新、删除的表上索引不宜过多,索引维护的开销可能比查询节省的成本更大。如果某个索引只为了偶尔一次管理后台查询而建,建议评估一下是否值得。
- 定期用performance_schema聚合慢SQL,定期更新统计信息,定期清理冗余索引。索引优化不是一次性工作,而是一个持续迭代的过程。
- 建索引时优先考虑已有联合索引是否能覆盖你的新查询场景,复用永远比新增更划算。
根据我个人经验,索引优化做到"能解释清楚每一步为什么"比直接给出三十条优化建议值钱得多。你面对一张新表时,不要急着加索引,先弄明白数据分布、业务查询模式、MySQL优化器的选择逻辑,然后有针对性地建一两个高质量联合索引,效果往往比一股脑加十个单列索引好得多。这个思路在你遇到下一个性能问题时,会比任何现成工具都管用。