☰
MySQL VARCHAR存储机制详解:从767字节到InnoDB行格式
2026/10/20 7:47:32 网站建设 项目流程

如果你踩过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为例:

字符集一个字符最多占用说明
latin11 字节基本只能存英文和西欧字符
utf8mb3(旧 utf8)3 字节一个汉字最多 3 字节
utf8mb44 字节支持 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 列存储长字段溢出策略默认情况
REDUNDANTAntelope占用固定空间保留 768 字节前缀 + 20 字节指针旧版遗留
COMPACTAntelope不占数据区,由 NULL 位图标记保留 768 字节前缀 + 20 字节指针MySQL 5.0 - 5.6 常见
DYNAMICBarracuda不占数据区,由 NULL 位图标记整列放溢出页,记录中只留 20 字节指针MySQL 5.7 / 8.0 默认
COMPRESSEDBarracuda不占数据区,由 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 实际长度 6

name列最大字节数 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 的列。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询