如果你踩过Specified key was too long; max key length is 767 bytes这个报错,那你就已经摸到了 MySQL 存储层的大门。之前群里有人发来一张截图,表结构简单得不行:
CREATE TABLE t_user ( name VARCHAR(255) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;就给name加个普通索引,MySQL 直接翻脸。他问我:255 个字符而已,怎么会超过 767 字节?我说:你被VARCHAR(255)里的 255 骗了,它说的是字符数,不是字节数。utf8mb4下一个字符最多占 4 字节,255 个字符就是 1020 字节,超过 767 太正常了。
但这只是 varchar 这潭水的第一层。顺着这个报错往下挖,你会碰到 InnoDB 的行格式、变长字段长度列表、NULL 值位图、隐藏列、行溢出……每一个概念都在解释同一个问题:**varchar 在 MySQL 里到底是怎么被存进磁盘的。**这篇文章就把这些坑一个一个填平。
1. varchar(255) 的限制到底在哪一层?
1.1 字符数和字节数是两回事
VARCHAR(n)里的 n,是字符上限,不是字节上限。一个英文字母算 1 个字符,一个中文汉字也算 1 个字符。但在磁盘上,英文字母可能占 1 字节,中文汉字根据字符集可能占 2 到 4 字节不等。
以最常见的utf8mb4为例:
| 字符集 | 一个字符最多占用 | 说明 |
|---|---|---|
| latin1 | 1 字节 | 基本只能存英文和西欧字符 |
| utf8mb3(旧 utf8) | 3 字节 | 一个汉字最多 3 字节 |
| utf8mb4 | 4 字节 | 支持 emoji、生僻字,也是现在的主流 |
所以VARCHAR(255)在utf8mb4下,理论最大需要 255 × 4 = 1020 字节的存储空间。这不是说每条数据都会占满 1020 字节,而是 InnoDB 在计算行大小、索引长度时,会拿这个理论最大值去套。
索引报错的关键就在这里:MySQL 旧版本里 InnoDB 索引键值的最大长度是 767 字节(文件格式 Antelope 时代),varchar(255)在 utf8mb4 下的 1020 字节超过了这个上限,建索引自然失败。把 varchar 改成varchar(191),191 × 4 = 764,刚好卡在 767 以内,所以很多老项目里经常出现varchar(191)这种看起来很奇怪的长度。说白了,191 不是业务需要,是被 767 逼出来的。
1.2 varchar 的上限不是 65535 个字符
很多人以为varchar最多能存 65535 个字符,这也是被“65535”这个数误导了。MySQL 文档里说的 65535 字节,是指一张 InnoDB 表里所有列加起来的行大小上限(不包括 TEXT/BLOB 类型)接近 65535 字节,不是字符数。
具体算一下:utf8mb4下,单列 varchar 的理论最大字符数大概是 16383(16383 × 4 = 65532,再加一些开销就顶到天花板了),超过就会报:
ERROR 1118 (42000): Column length too big for column 'xxx' (max = 16383); use BLOB or TEXT instead你在 utf8mb4 下表里写VARCHAR(20000),建表都会失败。如果多列 varchar 理论字节数加起来超过 65535,报的就是另一种错:
ERROR 1118 (42000): Row size too large. The maximum row size for the used table type, not counting BLOBs, is 65535.这个 65535 还只是 MySQL 服务层的行大小限制,等你真把表建起来往里插数据,还有 InnoDB 存储引擎这一层在等着你——也就是后面要说的 8126 字节页内限制。两层的限制叠加在一起,varchar 能不水深吗?
2. InnoDB 的行格式:一张表的“物理存储观”
2.1 四种行格式是什么关系
InnoDB 有四种行格式:REDUNDANT、COMPACT、DYNAMIC、COMPRESSED。它们的核心区别是:记录里那些变长的列,到底怎么组织、怎么处理溢出数据。
早期 InnoDB 用的是 REDUNDANT,格式比较“粗放”,NULL 列也会占用固定存储空间,浪费明显。MySQL 5.0 引入了 COMPACT,把 NULL 列的存储方式改了,空间利用率高了不少。再往后,InnoDB 的文件格式演进到 Barracuda,随之有了 DYNAMIC 和 COMPRESSED。DYNAMIC 是 MySQL 5.7 和 8.0 的默认行格式,它在处理特别长的 varchar/ TEXT / BLOB 时会更激进,倾向于把整列数据放到“溢出页”,只在主记录里留一个指针。
| 行格式 | 所属文件格式 | NULL 列存储 | 长字段溢出策略 | 默认情况 |
|---|---|---|---|---|
| REDUNDANT | Antelope | 占用固定空间 | 保留 768 字节前缀 + 20 字节指针 | 旧版遗留 |
| COMPACT | Antelope | 不占数据区,由 NULL 位图标记 | 保留 768 字节前缀 + 20 字节指针 | MySQL 5.0 - 5.6 常见 |
| DYNAMIC | Barracuda | 不占数据区,由 NULL 位图标记 | 整列放溢出页,记录中只留 20 字节指针 | MySQL 5.7 / 8.0 默认 |
| COMPRESSED | Barracuda | 不占数据区,由 NULL 位图标记 | 同 DYNAMIC,且页数据压缩 | 适合读多写少的静态数据 |
COMPACT是理解 InnoDB 存储的“教学格式”,因为它结构最典型,而且很多老系统里还在用。DYNAMIC则是你现在建表默认会遇到的行为。理解了 COMPACT,DYNAMIC 基本就是在“溢出策略”上换了个思路。
2.2 怎么查看一张表当前用的什么行格式
不需要读 ibd 文件,SQL 就能查:
SHOW TABLE STATUS LIKE 't_user'\G -- 看 Row_format 字段,可能显示 Compact / Dynamic / Redundant / Compressed也可以查information_schema:
SELECT TABLE_NAME, ROW_FORMAT FROM information_schema.TABLES WHERE TABLE_SCHEMA = '你的库名' AND TABLE_NAME = 't_user';全局默认行格式用这个看:
SHOW VARIABLES LIKE 'innodb_default_row_format';如果你在 8.0 里建表没指定ROW_FORMAT,默认就是 Dynamic。但如果你跟着老教程建表时写了ROW_FORMAT=COMPACT,那你就在用 20 年前的设计了,后面讲的行溢出差异才会真正影响到你。
3. 一条记录在 InnoDB 里的完整解剖
3.1 COMPACT 行格式的五段结构
去掉各种页级头尾之后,一条用户记录在 COMPACT 行格式下大致长这样:
变长字段长度列表 | NULL 值位图 | 记录头信息(5字节) | 隐藏列 + 用户数据字段前两个结构是新版行格式相对 REDUNDANT 最重要的改进,也是最容易忽略的“隐性开销”。
变长字段长度列表:VARCHAR、VARBINARY、TEXT、BLOB 这类变长列,真实数据有多长,就需要把长度记下来。列表里每一项记录一个变长列的实际字节长度,而且顺序是反着来的,先记最后一个变长列,再记倒数第二个,依次往前。为什么要逆序?InnoDB 这么做不是为了炫技,是让长度信息和后面的字段数据在偏移计算时更方便对位。实际业务里你不需要手算每个 bit,但要知道:一行里 varchar 列越多,这个列表就越长,每条记录都会背上这些额外字节。
列表项本身占 1 字节还是 2 字节,有明确规则:
- 如果该 varchar 列允许的最大字节数不超过 255,就用 1 字节;
- 如果允许的最大字节数超过 255,那么看这条记录的实际值:实际长度不超过 127 字节用 1 字节,超过 127 字节则用 2 字节。
所以VARCHAR(100)在 utf8mb4 下理论最大 400 字节,但存短字符串时长度项只占 1 字节;存超过 127 字节时,长度项变 2 字节。
NULL 值位图:记录里所有允许为 NULL 的列,按逆序每个列占一个 bit。某列为 NULL,对应 bit 置 1;不为 NULL 就置 0。如果整张表所有列都NOT NULL,这个位图直接不出现。位图按字节对齐,不足一个字节的补 0。这意味着:NULL 值本身不会在数据区占空间,但它的“标记”空间是省不掉的。可空列越多,这个位图越占地方(不过通常也就几个字节,不算大头)。
记录头信息:固定 5 字节,40 个 bit。里面主要有:
delete_mask:删除标记,记录被 UPDATE/DELETE 后不是物理抹掉,而是打个标记;n_owned:页目录分组时用到;heap_no:记录在页堆中的位置;record_type:0 是普通记录,1 是 B+ 树非叶节点记录,2/3 分别是页内最小/最大伪记录;next_record:指向下一条真实记录的相对偏移量,这是 InnoDB 记录之间形成“链表”的关键。
隐藏列:除了用户定义的字段,InnoDB 还会给每条记录加隐藏列。DB_TRX_ID(6 字节,事务 ID)和DB_ROLL_PTR(7 字节,回滚指针)必然存在。如果表没有主键也没有非空唯一键,InnoDB 会再加一个DB_ROW_ID(6 字节)作为聚簇索引键。这也是为什么 InnoDB 强烈建议你显式声明主键——没有主键时,那个隐藏 rowid 照样占空间,而且顺序对你不可控。
3.2 手工推导一条示例记录
用一个具体例子来回味上面的结构。假设有张表:
CREATE TABLE t_user ( id INT PRIMARY KEY, name VARCHAR(10) NOT NULL, age INT NOT NULL, note VARCHAR(100), gender VARCHAR(1) ) ENGINE=InnoDB ROW_FORMAT=COMPACT DEFAULT CHARSET=utf8mb4;插入一行数据:id=1, name='张三', age=18, note='hello', gender='M'。
先看变长字段:name、note、gender都是 varchar。name是 utf8mb4 下的 6 字节,note是 5 字节,gender是 1 字节。逆序记录长度:
变长字段长度列表(按列逆序): 0x01 -- gender 实际长度 1 0x05 -- note 实际长度 5 0x06 -- name 实际长度 6name列最大字节数 40(<=255),所以长度项固定 1 字节;note列最大字节数 400(>255),但实际只有 5 字节(<=127),长度项仍然只占 1 字节。这条记录的长度列表总共 3 字节。
NULL 值位图:可空列只有note、gender两列,正好 1 个字节。两个列当前都不是 NULL,所以位图字节是0x00。
数据区:隐藏列DB_TRX_ID6 字节、DB_ROLL_PTR7 字节(有主键,所以没有DB_ROW_ID);用户字段依次是 id 4 字节、name 6 字节、age 4 字节、note 5 字节、gender 1 字节。
整条记录大致开销:
| 部分 | 占用 |
|---|---|
| 变长字段长度列表 | 3 字节 |
| NULL 值位图 | 1 字节 |
| 记录头 | 5 字节 |
| 隐藏列 | 13 字节 |
| 用户数据字段 | 20 字节 |
| 合计 | 42 字节 |
42 字节只是这条记录本身,还不算页目录、槽位这些页级结构。但这已经能说明问题了:varchar 的实际行内大小,不只看你存了多少字符,还要叠加一堆记录级元数据。字段越多、可空列越多,这里的固定开销就越大。
3.3 隐藏列字段和 NULL 位图在实际问题中的体现
有次排查一个表,主键是业务编号,类型是 VARCHAR(32) 且非空,整表还不到 10 个字段,但SHOW TABLE STATUS看到Data_length比预期大不少。后来发现原因之一是:没有主键时 InnoDB 额外塞了 6 字节DB_ROW_ID,而且引擎按自增顺序插入,顺序可控;但如果表主键是随机字符串,B+ 树插入时会产生大量页分裂,空间碎片也多。单个 6 字节不多,可架不住表里几百万行。
NULL 位图这块也有一个经典误解:很多人觉得可空列反正不占空间,就疯狂给列加DEFAULT NULL。实际上位图本身有开销,但更关键的是 NULL 在索引里的行为不一致:多列索引中 NULL 值不会进入索引(或者说索引不记录 NULL),这会影响查询计划和唯一约束的判断。从存储角度,我不会说“不要用 NULL”,但建议明确区分业务上的“空字符串”和“未知值”,没有一个语义,就别让列可空。
4. 行溢出:当 varchar 大到一页装不下
4.1 16KB 页与 8126 字节的隐形天花板
InnoDB 默认一页 16KB,也就是 16384 字节。一页里面不只是数据,还有文件头、页头、系统记录、页目录、页尾这些杂项。真正留给用户记录的空间,大概在 16KB 减去约 200 字节左右。
但实际限制比这更苛刻:InnoDB 要求一个页至少要能容纳两行记录,否则就认为这一行“太大”,直接拒绝。所以在 16KB 页下,一条记录在页内能占用的空间大约只有 8000 字节多一点,常见的说法是8126 字节。这就是为什么你看一些非常大的单行,明明总字节数没到 65535,MySQL 依然报:
ERROR 1118 (42000): Row size too large. The maximum row size for the used table type, not counting BLOBs, is 8126.问题就出在:这一行需要的“页内本地空间”超过了半页。
4.2 COMPACT 的 768 字节前缀和 DYNAMIC 的彻底搬移
当一行记录的总长度超过页内剩余空间时,InnoDB 不是把整行直接拒掉,而是启动行溢出机制,把特别长的变长列挪到单独的“溢出页”上。
COMPACT(以及更早的 REDUNDANT)的策略是:对于需要溢出的长字段,记录里保留前 768 字节的数据,剩下的部分放到溢出页,同时存一个 20 字节的指针指向溢出页。下次读这列时,如果只需要前 768 字节,可以直接从记录里取,不用访问溢出页;需要更多数据,再顺着指针去读。
DYNAMIC 的策略更彻底:记录里不保留字段真实数据,只留 20 字节指针,整列数据完整放到溢出页。这样做的好处是,主记录非常“瘦”,一页能塞下更多普通字段的行;坏处是,查询这列时基本必然要跳去读溢出页。
行溢出的触发不是“列长度超过某个固定值”,而是“整行在页内放不下”时才触发。比如 utf8mb4 下一个VARCHAR(1000)如果只存了 10 个字节,它大概率还老老实实待在记录里,不会触发溢出。一旦这条记录的整体长度逼近页内空间上限,InnoDB 就会把最长的变长列“请出去”。
4.3 行溢出的性能影响:别把所有锅都甩给 varchar
行溢出的最大代价是随机 I/O。记录里的 20 字节指针能精确定位溢出页,但读取溢出页不是一个连续的物理顺序读,InnoDB 需要额外寻址。如果这张表的行大多都要读溢出列,查询延迟会明显上升。
但这里有一个容易被误伤的结论:表里有大 varchar,不代表所有查询都会变慢。如果查询选择的列不包含这个溢出字段,InnoDB 只需要读主记录里的指针,不会去碰溢出页。你SELECT id, name FROM t_large_text依然很快;只有SELECT content或者SELECT *把大字段带上时,溢出页的访问才躲不掉。
所以我的习惯是:如果业务里经常只读主信息、极少读详情大字段,那就不该用SELECT *,应该主动把大字段拆到单独 SQL 或单独接口里取。如果大字段写入频繁且更新频繁,更应该考虑拆表。比如把文章内容content LONGTEXT和文章标题、作者、状态等核心列拆成两张表,用主键关联,避免核心列表频繁触发行溢出和页分裂。
5. 实战排查:varchar 相关的常见报错速查
5.1Data too long for column 'xxx' at row 1
这是最容易让人误判的报错。它的判定依据是字符数超了,不是字节数超了。VARCHAR(10)存 10 个中文汉字(utf8mb4 下 40 字节)完全没问题;如果硬塞第 11 个汉字,才会报这个错。所以业务层校验长度时应该按CHAR_LENGTH来限制,而不是按字节。
排查方式很简单:
SELECT CHAR_LENGTH('这是一段很长的内容') AS chars, LENGTH('这是一段很长的内容') AS bytes;CHAR_LENGTH拿到字符数,LENGTH拿到字节数。应用层入参校验用字符数,存储层估算空间用字节数,两者别混。
5.2Column length too big for column 'xxx' (max = 16383)
出现在 utf8mb4 下 varchar 字符长度超过 16383 时。16383 × 4 字节 ≈ 65532 字节,再往上就冲破单列的行大小上限了。这时候要么把字段类型改成 TEXT / MEDIUMTEXT / LONGTEXT,要么直接换存储方案(比如把超长文本放对象存储),不要硬调 varchar。
5.3Row size too large:两个版本要分清
看错误信息里的数字:
The maximum row size for the used table type, not counting BLOBs, is 65535. -- 服务层行大小限制:所有非 TEXT/BLOB 列的理论字节和太大 The maximum row size for the used table type, not counting BLOBs, is 8126. -- InnoDB 页内空间限制:记录在页里放不下(16KB 页)前者主要跟建表声明有关,后者主要跟实际数据和行格式有关。出现后者的常见场景是:行格式还是 COMPACT、很多长 varchar 加起来接近页内上限,或者记录里掺杂了大量定长字段。换用 DYNAMIC 行格式有一定缓解,但最好还是从根上减少不必要的列、拆分大字段。
5.4Specified key was too long; max key length is 767 bytes
索引长度限制。utf8mb4 下要创建索引的 varchar 列,长度最好不要超过 191 个字符(191 × 4 = 764)。如果你必须在一个较长的 varchar 列上建索引,可以用前缀索引:
ALTER TABLE t_user ADD INDEX idx_name (name(191));如果前缀区分度不够,再考虑加一列对原值做哈希或摘要,用那列建索引。另外 MySQL 5.7.7 以上的版本,在默认 Dynamic 行格式下索引键上限已经放宽到 3072 字节,767 的限制主要是在旧文件格式或指定 COMPACT 的表上还会出现。所以处理索引超长问题,第一步先确认表的ROW_FORMAT。
5.5 快速估算一条记录实际占多大
SQL 层面可以直接量各 varchar 列的真实字节数:
SELECT id, LENGTH(name) AS name_bytes, LENGTH(note) AS note_bytes, LENGTH(gender) AS gender_bytes FROM t_user WHERE id = 1;再套上隐藏列 13/19 字节、记录头 5 字节、可空列位图ceil(可空列数 / 8)、变长长度列表等,就能粗略估计记录主存储大小。想更细的话,可以用innodb_page_size结合信息来估算页内能放多少行。这个方法不精确,但用于判断“这张表是否接近行大小上限”足够了。
6. 我的一些建表习惯和避坑建议
第一个习惯:varchar 长度能短则短,不要习惯性写 255。255 在很多老项目里是“省事”的代名词,但在 utf8mb4 下,255 意味着理论 1020 字节。如果这列还要建索引,又撞上 767/3072 的限制。根据业务实际最大长度来定,比如手机号VARCHAR(20),昵称VARCHAR(50),标题VARCHAR(200)(如果确实需要),摘要VARCHAR(500)。长度定得短,行内理论上限就低,将来改表结构的时候也轻松。
第二个习惯:大文本用 TEXT / MEDIUMTEXT,而不是超长 VARCHAR。很多人觉得 TEXT 有额外指针、性能不好,其实在 InnoDB 里 TEXT 和超长 VARCHAR 在溢出机制上差别不大。用 TEXT 还能让建表时的行大小计算更宽松(“not counting BLOBs”)。但 TEXT 的默认值是没法直接给默认字符串的,业务上要接受这一点。
第三个习惯:能拆列就拆列,别让一张表背上所有字段。日常核心查询需要的字段放主表,详情型大字段放副表或者放下层存储。一张表保持“瘦”,页内能容纳更多行,B+ 树层级更低,查询成本就是实打实降低。这个收益在数据量过千万以后会非常明显。
第四个习惯:建表时确定行格式,不要依赖历史默认值。新项目直接用默认 Dynamic 就好。老表如果还在用 Compact,并且有长 varchar 溢出问题,可以考虑ALTER TABLE ... ROW_FORMAT=DYNAMIC,但要评估 DDL 期间的锁表影响,建议用在线工具或者在业务低峰期操作。
最后再分享一个辅助技巧:排查 varchar 相关问题时,把information_schema.COLUMNS里的CHARACTER_MAXIMUM_LENGTH全部捞出来,按列理论字节数从大到小排一遍。这个排序能帮你快速找到哪些列在疯狂推高行大小上限——我处理过的绝大多数“行太大”问题,最后都指向那几个当初随手写了 255 或者 1024 的列。