有人问我一个问题:"为什么varchar上的索引必须要指定索引长度?"我第一反应是,这问题至少得拆成两层来答:一层是MySQL在引擎层面确实有历史包袱留下的硬性限制,另一层是即便引擎不拦你,你在设计索引时也必须考虑存储和性能的取舍。网上很多答案是扔一句"InnoDB索引键最大767字节"就完事,这话不能说错,但远远不够。它解决不了"我在MySQL 8.0、utf8mb4、varchar(255)上建完整索引明明没报错,到底还用不用指定前缀长度"这类更常见的现实困惑。
这篇文章就把这件事彻底说透。我会从你可能会遇到的报错出发,讲清楚767字节和3072字节是怎么来的,再用一笔一笔的字节账告诉你为什么前缀索引往往更划算,最后给出一套区分度测试和验证方法。无论你是在改一张报了"Specified key was too long"的表,还是纯粹想把索引设计得更合理,这篇都适用。
1. 先分清"必须"的两个层面:引擎硬限制与设计取舍
1.1 一个让无数人踩坑的报错
很多人第一次接触这个话题,都是被一段报错逼来的。在MySQL 5.5或者更早的版本里,只要你对一个较长的varchar字段直接建索引,大概率会看到这样一句话:
ERROR 1071 (42000): Specified key was too long; max key length is 767 bytes这句话翻译成人话就是:你建的索引键太长了,超过了InnoDB允许的最大767字节。
于是大家的第一个疑问就是:为什么是767字节,而不是1024,也不是800?这其实是个很"古老"的设计决定。InnoDB从早期版本开始就对索引键长度有物理层面的限制,767字节这个数字源于对索引页结构的约束——在COMPACT行格式下,一个索引项承载的字节数不能超过这个规格。虽然后来MySQL引入了innodb_large_prefix参数,允许把上限扩大到3072字节,但767这个数字因为在各类博客和文档里出现太频繁,已经成了无数DBA和开发者的集体记忆。
这里要特别提醒:你在MySQL 5.7和8.0里看到的默认限制并不是767,而是3072字节。很多人拿着老文章来问"为什么我建索引没报错",往往就是版本差异造成的。先把这个概念理清,后面所有的计算才不会乱。
1.2 字符集和字节数:为什么utf8mb4最容易触发限制
这里的关键不只是varchar的长度,还有字符集。MySQL里varchar(n)的n是字符数,不是字节数,真正占用多少字节取决于字符集。
常见的字符集对应关系是这样的:
| 字符集 | 单个字符最大字节数 | 举例 |
|---|---|---|
| latin1 | 1字节 | 西欧字符、ASCII |
| gbk | 2字节 | 中文简体常用字符 |
| utf8mb3 | 3字节 | 曾用名utf8,支持常用汉字 |
| utf8mb4 | 4字节 | 完整Unicode,含emoji |
utf8mb4是目前最主流的字符集,也是最容易触发索引长度问题的那个。咱们算一笔账:一个varchar(255)字段,如果采用utf8mb4,理论上最多能存255个字符,每个字符最多4字节,那么最大字节数就是:
255 × 4 = 1020字节
这已经超过767字节的旧限制,所以在老版本里,直接对这个字段建完整索引是必然失败的。为了解决这个问题,最常见的做法是把索引长度写成191:
191 × 4 = 764字节
764刚好卡在767字节之内,这也是为什么网上老有人说"varchar(191)是上限"——它其实是"在767字节限制和utf8mb4字符集下,最长的安全索引字符数"。这些数字都不是拍脑袋定的,全是从字节限制倒推出来的。
1.3 新版MySQL不报错了,为什么还要主动指定前缀长度
到了MySQL 5.7.7之后,innodb_large_prefix默认开启,InnoDB的默认行格式也变成了DYNAMIC,索引键上限被放宽到3072字节。再回到刚才的例子,varchar(255)utf8mb4完整索引需要1020字节,远小于3072,所以不指定前缀长度,数据库也不会报错。
问题来了:既然不报错,为什么大家还习惯性地说"必须指定索引长度"?
因为引擎不拦你,不代表这么做是合理的。一个varchar(255)的完整索引,键本身就要占用1020字节,和仅指定varchar(191)甚至更短前缀的索引相比,对索引页空间、内存缓冲池、磁盘IO和写入性能的影响完全不同。这才是真正值得深入挖掘的地方,也是我接下来要讲的重点。
2. 从16KB索引页出发,看完整索引 vs 前缀索引的空间账
2.1 一条索引记录大概占多少字节
InnoDB的索引页默认大小是16KB,也就是16384字节。无论是主键索引还是普通二级索引,数据最终都存放在这种固定大小的页里。一个页里能塞下多少索引记录,直接决定了整个索引的页数量、B+树的高度,以及查询时要访问多少个页。
一条二级索引的记录,除了你建的索引键本身之外,还会附带不少额外信息:
- 主键值(如果主键是bigint,占8字节)
- 记录头信息(compact行格式下通常几个字节)
- varchar变长长度的记录(1到2字节,取决于长度是否超过255字节)
- 可空标志位(字段允许NULL时有1字节)
所以一条索引记录的估算大小,大概是"索引键字节数 + 主键字节数 + 十几字节固定开销"。我用两个场景对比一下:
假设主键是bigint,字段采用utf8mb4,分别建完整索引和前缀索引,一页能放多少记录?
| 字段定义 | 索引写法 | 索引键字节 | 单条记录估算 | 一页约能放 |
|---|---|---|---|---|
| varchar(100) | 完整索引 | 400 | 约415字节 | 约39条 |
| varchar(100) | 前缀20 | 80 | 约95字节 | 约170条 |
| varchar(500) | 完整索引 | 2000 | 约2015字节 | 约8条 |
| varchar(500) | 前缀100 | 400 | 约415字节 | 约39条 |
这组数字是估算值,不同行格式、不同NULL属性会导致细节有出入,但量级关系是明确的:索引键越大,单个索引页能承载的记录越少,整个索引占用的页数就越多。
2.2 页容量下降如何推高B+树高度和磁盘IO
B+树索引的高度取决于数据量和每个页能记录的数量。假设一张表有1000万条记录,如果每个索引页能放39条记录,那么索引需要大约25.6万个页;如果每个页只能放8条记录,则需要125万个页——页数差了将近5倍。
页数变多带来的问题有两个。
第一,B+树更容易长高。B+树的一个关键特性是,只有叶子节点存数据,非叶子节点存索引键和指针。如果每页能容纳的记录少,树的"扇出"就低,树的层数就可能从3层变成4层。千万不要小看这"多一层",对InnoDB来说,每多一层,一次查询就可能多一次磁盘随机IO,延迟翻倍都是常事。
第二,索引页占用的内存缓冲池空间大涨。InnoDB会把热门的索引页缓存在内存里,页数越多,能缓存的"人均页面"就越少,缓存命中率下降后,磁盘IO自然增加。
用一个生活中的类比:查字典时,如果每页只能印8个词条,字典就得做得很厚,翻页次数多、查找也慢;如果能印39个词条,字典就薄了很多。"指定索引长度"这件事,本质上就是把字典的每一页尽量填得更满。
2.3 前缀索引本质上是"粗筛+回表精筛"
这里要打消一个常见的心理障碍:很多人总觉得"索引里存的数据不完整,查询不就出问题了吗"。其实不会。
前缀索引存的确实是字段的前N个字符,但它照样能用于等值匹配、LIKE 'abc%'这种前缀匹配查询。以WHERE nickname = '张三丰'为例,如果索引只存了前两个字符,数据库会先在索引里定位所有前缀为"张三"的记录,拿到这些记录的主键,再回表读取完整行,最后用完整的nickname = '张三丰'条件把真正的目标行筛出来。
所以前缀索引的角色是"粗筛",它把95%的无关数据挡在索引扫描之外,剩下的一小撮候选行回表确认。只要前缀长度选得够聪明,回表的行数就不会多。明白这一点之后,再去谈"怎么选长度"才会有底气。
3. 前缀索引的适用边界:排序、覆盖索引和范围查询的取舍
3.1 哪些查询形态会被前缀索引拖累
前缀索引虽然省空间,但代价是"索引里头没有完整数据"。以下三类场景会受影响:
第一类,排序分组。ORDER BY nickname或者GROUP BY nickname这种查询,数据库需要的是完整字段值的有序序列。前缀索引只能保证"前缀"有序,无法保证完整值有序。举个例子,索引前缀是前3个字符,记录abcdef和abcxyz会被排在一起,但完整字段真正比较时abcdef在前还是abcxyz在前,索引根本不知道。所以排序操作往往会退化成文件排序(filesort),严重时还会动用临时表。
第二类,覆盖索引。覆盖索引指的是"要查的列都在索引里,不用回表"。如果索引是前缀索引,而查询又要返回完整的nickname字段,那索引里给不出完整值,只能回表取。覆盖索引的优化效果直接就没了。
第三类,严格范围匹配。WHERE nickname > 'abc'需要把索引里所有和完整值比较后大于'abc'的记录取出来。前缀索引没有完整值,没法做这个边界判断,优化器就很难用它做高效的范围扫描。
3.2 用一张表判断"能不能用前缀索引"
实际开发中判断起来也不复杂,把它对应到查询的形态上:
| 查询场景 | 完整索引 | 前缀索引 |
|---|---|---|
| 等值匹配 nickname = 'abc' | 高效 | 可用,可能少量回表 |
| 前缀匹配 LIKE 'abc%' | 高效 | 前缀长度充足时可接受 |
| 后缀匹配 LIKE '%abc' | 不可用 | 不可用 |
| 范围查询 > < between | 可用 | 基本走不通 |
| ORDER BY / GROUP BY | 可走索引 | 基本会filesort |
| 覆盖索引 | 有机会 | 基本没机会 |
| 索引体积 | 大 | 小 |
这张表的关键结论是:如果你的查询形态以等值匹配和前缀匹配为主,前缀索引非常合适;如果查询里有排序、分组、范围匹配,那就要慎重,或者考虑其他方案。
3.3 一次真实索引调整带来的教训
之前某项目里,有个用户表需要按昵称精确查询,原表结构里nickname是varchar(255)utf8mb4,建的也是完整索引。当时只想着查询快,没太在意索引体积,后来表数据涨到几千万,发现每次写入和更新时索引维护的负担明显变大,内存缓冲池也不够用,偶尔出现查询抖动。
后来做了个调整:先用区分度测试算了一下,发现前缀16个字符的区分度已经能达到完整字段的99%以上,就把索引从完整索引改成了idx_nickname(nickname(16))。改动之后,索引体积缩小了一半多,等值查询的响应时间反而更稳定了,因为每次索引IO要扫描的页更少,回表也就是零星几行的事。
这个案例说明了一个很实际的原则:如果你的业务场景就是精确匹配和前缀匹配,短前缀索引往往是比完整索引更优的选择;反而是那些你以为"存全了肯定更好"的完整索引,在数据量大起来之后会变成负担。
4. 把索引长度选对:区分度测试、哈希列与EXPLAIN验证
4.1 用区分度测试找到"拐点长度"
"指定索引长度"不是拍脑袋指定一个数字,你需要依据数据分布来决定。判断指标叫区分度,简单说就是"这个前缀能区分出多少不同的值"。
在线上库或者测试库执行下面这条SQL,就能看出不同前缀长度下的去重表现:
-- 全字段的去重总数 SELECT COUNT(DISTINCT nickname) AS full_cnt FROM user; -- 不同前缀长度下的去重数 SELECT COUNT(DISTINCT LEFT(nickname, 8)) AS prefix8, COUNT(DISTINCT LEFT(nickname, 12)) AS prefix12, COUNT(DISTINCT LEFT(nickname, 16)) AS prefix16, COUNT(DISTINCT LEFT(nickname, 24)) AS prefix24 FROM user;算法是计算"选择性比例":某个前缀的去重数除以完整字段的去重数。比如prefix12 / full_cnt = 0.97,意味着取前12个字符就能保留97%的区分度,那这个长度就已经很够用了。
数据量特别大的时候,全表COUNT(DISTINCT)会让数据库跑很久,可以先抽一部分样做近似估算:
SELECT COUNT(DISTINCT LEFT(nickname, 16)) AS prefix16, COUNT(DISTINCT nickname) AS full_cnt FROM user WHERE id % 100 = 0;4.2 几个实用的长度选择经验
选前缀长度时,我一般按这几条经验来:
- 先看字段字符集。utf8mb4下长度最好不要超过191,如果字段本身需要存很长内容,更要主动控制前缀长度。
- 看区分度曲线的拐点。很多字段的前4个字符区分度很低,比如固定前缀的业务编号;到第12、16个字符的时候区分度突然涨上去;再往后增长趋缓。那个"涨上去之后开始趋缓"的位置,就是好选择。
- 不要为了追求极致短而砍得太狠。如果前缀太短,区分度掉到80%以下,回表行数会暴增,性能未必比完整索引好。
- 如果字段本身很短,比如
varchar(20)且平均长度差不多就是十几个字符,那没必要刻意指定前缀,直接完整索引也不占太大空间。
4.3 等值匹配场景的终极方案:哈希索引列
有些场景比较极端:字段值很长,区分度也高,无法用较短的字符前缀来替代,但查询形态只有等值匹配。这个时候前缀索引就显得不够用,更合理的做法是给字段增加一个哈希列,然后对哈希列建索引。
MySQL 5.7之后支持生成列(generated column),可以这样设计:
ALTER TABLE user ADD COLUMN nickname_hash BINARY(16) GENERATED ALWAYS AS (UNHEX(LEFT(SHA2(nickname, 256), 32))) STORED, ADD INDEX idx_nickname_hash (nickname_hash);查询时这样写:
SELECT * FROM user WHERE nickname_hash = UNHEX(LEFT(SHA2('张三丰', 256), 32)) AND nickname = '张三丰';用哈希列的索引定位候选行,再配合完整字段做精确匹配,是为了避免理论上极低概率的哈希冲突。哈希值是固定长度的二进制数据,索引体积非常可控,而且等值匹配效率极高。代价是它不能用于范围查询和排序,所以只适合"只用等值匹配"的字段。
4.4 什么情况下可以不指定长度
聊了这么多"要指定长度",也得说清楚哪些情况可以大大方方不指定:
- 字段本身就短,比如
varchar(20)、varchar(32),在utf8mb4下完整索引最多也就128字节,完全在合理范围内。 - 字符集是单字节或双字节,比如latin1、gbk,同样的字符数,字节数小很多。
- 字段有唯一性约束。唯一索引要求对整个字段值保证唯一,不能只对前缀建唯一索引,否则"张三"和"张三丰"会被误判成同一个值。
4.5 EXPLAIN里的key_len怎么读
建完索引后,用EXPLAIN看查询执行计划,重点看key_len字段。它显示的是优化器实际使用的索引键字节数,可以验证前缀是否真的生效。
举例:
CREATE TABLE `user` ( `id` bigint NOT NULL, `nickname` varchar(50) NOT NULL, PRIMARY KEY (`id`), KEY `idx_nickname` (`nickname`(12)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; EXPLAIN SELECT * FROM user WHERE nickname = '张三丰';这次查询的key_len会显示为50。算法是这样的:前缀12个字符 × utf8mb4每字符4字节 = 48,再加上varchar变长长度字段2字节 = 50。如果字段允许NULL,通常还会再加1字节。
反过来,如果这个索引是完整索引,key_len会是202(50×4+2)。看到50这个数字,你就能确认优化器确实在用前缀索引,而且用的是前缀12。学会读key_len之后,很多"为什么我的索引没生效"的疑惑都会迎刃而解。
5. 字符集、排序规则和"767字节"迷信
5.1 不同字符集下的索引长度天花板
既然索引长度限制的本质是字节数,那同样一个767字节上限,在不同的字符集下能对应的字符数差别很大:
| 字符集 | 单字符最大字节 | 767字节上限对应的字符数 | 3072字节上限对应的字符数 |
|---|---|---|---|
| latin1 | 1 | 767 | 3072 |
| gbk | 2 | 383 | 1536 |
| utf8mb3 | 3 | 255 | 1024 |
| utf8mb4 | 4 | 191 | 768 |
用utf8mb4的时候,191是那个被反复提到的数字;用gbk的时候,383才是界限。这说明"必须指定索引长度"并不是一个放之四海而皆准的铁律,它完全取决于字符集和版本。
5.2 排序规则影响的是比较不是长度
还有一些人把排序规则(collation)和索引长度混在一起,其实排序规则只影响字符串怎么比较大小,不影响一个字符占几个字节。utf8mb4_general_ci和utf8mb4_0900_ai_ci在索引长度的计算上没有区别,都是按utf8mb4的4字节上限算。
但排序规则有一点值得注意:建索引的字段,排序规则一定要和查询条件里的字符串排序规则一致,否则会触发隐式转换,让索引直接失效。这个坑不常见,踩一次却很疼。
5.3 别被"varchar(255)不能用"这种说法带偏
现在网上时不时能看到"varchar(255)尽量别用"的说法,其实里面很多讨论都把问题简单化了。varchar(255)本身没问题,关键在于你用它做了什么。如果你的列是varchar(255)、字符集是utf8mb4,然后直接建完整索引,且你的表数据量巨大,那索引体积确实会失控。但如果字符集是latin1,或者字段本身内容很短、很少被排序,那varchar(255)和完整索引也不是什么恐怖故事。
真正该养成的习惯是:看到一个新的字符串字段要加索引时,先算三笔账——字符集的最大字节数、字段的区分度拐点、你的查询形态是否依赖排序和覆盖索引。算完之后,要不要指定长度、指定多少,答案自然就出来了。
最后再分享一个我个人的习惯:每次给字符串字段建索引之前,无论表大小,我都会先跑一遍区分度测试,再快速用EXPLAIN看一眼key_len是否符合预期。这套流程看起来很基础,但它能帮你躲开大多数"索引越界"和"索引失效"的坑。毕竟,索引设计这事,怕的不是字段长,而是不清楚自己到底换来了什么、牺牲了什么。