聊MySQL的数据类型,绕不开char和varchar这对兄弟。尤其是每次建表的时候,总有人纠结同一个问题:这个字段用char还是varchar?手机号用char(11)还是varchar(11)?身份证号呢?状态码呢?评论内容呢?网上答案五花八门,有说char快的,有说varchar省空间的,还有说现在差距不大随便用的。这个系列写到第三篇,正好就把这个话题拆透。
我尽量不跟你讲虚的,直接说清楚三件事:char和varchar在底层到底怎么存、在不同场景下的真实表现差多少、以及落到你的业务表里到底该怎么选。文章里还会带一些我实际建表、插数据、跑查询的经验,包括踩过的坑。如果你正在被“主键能不能用varchar”“varchar到底能定义多大”“为什么char查出来的值和存进去的不一样”这些问题困扰,这篇应该能给你答案。
1. char和varchar的本质差异:先把定义吃透
1.1 N到底代表什么?是字符,不是字节
很多人在这一开始就搞混了。char(10)和varchar(10)里的数字10,指的是最多能存10个字符,而不是10个字节。也就是说,不管是存10个英文字母、10个汉字,还是10个emoji,只要字符数量不超过10,就都符合定义。
这一点在utf8mb4字符集下尤其关键。utf8mb4中的一个汉字最多占3个字节,一个emoji可能占4个字节,但char/varchar统计的是“字符数”。所以一个varchar(10)的列,理论上最多能容纳10个emoji,占40个字节;也能存10个英文字母,只占10个字节。
我见过不少人在面试里答错这里,一口咬定varchar(255)就是255个字节,结果后面的计算全崩了。记住:MySQL 4.1之后,CHAR(N)和VARCHAR(N)中的N都是字符数,不是字节数。该字段实际占用的磁盘空间,取决于字符集编码后的字节数。
1.2 存储方式:一个固定,一个可变
这是char和varchar最核心的差异。
char(N)是定长字符串。你定义一个char(10),无论你实际存的是1个字符还是10个字符,它在存储逻辑上都会按10个字符来对待。检索的时候,MySQL会去掉右侧填充的空格。换句话说,你插入abc,取出时看到的是abc,但在存储过程中,它会被补成abc (后面补了7个空格)来处理。
varchar(N)是变长字符串。它只存储你实际写入的内容,并且额外需要一个长度前缀来记录这个内容有多长。长度前缀的规则是:如果这一行的实际数据字节数不超过255,用1个字节记录长度;如果超过255,用2个字节记录。
我习惯用一个类比来理解:char就像电影院固定座位的票,座位号定了,不管你坐不坐,那个位置都给你留着;varchar像背包里的收纳袋,装多少东西袋子就鼓多大,袋口贴个标签写明里面装了什么。
1.3 尾部空格:行为差异最容易踩坑
尾部空格的处理,是char和varchar最容易被业务代码坑到的地方。
char(N)在存储时会右填充空格,在检索时又会把尾部空格去掉。所以插入'abc ',用SELECT CONCAT('[', col, ']')查出来,char列可能是[abc],而不是[abc ]。而varchar不会做这种处理,插入'abc ',取出就是'abc ',空格还在。
这个差异会导致一个经典问题:当你的业务逻辑需要严格区分'abc'和'abc '时,用char就会莫名其妙丢失尾部空格。反过来,如果你用的是默认的PAD SPACE排序规则,比较'abc'和'abc '时又可能会认为它们是相等的,导致唯一索引冲突或查询结果和你预期不一致。
注意:MySQL 8.0开始,默认的utf8mb4排序规则变成了
utf8mb4_0900_ai_ci,它是NO PAD语义,尾随空格比较可能不再等值。从5.7迁移到8.0后,同一个SQL的返回结果可能和以前不一样,这一点会在后面单开一节细讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 性能差异:别被“char更快”这句话忽悠了
2.1 InnoDB行格式下,char和varchar的真实差距
网上很多文章说“char比varchar快”,这个说法需要打个问号。这个结论最早主要来自MyISAM和Memory引擎的定长表时代:定长行可以让MySQL快速跳过不需要的列,直接定位到某一行,而且不需要记录额外的长度前缀。
但放到InnoDB里,情况完全不同。InnoDB的行存储本质上是按主键聚簇的,定位一条记录靠的是页目录和槽位,而不是靠“数到第几个固定长度列”。在COMPACT和DYNAMIC行格式下,char如果使用utf8这类变长字符集,存储时并不会像想象中那样严格占满N个字符。InnoDB会尽量按实际字节数来管理列数据,只是char的定长语义依然会影响行结构的编码方式。
我实测下来,在InnoDB里,同样存短字符串,char和varchar的查询性能差距通常在个位数百分比以内,远没有到“char明显更快”的程度。如果你的建表选型只看“性能”,那很可能选哪个都不会出大问题;真正需要关心的反而是存储空间和维护成本。
2.2 索引、排序、临时表场景下的实际影响
虽然普通查询差距不大,但到了索引、排序、临时表这些场景,char和varchar的成本差异就会被放大。
先看索引。InnoDB索引条目需要存储被索引列的值。char列因为定长语义,索引里往往需要为该列预留更大的空间,尤其是在多字节字符集下,一个char(32)存32个ASCII字符,索引条目仍然要按可能的最大字节数来判断空间;varchar则按实际值存储,只额外带上长度信息。结果就是,在大量数据下,varchar索引可能更紧凑,页能装下更多索引条目,扫描IO更少。
再看排序和临时表。当ORDER BY或GROUP BY使用一个varchar列时,MySQL可能需要在内存临时表中处理这个变长列。在MySQL 5.7及更早版本里,内部临时表处理varchar列时,会把它当作固定长度的字符数组来临时存储,列定义越大,临时表膨胀得越厉害。所以那时候有个不成文的经验:不要随手定义varchar(5000),排序查询可能直接崩内存。8.0以后有了新的内存临时表实现,但这个习惯仍然值得保留。
2.3 更新频繁时,列长度波动会带来什么
另一个还没被充分讨论的点是更新操作。
varchar列存的是变长数据,当一条记录被更新后,新值比旧值更长,就可能放不进原来的页位置,InnoDB不得不做额外的页分裂或记录迁移。频繁更新且长度不断变大的varchar列,会让表产生更多碎片,时间长了,扫描性能会下降,可能还要定期OPTIMIZE TABLE来整理。
char列因为定长,更新时不太会触发这种“越长越放不下”的问题。但也别高兴太早,定长的代价是空间利用率低,如果实际内容普遍小于定义长度,页里塞不满,同样会导致扫描页数增多。所以char更适合那些长度恒定、内容不会变长的字段,比如订单号、MD5值;而长度经常变化、波动明显的,天然适合varchar。
3. 选型实战:具体字段到底该用哪个
3.1 适合char的字段和理由
判断一个字段能不能用char,核心标准是:长度是否基本固定,而且能明确说出最大值。
这类字段现实中很常见。手机号,国内运营商号码在这几年内都是11位数字,定长,可以char(11);身份证号,18位,定长,可以char(18);银行卡号,虽然位数不完全统一,但常见是16到19位,你要是按业务约束写死长度,用char也可以;MD5摘要,固定32个十六进制字符;UUID去掉横线后是32个字符,也可以定长存储。
这些字段的共同点是:长度不会因为用户输入不同而变化,不需要考虑“最长可能多长”的弹性空间。用char的另一个好处是不会额外存长度前缀,存储上稍省一两个字节,语义上也更清晰。
不过要注意,定长不代表在utf8mb4下就一定省空间。一个char(18)的身份证号,虽然里面全是ASCII数字,但MySQL必须保留18个字符的最大编码能力,也就是最多72字节的空间;如果这一列大量存储的是短数据,实际的浪费会比varchar明显。所以“定长字段一律用char”的说法也不绝对,要结合字符集一起看。
3.2 适合varchar的字段和理由
只要长度是个“区间”,就应该考虑varchar。最典型的就是用户名、昵称、邮箱、地址、标题、简介、评论、URL这类的。用户可能写1个字符,也可能写50个字符,你不能为了“定长”去预留一个很大的char,那样空间浪费太严重。
varchar的好处是“按需分配”:实际存多少,就占多少空间,只多花1到2个字节记录长度。对于绝大多数真实业务表来说,这种可变长度字段是绝对主流,char只是少数特殊场景的补充。
同时,给varchar定长度时也要有据可依。常见的错误是拍脑袋写varchar(255),理由是“255够长了吧”。结果一个表里塞了十几个varchar(255),排序、临时表、索引空间全被拖累。更合理的做法是:先去业务上确认这个字段的合理上限。比如昵称产品规定20个字符,那varchar(20)就够了;评论内容限制500字,varchar(500)够用,没必要直接上varchar(5000)。
3.3 一个完整建表案例
拿一个用户表举例,可以这样设计:
sql复制CREATE TABLE `user` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`mobile` CHAR(11) NOT NULL COMMENT '手机号,长度固定11位',
`id_card` CHAR(18) NOT NULL DEFAULT '' COMMENT '身份证号,定长18位',
`nickname` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '昵称,产品限制最长20字',
`email` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '邮箱,常见最长64字符',
`bio` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '个人简介,限制200字以内',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0禁用,1正常',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_mobile` (`mobile`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
这个表里,mobile和id_card用char,因为定长;nickname、email、bio用varchar,因为长度可变,但都给了明确上限。你注意一下,我没有引用任何“网上说手机号推荐varchar(20)”的说法,因为手机号就是11位,给20位是在制造无意义的弹性空间。
3.4 容易被忽略的兄弟类型:binary、enum、text
聊char和varchar的时候,有几个兄弟类型也值得顺带提一嘴,因为它们的选型会影响同一张表里char/varchar怎么用。
binary和varbinary是字节版本的char和varchar,适合存二进制数据,比如固定长度的哈希值、IP地址的二进制表示。如果你要存的是UUID,与其用char(32)再转一次,不如直接存binary(16),空间直接砍半,查询也更快。
enum:当你遇到“状态”“类型”这种取值固定且数量少的字段,比如订单状态、支付方式,用ENUM('pending','paid','failed','canceled')会比varchar更省空间、更可读。但enum也有坑,比如加一个枚举值需要改表结构,排序时按索引位置而不是字母顺序,如果你不那么熟悉enum的语义,用TINYINT加字典表也是稳的选择。
text和blob:当varchar超过最大行长度或需要存储大段文本时,才考虑text。需要提醒的是,text不能有默认值(老版本里),索引只能加前缀索引,而且排序时更容易用到磁盘临时表。能用varchar解决的问题,尽量别引入text。
4. 实测记录:一张表看清char和varchar的行为
4.1 测试表准备和数据插入
光讲理论容易悬空。我自己建了两张结构一样的表,只有一个字段类型不同,分别插入了相同的数据,做了几组对比测试。下面把过程和结果记录下来,你可以在本地MySQL环境里复现。
我用的MySQL版本是8.0,字符集utf8mb4,排序规则默认。测试表结构如下:
sql复制CREATE TABLE t_char (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
c CHAR(10) NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE t_varchar (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
v VARCHAR(10) NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
然后插入同样一批数据,包括纯英文字符串、中文字符串、带尾部空格的字符串:
sql复制INSERT INTO t_char(c) VALUES
('abc'), ('你好世界'), ('abc '), (''), ('a b c');
INSERT INTO t_varchar(v) VALUES
('abc'), ('你好世界'), ('abc '), (''), ('a b c');
4.2 存储空间和字段长度观察
先看字段本身的字节长度。MySQL提供LENGTH()函数返回字节数,CHAR_LENGTH()返回字符数。
sql复制SELECT c, LENGTH(c), CHAR_LENGTH(c) FROM t_char;
SELECT v, LENGTH(v), CHAR_LENGTH(v) FROM t_varchar;
理论上,'abc'在utf8mb4下字节数都是3;'你好世界'是4个汉字,utf8mb4下每个汉字3字节,所以字节数是12。char列读取时去掉了尾部空格,varchar列保留,但字节数本身不受影响。
再看整体表的空间占用,可以查information_schema:
sql复制SELECT TABLE_NAME, DATA_LENGTH, INDEX_LENGTH, AVG_ROW_LENGTH
FROM information_schema.TABLES
WHERE TABLE_NAME IN ('t_char','t_varchar');
我测的时候插入了50万行数据,两表空间差距存在,但并不悬殊。char表因为定长语义和多字节字符集的保留空间,整体行大小会略大;varchar表按实际内容存储,整体更紧凑。具体数值和插入内容的长度分布强相关,如果你的数据普遍很短,varchar优势会更明显。
4.3 尾部空格和比较行为实测
这是最能体现“踩坑”的一组测试。
sql复制-- 先看检索返回值
SELECT CONCAT('[', c, ']') FROM t_char WHERE id = 3;
-- 结果: [abc]
SELECT CONCAT('[', v, ']') FROM t_varchar WHERE id = 3;
-- 结果: [abc ]
同样插入的是'abc ',char列出来是abc,varchar列出来是abc 。业务代码如果在这里做精确匹配,比如判断用户输入的验证码是否被额外加了空格,char存进去之后连比较的机会都没有。
再看等值比较:
sql复制SELECT COUNT(*) FROM t_char WHERE c = 'abc';
SELECT COUNT(*) FROM t_varchar WHERE v = 'abc';
在MySQL 8.0默认的utf8mb4_0900_ai_ci排序规则下,'abc'和'abc '比较时,char列由于读取时已去掉尾部空格,通常能得到和预期一致的结果;varchar列则要小心,NO PAD语义下'abc '和'abc'可能被视为不同值,如果你的代码之前依赖PAD SPACE的“忽略尾随空格”行为,升级到8.0后就是坑。
4.4 排序与查询计划的简单对比
对两表分别执行ORDER BY c/v,用EXPLAIN看执行计划,通常都能直接看到Using filesort,在没有索引的情况下两者都会做一次排序。差别主要在于:如果有索引,varchar的索引条目更紧凑,扫描范围相同的话,varchar可能读得更快;char的索引条目则需要预留更多空间,页能容纳的条目数更少,极端情况下会多读几个页。
我测试时还加了一个二级索引对比,同样的100万行数据,varchar列上的索引占用的页数明显更少。这也就解释了为什么说“选char不一定更快”,在索引扫描场景下,varchar反而可能因为空间紧凑而更占优。
5. 面试高频题与设计误区
5.1 varchar到底最大能定义多大
这是我在面试中特别喜欢问的一道题,看似简单,展开后能聊很久。
varchar的最大长度理论上受65535字节的行大小限制。注意,不是字符数,是字节数。在utf8mb4下,一个字符最多占4字节,所以理想情况下varchar(N)中的N最大约等于65535 / 4 = 16383。但实际还要减去长度前缀(1或2字节)、可空字段的NULL标志位等,所以并不是随便贴个16384就能建表成功。
一个常见的计算示例:
sql复制-- 在utf8mb4下,NOT NULL的varchar列,理论上限大约是16383字符
CREATE TABLE t_max (
c VARCHAR(16383) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
如果字段允许NULL,实际可用长度还要再降。如果要在一个表里放多个大varchar列,受65535字节的行大小限制更明显,要么拆表,要么改用TEXT。
但我的建议是,不要为了追求极限去定义超大varchar。工程上,把单个字段长度定义在业务合理范围内,比如几十到几千字符,已经覆盖绝大多数场景。定义一个varchar(16383),会让索引、排序、临时表全都付出额外代价,完全是给自己找麻烦。
5.2 主键到底选char还是varchar
“主键能不能用varchar/char”这个问题,我倾向于这样回答:能用,但要清楚代价。
如果主键是自增id,肯定用整数类型,INT或BIGINT,没必要讨论。如果是业务主键,比如用订单号、身份证号、手机号做唯一标识,就需要考虑两点。
第一,长度是否固定。订单号长度如果业务上写死为20位,用char(20);如果可能变长,比如前5年是18位,后来改成24位,这时用varchar更灵活,代价是索引占用更大。第二,字符串主键在InnoDB聚簇索引中的存储成本。二级索引会复制主键值,字符串主键比整数主键要占更多空间,整张表的二级索引都会跟着膨胀。如果你的表有大量二级索引,能不用字符串主键就尽量别用。
还有一个折中方案:业务唯一标识用varchar/char加唯一索引来约束,但真正的物理主键仍然用自增整数。这样既能保证业务上“某字段唯一”,又不会让聚簇索引变得又肥又慢。
5.3 排序规则对尾随空格的影响:进阶坑
这个坑值得单独说,因为它影响的是查询结果的一致性。
MySQL的排序规则(collation)有两种尾随空格处理方式:PAD SPACE和NO PAD。PAD SPACE在比较字符串时会忽略尾随空格,所以'abc'和'abc '会认为是相等的;NO PAD则不会忽略,两者不等。
MySQL 5.7及之前,utf8mb4默认排序规则是utf8mb4_general_ci,属于PAD SPACE;MySQL 8.0默认变成utf8mb4_0900_ai_ci,属于NO PAD。这就导致了一个很隐蔽的迁移问题:同样的数据、同样的表结构,从5.7迁到8.0后,某些带尾随空格的查询结果可能发生变化,唯一索引也可能开始报重复键。
如果你在维护老项目,迁移8.0之前最好先扫一遍那些会被用作判断条件或唯一约束的字符串字段,确认其中不存在尾随空格。发现问题的方法也简单:
sql复制SELECT id, CONCAT('[', field, ']') FROM your_table
WHERE CHAR_LENGTH(field) != CHAR_LENGTH(TRIM(field));
把查出来的数据清洗一遍再上线,比上线后半夜被叫起来处理重复键要舒服得多。
聊到这里,char和varchar的账基本算清楚了。我在实际项目中最大的体会是,太多人在这个基础问题上吃亏,不是不知道区别,而是不重视字符集和排序规则带来的连锁反应。一个看似简单的char(18)身份证号,在utf8mb4下和varchar(18)的实际存储表现就不一样;一个随手写的varchar(255),在临时表和索引空间上的浪费,要等数据量大了才看得出来。
所以我最后给你一个可以抄作业的原则:字符串字段先问业务这个值的长度是否恒定;恒定就优先char,不恒定就varchar;能预估上限就按预估上限定义,不要用255充当默认值;涉及唯一约束和等值匹配的字段,额外关注一下当前数据库的排序规则和字符集。按这个流程走下来,至少能避开我早年踩过的大部分坑。
