MySQL 联合索引怎么建:10 道索引失效练习题实测,rows_examined 一样是 8 行,耗时 0.2 ms 和 49 ms
在 MySQL 8.0.46 测试库(orders 200 万行自造数据)上把联合索引最常见的 10 个问题一次跑完:最左前缀与 skip scan、key_len 判断用了几列、等值列和范围列谁在前、IN + ORDER BY 触发 filesort、混合升降序与降序索引、OR 与 index_merge、低区分度列和 IS NULL 让优化器选错索引、LIKE 前导 %、NOT IN / <>、每多一个索引写入慢多少。每题都有 EXPLAIN、EXPLAIN ANALYZE、Handler 计数和 3 次耗时。附一键练习脚本、只读索引体检 SQL 和答案 CSV。
MySQL 8.0.46 实测(测试库、自造数据):同一条只返回 8 行的查询,(user_id, created_at) 索引 0.2 ms,(created_at, user_id) 索引 48–56 ms,慢约 240 倍,而 rows_examined 两边都是 8,因为被索引下推(ICP)在引擎层滤掉的条目不计入;关掉 ICP 才看到后者扫了 673414 条。IN (7,8) + ORDER BY LIMIT 20 触发 filesort 要 372–390 ms,改写成 UNION ALL 后 1.0–1.1 ms。status=1 占 60%、coupon_code IS NULL 占 98% 时,优化器仍选了索引,比全表扫描慢 4.5 倍和 7.6 倍。写入 20 万行:0 / 2 / 6 个二级索引分别约 1.0 / 2.0 / 3.7 秒。
适用对象
- 联合索引列顺序:等值条件的列放前面,范围条件的列放后面。本次 user_id=? AND created_at 3 个月,(user_id, created_at) 0.2 ms,(created_at, user_id) 48–56 ms。
- 别只看 rows_examined 判断索引好坏。开着索引下推时,被引擎层滤掉的索引条目不计入 rows_examined 和 Handler_read_next,上面两种索引都显示 8;加 /*+ NO_ICP */ 后才显示 673414。看 EXPLAIN 的 key_len 和 EXPLAIN ANALYZE 的实际耗时更可靠。
- key_len 能告诉你联合索引用到了第几列:(tenant_id, user_id, created_at) 上查 tenant_id=7 AND created_at 一天,key_len 只有 2(SMALLINT),说明 created_at 没参与定位,10.2–11.1 ms;换成 (tenant_id, created_at) 后 key_len 7,0.7–0.8 ms。
- IN 多个值 + ORDER BY 第二列 + LIMIT,会把所有命中行读出来再排序(本次 199708 行,372–390 ms)。值不多时改写成每个值各自 LIMIT 再 UNION ALL,1.0–1.1 ms。
- 区分度低的列(60% 是同一个值)、大部分是 NULL 的列,单独建索引往往有害:优化器估算偏差时会选它,本次比全表扫描慢 4.5–7.6 倍。可以用只读体检 SQL 找出这类索引,再拿 IGNORE INDEX 对比验证。
10 道题的结论一览:哪种写法慢、慢多少
每道题脚本都会先删掉 orders 上全部二级索引,只建本题需要的,再跑 EXPLAIN、EXPLAIN ANALYZE、Handler 计数和 3 次正式执行,耗时取自 performance_schema.events_statements_history_long。下表是复跑结果,完整 34 行答案在样例 CSV 里。
| 题 | 问题 | 慢的写法 | 快的写法 | 结论 |
|---|---|---|---|---|
| Q1 | 不带最左列能用联合索引吗 | SELECT * 全表 448–467 ms | 只查索引列时 skip scan 0.2–0.3 ms | 能,但只在不用回表、最左列值很少时 |
| Q2 | 跳过中间列,key_len 说明什么 | (tenant_id,user_id,created_at),key_len 2,10.2–11.1 ms | (tenant_id,created_at),key_len 7,0.7–0.8 ms | 中间断开后,后面的列只能在引擎层过滤 |
| Q3 | 等值列和范围列谁在前 | (created_at,user_id) 48–56 ms | (user_id,created_at) 0.2 ms | 等值在前、范围在后 |
| Q4 | IN + ORDER BY + LIMIT | filesort 372–390 ms | UNION ALL 各自 LIMIT 1.0–1.1 ms | IN 会让索引顺序失效 |
| Q5 | ORDER BY 一升一降 | 全索引扫描 + filesort 317–334 ms | (tenant_id, created_at DESC) 0.2 ms | 8.0 的降序索引真有用 |
| Q6 | OR 条件 | 只有一边有索引:全表 527–547 ms | 两边都有索引:index_merge 0.4 ms | OR 两边都要有索引 |
| Q7 | 区分度低的列 | status=1(60%)走索引 1326–1346 ms | IGNORE INDEX 全表 292–317 ms | 优化器会选错,慢 4.5 倍 |
| Q8 | LIKE | '%01234' 全表 579–587 ms | 'SKU0123%' range 1.4–1.6 ms | 前导 % 用不了索引定位 |
| Q9 | NOT IN / <> / IS NULL | IS NULL(98%)走索引 2159–2321 ms | IGNORE INDEX 288–293 ms | NULL 占多数的列别指望索引 |
| Q10 | 索引越多越好吗 | 6 个二级索引写 20 万行 3.7–3.8 秒 | 0 个 1.0 秒、2 个 2.0 秒 | 每个索引都是写入成本 |
不带最左列也能用上联合索引?8.0 的 skip scan 只在不回表时出现
索引 (tenant_id, user_id, created_at),查询只带 user_id = 4242。按“最左前缀”规则应该用不上,但 SELECT id, created_at 时 EXPLAIN 是 type=range、key_len 6、Extra 里写着 Using index for skip scan,0.2–0.3 ms:tenant_id 只有 20 个值,MySQL 按 tenant_id 的每个值各做一次 (tenant_id, user_id=4242) 的查找,Handler_read_key=41、Handler_read_next=17。
同一个条件改成 SELECT *,需要回表,优化器就不用 skip scan 了,直接全表扫描 200 万行,448–467 ms。最后补一个 (user_id, created_at) 索引,type=ref、key_len 4,0.2–0.3 ms,rows_examined 17。
另一个容易误判的点:skip scan 那次 performance_schema 里 rows_examined 记的是 0,而 Handler 计数显示实际读了 17 条。所以判断走没走索引,以 EXPLAIN 的 type / key / key_len 为准。
跳过中间列:key_len 2 说明只用上了第一列,靠 ICP 过滤才没那么慢
还是 (tenant_id, user_id, created_at),查询 tenant_id = 7 AND created_at 在 2026-09-01 这一天。中间的 user_id 没给条件,created_at 不能参与定位:EXPLAIN 显示 type=ref、key_len 2(tenant_id 是 SMALLINT,2 字节),Extra 是 Using index condition。10.2–11.1 ms,返回 340 行。
key_len 是判断联合索引用到第几列最快的办法:按列类型累加字节数,SMALLINT 2、INT 4、DATETIME 5,可空列再加 1,VARCHAR(n) 按字符集乘以 n 再加 2。这一题如果 created_at 也参与定位,key_len 应该是 2+4+5=11。
给同一条 SQL 加 /*+ NO_ICP(orders) */,EXPLAIN 变成 Using where,耗时 139–242 ms,rows_examined 变成 99535,也就是 tenant_id = 7 的全部索引条目都回表读了一遍。建一个 (tenant_id, created_at) 索引后,key_len 7,type=range,0.7–0.8 ms,只读 340 条。
| 索引 | key_len | Extra | 3 次耗时 | rows_examined |
|---|---|---|---|---|
| (tenant_id,user_id,created_at) | 2 | Using index condition | 10.4 / 10.2 / 11.1 ms | 340 |
| 同上 + NO_ICP | 2 | Using where | 242.0 / 139.3 / 163.9 ms | 99535 |
| (tenant_id,created_at) | 7 | Using index condition | 0.8 / 0.7 / 0.7 ms | 340 |
等值列在前、范围列在后:同样 8 行,0.2 ms 对 49 ms,rows_examined 却一样
这是我最想让大家记住的一题。查询是 user_id = 4242 AND created_at >= '2026-03-01' AND created_at < '2026-06-01',返回 8 行。用 FORCE INDEX 分别强制两个索引:(user_id, created_at) 0.2 ms;(created_at, user_id) 48.4–56.2 ms。两个 EXPLAIN 都是 type=range、key_len 9、Using index condition,表面看不出区别。
我先看了 performance_schema 的 rows_examined:两边都是 8。又看了 Handler_read_next:也都是 8。这两个数字都不算被 ICP 在存储引擎层滤掉的索引条目。给 (created_at, user_id) 那条加上 /*+ NO_ICP(orders) */,rows_examined 和 Handler_read_next 一下变成 673414,耗时 1191–1211 ms,这才是 3 个月范围内的全部索引条目。
原因是 B+ 树按索引列顺序排序:范围列在前时,3 个月内所有用户的条目连在一起,user_id 只能逐条检查;等值列在前时,user_id = 4242 的条目连在一起,再在里面按 created_at 取一小段。两个索引都在、不加 hint 时,优化器选了 (user_id, created_at),0.2 ms。
| 写法 | 3 次耗时 | rows_examined | Handler_read_next |
|---|---|---|---|
| FORCE INDEX (user_id, created_at) | 0.2 / 0.2 / 0.2 ms | 8 | 8 |
| FORCE INDEX (created_at, user_id) | 48.9 / 48.4 / 56.2 ms | 8 | 8 |
| 同上 + /*+ NO_ICP */ | 1211.1 / 1206.6 / 1190.6 ms | 673414 | 673414 |
| 不加 hint(两个索引都在) | 0.2 / 0.2 / 0.2 ms | 8 | 8 |
Q3 平均耗时(同样返回 8 行)
(user_id, created_at)
0.2 ms
(created_at, user_id)
48–56 ms
(created_at, user_id) + NO_ICP
1191–1211 ms
MySQL 8.0.46,测试库、自造数据,orders 200 万行。
排序能不能用索引:IN 两个值就 filesort,一升一降要建降序索引
索引 (tenant_id, created_at)。tenant_id = 7 ORDER BY created_at DESC LIMIT 20:Backward index scan,0.6–0.7 ms,只读 20 行。改成 tenant_id IN (7, 8),Extra 变成 Using index condition; Using filesort,372–390 ms,rows_examined 199708:两个租户的数据都读出来,排序后再取 20 行。
值不多时可以改写成 (… WHERE tenant_id = 7 ORDER BY created_at DESC LIMIT 20) UNION ALL (… WHERE tenant_id = 8 …) ORDER BY created_at DESC LIMIT 20,每段各自走索引倒序取 20 行,外层只排 40 行,1.0–1.1 ms。
Q5 是 ORDER BY tenant_id ASC, created_at DESC LIMIT 20。只有 (tenant_id, created_at) 时,方向不一致,只能全索引扫描 + filesort,317–334 ms,rows_examined 2000020。MySQL 8.0 开始支持真正的降序索引,建 (tenant_id, created_at DESC) 后 Extra 只剩 Using index,0.2 ms。
| SQL | 索引 | Extra | 3 次耗时 | rows_examined |
|---|---|---|---|---|
| tenant_id=7 ORDER BY created_at DESC | (tenant_id,created_at) | Backward index scan | 0.7 / 0.7 / 0.6 ms | 20 |
| tenant_id IN (7,8) ORDER BY created_at DESC | (tenant_id,created_at) | Using filesort | 372.4 / 381.1 / 389.9 ms | 199708 |
| 改写 UNION ALL | (tenant_id,created_at) | 两段各自倒序扫描 | 1.1 / 1.0 / 1.0 ms | 100 |
| ORDER BY tenant_id ASC, created_at DESC | (tenant_id,created_at) | Using index; Using filesort | 333.8 / 318.6 / 317.4 ms | 2000020 |
| 同上 | (tenant_id,created_at DESC) | Using index | 0.2 / 0.2 / 0.2 ms | 20 |
OR 和 LIKE:OR 两边都要有索引,前导 % 只能全扫
user_id = 4242 OR sku_code = 'SKU01234':user_id 和 sku_code 各有一个单列索引时,EXPLAIN 是 index_merge、Using union(idx_user, idx_sku),0.4 ms,68 行。删掉 sku_code 的索引,整条退化为全表扫描,527–547 ms。我顺手试了改写成 UNION,user_id 那半走索引,sku_code 那半照样全表,500–507 ms,改写替代不了缺的索引。
sku_code LIKE 'SKU0123%' 前缀匹配是 range,1.4–1.6 ms;LIKE '%01234' 是全表扫描,579–587 ms。只查 id, sku_code 时会变成扫描整棵 sku_code 索引(Using index),295–299 ms,比全表快一半,但还是扫了 200 万条。真需要后缀搜索,可以另存一列反转后的字符串再建索引,或者用全文检索。
区分度低的列:优化器选了索引,比全表扫描慢 4.5 倍和 7.6 倍
status 只有 4 个值,status = 1 占 60%(1200136 行)。SELECT SUM(amount) WHERE status = 1,优化器估计 992521 行,仍然选了 idx_status,1326–1346 ms;IGNORE INDEX 走全表扫描 292–317 ms。走二级索引要按主键逐行回表,命中行数一多,比顺序扫整张表还慢。换成 status = 9(5%),索引 156–174 ms,全表 272–289 ms,这时候索引才划算。
coupon_code 98% 是 NULL。IS NOT NULL(2%)走 range,65–68 ms;IS NULL 时优化器估计 992521 行,实际 1960238 行,选了 ref 访问,2159–2321 ms,IGNORE INDEX 后 288–293 ms,差 7.6 倍。status NOT IN (1, 2, 3) 被拆成 4 段区间走索引,157–165 ms;status <> 1 优化器估 50% 选了全表,266–268 ms。
所以“NOT IN、<>、IS NULL 不走索引”的说法不准确,能不能走、该不该走,取决于命中比例和统计信息。这类单列索引我一般会先用只读体检 SQL 列出来,再在测试环境用 IGNORE INDEX 对比;确认没用之后,先在 8.0 里把它设为 INVISIBLE 观察一段时间再删。
命中行数多时,走索引反而慢(SUM(amount),3 次平均)
status=1 走索引
1326–1346 ms
status=1 全表
292–317 ms
IS NULL 走索引
2159–2321 ms
IS NULL 全表
288–293 ms
MySQL 8.0.46,测试库、自造数据:status=1 占 60%,coupon_code IS NULL 占 98%。
每多一个索引,写入就多一份成本:0 / 2 / 6 个二级索引写 20 万行
把 orders 前 20 万行 INSERT … SELECT 到结构相同的新表,每种跑 2 次:只有主键 987–1029 ms;2 个二级索引 1999–2042 ms;6 个二级索引 3671–3816 ms,约 3.7 倍。数据都是 24.6 MB,二级索引分别 0、15.0、39.1 MB。
这里我踩了一个坑:第一版脚本用 CREATE TABLE w0 LIKE orders 建测试表,LIKE 会把源表当时的索引一起复制,而上一题刚给 orders 建了 status 和 coupon_code 两个索引,于是所谓的“0 个二级索引”其实有 2 个,第一次测出来是 1.4 / 2.4 / 4.2 秒,w0 的索引还占了 7.0 MB。看到 0 个二级索引的表有索引大小才发现,下载版脚本已经改成建表前先删光 orders 的二级索引。
| 二级索引 | 第 1 次 | 第 2 次 | 二级索引大小 |
|---|---|---|---|
| 0 个 | 987 ms | 1029 ms | 0.0 MB |
| 2 个:(user_id,created_at)、(tenant_id,created_at) | 1999 ms | 2042 ms | 15.0 MB |
| 6 个:再加 status、sku_code、coupon_code、(created_at,user_id) | 3671 ms | 3816 ms | 39.1 MB |
在自己的库上先跑只读 SQL:找冗余索引、没用过的索引、低区分度索引和没走索引的语句
练习脚本会建删库,只能在测试实例跑。到自己的库上,我建议先跑这份只读体检 SQL(7 段,只有 SELECT,已过站内只读守卫测试):版本和 skip_scan / index_merge / ICP 开关;二级索引最多的表;sys.schema_redundant_indexes 里被最左前缀覆盖的冗余索引;sys.schema_unused_indexes 里启动以来没被读过的索引;基数 / 行数 < 0.001 的单列索引;SUM_NO_INDEX_USED > 0 的语句摘要;用了索引但平均超过 10 ms 的语句摘要。
我在练习库上故意建了 idx_user(user_id) 和 idx_u_c(user_id, created_at)、idx_status、idx_coupon 再跑:第 3 段报出 idx_user 被 idx_u_c 覆盖;第 5 段报出 idx_status 基数 3、选择性约 0.0000015;第 6 段把 LIKE '%…'、OR、IGNORE INDEX 的语句按总耗时排在前面。要注意两点:第 4 段依赖实例运行时间,刚重启过的库不能据此删索引;大部分是 NULL 的列(例如本次 coupon_code)基数并不低,第 5 段查不出来,要自己看 IS NULL 的占比。
怎么用这套练习库:先猜答案,再跑脚本对答案
本节判断
- 在测试实例上执行:MYSQL="mysql -uroot -h127.0.0.1" DB=idx_lab ./mysql-index-practice-lab-v1.0.sh | tee result.txt。库名必须以 _lab 结尾,脚本会 DROP / CREATE 这个库并修改 performance_schema 的消费者开关。本机约 2 分钟(造数约 15 秒)。
- 每题先只看 SQL 和索引,自己写下会不会走索引、key_len 多少、大概多慢,再看 result.txt 里的 EXPLAIN 和耗时;对不上的地方,回到上面对应的小节看原因。
- 已经造好数据后可以 SKIP_INIT=1 重复跑;想换成自己的表结构,只需要改 init 和每题的 idx / q 两行。
- 你的数字会和本文不同:本次数据全在 1G buffer pool 里,生产库有冷数据、并发和更大的行,回表代价更高,Q7 / Q9 那类差距通常更大。
觉得有用?转给正在做这件事的同事或朋友
参考依据
以下来源用于确认市场趋势、政策背景和术语边界;具体落地方案仍以客户的数据范围、权限和交付目标为准。
常见问题
MySQL 联合索引的列顺序怎么定?
等值条件的列放前面,范围条件和排序的列放后面,再考虑区分度。本次 user_id = ? AND created_at 范围,(user_id, created_at) 0.2 ms,(created_at, user_id) 48–56 ms。
EXPLAIN 里 key_len 怎么看联合索引用了几列?
按列类型累加字节数:SMALLINT 2、INT 4、DATETIME 5,可空列加 1,VARCHAR(n) 是字符集字节数乘 n 再加 2。本次 (tenant_id, user_id, created_at) 上 key_len 2,说明只用了 tenant_id。
rows_examined 很小,查询为什么还是慢?
开着索引下推时,被存储引擎层滤掉的索引条目不计入 rows_examined 和 Handler_read_next。本次两个索引都显示 8 行,加 /*+ NO_ICP */ 才看到其中一个扫了 673414 条。用 EXPLAIN ANALYZE 看实际耗时更可靠。
IS NULL、NOT IN、<> 会让索引失效吗?
不一定。本次 NOT IN (1,2,3) 和 IS NOT NULL 都走了 range;IS NULL 也走了索引,但 NULL 占 98%,比全表扫描慢 7.6 倍。关键看命中比例,不看写法。
索引是不是建得越多越好?
不是。本次写入 20 万行,0 / 2 / 6 个二级索引分别约 1.0 / 2.0 / 3.7 秒,二级索引 0 / 15.0 / 39.1 MB。冗余索引和没用过的索引可以用文中只读 SQL 列出来。
下一步
推荐动作
你提供只读体检 SQL 的结果和几条慢 SQL 的 EXPLAIN(可脱敏),我们逐条判断是缺索引、索引列顺序不对还是优化器选错,给出建议的索引和需要在测试环境验证的步骤;生产变更由你方授权执行。