MySQL Key列PRI/UNI/MUL判定与SHOW INDEX排查

打开一张表的结构,目光扫到Key列,看到PRI、UNI、MUL三个大写字母,然后脑子里冒出一堆问号——这个场景我相信每个和 MySQL 打交道的人都经历过。有人以为MUL是“多重索引”,有人以为UNI就是“这列不能重复”,还有人看到联合唯一索引的首列显示UNI,回去就把业务代码里的去重逻辑删了,结果线上炸出一堆脏数据。这三个字母看着简单,实际上它只是 MySQL 给你的一层“显示结论”,背后藏着一整套判定规则,而且这些规则和你的直觉经常是反着来的。

我打算把这件事一次讲透:PRI、UNI、MUL各自的判定条件是什么,为什么同一列只能显示一个字母,联合索引和非空约束会怎么影响显示结果,以及最关键的——怎么用SHOW INDEX把这层“显示结论”撕开,看到真正的索引结构。内容会从规则讲到建表验证,再讲到几个我踩过的真实坑,适合刚接触 MySQL 索引的新手,也适合那些“用了一年但一直没细看 Key 列”的开发者。

1. 先把这三个字母的身份定位搞对

1.1 它不是索引类型,是一句“结论性的备注”

很多人第一反应是把PRI、UNI、MUL当成三种索引类型,这从根上就错了。MySQL 的索引类型只有PRIMARY、UNIQUE、FULLTEXT、SPATIAL、普通索引这几种,你在SHOW INDEX里看INDEX_NAME和NON_UNIQUE才能看到真身。而PRI/UNI/MUL是DESC 表名或SHOW COLUMNS FROM 表名输出结果里Key这一列的值,它本质上是“MySQL 根据这张表上所有索引的定义,倒推出来给这一列贴的一个标签”。

理解这个定位非常关键。标签是结论,索引定义才是事实。结论可能被简化、被覆盖、被多种索引定义挤压成同一个字母,所以当你看到UNI的时候,正确的反应不是“这列唯一”,而是“这列参与了某个唯一索引,而且排在第一顺位”。这两句话差别巨大,后面我会用一个具体例子把这个差别撕开给你看。

DESC的完整输出有六列:Field、Type、Null、Key、Default、Extra。Key只是其中一列,它的取值只有三种有效字母加上空值。命令行里空值显示为空白,很多图形化客户端会渲染成NULL或者干脆空着。这个“空白”其实也是信息,代表“这一列参与了索引,但既不是主键、也不是唯一索引的首列、也不是普通索引的首列”。

1.2 判定优先级:PRI 压 UNI,UNI 压 MUL

同一个列可能同时挂着好几个索引,比如一列既是主键、又建了个唯一索引、还顺手加了个普通索引。但Key列只能填一个值,所以 MySQL 内部有明确的优先级顺序:

  • 第一优先:PRI
  • 第二优先:UNI
  • 第三优先:MUL
  • 兜底:空

这个优先级不是随便定的,它反映的是约束的强弱。主键约束最强(唯一且非空),唯一索引次之(唯一但可空),普通索引最弱(不唯一)。列被贴标签时按“最强约束”优先展示,方便你一眼识别出这一列最重要的身份。

提示:正因为存在优先级覆盖,Key列永远无法告诉你“这一列一共有几个索引”。想知道全貌,必须走SHOW INDEX FROM 表名或者查information_schema.STATISTICS。

1.3 一张表先把规则固定下来

下面这张表是我自己整理速查用的,把三个字母的判定条件、容易误解的点、以及它到底保证了什么,一次性列清楚。

Key 值触发条件保证什么不保证什么
PRI该列是PRIMARY KEY的一部分(含联合主键的每一列)全表唯一,且一定非空不保证是单列主键,也不保证自增
UNI该列是某个UNIQUE索引的第一列(含单列唯一索引)在索引定义范围内唯一不保证单列唯一,不保证非空,不保证整个索引只有它一列
MUL该列是某个非唯一索引的第一列,或该列上只有普通索引这一列的值可以重复不保证一定可重复(联合唯一索引的非首列也可能显示 MUL)
空该列只出现在多列索引的非首列位置,且没有独立索引无额外约束不保证没索引,它可能在联合索引里排第二、第三

这张表建议直接存进备忘录。我见过太多人把UNI等同于“唯一约束”,然后在联合唯一索引上栽跟头。

2. PRI 的真实边界:它只认真正的 PRIMARY KEY

2.1 只有主键配叫 PRI,没有例外

PRI的判定条件非常干净:这一列是不是PRIMARY KEY的组成部分。是,就打PRI;不是,无论它上面挂了多少个唯一索引,都轮不到PRI。

这里有个常见的误解需要澄清。有些资料说“如果一个表没有主键,但有唯一非空索引,InnoDB 会把它当聚簇索引,所以显示 PRI”。前半句是对的——InnoDB 在没有显式主键时,确实会找一个所有列都NOT NULL的唯一索引来当聚簇索引,如果找不到就隐式生成一个 6 字节的ROW_ID。但DESC的Key列不会因此显示PRI,因为PRI严格对应PRIMARY KEY这个约束本身。没有主键的表,Key列里一个PRI都不会出现。

这个细节在实际排查中很有用。当你接手一张老表,DESC看下来全是UNI和MUL、一个PRI都没有,那基本可以判定这表没有主键。这种表在 InnoDB 里是隐式ROW_ID聚簇,主从复制走 row 格式时,如果ROW_ID冲突会导致主从不一致,是个实打实的隐患。

2.2 联合主键:每一列都被打上 PRI

这是我个人觉得最容易让人困惑的一点。假设建了这么一张表:

CREATE TABLE t_order_item ( order_id BIGINT UNSIGNED NOT NULL, item_id BIGINT UNSIGNED NOT NULL, sku VARCHAR(32) NOT NULL, qty INT NOT NULL DEFAULT 1, PRIMARY KEY (order_id, item_id), KEY idx_sku (sku) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

DESC t_order_item的输出里,order_id和item_id两列都会显示PRI。第一次看到这个结果的人经常会问:“item_id明明可以重复啊,怎么也是主键?”答案是:联合主键的约束单位是“组合”,不是“单列”。item_id单独看当然可以重复,但(order_id, item_id)这个组合不能重复。

所以PRI的准确语义是“这一列参与了主键约束”,而不是“这一列单独唯一”。在联合主键表上,如果你只拿item_id去做INSERT ... ON DUPLICATE KEY UPDATE,或者以为它能当唯一键用,逻辑就会出错。判断联合主键的各列顺序,靠DESC没用,得看SHOW INDEX FROM t_order_item WHERE Key_name = 'PRIMARY'里的Seq_in_index。

2.3 主键隐含 NOT NULL,写了 NULL 也会被改

建表的时候如果写了id INT PRIMARY KEY NULL,MySQL 不会报错,它会默默把NULL改成NOT NULL并给一条 warning。所以任何标了PRI的列,Null那一栏一定是NO。反过来,如果你看到Null是YES而Key是UNI,那这列就允许存 NULL,而且允许存多个NULL——这一点是UNI和PRI最本质的行为差异,下一节展开。

注意:TEXT、BLOB类型的列不能直接做主键(除非指定前缀长度),JSON列完全不能做索引。建表时如果想让一个大字段参与唯一约束,只能先转成VARCHAR落一个哈希列,或者用生成列。

3. UNI 最容易骗人的地方

3.1 判定条件:唯一索引的“第一列”

UNI的官方判定是:该列是某个UNIQUE索引的第一列。注意“第一列”这三个字,它是整个UNI语义的核心,也是九成误判的来源。

单列唯一索引UNIQUE KEY uk_mobile (mobile),mobile是第一列,显示UNI,没毛病。但联合唯一索引UNIQUE KEY uk_user_city (user_id, city)里面,user_id是第一列,它也会显示UNI。而city是第二列,它不显示UNI,如果city上没有别的独立索引,Key那一栏就是空白;如果它恰好又是另一个普通索引的首列,就显示MUL。

这个规则放到业务里看就很吓人了。假设你有一张用户城市绑定表,(user_id, city)唯一,意思是“同一个用户同一个城市只能有一条记录”。但DESC显示user_id是UNI,新来的同事一看,以为user_id只能出现一次,于是在代码里写user_id查不到就去 Insert,结果一个用户绑三个城市立刻触发唯一键冲突。这种问题排查起来极快,但写的时候是真的容易踩。

3.2 允许 NULL 的唯一索引依然是 UNI

第二个高频误解:UNI不等于非空。UNI只保证“在索引定义范围内,非 NULL 的值不重复”,而 SQL 标准里NULL != NULL,所以唯一索引允许多行存 NULL。

CREATE TABLE t_user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, email VARCHAR(64) DEFAULT NULL, mobile VARCHAR(20) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_email (email), UNIQUE KEY uk_mobile (mobile) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

DESC t_user里,email和mobile都显示UNI,但email的Null是YES,mobile的Null是NO。行为上:

  • 可以插入 10 行email = NULL,不会冲突
  • 插入第 2 行mobile = '13800000000'立刻报Duplicate entry

所以拿到一张表,判断某列到底能不能重复,不能只看Key是不是UNI,必须把Key和Null两列一起看:UNI+NO才是真正的单列唯一非空约束;UNI+YES意味着“非空值唯一,NULL 可以无限重复”。

提示:这个特性在某些场景下是白送的便利。比如软删除场景用deleted_at做唯一索引的一部分,未删除时是 NULL 不参与唯一性判断,删除后写时间戳,天然避开重复问题。但反过来,如果业务上要求email必填唯一,那就必须显式写NOT NULL,否则脏数据会从 NULL 这个口子溜进来。

3.3 版本与客户端的显示差异,别硬背结论

这里要老实说一句:不同版本、不同客户端对Key列的渲染并不完全一致,尤其是“唯一索引 + 允许 NULL”这种组合,以及函数索引、前缀索引这些场景,个别环境下的显示会有出入。我不建议把这套规则当成绝对真理背下来。

真正可靠的做法是:Key列用来快速扫一眼,真要下判断就走SHOW INDEX FROM 表名。它的输出里有Non_unique(0 表示唯一索引,1 表示非唯一)、Seq_in_index(列在索引里的位置)、Column_name(列名)、Sub_part(前缀长度)、Nullable(是否可空),这五个字段组合起来,索引的真实结构一目了然,比Key列那一个字母靠谱得多。

4. MUL 是个“兜底标签”,别过度解读

4.1 三种典型来源

MUL的含义最容易被简化成“这列可以重复”,这个简化在大部分情况下碰巧是对的,但它是结论而非原因。MUL的实际触发条件有这么几种:

  • 该列是某个非唯一索引的第一列,比如KEY idx_nick (nick)
  • 该列只是多列唯一索引的非首列,但同时又独立建过普通索引
  • 该列是外键自动创建的索引的首列(InnoDB 建外键时如果引用列上没有可用索引,会自动建一个普通索引,名字通常和约束名一致)

第三种情况特别隐蔽。很多人建了外键约束后,发现子表某个字段莫名其妙多了个MUL,还以为是哪次手滑加的索引。实际上那是 InnoDB 为了保证外键引用完整性自动补的,删外键的时候可以一并清掉。

4.2 多列索引的非首列,Key 是空的

这个点必须单独拎出来说,因为它和“没有索引”看起来一模一样,但含义完全不同。

KEY idx_city_created (city, created_at)这种联合索引,city显示MUL,created_at的Key是空白。看到空白不代表created_at上没索引,它明明在idx_city_created里排第二位。这个显示设计的逻辑是:DESC是按“列”组织的视图,而索引是按“列的组合”组织的,一个多列索引没法在单列视图里被完整表达,只好把标签打给首列,后面的列留空。

这个空白的实际危害在于误导。有人看到created_at的Key是空,判断“这个字段没索引,查询会全表扫”,于是又补了一个KEY idx_created (created_at)。结果表上多了个冗余索引,写性能白掉一截。判断到底有没有索引覆盖,永远以SHOW INDEX为准。

4.3 前缀索引、降序索引、函数索引的显示

前缀索引KEY idx_title (title(20)),title显示MUL,SHOW INDEX里Sub_part是 20。前缀索引只能建在字符串类型上,作用是省空间,代价是排序和覆盖索引都会失效,而且不能用作ORDER BY title的优化。

唯一前缀索引UNIQUE KEY uk_title (title(20))是合法的,DESC里依然显示UNI,但它保证的只是“前 20 个字符不重复”,第 21 个字符开始随便。如果你的业务字段长度超过前缀长度,这个唯一约束就是残缺的,实际生产里我建议宁可改字段类型、缩短长度,也别用唯一前缀索引。

MySQL 8.0 之后支持降序索引和函数索引,DESC的Key列显示规则基本沿用上面的逻辑,但函数索引在DESC里可能根本不体现,因为它是表达式而不是列。这类索引一律走SHOW INDEX看Expression字段。

5. 动手验证:一张表把三个字母全造出来

理论讲完,不如自己跑一遍。下面这张表的设计目标就是:在一张表里同时产出PRI、UNI、MUL和空白四种状态,并且埋一个“联合唯一索引首列显示 UNI 但实际可重复”的坑。

5.1 完整建表脚本

DROP TABLE IF EXISTS t_demo; CREATE TABLE t_demo ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID', mobile VARCHAR(20) NOT NULL COMMENT '手机号,唯一非空', email VARCHAR(64) DEFAULT NULL COMMENT '邮箱,唯一可空', nick VARCHAR(32) DEFAULT NULL COMMENT '昵称,普通索引', city VARCHAR(32) DEFAULT NULL COMMENT '城市', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_mobile (mobile), UNIQUE KEY uk_email (email), UNIQUE KEY uk_user_city (user_id, city), KEY idx_nick (nick), KEY idx_city_created (city, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PRI/UNI/MUL 验证表';

这张表一共 5 个索引:1 个主键、3 个唯一索引、2 个普通索引(其中city同时出现在uk_user_city第二位和idx_city_created第一位)。

5.2 DESC 结果逐行解读

执行DESC t_demo;,输出大致如下:

FieldTypeNullKeyDefaultExtra
idbigint unsignedNOPRINULLauto_increment
user_idbigint unsignedNOUNINULL
mobilevarchar(20)NOUNINULL
emailvarchar(64)YESUNINULL
nickvarchar(32)YESMULNULL
cityvarchar(32)YESMULNULL
created_atdatetimeNOCURRENT_TIMESTAMP

逐行拆开看:

id显示PRI,因为它是PRIMARY KEY (id)的唯一一列,Extra里的auto_increment说明它自增。这里有个细节:自增列一定是某个索引的第一列,但自增列不一定是主键。MyISAM 允许自增列建在普通索引上,InnoDB 则要求自增列必须是索引的第一列,通常我们就直接绑主键了。

user_id显示UNI,这是整张表最坑的一行。它确实“唯一”,但它唯一的是(user_id, city)这个组合,单独一个user_id在表里可以出现无数次(只要city不同)。如果你只盯着Key列看,很容易得出“user_id 不能重复”的错误结论。

mobile显示UNI,Null为NO,这是最标准的单列唯一非空约束,插重复直接报错。

email显示UNI,Null为YES,也是唯一索引,但允许多行 NULL。实际验证一下:

INSERT INTO t_demo (user_id, mobile, email, nick, city) VALUES (1, '13800000001', NULL, 'a', '北京'); INSERT INTO t_demo (user_id, mobile, email, nick, city) VALUES (2, '13800000002', NULL, 'b', '上海'); -- 两条都成功,email 都是 NULL 不冲突 INSERT INTO t_demo (user_id, mobile, email, nick, city) VALUES (3, '13800000003', 'x@example.com', 'c', '广州'); INSERT INTO t_demo (user_id, mobile, email, nick, city) VALUES (4, '13800000004', 'x@example.com', 'd', '深圳'); -- 第二条报 Duplicate entry 'x@example.com' for key 'uk_email'

nick显示MUL,因为它只有idx_nick这一个普通索引。

city显示MUL,因为它既出现在idx_city_created的第一位(普通索引首列),又出现在uk_user_city的第二位。按优先级,普通索引首列触发MUL。

created_at的Key是空白,因为它只出现在idx_city_created的第二位,没有独立索引。但它的查询性能其实是好的,只要WHERE city = ? ORDER BY created_at这样用,联合索引能完整覆盖。

5.3 SHOW INDEX 才是看结构的正确姿势

SHOW INDEX FROM t_demo;

输出字段比较多,我只挑关键的看:

字段作用
Key_name索引名,PRIMARY就是主键
Non_unique0 = 唯一索引,1 = 非唯一索引(注意是反的)
Seq_in_index该列在这个索引里排第几,从 1 开始
Column_name列名
Sub_part前缀长度,NULL 表示整列索引
Cardinality基数估算值,越大区分度越高
Index_typeBTREE、HASH、FULLTEXT等

想要一个更干净、更适合排查的视角,直接查information_schema.STATISTICS:

SELECT INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME, SUB_PART, CARDINALITY, NULLABLE, INDEX_TYPE FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_demo' ORDER BY INDEX_NAME, SEQ_IN_INDEX;

跑完你会看到uk_user_city有两条记录:user_id是SEQ_IN_INDEX = 1,city是SEQ_IN_INDEX = 2。这一眼就把前面那个“user_id显示UNI但其实不唯一”的谜团解开了。

提示:Cardinality是采样估算,不是精确值。刚建完表、批量导入数据后它可能严重失真,导致优化器选错索引。用ANALYZE TABLE t_demo;手动刷新一次,比盯着这个数字猜有用。

5.4 顺带说清最左前缀

联合索引的匹配规则叫“最左前缀”,通俗点说就是:一本电话簿按“姓氏 + 名字”排序,你只知道名字不知道姓氏,就没法高效翻。uk_user_city (user_id, city)能高效支撑WHERE user_id = ?、WHERE user_id = ? AND city = ?,但支撑不了WHERE city = ?。

这条规则和DESC的Key列有个很妙的对应关系:首列显示 MUL 或 UNI,后面的列显示空白,这个显示结构本身就是最左前缀规则的可视化。首列是入口,后面的列是排序的次要键。所以看到空白也别急着补索引,先想想查询条件是不是总能带上首列。

验证方式很直接:

EXPLAIN SELECT * FROM t_demo WHERE user_id = 1 AND city = '北京'; -- type = ref, key = uk_user_city, key_len 会用满两列 EXPLAIN SELECT * FROM t_demo WHERE city = '北京'; -- type = index 或 ALL, key 大概率走 idx_city_created

第二句之所以还能走idx_city_created,是因为city恰好是那个索引的首列。如果没这个索引,它就真的全表扫了。这也是为什么同一个字段建不建索引,取决于你查询时它是“首列”还是“跟随列”。

6. 排查实录:几个反复出现的问题

6.1 常见现象速查表

现象大概率原因处理思路
明明建了唯一索引,Key 显示 MUL索引是多列唯一索引,该列在第二位查SHOW INDEX看Seq_in_index
主键列DESC显示 UNI 不是 PRI表上真的没有PRIMARY KEY,只有唯一索引查SHOW INDEX有没有Key_name = 'PRIMARY'
字段 Key 是空白,但慢查询走得了索引它是某个联合索引的非首列别补索引,优化查询条件带上首列
允许 NULL 的唯一列插了两个 NULL 没报错唯一索引允许多个 NULL,NULL != NULL需要真唯一就加NOT NULL
外键字段莫名多了个 MULInnoDB 自动建的辅助索引删外键时可一并清理
联合主键的两列都显示 PRI联合主键每一列都参与约束唯一性看组合,不看单列

6.2 隐式类型转换让唯一索引失效

这个坑和Key列没直接关系,但它是最典型的“明明有UNI却查不到/插不进”的现场,值得单独说。

假设mobile是VARCHAR(20),你在代码里传了个数字:SELECT * FROM t_user WHERE mobile = 13800000000。MySQL 会按“字符串转数字”还是“数字转字符串”来比较?规则是:字符串列和数字比较时,会把字符串列转成数字再比。这意味着每一行都要做一次转换,索引直接失效,全表扫。同时'13800000000 '(带空格)、'13800000000x'这类脏值在做数字转换后都等于13800000000,唯一索引里明明两条不同的字符串,查出来却是同一条。

处理办法只有一条:参数类型必须和列类型严格对齐。VARCHAR就老老实实传字符串,Java 里别用Long接手机号,PHP 里别指望弱类型自动转对。这条规矩我在任何项目里都会写进代码规范。

6.3 索引不是越多越好,Key列也不代表性能

DESC看多了会有一种错觉:只要Key列里没字母,这列就该补个索引。这个思路很危险。

每个索引都是一棵独立的 B+ 树,写入时都要维护。一张表上挂七八个索引,插入性能能掉一半以上。而且 MySQL 优化器在候选索引太多时,选择成本也会上升,偶尔还会选错。我一般的判断顺序是:先看慢查询日志里真实的慢 SQL,再看EXPLAIN的key和rows,最后才考虑加索引。DESC只在接手新表、快速摸清结构时用,它从来不是我决定加不加索引的依据。

注意:删除索引前先确认它没有被外键依赖。InnoDB 里如果某个索引是外键的支撑索引,直接DROP INDEX会报错,得先删外键约束。

7. 我个人的几个使用习惯

日常开发里,DESC我基本只用来做两件事:快速确认主键在不在,以及扫一眼有没有明显的UNI误读风险。真正做索引评估,一律走SHOW INDEX或者information_schema.STATISTICS,因为那里才有Seq_in_index这个决定性字段。

还有个小习惯值得分享:接手一张陌生的表,我会先跑一条 SQL 把所有索引按列展开,直接看结构比看DESC快得多:

SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols, MIN(NON_UNIQUE) AS non_unique_flag FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_demo' GROUP BY INDEX_NAME;

一眼就能看出uk_user_city是user_id,city这样的组合,idx_city_created是city,created_at。哪些是联合索引、哪些是单列索引、有没有重复定义的冗余索引,全在这一张结果里。

最后再补一句关于UNI的实操建议:如果你的业务真的要求“某列全局唯一”,建表时请把NOT NULL和UNIQUE一起写上,别给 NULL 留口子。数据库层面的约束是最后一道防线,代码里的判重逻辑再严,也挡不住并发和手工改数据。这一点我吃过亏——线上有一批email为 NULL 的重复用户,是运营手工导入时留下的,等发现的时候已经几千条了,清理起来极其麻烦。