2023 年我做一个订单管理后台,有个最常用的功能是按各种条件查订单——按用户查、按时间查、按状态查。数据量一上来,这些查询开始变慢。怎么让它快起来?这件事我没多想,就有了方案:加索引。第一版我做得很顺手——查哪个列慢,我就给哪个列 CREATE INDEX 建一个索引,涉及到的列基本都建上了。本地拿测试库一测,确实快了不少,我心里很笃定:加索引嘛,不就是哪个列查得慢就给哪个列建索引,这查询性能稳了。可等数据量再涨、查询条件再变复杂,一串问题冒了出来。第一种最先把我打懵:我明明给下单时间列建了索引,可一个查"某一年订单"的查询还是慢得要命,我去看执行计划,它压根没用那个索引,在老老实实做全表扫描。第二种最难缠:按手机号查用户,手机号那列明明有索引,查询却还是不走索引。第三种最头疼:我建了个三个列的联合索引,有的查询能用上它,有的查询条件里明明带着索引的列,却就是用不上。第四种最莫名其妙:有个查询确实走了索引,可还是不够快,执行计划里写着它先用索引找到了行、又"回表"去取完整数据,这个回表成了新的瓶颈。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为只要给查询涉及的列建了索引,查询就一定会走这个索引、就一定快。可索引建出来,根本不保证查询会用它——索引只是给数据库的优化器提供了一个"可选项";一个查询到底走不走某个索引,是优化器根据它对成本的估算来决定的。而你写 SQL 的方式——有没有在索引列上做运算、有没有触发隐式的类型转换、联合索引的列用得对不对、用了什么样的条件——会直接让优化器"没法用"或"不愿用"这个索引。要把查询性能做扎实,根上要明白:索引能不能生效,一半在于你建了什么索引,另一半、而且常常是更关键的一半,在于你怎么写查询。本文从头梳理:为什么"建了索引"不等于"会走索引",为什么在索引列上做运算会让索引失效,隐式类型转换这个最隐蔽的坑,联合索引的最左前缀原则,范围、模糊、OR 查询对索引的影响,以及一些把它做扎实要避开的工程坑。
问题背景
先把索引这件事的目标说清楚。索引,是数据库为某一列(或几列)的值额外维护的一份排好序的数据结构。有了它,按这一列查找时,数据库可以像查字典一样快速定位,而不必从头到尾翻遍整张表。它是数据库查询性能的基石。
错误认知是:索引是"给列加的加速buff",只要某列建了索引,所有按这列的查询就自动变快。真相是:索引只是给优化器准备的一个候选工具,查询能不能、会不会用上它,取决于两件事——一是你的 SQL 写法有没有破坏索引的使用条件,二是优化器估算下来走索引划不划算。把这层认知摆正,第一版的几类问题就都能解释了:
- 索引列被运算包住:在建了索引的列上套函数、做算术运算,数据库要找的就不再是索引里存的原始值,索引直接失效。
- 隐式类型转换:列是字符串类型,查询却用数字去比,数据库在背后自动加了一层类型转换,等效于在索引列上套了函数,索引失效。
- 联合索引用错:联合索引有严格的最左前缀规则,跳过最左列、或在范围条件之后接列,都会让索引只生效一部分甚至完全用不上。
- 走了索引仍不够快:二级索引命中后若查询还要别的列,需"回表"去主键索引取数据,回表多了也会成为瓶颈。
所以让索引真正生效,核心不是把索引建得更多,而是理解索引在什么条件下能被用上、你的 SQL 又是怎么一步步破坏这些条件的。下面六节,就从第一版"建了索引却全表扫描"讲起,一个坑一个坑地拆。
一、为什么"建了索引"不等于"查询会走索引"
第一版的思路里,藏着一个想当然的假设:建索引和走索引是一回事。其实它们是两件事。建索引,是你告诉数据库"请为这一列维护一份排序结构";走索引,是某一个具体的查询在执行时,真的用上了这份结构。中间隔着一个角色——查询优化器。优化器拿到一条 SQL,会评估有哪些可行的执行方式(全表扫描、走这个索引、走那个索引),估算每种方式的成本,挑一个它认为最便宜的。索引对它来说只是"选项之一"。
-- 反面教材:给列建了索引,查询却还是全表扫描
-- 给下单时间列建了索引
CREATE INDEX idx_created_at ON orders (created_at);
-- 想查 2023 年的订单,很直觉地这样写
EXPLAIN SELECT * FROM orders
WHERE YEAR(created_at) = 2023;
-- 执行计划里的关键三行:
-- type 为 ALL 表示这是一次全表扫描
-- key 为 NULL 表示没有用上任何索引
-- rows 为 2000000 表示预计要逐行扫两百万行
-- 索引明明建了,在这个查询面前却像不存在一样
要判断一个查询到底走没走索引,不能靠猜,要靠 EXPLAIN。把 EXPLAIN 加在 SELECT 前面,数据库会告诉你它打算怎么执行这条查询。它是你和优化器之间唯一的"对话窗口"。
-- 看一个查询走没走索引,全靠 EXPLAIN
EXPLAIN SELECT * FROM orders WHERE user_id = 8848;
-- 重点看这几列:
-- type 访问类型,从好到坏大致是 const ref range index ALL
-- 一旦出现 ALL,基本就是全表扫描,要警惕
-- key 实际使用的索引名,为 NULL 说明没走索引
-- rows 优化器估算要扫描的行数,越小越好
-- Extra 附加信息,Using index 是好事(覆盖索引),
-- Using filesort 和 Using temporary 往往是坏信号
这一节要建立的认知是:索引不是"建了就一定用",它只是给优化器多备了一个选项;查询走不走索引,是优化器基于成本估算做出的决定,而你的 SQL 写法,决定了优化器"能不能用"和"愿不愿用"这个选项。第一版的错,是把索引当成了"贴在列上就生效的加速贴纸"。实际上,索引更像一把放在工具箱里的钥匙——钥匙在不在工具箱(建没建索引)是一回事,某次开门时这把钥匙能不能插进锁孔、值不值得用它(查询走不走索引)是另一回事。后面五节讲的所有"索引失效",本质都是同一件事:你的 SQL 写法,让这把钥匙插不进锁孔了。所以遇到查询慢,第一个动作永远不是再加一个索引,而是 EXPLAIN 一下,看清楚现在到底走没走索引、为什么没走。
二、为什么在索引列上做运算,索引就失效
第一版那个查 2023 年订单的语句,失效的根子在 YEAR(created_at) 这个写法上。要理解它为什么致命,得先想清楚索引到底是什么。索引,是把某一列的"原始值"取出来、排好序、单独存一份。created_at 的索引里,存的是一个个原始的时间戳,并且是有序的。数据库能快速查找,靠的正是这份"原始值的有序性"。
而 YEAR(created_at) = 2023 这个条件,要找的不是某个原始时间戳,而是"经过 YEAR 函数计算之后等于 2023"的行。可索引里压根没有存"YEAR 计算之后的值"。数据库为了知道每一行 YEAR 之后等于几,只能把每一行的原始 created_at 取出来、挨个算一遍 YEAR——这就退化成了全表扫描。在索引列上做算术运算,同理:amount + 100 这个值,索引里也没存。
-- 索引列被函数或运算包住,索引就用不上了
-- 失效写法一:索引列 created_at 被 YEAR() 函数包住
SELECT * FROM orders WHERE YEAR(created_at) = 2023;
-- 失效写法二:索引列 amount 参与了算术运算
SELECT * FROM orders WHERE amount + 100 > 1000;
-- 正确写法:让索引列"裸"在条件的一侧,
-- 把函数和运算挪到不含索引列的另一侧去
-- 把"某年"翻译成对原始值的范围查询
SELECT * FROM orders
WHERE created_at >= '2023-01-01'
AND created_at < '2024-01-01';
-- 把运算从索引列那侧移走
SELECT * FROM orders WHERE amount > 900;
改写的诀窍是固定的:不要动索引列,去动条件里的另一侧。要查"YEAR 等于 2023",就把它翻译成"created_at 在 2023-01-01 和 2024-01-01 之间"——这样条件里的 created_at 是裸的,索引里存的就是这个原始值,范围查找一下就定位到了。amount + 100 > 1000 改成 amount > 900,把运算从索引列那侧挪到了常数那侧,索引列同样裸了出来。
这一节的认知是:索引的本质,是"对列的原始值排好了序";一旦你在索引列上套了函数或运算,你要找的就不再是那个排好序的原始值,而是一个"计算之后的值"——而索引里根本没有存这个计算后的值。这是所有索引失效里最根本的一类,理解了它,很多失效就不用死记了。判断标准非常简单:看你的 WHERE 条件里,索引列是不是"干干净净、孤零零"地待在某一侧,左右没有任何函数、没有任何运算。只要它被任何东西包了一层,索引大概率就用不上。所以写查询时养成一个习惯——让索引列保持"裸露",所有的计算、转换、函数,都尽量挪到不含索引列的那一侧去。这个习惯能帮你避开后面好几类失效。
三、隐式类型转换:一个最隐蔽的索引失效
上一节说"在索引列上套函数会失效",你可能觉得:那我不写函数不就行了?可有一种失效,你明明没写任何函数,索引照样失效——因为函数是数据库在背后偷偷替你加的。这就是隐式类型转换。第一版"手机号有索引却不走索引",根子就在这里。
设想 users 表的 phone 列是字符串类型(varchar),上面建了索引。你写 WHERE phone = 13800138000——注意,等号右边是个数字,没加引号。这下类型对不上了:左边是字符串列,右边是数字。数据库不会报错,它会自动地、悄悄地做一件事:把每一行的 phone 字符串转换成数字,再和右边比较。而"把列的值转换成数字"这个动作,本质就是在 phone 列上套了一个转换函数——于是,我们回到了上一节的情形,索引失效。
-- 隐式类型转换:你没写函数,数据库替你偷偷加了一个
-- users 表的 phone 列是 varchar 字符串类型,且建了索引
CREATE INDEX idx_phone ON users (phone);
-- 失效:phone 是字符串列,右边却是个数字
-- 数据库会把每一行的 phone 都转成数字再比较,
-- 等效于在索引列上套了一层转换函数,索引失效
SELECT * FROM users WHERE phone = 13800138000;
-- 正确:用字符串字面量去比,两边类型一致,
-- 不触发任何转换,索引可以正常使用
SELECT * FROM users WHERE phone = '13800138000';
它隐蔽就隐蔽在:你的 SQL 字面上看不出任何问题,没有 YEAR、没有加减乘除,就是一个最朴素的等值查询。少打的那一对引号,在文本上太不起眼了。可它的后果,和明写一个函数一模一样。反过来的方向也有坑:列是数字类型,你传了带引号的字符串,有的数据库能正常走索引、有的不能,行为不统一,最稳妥的办法是从源头杜绝——让字面量的类型,严格匹配列的类型。
这一节的认知是:隐式类型转换之所以是最隐蔽的索引失效,是因为导致失效的那个"函数"不在你的 SQL 字面上——它是数据库根据类型不匹配,在背后自动补上的。你排查的时候,盯着 SQL 看半天也看不出哪里套了函数,因为确实没套,是数据库套的。要躲开这个坑,得把意识往前提一步:不能只检查"我写没写函数",还要检查"我的字面量类型,和列的类型对得上吗"。字符串列就用带引号的字符串字面量去比,数字列就用不带引号的数字去比。这件事很小,小到容易被完全忽略,但它造成的全表扫描,和一个明晃晃的 YEAR() 函数造成的,严重程度完全一样。SQL 里很多最坑的问题,都藏在这种"数据库默默替你做了某件事"的地方。
四、联合索引的最左前缀原则
前三节讲的是单列索引。实际项目里用得更多的是联合索引——把好几个列放进同一个索引。第一版"联合索引有时能用有时不能用",问题就出在没搞懂联合索引的使用规则:最左前缀原则。
关键要理解联合索引是怎么排序的。一个 (user_id, status, created_at) 的联合索引,不是分别给三个列各排一次序,而是把这三个列拼成一个整体,先按 user_id 排,user_id 相同的再按 status 排,status 也相同的再按 created_at 排。这就像电话簿先按姓排、姓相同按名排。这种排序方式决定了:你必须从最左边的列开始、连续地用,索引才能发挥作用。只给名、不给姓,电话簿就帮不上忙,因为它不是按名排的。
-- 联合索引:列的顺序,决定了哪些查询能用上它
CREATE INDEX idx_u_s_c
ON orders (user_id, status, created_at);
-- 能用上索引(满足最左前缀,从最左列开始连续匹配):
SELECT * FROM orders WHERE user_id = 8848;
SELECT * FROM orders WHERE user_id = 8848 AND status = 'paid';
SELECT * FROM orders
WHERE user_id = 8848 AND status = 'paid'
AND created_at >= '2024-01-01';
-- 用不上索引(跳过了最左的 user_id):
SELECT * FROM orders WHERE status = 'paid';
SELECT * FROM orders WHERE created_at >= '2024-01-01';
还有一个更细的规则:范围条件会"截断"它后面的列。如果某个列用的是范围条件(大于、小于、between),那么这个列还能用上索引,但它在联合索引里后面的列,就接不上了。因为一旦某列是个范围,这个范围内部,后面那个列的顺序就是乱的。
-- 联合索引里,范围条件会"截断"它后面的列
-- 索引仍是 (user_id, status, created_at)
-- status 用了范围条件(不等值),它后面的 created_at
-- 就接不上索引了,只有 user_id 和 status 这两段生效
SELECT * FROM orders
WHERE user_id = 8848
AND status > 'a'
AND created_at >= '2024-01-01';
-- 中间整个跳过了 status,created_at 也接不上,
-- 这个查询里只有最左的 user_id 这一段走了索引
SELECT * FROM orders
WHERE user_id = 8848
AND created_at >= '2024-01-01';
这一节的认知是:联合索引不是"给这几个列各自都加了索引",而是"按一个固定顺序把它们拼成的一把复合钥匙";最左前缀原则,就是这把钥匙的"插法"——必须从最左的齿开始、一节一节连续地插。这把钥匙的齿的顺序,在你 CREATE INDEX 的那一刻就焊死了,事后改不了。所以建联合索引,绝不能随手把几个列一堆了事,而要先想清楚:我的查询里,哪些列几乎总会出现(放最左)、哪些列经常是范围条件(放靠右,因为它会截断后面)、哪些列只是偶尔出现。列的顺序设计对了,一个联合索引能服务好多种查询;设计错了,这个索引可能一大半查询都用不上。建联合索引之前,先把高频查询模式列出来对照一遍,这是值得花的时间。
把"一个带条件的查询到底能不能走索引"这条决策链画出来,就是下面这张图:
[mermaid]
flowchart TD
A[一个带 WHERE 的查询] --> B{条件列上有索引吗}
B -->|没有| C[退化为全表扫描]
B -->|有| D{索引列被函数或运算包住吗}
D -->|是| C
D -->|否| E{条件存在隐式类型转换吗}
E -->|是| C
E -->|否| F{联合索引满足最左前缀吗}
F -->|否| C
F -->|是| G{优化器估算走索引更划算吗}
G -->|否| C
G -->|是| H[走索引扫描]
五、范围、模糊、OR 查询对索引的影响
还有几类常见的查询条件,对索引的影响需要单独说清楚。第一类是模糊查询 LIKE。LIKE '张%' 能用索引,LIKE '%志强' 不能。原因还是回到索引的有序性:索引是按列值从左到右的字符顺序排的。'张%' 的前缀 '张' 是固定的,数据库能在有序索引里定位到所有 '张' 开头的范围。而 '%志强' 开头就是通配符,前缀完全不固定,有序的索引帮不上忙,只能全表扫描挨个匹配。
-- 模糊查询:通配符在开头,索引就失效
-- 失效:以 % 开头,前缀不固定,
-- 按前缀排序的索引没法定位
SELECT * FROM users WHERE name LIKE '%志强';
-- 可用:通配符在结尾,前缀 '张' 是固定的,
-- 能利用索引定位到所有 '张' 开头的范围
SELECT * FROM users WHERE name LIKE '张%';
第二类是 OR。OR 是个"或"的关系,只要它连接的任意一个分支命中,这一行就要被选出来。问题在于:如果 OR 的某一个分支的列没有索引,数据库为了不漏掉任何一行,往往会干脆放弃所有索引,直接全表扫描——因为它必须把每一行都检查一遍那个没索引的分支。要让 OR 用上索引,要么 OR 涉及的每个列都有索引(让数据库做索引合并),要么把 OR 拆成两个独立查询再 UNION 起来。
-- OR:只要一个分支的列没索引,整体就可能退化为全表扫描
-- user_id 有索引,但 remark 没有索引
-- OR 连接后,为了不漏行,数据库往往直接全表扫描
SELECT * FROM orders
WHERE user_id = 8848 OR remark = '加急';
-- 改法一:给 remark 也建上索引,让优化器能做索引合并
-- 改法二:拆成两个查询再 UNION,各自走各自的索引
SELECT * FROM orders WHERE user_id = 8848
UNION
SELECT * FROM orders WHERE remark = '加急';
这一节的认知是:范围、模糊、OR 这几类条件,共同点是它们让"要查的目标"变得边界不确定;而索引最擅长的,恰恰是"定位一个确定的点、或一段连续的范围"。索引是一份有序的数据,有序意味着"确定的东西能快速定位"。前导通配符 '%x' 让前缀不固定了,目标的起点散开了;OR 让结果集变成两个集合的并,目标不再是单一连续的一段。一旦目标的边界散开,索引的定位能力就被削弱、甚至作废。理解了这条主线,你就不用去背"哪些写法失效"这张表了——你只要问自己:我这个条件,是在索引那份有序数据里"圈出一段连续、确定的范围",还是在"满世界找一些零散的、边界不清的目标"?是前者,索引能帮上忙;是后者,索引多半使不上劲。
六、把索引用扎实,要避开的工程坑
前面五节讲清了各类索引失效。但要在生产里真正把查询性能做扎实,还有几个坑得专门讲。第一个,是第一版第四类问题的来源:回表。当你用一个二级索引(非主键索引)查到了符合条件的行,索引里通常只存了索引列和主键值。如果你的查询要 SELECT *、要取别的列,数据库还得拿着主键,再去主键索引里把整行数据捞出来——这个动作叫回表。命中行数多时,大量的回表本身就很慢。解法是覆盖索引:把查询要用到的列,都放进联合索引里,这样索引自己就能满足查询,不用回表。
-- 覆盖索引:让查询要的列都在索引里,省掉回表
-- 普通情况:索引里只有 user_id,SELECT * 要的其他列
-- 不在索引上,命中后还要拿主键回表去取整行数据
SELECT * FROM orders WHERE user_id = 8848;
-- 覆盖索引:把这个查询要用到的列都放进联合索引
CREATE INDEX idx_cover
ON orders (user_id, status, amount);
-- 这个查询要的 status 和 amount 都在索引里了,
-- 不需要回表,EXPLAIN 的 Extra 会显示 Using index
SELECT status, amount FROM orders WHERE user_id = 8848;
第二个坑,是优化器的统计信息会过期。优化器决定走不走索引,靠的是它对表的"统计信息"(大概有多少行、某列的值分布如何)的估算。一张表经过大批量的增删改之后,这份统计信息可能严重失真,优化器据此算出的成本就不准,可能会放弃一个本该走的索引。这时需要手动刷新统计信息。
-- 优化器靠统计信息估算成本,统计信息过期会让它误判
-- 大批量增删改之后,表的统计信息可能严重失真,
-- 优化器据此算出的成本不准,可能放弃本该走的索引
-- 手动刷新这张表的统计信息
ANALYZE TABLE orders;
-- 刷新后再看执行计划,优化器对 rows 的估算
-- 会更贴近真实,索引的选择也更靠谱
EXPLAIN SELECT * FROM orders WHERE status = 'paid';
还有几个坑值得点一下。其一,索引不是越多越好。每个索引都要占存储,而且每次增删改数据时,所有相关索引都得跟着更新——索引建太多,写入会被拖慢。其二,要关注列的"选择性"——一个列如果取值很少(比如性别只有两种值),给它单独建索引意义不大,因为走索引也得扫掉接近一半的行,优化器多半还是会选全表扫描。其三,EXPLAIN 出来的 rows 是"估算值"不是精确值,但它的数量级很有参考意义,几百和几百万的差别,足以告诉你索引有没有起作用。下面把常见的索引失效场景集中对照一下:
索引失效的常见场景对照
写法 会失效吗 原因
--------------------------------------------------------------
WHERE func(col) = x 失效 索引列被函数包裹
WHERE col + 1 = x 失效 索引列参与了运算
WHERE col = 数字 col是字符串 失效 隐式类型转换
WHERE 跳过联合索引最左列 失效 违反最左前缀原则
WHERE col LIKE '%x' 失效 前导通配符前缀不固定
WHERE col LIKE 'x%' 可用 前缀固定能定位范围
WHERE col >= x AND col < y 可用 范围扫描索引支持
OR 连接 有一边的列无索引 多半失效 为不漏行而全表扫描
口诀:让索引列干干净净地待在条件的一侧,
不包函数 不做运算 不变类型 不跳列。
这一节这几个坑,串起来是同一个道理:索引不是"建了走了就万事大吉",它是一个要算总账的东西。覆盖索引提醒你,"走了索引"和"走得快"之间还隔着一个回表;统计信息提醒你,优化器的决策依赖一份会过期的数据;"索引不是越多越好"提醒你,索引是用写入速度和存储空间换来的查询速度,不是免费的;"选择性"提醒你,有些列即便建了索引优化器也不会用。把这些坑放在一起看,你会发现索引是一笔交易——拿写入的代价、存储的代价,去换查询的速度。做交易就要算总账:这个索引服务多少查询、拖慢多少写入、占多少空间、优化器会不会用它。把索引当成需要权衡和评估的对象,而不是"哪里慢就贴一张加速贴纸",查询性能才有持续做好的可能。
关键概念速查
| 概念 | 说明 |
|---|---|
| 索引 | 为某列(或几列)的值额外维护的一份排好序的结构,加速按该列查找 |
| 全表扫描 | 不走索引,逐行扫描整张表,EXPLAIN 中 type 显示为 ALL |
| EXPLAIN | 查看 SQL 执行计划的命令,用来判断查询有没有真的走索引 |
| 查询优化器 | 数据库中决定一条查询如何执行、走不走索引的组件 |
| 索引列裸露 | 索引列不被函数和运算包裹,是该列索引能被用上的前提 |
| 隐式类型转换 | 列类型与字面量类型不一致时数据库自动转换,等效套函数,致索引失效 |
| 联合索引 | 多个列按一个固定顺序拼成的索引 |
| 最左前缀原则 | 联合索引须从最左列开始、连续匹配,才能命中 |
| 回表 | 用二级索引查到主键后,再去主键索引取完整行数据的动作 |
| 覆盖索引 | 查询所需的列全在索引中,无需回表,Extra 显示 Using index |
避坑清单
- 不要以为建了索引查询就一定走索引:走不走由优化器决定,SQL 写法会破坏使用条件。
- 不要凭感觉判断有没有走索引:用 EXPLAIN 看 type 和 key,这是唯一可靠的方式。
- 不要在索引列上套函数:YEAR(col) 之类会让索引失效,把计算挪到条件另一侧。
- 不要在索引列上做算术运算:col + 1 让索引失效,改写成对裸列的条件。
- 不要让字面量类型和列类型不一致:字符串列用数字比会触发隐式转换,索引失效。
- 不要随手堆砌联合索引的列顺序:列序建索引时即焊死,要先按高频查询模式设计。
- 不要跳过联合索引的最左列:违反最左前缀,索引整体或部分用不上。
- 不要用前导通配符做模糊查询:LIKE '%x' 索引失效,'x%' 才能用上。
- 不要忽视回表成本:走了索引仍可能因大量回表而慢,用覆盖索引消除回表。
- 不要无节制地加索引:索引拖慢写入、占用存储,低选择性的列建索引也常被弃用。
总结
回头看第一版那个"哪个列慢就给哪个列建索引"的做法,它的错误很典型。它不在某一行代码,而在一个对索引的根本误解:以为索引是贴在列上就自动生效的加速贴纸,建了就一定走、走了就一定快。真相是,索引只是给优化器准备的一个候选选项;一个查询能不能用上它,取决于你的 SQL 写法有没有破坏索引的使用条件——有没有在索引列上做运算、有没有触发隐式类型转换、联合索引的列用得对不对、用了什么样的条件。建索引只是开了个头,把查询写得"能让索引生效",才是更关键、也更容易被忽略的另一半。
而把索引用对,工程量并不小。它不是 CREATE INDEX 一行命令那么简单,而是要养成 EXPLAIN 查看执行计划的习惯、要让索引列保持裸露、要警惕隐式类型转换、要按高频查询模式设计联合索引的列顺序、要理解范围和模糊和 OR 各自对索引的影响,还要考虑回表成本用覆盖索引优化、关注统计信息的新鲜度、克制不滥建索引。一套真正高效的索引方案,是这些环节一个不少地拼起来的。
这件事其实很像用一本厚厚的字典查字。索引,就是字典前面那个按拼音、按部首排好的检字表。你查"张"字,顺着检字表一下就翻到了——这是走索引。可如果有人问你"字典里所有读音里带 ang 的字",检字表就帮不上忙了,因为它是按完整拼音的首字母排的,不是按"读音里带不带 ang"排的,你只能一页页全翻——这就是前导通配符导致的失效。如果有人让你查"笔画数加一等于十的字",检字表同样没用,因为它没按"笔画加一"排过序——这就是在索引列上做运算的失效。检字表能帮上忙的前提,永远是:你问的方式,得和它排序的方式对得上。索引也是一样,它能不能加速你的查询,取决于你查询的写法,合不合它那份"有序"的脾气。
这类问题还有一个共同的麻烦:它在本地、在小数据上几乎暴露不出来。你在开发机上用几千行的测试数据,就算某个查询走了全表扫描,几毫秒也就跑完了,你根本看不出它没走索引——因为数据太少,扫全表和走索引快慢差不多。真正会把索引失效的代价撑开的,是上线后那张几百万、几千万行的真实大表:同一个走了全表扫描的查询,会从几毫秒变成几秒,慢得触目惊心。所以如果你正在为查询性能做优化,别等线上大表上的查询慢成灾、用户开始投诉,才回头一个个 EXPLAIN。在写下每一个重要查询时,就顺手 EXPLAIN 一下,确认它真的走了你期望的那个索引——把"建了索引"和"查询真的走了索引"当成两件必须分别确认的事,这是这篇文章最想留给你的一句话。
—— 别看了 · 2026