把MySQL的CHAR和VARCHAR搞混,本质上是在跟存储引擎的物理特性较劲。很多开发者建表时随手写个VARCHAR(255),以为这就是“万能字符串”,结果索引建不上、查询走不动、空间浪费得一塌糊涂。这两种类型根本不是简单的“定长”和“变长”两个词能概括的,它们在存储结构、内存分配、索引行为、更新代价上完全是两套逻辑。选错了,不是性能差一点的问题,是整体架构会因此埋下慢性病根。
存储层面的根本差异CHAR(N)在磁盘上永远占用N个字符的空间。注意是“字符”不是“字节”,在utf8mb4编码下,一个字符可能占1到4个字节,所以CHAR(10)实际可能占用40个字节。如果存的内容不足N个字符,MySQL会用空格填充到N,但在查询返回结果时,默认会把尾部空格去掉。这个“填充再截断”的行为很容易让人产生错觉,以为CHAR也是变长的。实际上在InnoDB的聚簇索引记录里,CHAR字段的未使用空间全被空格填满,物理长度恒定。
VARCHAR(N)则完全不同。它在磁盘上只占用实际数据长度加上1到2个字节的长度前缀。如果VARCHAR列定义的长度不超过255字节,长度前缀占1字节;超过255字节则占2字节。比如VARCHAR(100)在utf8mb4下最大需要400字节,长度前缀就是2字节。存一个“abc”字符串,实际占用3个字符的字节数加上长度前缀,没有空格填充,空间利用率极高。但这也意味着每次更新变长数据,如果新值比旧值长,可能触发页内数据的移动甚至页分裂,代价远比CHAR高。
索引行为与内存分配很多人不知道,MySQL在创建索引时对CHAR和VARCHAR的处理有本质区别。对于CHAR列,索引键值直接使用定长存储,比较时按固定长度逐位对比,速度极快。VARCHAR列在索引中虽然也是变长存储,但InnoDB在内存的adaptive hash index里,对VARCHAR的处理需要额外的解引用操作,因为数据长度不固定,每次比较前必须先读取长度信息。在高并发场景下,这种额外开销会被放大,尤其是联合索引中混用CHAR和VARCHAR时,优化器生成执行计划的成本估算也可能出现偏差。
在临时表和排序缓冲区里,这个差异更加明显。MySQL做filesort时,如果排序字段是VARCHAR,会按照定义的最大长度分配内存,而不是实际数据长度。这意味着一个VARCHAR(1000)的列,即使每条记录只存了10个字符,排序时仍然按1000字符分配sort_buffer空间,内存消耗直接爆炸。CHAR则没有这个问题,因为定义多少就分配多少,不会多也不会少。很多DBA发现排序查询疯狂消耗内存,根因就是VARCHAR定义过宽。
更新操作的隐藏代价CHAR的定长特性在更新时是巨大优势。因为每一行占用的空间固定,更新CHAR列的值时,新数据直接覆盖旧数据的位置,不需要移动行内其他字段,也不会产生页内碎片。对于频繁更新的业务表,比如订单状态、用户登录时间这类字段,用CHAR能显著减少页分裂和空间回收的开销。
VARCHAR的变长特性在更新时就成了劣势。如果新值的长度大于旧值,而当前页又没有足够的空闲空间,InnoDB必须把整行数据移动到新页,并在原位置留下一个指向新页的指针。这个过程叫“页分裂”,会产生大量随机IO,并且导致索引树的平衡性下降。如果新值长度小于旧值,虽然不需要移动,但释放出来的空间可能成为碎片,长期运行后表空间膨胀、扫描效率降低。所以VARCHAR不适合频繁更新的场景,更适合写入后较少修改的数据,比如日志、评论内容、文章正文。
字符集与最大长度的陷阱定义VARCHAR(255)这个操作,在utf8mb4下实际上限是255个字符,但很多人误以为是255个字节。utf8mb4一个字符最多占4字节,所以VARCHAR(255)最大需要1020字节,加上2字节长度前缀,总共1022字节。而InnoDB单列索引的最大长度限制是767字节(在innodb_large_prefix关闭时)或3072字节(开启时)。如果你在一个utf8mb4的VARCHAR(255)列上建索引,实际索引键值长度可能达到1020字节,超过767的限制就会直接报错。这就是为什么很多人在建表时莫名其妙遇到“Specified key was too long”错误,查了半天才发现是字符集和VARCHAR长度没算对。
CHAR也有类似问题。CHAR(255)在utf8mb4下固定占用1020字节,如果拿它做主键或者唯一索引,索引长度直接就是1020字节,对性能和空间都是灾难。所以CHAR通常只用于短字符串,比如手机号、身份证号、MD5哈希值、状态码这些长度固定的数据。一旦超过50个字符,CHAR的存储效率和性能优势就会急剧下降。
存储引擎层面的实现细节在InnoDB的行格式(ROW_FORMAT)下,CHAR和VARCHAR的物理存储还有更细微的差别。COMPACT行格式中,VARCHAR列的前768字节会跟其他列一起存在聚簇索引记录里,超出部分存到溢出页。而CHAR列无论多长,只要总行大小不超过页大小的一半,就会全部存在主记录里。这意味着一个CHAR(200)在utf8mb4下占用800字节,全部在主记录中,查询时一次IO就能拿到。而VARCHAR(200)如果实际存了200个字符,800字节可能部分在溢出页,查询时可能需要两次IO。对于高频访问的小表,这个差异足以影响QPS。
DYNAMIC和COMPRESSED行格式下,VARCHAR的溢出策略更激进,只要行大小超过页大小的一半,就把变长列整个移到溢出页,主记录只保留20字节的指针。CHAR列则不受此影响,因为它不是变长列。所以对于需要频繁全表扫描的表,如果字符串列定义成VARCHAR且实际数据较长,扫描时会产生大量随机IO去读取溢出页,性能会断崖式下跌。这时候反而应该考虑用CHAR或者TEXT类型,根据实际访问模式来选。
实际选型决策框架第一,看数据长度是否固定。如果长度绝对固定,比如手机号11位、身份证号18位、UUID去掉横线后32位,直接用CHAR,不要犹豫。定长数据用VARCHAR不仅浪费那1到2字节的长度前缀,还会让优化器在估算行大小时产生误差,影响join buffer和sort buffer的分配。
第二,看数据长度波动范围。如果长度波动很小,比如大部分值都在8到12个字符之间,定义CHAR(12)比VARCHAR(12)更优。因为VARCHAR的长度前缀和变长处理开销,在长度波动小时得不偿失。如果长度波动巨大,比如用户昵称从2个字符到50个字符不等,那就必须用VARCHAR,CHAR会导致大量空间浪费。
第三,看更新频率。高频更新的字符串列,即使长度不完全固定,如果最大长度不大(比如50以内),也建议用CHAR。用空间换时间,避免页分裂和碎片。低频更新或者只读场景,VARCHAR的空间优势就能充分发挥。
第四,看索引需求。如果要在字符串列上建索引,优先考虑CHAR,因为定长索引键的比较效率更高。如果必须用VARCHAR且长度较长,考虑建前缀索引,但前缀索引有选择性的问题,需要根据数据分布计算合适的前缀长度。比如一个VARCHAR(200)的邮箱列,建前缀索引时用下面这个SQL测试选择性:
SELECT COUNT(DISTINCT LEFT(email, 10)) / COUNT(*) AS selectivity_10, COUNT(DISTINCT LEFT(email, 15)) / COUNT(*) AS selectivity_15, COUNT(DISTINCT LEFT(email, 20)) / COUNT(*) AS selectivity_20 FROM users;
选择性越接近1越好,同时前缀长度越短越好,找到平衡点。但要注意,前缀索引无法用于ORDER BY和GROUP BY,因为索引中只有前缀信息,没有完整值。
常见误用与纠正误区一:所有字符串都用VARCHAR(255)。255这个数字在历史上是某些数据库的VARCHAR最大长度限制,在MySQL里完全没有这个限制,VARCHAR最大可以到65535字节。但定义过宽的VARCHAR会导致临时表和排序时内存分配过大,而且优化器在计算索引键长度时可能直接放弃使用索引。应该根据业务实际最大长度来定义,比如姓名最长50个字符就设VARCHAR(50),不要偷懒。
误区二:用CHAR存长文本。见过有人用CHAR(500)存文章摘要,一行就占2000字节(utf8mb4),一个数据页16KB只能存8行,扫描效率极低,而且大量空间被空格填充浪费。长文本应该用VARCHAR或者TEXT,让溢出页机制来分担存储压力。
误区三:以为VARCHAR不会产生碎片。VARCHAR的更新和删除同样会产生页内碎片,只是表现形式不同。定期用OPTIMIZE TABLE整理碎片对VARCHAR列同样重要,尤其是在大量删除和更新操作之后。
误区四:忽略字符集对长度计算的影响。在latin1下CHAR(10)就是10字节,在utf8mb4下CHAR(10)是40字节。建表时定义的N是字符数,但索引长度限制是按字节算的,两者必须换算清楚。建议所有新项目统一用utf8mb4,然后根据字节上限反推字符数,避免建索引时报错。
理解CHAR和VARCHAR的本质,其实是在理解MySQL如何在磁盘和内存之间做权衡。定长意味着可预测性,变长意味着灵活性。没有哪个更好,只有哪个更适合当前的数据特征和访问模式。下次建表时,先问自己三个问题:这个字段的长度是否固定?它会被频繁更新吗?它上面需要建索引吗?三个答案决定了你该选CHAR还是VARCHAR,而不是凭感觉或者跟风用VARCHAR(255)。
