资讯动态

MySQL索引Key列PRI、UNI、MUL全解析与冗余索引治理

发布时间:2026/10/29 12:46:42 来源:尧图企业网站定制
上周帮人看一个订单库SHOW CREATE TABLE的输出拉到一半对面指着 Key 列问我这列我明明建的是唯一索引怎么显示 MULPRI、UNI、MUL 到底谁是谁这种问题我这些年被问过不下二十次问的人里有刚学 SQL 三天的也有写了五六年业务代码、但从来没认真看过索引细节的。这三个字母确实容易让人犯迷糊它们长得像索引类型实际上只是SHOW COLUMNS给你的一句提示语告诉你这一列在整个索引体系里大概扮演什么角色。搞不清它代价不是“看着别扭”这么简单你会误判唯一约束到底有没有生效会给同一列重复建两个功能重叠的索引会在写入量上来之后才发现表上堆了一堆根本没人用的 B 树。下面我把这套东西彻底拆开从判定规则、复合索引的显示差异一路讲到怎么用information_schema批量盘索引、怎么手算索引长度、怎么清理冗余。刚入行的后端、做数据支持的、运维同学都能看不需要先懂 B 树内部结构从最小可运行的建表脚本开始。1. 先把这三个字母的身份摆清楚1.1 它们描述的是“角色”不是“索引种类”很多人第一反应是把 PRI、UNI、MUL 当成三种索引类型然后在心里排座次是不是 PRI 比 UNI 高级UNI 比 MUL 高级。这个理解方向从一开始就偏了。MySQL 里真正的索引类型是写在SHOW INDEX的Index_type列上的常见的就是 BTREE、HASH、FULLTEXT、SPATIAL 这几种而 PRI、UNI、MUL 出现在SHOW COLUMNS也就是DESC 表名的Key列里它回答的问题是这一列在它参与的所有索引中处于最“重要”的那个位置是什么。换句话说Key列是汇总视图不是明细视图。一张表可能有五六个索引但DESC输出里每列只显示一个字母它必须做取舍所以 MySQL 定了一套优先级。这就像一个人同时是部门负责人和技术委员会成员名片上只会印那个更“重”的头衔不代表另一个身份不存在。你要看全貌得去SHOW INDEX FROM 表名或者查information_schema.STATISTICS那里一行代表一个索引的一列信息是完整的。理解这一点之后很多困惑会自动消解。比如“为什么我建了唯一索引但显示 MUL”大概率是那一列本身还挂着一个普通索引而它在那个普通索引里是第一列在唯一索引里不是第一列MySQL 一看唯一索引那边它排不上号就退而求其次显示 MUL 了。不是数据库出错是你看到的信息被压缩过了。1.2 三种取值的官方语义与最容易忽略的限定词把定义逐字抠一遍会发现关键都藏在括号里。PRI表示这一列是主键或者主键的一部分。注意“一部分”三个字复合主键的每一列都会显示 PRI而不是只有第一列。UNI表示这一列是某个唯一索引的第一列。限定词是“第一列”复合唯一索引(a, b)里只有 a 显示 UNIb 在 Key 列里是空的。另外要澄清一个流传很广的误解唯一索引允许 NULL 并不会让它退化成 MULMySQL 官方文档明确写了UNI 的列是否允许 NULL 要去看同一行的Null字段两者是独立信息。MUL表示这一列是某个非唯一索引的第一列也就是说在这个索引里同一个值允许重复出现。同样带“第一列”的限定复合普通索引(a, b)里只有 a 显示 MUL。除此之外 Key 列还可能是空字符串意思是这一列没有参与任何索引或者只在某个索引里处于非首位。这种情况最容易被忽略也最容易出事——你看着DESC觉得“这列没索引啊”实际上它在某个联合索引的第二位某些查询照样能用上它。Key 列取值准确含义典型场景PRI主键或主键的组成列显式PRIMARY KEY无主键时被提升为聚簇索引的唯一索引UNI某唯一索引的第一列UNIQUE KEY uk_x (x)MUL某非唯一索引的第一列KEY idx_x (x)、联合普通索引首列、全文/空间索引首列空未参与索引或仅处非首位联合索引(a, b)中的 b1.3 优先级规则一列挂多个索引时看谁当一列同时命中多个条件MySQL 按PRI UNI MUL的顺序取最高的那个显示。这条规则值得单独记因为它是你判断“我看到的字母到底代表哪个索引”的唯一依据。举个具体例子表上同时有PRIMARY KEY (id)和UNIQUE KEY uk_code (code)还额外建了个KEY idx_code (code)用于某些统计查询。这时候code列的 Key 会显示 UNI因为 UNI 优先级高于 MUL而SHOW INDEX里你能清楚看到uk_code和idx_code两个索引并存且idx_code完全是冗余的因为它能被uk_code覆盖。再比如一张日志表PRIMARY KEY (id)同时有KEY idx_id_created (id, created_at)。id列的 Key 显示 PRI 而不是 MUL因为主键优先级最高。这个细节常被用来解释“为什么我看 DESC 里 id 是 PRI但执行计划里还能走上 idx_id_created”——两者不矛盾它们说的不是同一件事。注意不要用DESC的 Key 列来判断索引是否存在。它只是摘要唯一可靠的明细来源是SHOW INDEX或information_schema.STATISTICS。养成看明细的习惯能省掉大量误判。2. 一张表把 PRI、UNI、MUL 的差异固定下来2.1 核心对照从约束强度到写入代价标题里说“一张图”我这里把它做成一张表因为表格比图更适合贴在笔记里随时查。这张表的每一行都对应一类真实业务场景你可以直接对号入座。维度PRIUNIMUL唯一性全表唯一索引内唯一NULL 除外允许重复是否允许 NULL不允许允许多个 NULL 可共存允许表中数量最多一个可为复合可多个可多个叶子节点存什么整行数据聚簇索引索引列 主键值索引列 主键值写入开销需维护聚簇索引可能页分裂需唯一性校验仅插入索引项典型用途行标识、外键关联业务唯一键订单号、手机号高频查询条件、排序字段能否为 NULL 后再更新否是是这张表里最值得反复看的是第四行和第五行。PRI 对应的聚簇索引叶子节点直接就是整行数据所以按主键查是“一次定位”。而 UNI 和 MUL 都是二级索引叶子节点存的是索引列加上主键值查完还得回表再查一次聚簇索引——这就是为什么“查订单号”比“查订单 ID”慢一截哪怕订单号上也有索引。写入代价的区别同样实在。普通索引插入就是往 B 树里塞一个键值对位置冲突了往后挪唯一索引还要多一步唯一性检查虽然主键定位到位置后判断很快但高并发下这步检查会带来额外的锁竞争。这也是为什么有些团队宁可把唯一约束放到应用层用分布式锁做也不愿意在写入最热的表上加一堆唯一索引——不是不知道唯一索引好是写入吞吐扛不住。2.2 复合索引为什么第二列在 Key 列里是空的这是新手最容易困惑的一点。建一个KEY idx_ab (a, b)DESC出来 a 是 MULb 是空的。很多人第一反应是“索引没建成功”然后跑去重建一遍还是空的开始怀疑人生。原因在于 MySQL 的显示逻辑只有索引的第一列才会被标记。因为 B 树的排序是先按 a 排a 相同再按 b 排单独拿 b 出来是无法定位的所以它不配拥有一个独立的标记。这不代表 b 用不上索引恰恰相反在WHERE a ? AND b ?这种查询里b 参与了索引内的精确定位甚至在 MySQL 5.6 之后还能参与索引下推ICP把过滤条件下推到存储引擎层执行减少回表次数。复合唯一索引同理。UNIQUE KEY uk_ab (a, b)里 a 显示 UNIb 是空的。判断这个索引是不是唯一索引不能看 b 的 Key 列要看SHOW INDEX里那两行的Non_unique是不是都为 0。这个字段才是权威0 表示唯一1 表示非唯一。我见过有人写脚本靠DESC的 Key 列去生成索引文档结果复合索引全部漏标文档和实际完全对不上。顺便说个延伸点。复合索引的列顺序不是随便排的最左前缀原则决定了(a, b)能服务WHERE a?和WHERE a? AND b?但服务不了WHERE b?。所以如果你的查询是“先按状态筛再按时间排”索引就该建成(status, created_at)而不是反过来。这个顺序一旦定错后面想改就得重建索引大表上动辄几十分钟能提前想清楚就别偷懒。2.3 三个看起来反直觉、实际很常见的真实案例第一个案例一张表没有显式主键。InnoDB 会去找第一个所有列都NOT NULL的唯一索引把它提升为聚簇索引此时那一列如果是复合唯一索引则是每一列在DESC里会显示 PRI 而不是 UNI。我见过一个日志表就是这么设计的建表时忘了写主键只写了个UNIQUE KEY uk_trace (trace_id)结果trace_id显示 PRI维护的人以为是别人后来加的主键查了半天。第二个案例全文索引。FULLTEXT KEY ft_content (content)建好之后content列的 Key 显示 MUL。有人看到 MUL 就以为这是个普通 B 树索引然后拿它去跑LIKE %关键词%发现没走索引回头质疑全文索引没用。实际上全文索引要用MATCH ... AGAINST语法才会命中这是两套完全不同的检索机制。第三个案例一列同时出现在两个索引的不同位置。表上有KEY idx_a_b (a, b)和UNIQUE KEY uk_b_c (b, c)。a 显示 MULb 显示 UNIc 空。假如你只盯着 b 看到 UNI就以为WHERE b ?快得飞起实际上uk_b_c的首列是 b确实能用但如果你查的是WHERE c ?那就完全走不了索引。这类误判在联合索引多的表上特别普遍唯一的解法就是老老实实看SHOW INDEX把每个索引的列顺序和序号都列出来。3. 动手搭环境从零把三种标识都造出来3.1 建库建表脚本光看定义记不住得自己跑一遍。下面这套脚本在 MySQL 5.7 和 8.0 上都能直接执行我把常见的边界情况都塞进去了复合主键、复合唯一索引、允许 NULL 的唯一索引、联合普通索引、以及一列挂多个索引的情况。CREATE DATABASE IF NOT EXISTS idx_demo DEFAULT CHARSET utf8mb4; USE idx_demo; DROP TABLE IF EXISTS t_order; CREATE TABLE t_order ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT UNSIGNED NOT NULL, mobile VARCHAR(20) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0, amount DECIMAL(12,2) NOT NULL DEFAULT 0.00, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, remark VARCHAR(200) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_mobile (mobile), KEY idx_user_status (user_id, status), KEY idx_status_created (status, created_at), KEY idx_user_id_dup (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; DROP TABLE IF EXISTS t_log; CREATE TABLE t_log ( trace_id VARCHAR(64) NOT NULL, span_id INT NOT NULL, msg VARCHAR(500) DEFAULT NULL, level TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uk_trace_span (trace_id, span_id), KEY idx_level (level) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;t_order是主战场t_log用来验证“无显式主键时谁被提升为 PRI”。注意t_order里的idx_user_id_dup它和idx_user_status的首列都是user_id是我故意留的冗余索引后面第五部分会拿它做清理演示。3.2 逐条验证把预期和实际对上先看t_order的列摘要。SHOW COLUMNS FROM t_order;预期结果是id显示 PRIorder_no显示 UNImobile显示 UNIuser_id显示 MUL因为它在idx_user_status和idx_user_id_dup两个普通索引里都是首列UNI 优先级轮不到它status显示 MUL其余列为空。这里有个细节值得停下来想mobile允许 NULLDEFAULT NULL但它依然显示 UNI。这就直接证明了前面说的“唯一索引允许 NULL 不会退化成 MUL”。很多人被网上一些过时的说法带偏以为允许 NULL 就是 MUL实测一下就清楚了。再看索引明细。SHOW INDEX FROM t_order;重点关注三列。Non_unique主键和两个唯一索引都是 0三个普通索引是 1。Seq_in_index复合索引里从 1 开始编号idx_user_status有 1 和 2 两行。Column_name和Seq_in_index对应告诉你每一行到底是哪一列。把这三列记住以后看任何表的索引都不需要猜。然后是t_log验证“隐式提升”。SHOW COLUMNS FROM t_log;你会看到trace_id和span_id都显示 PRIlevel显示 MULmsg为空。这印证了两件事没有显式主键时第一个全 NOT NULL 的唯一索引会被提升为聚簇索引复合唯一索引被提升时它的每一列都显示 PRI不是只有第一列。这是个很容易在文档里漏掉的知识点但在排查“为什么这表没主键却有 PRI”时特别有用。3.3 用 information_schema 批量盘索引SHOW INDEX一次只能看一张表数据库有几百张表的时候就不够用了。information_schema.STATISTICS是同一份数据的结构化版本可以随意 JOIN、聚合、导出。-- 所有非唯一索引按表分组看看哪些是纯普通索引 SELECT TABLE_SCHEMA, TABLE_NAME, INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols, COUNT(*) AS col_cnt FROM information_schema.STATISTICS WHERE TABLE_SCHEMA idx_demo AND NON_UNIQUE 1 GROUP BY TABLE_SCHEMA, TABLE_NAME, INDEX_NAME ORDER BY TABLE_NAME, INDEX_NAME;这段查询输出的cols就是联合索引的完整列顺序比DESC靠谱得多。我一般会在新接手一个库的时候先跑一遍把结果导出成表格肉眼扫一遍通常能发现几处明显的列顺序问题或者重复索引。再进一步把每张表的索引总字节数估出来判断哪些表的索引比数据还大。SELECT TABLE_NAME, ROUND(DATA_LENGTH/1024/1024, 2) AS data_mb, ROUND(INDEX_LENGTH/1024/1024, 2) AS index_mb, ROUND(INDEX_LENGTH/NULLIF(DATA_LENGTH,0), 2) AS idx_data_ratio FROM information_schema.TABLES WHERE TABLE_SCHEMA idx_demo ORDER BY INDEX_LENGTH DESC;idx_data_ratio超过 1 就说明索引占用比数据本身还大值得看看是不是有冗余索引或者选错了索引列。这个比值在宽表上尤其明显因为INDEX_LENGTH只统计二级索引聚簇索引算在DATA_LENGTH里。提示information_schema里的统计值是估算的Cardinality尤其不准它只是采样估算。别拿它做唯一性判断也别拿它决定“这个索引到底有没有用”那得靠执行计划和对业务查询的实际观察。4. 标识背后的索引原理与选型逻辑4.1 聚簇索引与二级索引主键为什么特殊InnoDB 的表组织方式是索引组织表数据本身就存在主键对应的 B 树叶子节点上。这意味着“按主键查询”只有一次树查找查到叶子就拿到整行数据。而所有二级索引不管是 UNI 还是 MUL叶子节点存的都是“索引列值 主键值”查完还得多走一次聚簇索引才能拿到其他列这就是回表。回表的成本在小结果集时可以忽略但一旦范围扫描命中几万行就是几万次额外的树查找。这也是覆盖索引为什么重要的原因如果查询要的列恰好都在二级索引里就不需要回表。比如SELECT user_id, status FROM t_order WHERE user_id 100可以直接从idx_user_status拿到全部信息执行计划的 Extra 会显示Using index。理解了这个结构主键选型的逻辑就清楚了。二级索引里存的是主键值主键越长所有二级索引就越大。假设表上有 8 个二级索引主键从BIGINT8 字节换成CHAR(36)的 UUID36 字节每行数据在每个二级索引上多出 28 字节8 个索引就是 224 字节。100 万行算下来大约多占 214 MB这还没算 B 树节点分裂带来的额外开销和缓存命中率下降。每行额外开销 (36 - 8) 字节 × 8 个二级索引 224 字节 100 万行额外占用 ≈ 224 × 1,000,000 / 1024 / 1024 ≈ 214 MB所以主键这东西能用自增BIGINT就别用字符串能用短字符串就别用长字符串。UUID 有它的好处分布式生成不冲突但代价要算清楚。真要用可以考虑BINARY(16)存压缩后的 UUID或者用有序的雪花 ID 之类方案本质都是把“无序 长”这两个缺点里的至少一个消掉。无序主键的另一个坑是插入时随机落到 B 树各处频繁页分裂写性能掉得厉害这点在写入密集的表上比空间占用更致命。4.2 唯一索引与 NULL 的纠缠以及那个最坑的“唯一约束失效”这是我认为最值得单独讲的一段因为它造成的线上问题远比“看不懂字母”严重。SQL 标准里NULL表示“未知”两个未知值不能判定为相等所以UNIQUE索引允许多个NULL共存。MySQL 严格遵循这一语义。后果是什么你在mobile列上建了唯一索引指望它拦住重复手机号结果用户不填手机号时插入了一堆NULL一个都不冲突。等哪天业务要求“没填手机号的记录也只能有一条”你才发现这个约束从来没生效过。正确做法是把业务上要求唯一的列设为NOT NULL或者用空字符串加应用层兜底。两者取舍看场景NOT NULL DEFAULT 简单直接但空字符串会参与唯一性校验第二条空值就插不进去了用NULL则要接受“多个空值共存”并且所有查询都得处理IS NULL的情况。我个人的偏好是业务唯一键一律NOT NULL宁可在插入前做一次校验也不想让约束处于“半生效”状态。还有一类更隐蔽的问题字符集和排序规则导致的“明明不一样却判定重复”。utf8mb4_general_ci是大小写不敏感的ABC和abc在唯一索引里会冲突。如果业务上认为这是两个不同的值比如某些激活码、券码就得用utf8mb4_bin或者加COLLATE。另外尾随空格在某些排序规则下也会被忽略abc和abc 可能冲突。这些坑不会报错只会在某天突然插入失败报一个Duplicate entry然后你对着两条看起来完全一样的数据发呆。4.3 普通索引MUL什么时候是真的需要有人觉得既然唯一索引既能加速又能保证唯一那所有索引都建成唯一索引不就完了。这个想法在只读场景下没什么问题但在真实业务里会撞墙。最直接的原因是业务上确实允许重复。日志表的level、订单表的status、商品表的category_id这些列天生就是多对一的硬加唯一约束等于数据都插不进去。第二个原因是写入性能。唯一索引每次插入都要做唯一性检查在高并发写入场景下这个检查需要在索引上加锁热点值上容易产生锁等待。普通索引没有这个负担插入就是插入。对于一些可以容忍最终一致的场景比如埋点、消息去重表用普通索引加应用层幂等往往比数据库唯一约束更划算。第三个原因是前缀索引。有些列特别长比如 URL、备注、文章标题全列建索引太占空间。这时候可以建前缀索引KEY idx_url (url(64))只索引前 64 个字符。前缀索引一般只能是普通索引唯一前缀索引语义上是“前缀唯一”业务上很少这么用显示出来就是 MUL。选前缀长度的标准是选择性前缀的选择性接近全列选择性时长度就够了。-- 计算不同前缀长度的选择性越接近 1 越好 SELECT COUNT(DISTINCT LEFT(url, 16)) / COUNT(*) AS sel_16, COUNT(DISTINCT LEFT(url, 32)) / COUNT(*) AS sel_32, COUNT(DISTINCT LEFT(url, 64)) / COUNT(*) AS sel_64, COUNT(DISTINCT url) / COUNT(*) AS sel_full FROM t_url_log;如果sel_32已经到 0.98sel_64是 0.99那就没必要建 6432 就够省一半空间。这个计算我一般会在建索引前跑一次几十秒的事能避免拍脑袋定长度。5. 常见问题与排查实录5.1 明明建了唯一索引重复数据还是插进去了按发生频率排序第一个原因就是前面说的NULL。第二个原因是唯一索引建在了错误的列组合上比如业务要保证“同一用户同一活动只能领一次”索引却建成了UNIQUE (user_id)和UNIQUE (activity_id)两个单列唯一索引那结果就是每个用户只能有一条记录、每个活动只能有一条记录完全不是想要的效果。正确的是UNIQUE (user_id, activity_id)。第三个原因是大小写和空格前面提过了。第四个原因比较少见但确实存在在分库分表环境下唯一索引只在单表内生效跨表不保证。这个不是 MySQL 的问题是架构决定的得靠全局唯一 ID 或者中间件来兜。排查方法很简单先看索引定义再看数据。-- 看某列的重复情况 SELECT mobile, COUNT(*) AS cnt FROM t_order WHERE mobile IS NOT NULL GROUP BY mobile HAVING cnt 1; -- 看索引到底是不是唯一的 SELECT INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA idx_demo AND TABLE_NAME t_order AND COLUMN_NAME mobile ORDER BY INDEX_NAME, SEQ_IN_INDEX;如果NON_UNIQUE是 0索引确实是唯一的那问题一定出在数据本身有NULL或者逻辑上有别的入口绕过了约束。5.2 冗余索引清理清单以及怎么安全地删冗余索引是慢性病不会立刻出事但会持续拖慢写入、占用内存、干扰优化器。判断标准有三条。第一条索引 A 的列序列是索引 B 的列序列的前缀那 A 就是冗余的。比如idx_user_status (user_id, status)和idx_user_id_dup (user_id)后者完全被前者覆盖可以删。第二条唯一索引和普通索引列完全相同普通索引冗余。因为唯一索引也能用于等值查询和范围查询性能没有差别。第三条长期没有被任何查询用到的索引。MySQL 5.7 之后有sys.schema_unused_indexes视图虽然统计的是“本次实例启动以来”但只要重启时间够久参考价值就足够。-- 冗余索引MySQL 5.7 sys 库 SELECT * FROM sys.schema_redundant_indexes; -- 疑似未使用索引 SELECT * FROM sys.schema_unused_indexes WHERE object_schema idx_demo;删索引的正确姿势是先设不可见。MySQL 8.0 支持ALTER TABLE ... ALTER INDEX ... INVISIBLE设成不可见之后优化器不会再选它但索引结构还在万一出事一秒改回来。观察一周确认没有查询变慢再真正DROP。5.7 没有这个功能只能靠备份加低峰期操作风险高不少这也是升级 8.0 的一个实际理由。注意删索引前一定要确认没有FORCE INDEX之类的强制提示在用它也没有视图、存储过程、触发器依赖它。生产环境上我见过删掉一个“看起来没用”的索引导致报表任务从三分钟变成四十分钟的事故。5.3 索引失效的六种典型表现速查现象根本原因处理方式联合索引只用后几列违反最左前缀调整索引列顺序或补建索引查询列上套了函数索引存的是原值改写条件把函数移到常量侧字符串列传了数字隐式类型转换参数加引号保持类型一致LIKE以通配符开头无法前缀定位改全文索引或反向存储索引选择性太低优化器判断回表更划算扩大索引覆盖列数或直接全表两索引各自过滤后合并index_merge 成本高建一个覆盖两者的联合索引这张表我贴在工位上过很长一段时间。里面最容易被忽略的是第二行和第三行因为它们不报错只是悄悄不走索引。函数那条尤其隐蔽WHERE DATE(created_at) 2024-01-01这种写法在很多代码里都能看到正确写法是WHERE created_at 2024-01-01 AND created_at 2024-01-02两者结果一样执行效率差一个数量级。6. 从标识到执行计划把静态信息用起来6.1 EXPLAIN 输出怎么和 Key 列对上DESC告诉你“有什么索引”EXPLAIN告诉你“这次查询用了哪个”。两者结合才是完整信息。EXPLAIN SELECT id, order_no, amount FROM t_order WHERE user_id 100 AND status 1;重点看四列。possible_keys是候选索引列表可能有好几个key是实际选中的那个如果是NULL就说明没走索引key_len是实际使用的索引字节数能反推出用到了联合索引的前几列Extra里如果出现Using index说明是覆盖索引没回表。key_len这个字段特别有用但很多人不会读。计算规则是这样的定长类型按实际字节算INT是 4BIGINT是 8VARCHAR要额外加 2 字节存长度允许NULL的列再加 1 字节标记字符类型还要乘以字符集的最大字节数utf8mb4是 4。VARCHAR(32) NOT NULL, utf8mb4 → 32 × 4 2 130 VARCHAR(32) NULL, utf8mb4 → 32 × 4 2 1 131 BIGINT UNSIGNED NOT NULL → 8 TINYINT NOT NULL → 1拿idx_user_status (user_id BIGINT UNSIGNED NOT NULL, status TINYINT NOT NULL)来说如果查询带了这两个条件key_len应该是 98 1如果只带了user_idkey_len是 8。看到key_len是 8你就知道status部分没被用上可能是条件里对status做了运算也可能是优化器判断只用第一列就够筛了。这个反推技巧在排查“为什么联合索引只用了一半”时非常好使。6.2 一次慢查询复盘从全表扫到覆盖索引某张业务表八百万行EXPLAIN出来type是ALLrows接近八百万查询耗时 4 秒多。SQL 大概是SELECT id FROM t WHERE city_id ? AND biz_type ? ORDER BY created_at DESC LIMIT 20这个形状。表上已有的索引是PRIMARY (id)、KEY idx_city (city_id)、KEY idx_biz (biz_type)。优化器选了idx_city但因为两个条件都要过滤、还要排序它判断回表成本太高干脆全表扫。第一步把idx_city改成联合索引KEY idx_city_biz_created (city_id, biz_type, created_at)。这一改WHERE的两个等值条件能用上前两列ORDER BY也用上了第三列LIMIT 20直接取前 20 条就能停不用把结果集全排一遍。EXPLAIN的Extra从Using where; Using filesort变成了Using index condition耗时降到 30 毫秒左右。第二步既然只查id而id是主键、本来就在二级索引的叶子节点里可以直接改成覆盖索引形式。我把索引调整为(city_id, biz_type, created_at, id)虽然 InnoDB 会自动把主键附在二级索引末尾但显式写出来能让优化器更有把握。改完Extra变成Using index耗时再降到 15 毫秒上下。这个案例里有几个可以复用的判断。第一ORDER BY加LIMIT的组合只要能走索引顺序就不需要filesort收益极大。第二等值条件在前、范围或排序条件在后是联合索引列顺序的基本原则。第三覆盖索引省掉的回表次数在深分页场景下是数量级的差别。第四别急着把所有列都塞进索引索引宽度涨了单页能存的索引项就少了树高增加读放大反而可能变差。6.3 索引设计的三条硬规矩第一条联合索引的列顺序按“等值在前、范围在后、排序最后”排。这条规则覆盖了八成以上的场景剩下两成需要结合具体数据分布来调。第二条单表索引数量控制在五个以内写入密集的表压到三个以内。每个索引在写操作时都要维护索引越多插入和更新的成本越高缓冲池里能留给热数据页的空间也越少。第三条任何新索引上线前先用真实数据量级的测试库跑一次EXPLAIN确认type达到ref或rangerows在可接受范围。别在开发库那几千行数据上验证几千行的表全表扫都比走索引快优化器会做出完全不同的选择你在开发环境看到的效果和生产环境可能反着来。最后分享一个我自己的习惯。接手新库时我会先跑一遍information_schema.STATISTICS的导出把每张表的索引列顺序抄到一张纸上然后对着 Top 20 的慢查询逐条比对看每个查询能不能在纸上找到匹配的前缀。找不到的就是待优化的找到但冗余的就是待清理的。这套笨办法花不了两小时但通常能在一周内把整体慢查询数量压下去一大截。索引这件事没有什么高级技巧就是把“表上有什么”和“查询要什么”这两份清单对齐剩下的都是体力活。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑