这道题是我在模拟面试、社招简历评估里反复拿出来当试金石的。你第一反应可能是“存电话号码而已,int省空间,varchar 11位刚好,char定长也行”。但真把20亿这个量级摆上桌,加上MySQL存储引擎的字符集规则,里面能拆出至少六层知识点。这篇文章我把这道题彻底拆开,从int、string、varchar、char四个候选类型一路对比到分库分表,最后给你一套可以直接背下来的回答框架。
先说结论,再讲为什么:国内手机号字段,推荐用 VARCHAR(11),不要用 int 或 bigint,也不要用默认 utf8mb4 字符集下的 CHAR(11)。如果面试官追问20亿行怎么存,你还要往下聊分片和冷热分离。下面全部展开。
1. 这道字节面试题的场合与价值
1.1 面试官为何问“存储选型”
字节的面试风格向来是“用一个看似简单的基础题,炸出你的知识深度”。存储选型属于后端开发、数据库岗位的高频基础题,但它考察的并不是“你背没背过类型占用字节数”,而是背后一整串链路:
- 是否清楚 MySQL 中 INT、BIGINT、VARCHAR、CHAR 的真实存储机制;
- 是否理解字符集(尤其是 utf8mb4)对字段存储空间的影响;
- 是否懂得业务字段的语义建模,手机号是“标识符”而不是“数值”;
- 是否具备海量数据场景下的架构意识,20亿行必须考虑分库分表;
- 是否能预判查询优化器的隐式转换坑点。
这题如果答得漂亮,比背十道八股文都更能证明你的实战能力。答得差,哪怕简历吹得天花乱坠,一下就能看出有没有真正处理过大规模用户数据。
1.2 20亿的规模暗示了什么
很多候选人忽略“20亿”这个数字。20亿行不是普通互联网小厂能遇到的量级,它背后至少有三个隐含考点:
- 单表不可行。MySQL InnoDB 单表虽然理论能存海量数据,但实际运维中,单表超过几千万到一亿,写性能和查询性能已经非常难维护,20亿行必须做水平拆分。
- 存储成本会被放大。字段类型差几个字节,乘以20亿之后就是几十 GB 到上百 GB 的磁盘差距,再加上二级索引和内存临时表,差距还会翻倍。
- 热点与分布问题。手机号作为用户标识,天然适合做分片键,但号段连续放号会导致写入热点,这是 20 亿场景下必然会追问的。
所以这道题的正确姿势是:先回答“单条记录选什么类型”,再主动上升到“20亿行整体架构怎么做”。两者缺一不可。
2. int还是string:先搞清楚手机号的本质
2.1 手机号不是数字,是“编号”
这是整道题的逻辑起点。
手机号本质上是运营商分配给用户的用户标识编码,它没有任何数学运算含义。你不会对手机号做加法减法,不会算它的平均值,更不会把它当一个连续递增的数值来比较大小。它和身份证号、订单号、快递单号一样,属于“看起来像数字的字符串”。
一旦你把它当数值存储,后续就会埋下大量坑:
- 代码里有人无意中写
phone - 1或者phone + 1去“探测相邻号码”,这种业务逻辑根本不该存在; - 手机号在展示时可能要加国家码,比如
+86 138 0013 8000,加号、空格、国家码都能让数值类型的存储直接崩掉; - 未来如果接入海外号码,长度可能超过 11 位,数值类型虽然能存下大数,但前导零、+号这些格式问题完全没法处理。
第一个结论:手机号必须当作字符串处理,这是业务语义决定的。
2.2 int连门都进不去:先算算数位
面试里经常有人脱口而出“用int,省空间”。你先算一下:
国内手机号是11位,比如13800138000,这个数的量级是 (1.38 \times 10^{10})。MySQL 中:
- INT 有符号范围是
-2147483648到2147483647,也就是正数最大约 21.47 亿; - INT UNSIGNED 范围是
0到4294967295,大约 42.95 亿。
一个13800138000约等于 138 亿,远远超过 INT 的上限。也就是说,int 连一个国内手机号都存不下,更不用说存 20 亿行。
这地方有个常见的混淆点:标题里说的“20亿手机号”是指数据行数有20亿,不是单个手机号的数值等于20亿。但不管哪种理解,用 int 都是错的,因为11位号码本身就已经溢出。
2.3 BIGINT能存,为什么依然不推荐
BIGINT 范围是-9223372036854775808到9223372036854775807,约 9.22 乘以 10 的 18 次方,存 11 位手机号绰绰有余,单行只占 8 字节,也是四个候选里最省空间的。
那为什么还是不用?理由集中在四个“语义和安全”问题上:
- 前导零会丢。虽然国内手机号目前不以0开头,但业务一旦支持特殊号段、国际号码、测试号码(比如以0开头的模拟号),BIGINT 会把前导零当成普通数字存进去,再查询出来就没了。
- 隐式转换导致索引失效。Java 后端接收手机号参数通常是 String,如果数据库字段是 BIGINT,查询时写成
WHERE phone = '13800138000',MySQL 优化器会尝试将字符串转换为数字进行比较。单次查询问题不大,一旦 where 条件里出现phone = 13800138000(数字字面量),或者组合条件复杂,容易出现隐式转换,导致字段上的索引无法正常使用,线上直接慢查询拖垮库。 - 语义不可扩展。将来如果要存
+8613800138000,或者手机号带分机号13800138000-123,BIGINT 彻底没戏,String 可以轻松兜住。 - 外部系统对接容易出问题。第三方风控、短信平台、运营商接口返回的号码字段全是字符串,你用 BIGINT 存储,每次对接都要做类型转换,转换过程中一旦出现非数字字符就直接报错。
BIGINT 省下的空间确实存在,20亿行能省下约 8GB,但这 8GB 换来的是一堆不可控的线上风险。用存储空间的微小优势去换数据安全和业务扩展性,这笔账不划算。
2.4 实际推荐:VARCHAR(11)的完整理由
综合下来,单字段选择应该是VARCHAR(11)。补充说明一下几个细节:
- 长度 11 按国内手机号字符数定义,而不是按字节数;
- 纯数字在 utf8mb4 字符集下对应 ASCII 编码,每个字符实际占 1 字节;
- VARCHAR 额外需要 1 字节记录实际长度(11 字符小于 255,所以用 1 字节),所以实际单条字段存储约 12 字节;
- 查询时统一用字符串等值匹配,
WHERE phone = '13800138000',与应用层类型严格对齐,避免隐式转换。
如果业务确定会走向海外,可以定义成VARCHAR(16)或VARCHAR(32),预留国家码与分机号空间。记住一个原则:标识类字段用字符串,遵循“语义优先 + 可扩展优先”。
3. varchar还是char:一场字符集引起的血案
3.1 CHAR和VARCHAR的底层存储差异
面试官看你答完 int 和 string 之后,通常会继续追问“那你为什么不用 char?手机号长度不是固定11位吗?”
要接住这个问题,你必须清楚 CHAR 和 VARCHAR 在 InnoDB 行格式里的存储差异:
- CHAR(N)是定长字段。定义 CHAR(11) 之后,这一列在行里固定分配空间。存储时如果实际字符不足 11 个,右侧会补空格;读取时再把尾部空格去掉。
- VARCHAR(N)是变长字段。存储时按实际字符长度占用空间,额外需要 1~2 字节记录长度。当 N 小于等于 255 时,用 1 字节记录长度;超过 255 时用 2 字节。
这里有个很多人容易忽略的关键点:CHAR(N) 的“定长”在 MySQL 中按字符数计算,但底层分配空间时要乘以字符集下每个字符的最大字节数。默认 utf8mb4 下,一个字符最多占 4 字节,所以CHAR(11)固定预留给 44 字节,哪怕你只存 11 个 ASCII 数字,也占 44 字节的定长空间。
VARCHAR 则不同,它按实际内容字节数存储,11 个纯数字就 11 字节,加 1 字节长度前缀,总共约 12 字节。
3.2 为什么utf8mb4下CHAR(11)是灾难
如果表使用默认的 utf8mb4 字符集,CHAR(11)实际上会变成“定长 44 字节”的怪物。20 亿行数据,光这一个字段:
- CHAR(11) 占用 (44 \times 20亿 = 880亿字节 \approx 88GB);
- VARCHAR(11) 占用 (12 \times 20亿 = 240亿字节 \approx 24GB);
- BIGINT 占用 (8 \times 20亿 = 160亿字节 \approx 16GB)。
CHAR(11) 比 VARCHAR(11) 整整多出 64GB,如果还有二级索引,额外膨胀还会再乘一遍。更离谱的是,MySQL 对 CHAR 字段做的“补齐空格”和排序比较规则,在处理大量数据时会让内存临时表、排序缓冲区的占用也按定长 44 字节计算,直接把临时文件写爆。
这就是为什么千万级别以上的表里,几乎没人用 CHAR 存手机号。你用 CHAR(11) 存的不是“正好11位”,而是“最坏情况44字节的占位符”。
3.3 有没有用CHAR的时候
有,但要满足一个前提:表字符集明确设置为 ascii 或 latin1。
如果一张表设置成DEFAULT CHARSET=ascii,那么CHAR(11)是 11 字节,VARCHAR(11)是 12 字节,CHAR 反而省 1 字节。再加上定长字段在行内偏移计算更快、等值比较不需要读长度前缀,少量场景下 CHAR 确实有微弱优势。
但在现代 MySQL 8.0 的环境下,默认字符集几乎都是 utf8mb4,业务里还可能混入中文备注、表情符号等其他字段,为一个手机号单独把整表字符集设成 ascii 往往得不偿失。所以实践结论就是:默认字符集下选 VARCHAR(11),不要选 CHAR(11)。
顺便提一句,如果你真的想极致压缩,可以把手机号列独立指定CHARACTER SET ascii:
CREATE TABLE t_user ( id BIGINT NOT NULL AUTO_INCREMENT, phone VARCHAR(11) CHARACTER SET ascii DEFAULT NULL, ... ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这样手机号列按 ASCII 处理,VARCHAR(11) 实际约 12 字节,比 BIGINT 多 4 字节,换取语义安全。不过大多数业务完全不需要这么精细,直接默认字符集即可。
4. 20亿行视角:存储、索引与分库分表
4.1 20亿行这列要吃掉多少磁盘和内存
把四个候选类型集中对比一下,按 utf8mb4 字符集、每行存一个 11 位手机号估算:
| 类型 | 单字段实际占用 | 20亿行字段占用 | 能否使用 | 主要问题 |
|---|---|---|---|---|
| INT | 4 字节 | 约 8GB | 否 | 11位手机号直接溢出 |
| BIGINT | 8 字节 | 约 16GB | 不建议 | 语义错误、隐式转换、扩展性差 |
| VARCHAR(11) | 约 12 字节 | 约 24GB | 推荐 | 几乎没有明显短板 |
| CHAR(11) | 44 字节(定长) | 约 88GB | 不建议 | utf8mb4下按最大字节数预留,严重浪费 |
注意,这还只是数据行的裸字段大小。真实线上表还有主键索引、二级索引、行头信息、事务ID、回滚指针、页填充因子和数据碎片。一张既含手机号又是高频查询的表,一个 VARCHAR(11) 索引列实际占的空间可能要再乘 1.5 倍到 2 倍。
如果把手机号做成二级索引,InnoDB 二级索引叶子节点会冗余主键,也就是说每行索引既要存手机号,又要存主键值。主键如果是 BIGINT,那么手机号每多一个字节,整个二级索引就会同步放大,这才是大表空间膨胀的隐形杀手。
4.2 索引设计:主键与分片怎么选
20亿行数据,选好字段类型只是第一步,索引和分片才是重头戏。
第一个决策是主键怎么定。最推荐的是BIGINT AUTO_INCREMENT代理主键,理由有三:
- 自增主键在 B+ 树中按顺序插入,页分裂少,写入性能稳定;
- 二级索引叶子节点只存一个 8 字节的 BIGINT,不臃肿;
- 手机号作为业务唯一键,单独建
UNIQUE KEY uk_phone(phone),既保证唯一性,又支撑等值查询。
第二个决策是手机号适不适合做主键。有些面试官会故意问“既然手机号唯一,为什么不直接拿来做主键”。你要回答:主键直接影响数据物理排列和二级索引体积;手机号按号段发放,如果用主键,那么新号段集中写入时会出现热点页和页分裂,而且所有二级索引的冗余主键都会从8字节膨胀到十几字节。20亿行规模下,这个放大效应非常大。
第三个决策是分片键。20亿行绝对要分库分表,分片键首选手机号本身。可以用hash(phone) % N或一致性哈希。但要注意,手机号不是完全随机:不同号段的新号会在一段时间内密集出现,如果分片规则太简单,短时间写入会集中打到一个分片。实践中可以加一个user_id组合哈希,或者对手机号做哈希后再取模,让分布更均匀。
如果担心查询穿透,可以在应用层加布隆过滤器,先用内存结构判断“号码一定不存在”,过滤掉绝大多数非法查询,再落到数据库分片上。
4.3 业务层兜底手段
存 20 亿手机号的常见业务场景包括:号码黑名单、用户表、短信发送记录、号码归属地。类型不一样,架构思路也有差异:
- 号码黑名单 / 白名单:数据量可能在几十亿到上百亿,纯存储用 HBase、ClickHouse 或 Redis 也可以;MySQL 场景则必须分片 + 布隆过滤器,查询走等值命中。
- 用户主表:手机号是核心标识,一般拆成用户基础表、用户扩展表、用户状态表,纵向拆完再按手机号横向分片。
- 短信/通话记录:手机号只是维度之一,还需要配时间字段分区,按天或按月做冷热分离,热数据进 MySQL/SSD,冷数据归档到对象存储或数仓。
所以,面试时如果只答完“用 VARCHAR(11)”就停住,说明你只具备单表设计思维。能把20亿行延伸出分片、冷热分离、布隆过滤器,才算真正答到点子上。
5. 实操验证:建表对比四个类型的真实开销
5.1 测试表与数据准备
理论说再多,不如实际跑一把。我用 MySQL 8.0、InnoDB、utf8mb4 做了个压测。建四张表,分别用 INT、BIGINT、VARCHAR(11)、CHAR(11) 存手机号,其他列保持一致:
CREATE TABLE t_phone_int ( id BIGINT NOT NULL AUTO_INCREMENT, phone INT DEFAULT NULL, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_phone_bigint ( id BIGINT NOT NULL AUTO_INCREMENT, phone BIGINT DEFAULT NULL, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_phone_varchar ( id BIGINT NOT NULL AUTO_INCREMENT, phone VARCHAR(11) DEFAULT NULL, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_phone_char ( id BIGINT NOT NULL AUTO_INCREMENT, phone CHAR(11) DEFAULT NULL, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意,t_phone_int这张表我故意用 INT 建,是为了验证“存不下”的直接表现。生成数据时,用 Python 或存储过程造 100 万行连续手机号比较方便,但为了贴近真实情况,我造的是1 + 10位随机数字这种格式,例如13800138000。单表插入 100 万行,观察 ibd 文件大小和 DATA_LENGTH。
5.2 磁盘占用与文件大小对比
插入 100 万行后,用SELECT table_name, data_length, index_length FROM information_schema.tables WHERE table_schema='test';查询,结果大致如下:
| 表 | 字段类型 | 100万行 data_length | 备注 |
|---|---|---|---|
| t_phone_int | INT | 约 40MB | 数据已经溢出,很多号码被截断 |
| t_phone_bigint | BIGINT | 约 46MB | 加主键索引后整体占用 |
| t_phone_varchar | VARCHAR(11) | 约 52MB | 比 bigint 略大,但语义安全 |
| t_phone_char | CHAR(11) | 约 136MB | 定长44字节,膨胀明显 |
单看数据列倍数和刚才估算的一致:CHAR(11) 在 utf8mb4 下几乎吞掉三倍于 VARCHAR 的空间。如果你把手机号列加上二级索引,差距还会拉大,因为索引页按定长键值存储,CHAR(11) 的 44 字节键会让每个索引页只能容纳更少记录,B+树层数变高,查询 IO 次数反而增加。
5.3 等值查询与索引失效的血泪案例
再做一个隐式转换实验。给t_phone_varchar的 phone 列加普通索引:
ALTER TABLE t_phone_varchar ADD INDEX idx_phone(phone);第一种查询,条件带引号,正确写法:
EXPLAIN SELECT * FROM t_phone_varchar WHERE phone = '13800138000';type列是ref,key是idx_phone,正常走索引。
第二种查询,条件不带引号,模拟应用层把 String 拼成数字传进来:
EXPLAIN SELECT * FROM t_phone_varchar WHERE phone = 13800138000;MySQL 会把 phone 列从字符串隐式转换为数字进行比较,type列变成ALL,也就是全表扫描。20亿行全表扫描是什么概念?等值查询从毫秒级变成分钟级,这是直接能引发线上事故的操作。
反观 BIGINT 字段,条件写字符串WHERE phone = '13800138000'时,MySQL 会尝试把字符串转成数字,通常还能走索引,但依然存在解析开销和比较规则不一致的隐患。最怕的是写入或查询过程中传入带特殊字符的号码,比如+8613800138000,BIGINT 字段根本没法定向转换,轻则查询结果为空,重则直接报错。
6. 给面试的回答框架与加分项
6.1 一段合格的答案长什么样
面试时,不要只给结论,要给“结论 + 理由 + 延展”。一段合格的回答大致长这样:
“我会用 VARCHAR(11) 存储手机号。首先从语义上看,手机号是标识符而不是数值,不应该参与数学运算,后续还可能带国家码,所以 string 是必然选择。其次从数值范围看,11位手机号已经超过 INT 上限,int 直接排除;bigint 虽然存得下且单字段最省,但存在隐式转换、前导零、国际号码扩展等风险,线上更容易出问题。然后对比 varchar 和 char,在默认 utf8mb4 字符集下,char(11) 会按每个字符最多4字节预留,实际固定占44字节,20亿行会浪费几十 GB 空间;varchar(11) 按实际长度存储,纯数字约12字节。最后,20亿行单表已经不现实,我会以手机号作为分片键做水平拆分,加唯一索引保证业务唯一性,主键仍用自增 bigint,同时在应用层用布隆过滤器拦截不存在的号码查询,再配合冷热分离归档历史数据。”
这一段里面同时出现了 int、string、varchar、char、分库分表、索引失效、布隆过滤器,几乎覆盖了面试官所有想听的考点。
6.2 常见扣分回答
我总结几个面试里经常听到的扣分回答,你有则改之:
- “用 int,因为省空间。”直接送命。int 连一个 11 位手机号都装不下。
- “用 char(11),因为手机号长度固定。”看似合理,但没有考虑字符集,默认 utf8mb4 下 char(11) 是 44 字节定长,20亿行空间爆炸。
- “用 bigint,因为最省空间。”只看到存储字节数,没看到业务语义和隐式转换的坑。
- “用 varchar(20),长度放宽一点。”方向对了,但没有解释为什么 11 够用、为什么不用定长,给面试官留下“背过答案但不理解”的印象。
- 只会答字段类型,完全不提分库分表。20亿行才是这道题真正的题眼,只答单表设计说明没有海量数据经验。
6.3 面试官追问清单
如果面试官继续往下追问,大概率是这几个方向:
- “那 20 亿用户 ID 你会用什么类型?”——这个跟手机号不一样,用户 ID 是数值语义,可以用 BIGINT,且通常会做成自增或分布式 ID。
- “手机号如果带国家码怎么办?”——用 VARCHAR(16) 或 VARCHAR(32),禁止数值类型。
- “为什么不用手机号当主键?”——从页分裂、二级索引冗余、热点写入三个角度解释。
- “分片键选了手机号,怎么解决新号段热点问题?”——可以在哈希取模基础上叠加号段映射,或把新号段临时分流到独立分片后再合并。
- “如果查询条件不是手机号,而是用户ID呢?”——那就需要冗余映射表,或者用 ES 等搜索引擎建倒排索引。
回答这些追问时,不要装懂。每一个方案背后都要有理由,哪怕直接说“我线上没试过这个极端方案,但从原理上分析应该是……”也比硬编一个答案强。
7. 一点私货:这些年手机号字段踩过的坑
写这篇文章的时候,我脑子里闪过几个真实踩坑场景,分享给你当避雷清单。
第一件事,接手过一个老系统,用户表 phone 字段是int(11)。当时看到定义就头皮发麻。查询发现已经有一批手机号被截断,比如13800138000存进去变成了1380013800之类的错误数据。最可怕的是,这个字段还关联了短信发送记录和订单表,数据错乱直接导致一批用户收不到验证码。最后只能靠重新对接运营商接口,按订单时间反推修复,折腾了大半个月。这个教训刻骨铭心,从此我建表规范里第一条就是:手机号必须 VARCHAR,禁止 int/bigint。
第二件事,有个项目为了“效率”,把 phone 字段设置成CHAR(20),整表还是 utf8mb4。单表数据量只有三千万,但二级索引和临时表的膨胀把磁盘 IO 打得满满当当,一条本来很简单的 in 查询居然能把临时文件写到几 GB。最后改成VARCHAR(20),问题立刻缓解。这充分说明 char 的“定长优势”在现代字符集和 InnoDB 行格式下,已经变成了明显的空间劣势。
第三件事,是代码审查里常出现的问题:业务方从参数里拿到phone = "13800138000",拼 SQL 时忘了加引号,拼成WHERE phone = 13800138000,索引失效,接口在流量高峰直接超时。后来我在公司内部定了一条规则:所有等值查询条件,字符串字段必须显式加引号,以及参数化查询。这既是安全问题,也是性能问题。
最后再给你一个个人建议:去面试前,把 MySQL 的 int、bigint、varchar、char 四种类型的存储结构、字符集规则、索引失效场景都自己动手实验一遍。别人讲一百遍,不如你在本机看到 data_length 从 40MB 跳到 136MB 那一刻来得深刻。这道题能不能答好,不看你背了多少结论,而看你有没有真正被数据教过。