拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

20亿手机号存储选型:int还是string?varchar还是char?

20亿手机号存储选型:int还是string?varchar还是char?

前两天有个学弟面试字节回来,跟我复盘一面环节,说有一道题答得并不好:20亿手机号存储,选int还是string?varchar还是char?为什么?我听完第一反应是,这题其实很典型,表面在问类型选型,实际上在考一个人有没有真正理解存储。

这道题网上其实也有不少答案,但大多止步于“手机号要用varchar存,因为int会溢出”,这个回答放在一面,基本等于没答。面试官紧接着一个“那为什么不用bigint?”就能把人问懵。

所以这篇我打算把这道题从头到尾拆一遍:从数值边界、存储字节、字符集、索引行为,一直聊到20亿行数据下的分表和扩展设计。这不是八股背诵,而是把“选型”这件事的完整决策链路走一遍,看完你不仅能正面回答这道题,还能接住面试官后面可能追的三个问题。

1. 先说结论:这题考的不是“选哪个”

1.1 面试官真正想听什么

很多候选人拿到这类问题时,第一反应是给出一个正确的类型名,然后开始背优缺点。但面试官并不想听你一锤子定音,他真正想考察的是你的决策过程。

一道存储选型题,在面试官心里通常有三个层次:

  • 第一层:你知不知道int和string各自的范围、长度限制,这是最基础的语言/数据库常识。
  • 第二层:你知不知道char和varchar在存储引擎里是怎么落盘的,比如固定长度、变长记录、字符集、行格式这些概念。
  • 第三层:你有没有工程全局观,能不能把“20亿行”这个规模放到真实场景里,想到容量、分表、号码扩展、敏感数据这些后续问题。

三层都答到,才算答完一道题。只答到第一层,面试官会觉得你是背过答案的,于是紧接着追问“为什么不用bigint”“varchar和char在InnoDB里差多少字节”“如果以后要存国际号码怎么办”,基本上三个问题就能判断出你是真懂还是假懂。

1.2 为什么“20亿手机号”这个数字很关键

先注意题目里的“20亿”不是随便写的。20亿这个数,正好卡在int有符号范围的上界附近——int最大是21.47亿。出题人故意放一个这么暧昧的数量级,就是看你有没有意识到:

  • 20亿已经远超单表能承载的合理规模,这背后是分库分表问题;
  • 手机号本身不是“序号”,不能在20亿这个数量级里直接用自增int塞;

更关键的是,20亿行数据意味着存储成本会被强烈放大。假如手机号这一列每个值多占1个字节,20亿行就是20GB的额外磁盘和内存开销。在这个量级下,“varchar还是char”的字节级差异就不是抠细节,而是实打实的成本问题。

所以这个数字决定了,你不能简简单单说“都能存,看需求”,而必须拿出另外一套容量判断的逻辑。

1.3 常见的答题误区

我先列几个我见过的高频错误答法,这些都是会直接把一面聊死的那种:

  • 上来就说“用varchar(11)就完了”,没有任何计算过程,面试官问“为什么不用bigint”就卡住。
  • 说“手机号是数字,所以用bigint”,这个答案比int好一点,但没意识到手机号是标识符,不是参与运算的数。
  • 说“用char(11),因为手机号长度固定”,听起来有道理,但完全忽略了字符集和存储开销,在utf8mb4下char(11)反而是最费空间的选择。
  • 把“varchar”和“char”的区别背得像教科书一样,但没结合InnoDB实际情况,听上去就很悬浮。

下面我按真正的答题顺序展开:先解决int还是string,再解决varchar还是char。

2. 类型第一关:int为什么直接出局

2.1 int的取值范围根本不支持11位手机号

先把数字边界放到桌面上。

MySQL的int有符号范围是 -2147483648 到 2147483647,无符号范围是 0 到 4294967295。也就是不管有没有符号,int最多只能表示到42.9亿左右。

再看手机号。一个标准的国内手机号是11位,以1开头,所以它的最小形式是10000000000,最大形式大约是19999999999,位数上已经达到百亿级别。光从数值大小来看,int连10亿都不够,11位手机号更是远超int上限。

这里还有一个更隐蔽的点:哪怕你自作聪明只存手机号的“后10位”,仍然装不进int。后10位最大是9999999999,接近100亿,比无符号int的42.9亿还要大一倍多。所以int这条路的物理边界是死的,没有任何绕过去的空间。

我建议你在面试中直接把这段换算说出来,而不是简单地扔一句“int会溢出”。能在纸上写出“11位手机号大于int上限,所以int直接排除”,就已经比绝大多数候选人专业了。

2.2 能装下的bigint为什么也不推荐

int排除之后,很多人会立刻想到bigint。bigint有8字节,有符号范围大约是 -9.22×10^18,存11位手机号绰绰有余。从“装得下”这个角度看,bigint确实没问题,但它依然不是好选择。

首先是语义问题。手机号的本质是一个标识符,不是数值。你用bigint存,等于把一个不需要参与加减乘除的账号ID当成了数字。这在逻辑上就和用int存身份证号、订单号是一样的错误:类型暗示了用途,一旦字段类型是数值,后续开发就可能不自觉地对它做数值处理。

其次是扩展性问题。国内手机号看起来是纯数字,但业务一旦走到海外,就会遇到“+86 13800138000”“0044 20 7946 0958”这样的格式。bigint只能存纯数字,面对+号、空格、括号直接崩。而现实业务中,手机号字段为了兼容国际号码改成varchar的情况非常常见,但把bigint改成varchar的代价极大。

再说一个我实际见过的坑:如果用bigint存手机号,业务代码里查询时很容易写成WHERE phone = 13800138000,看起来没问题,但因为传入的是字符串类型,MySQL需要做隐式类型转换,一旦数据量上来,索引能不能命中就是另一回事。这个我在第4节详细讲。

2.3 手机号的本质是标识符,不是数值

把这个问题想透,其实就理解了为什么业界普遍推荐用string。

你可以把手机号类比成快递单号、车牌号、身份证号。它们的共同点是:

  • 不需要参与任何数学运算;
  • 有固定的格式和号段语义;
  • 未来可能包含字母、符号或国家码;
  • 查询场景通常只有等值匹配、前缀匹配、模糊匹配。

把这些需求套到int、bigint上,你会发现数值类型天生就不适合。比如你想查“所有13x开头的手机号”,用varchar可以直接LIKE '13%',但用bigint就没法优雅地做前缀匹配,你只能先算数值范围,再逐步缩小条件,繁琐且容易出错。

所以这里真正重要的不是“装不装得下”,而是“这个字段在业务里到底扮演什么角色”。它是标识符,就该按字符串的思维去设计,而不是因为它长了一副数字的样子就扔进数值类型。

3. 存储第二关:varchar和char的差别要算到字节

3.1 定长与变长,理解清楚存储开销

类型落到字符串之后,才是varchar和char的较量。

char是定长类型,声明char(11)后,每条记录都会按11个字符占位。如果实际内容不足11个字符,MySQL会用空格填充,读取时再去掉尾随空格。varchar是变长类型,varchar(11)只表示“最多11个字符”,实际存几个字符就占几个字符的空间,同时额外用1到2个字节记录实际长度。

听起来好像char是浪费,varchar是省空间,但事情没那么简单,字符集和行格式会让结果反转。先记一个最基础的结论:在InnoDB里,char的存储是按“声明字符数 × 该字符集最大字节数”提前预留的,varchar则是按实际字节数加长度前缀存放。这两个机制在不同字符集下的表现天差地别。

3.2 字符集是最大的隐藏变量

手机号里的数字、加号这些都是ASCII字符,在utf8mb4下只占1个字节。但MySQL声明CHAR(11)的时候可不看你的实际内容,它只看字符集和声明长度。

  • 如果表的默认字符集是utf8mb4,一个中文字符最多占4个字节,那么char(11)会按11×4=44字节预留空间,不管你存的是不是纯数字。
  • 如果是varchar(11),它没预留,实际存11位数字就只占11字节,再加上1字节的长度前缀,总共12字节。

一对比就很清楚:在utf8mb4下,char(11)存一个手机号要浪费32字节。20亿条数据,光是这一列,char就要比varchar多出大约60GB以上的空间开销。这还只是一个列的差距,如果是大宽表,差距会更夸张。

那是不是说char就一定不好?也不是。如果表的字符集是latin1这种单字节字符集,char(11)就占11字节,varchar(11)反而要12字节,char又成了更省的那个。所以下次面试如果只说“char定长费空间”,是不够严谨的,一定要补充字符集这个前提。

3.3 行格式、碎片和更新场景的影响

除了静态字节数,还要考虑InnoDB的行存储行为。

InnoDB默认的compact和dynamic行格式,对varchar这类变长字段的处理是:如果一行里变长字段多了,或某条记录的varchar值变长,记录可能发生页内移动,甚至造成页分裂。char定长时,存储位置相对固定,更新同长度内容不需要移动数据,这是定长字段在老存储引擎里的优势。

但注意,“定长不移动”这块优势在InnoDB里被削弱了。InnoDB本身按主键聚簇,B+树节点由行记录填充,每条记录还有事务ID、回滚指针等额外信息。char定长带来的“随机访问快”红利,在InnoDB这种行存储结构里并不明显;反而因为char在utf8mb4下占44字节,把一页能容纳的行数压得更低,导致同样数据量需要读更多页,IO反而更差。

还有碎片问题。varchar如果不断从短变长,Page内会有碎片,但可以通过表重建来整理。char虽然不会因为长度变化产生碎片,但如果各种定长字段堆在一起,行变大,一个页里能放的行数少,也是变相的性能损失。

所以,内存中、MyISAM时代那种“char更快”的惯性思维,放在InnoDB+utf8mb4的现代组合下基本不成立。更好的判断标准是:先看字符集,再看实际业务更新频率,最后看扩展空间。

3.4 我的建议:默认varchar(11)

综合上面的字节计算和InnoDB行为,我的倾向非常明确:

  • 国内手机号纯数字11位,字段设计推荐phone varchar(11) NOT NULL;
  • 如果业务确定只存国内号码且不会国际化,也建议保留varchar(11),不要用char,因为utf8mb4下char浪费空间;
  • 如果有海外号码可能,直接用phone varchar(32),一步到位,别等到需要支持“+86”时再改表。

有人可能会问,那用char(11)配合latin1字符集不也能省1个字节吗?理论上成立,但你为了一个字段单独把表改成latin1,会牺牲其他中文字段的存储能力。除非整张表确定只有ASCII内容,否则这个优化得不偿失。而且手机号未来的格式变化,远比省那1个字节重要。

4. 从答案到方案:DDL、索引与面试追问

4.1 一段能直接落地的建表语句

光说结论不给方案等于没说。我直接把一个比较合理的设计写在下面:

CREATE TABLE user_phone ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, phone VARCHAR(11) NOT NULL COMMENT '国内手机号', status TINYINT NOT NULL DEFAULT 1 COMMENT '账号状态', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY (id), UNIQUE KEY uk_phone (phone) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='用户手机号表';

几个细节值得说:

  • phone用varchar(11)并加唯一索引,保证同一手机号不重复注册。
  • collate用utf8mb4_bin,避免某些字符集的排序规则把手机号当成文本做奇怪的大小写转换。手机号是纯数字,用bin比较最直接。
  • 主键用自增bigint,而不是手机号做主键。原因很简单:手机号有隐私属性,且后续可能变化,不适合做聚簇主键。

如果业务允许“用户没有手机号”,我建议用空字符串''而不是NULL。因为MySQL唯一索引允许出现多个NULL,一旦用NULL代表“无手机号”,唯一索引的约束效果就会被绕过,导致同一手机号被插入多行NULL。

4.2 索引失效、隐式转换这些坑要怎么避

移动号段查询是典型场景。很多人把手机号存成数值类型后,写查询时喜欢写:

WHERE phone = 13800138000

这条语句在phone是整数类型时也许能走索引,但如果phone是varchar类型,MySQL会把字符串常量转成数字去比较,还是会把列类型隐式转为数字?这里有一个很容易搞错的行为:

  • 当列类型是varchar,条件里给的是数字时,MySQL会尝试把列值转换成数字进行比较,此时索引可能失效,因为每一行的列值都需要被转换后才能参与比较。
  • 当列类型是int/bigint,条件里给的是字符串时,MySQL会把字符串转换成数字,然后与列值比较,索引通常还是能用的。

所以哪怕你选了string类型,也要让查询条件里的参数保持字符串类型,养成写WHERE phone = '13800138000'的习惯。这个问题在多语言ORM里尤其隐蔽,比如Java里直接用Long类型接手机号参数,到了SQL里就会变成数字,坑很多。

另一个常见坑是LIKE。手机号查询常需要按号段匹配,比如统计某个运营商的用户量。varchar列上写WHERE phone LIKE '138%',如果前缀足够有区分度,是有机会走索引的;但如果是int/bigint列,这种写法根本没法成立。这也是字符串存储的一个隐形优势。

4.3 一套完整的回答参考

我把前面的分析压缩成一段面试答案,你可以直接拿去参考,但建议改成自己的话:

我会分三层来分析。第一层,int不能选,因为int有符号最大2147483647,无符号最大4294967295,而11位手机号已经到百亿规模,连存后10位都放不进int。第二层,bigint虽然能装下,但手机号是标识符不是数值,不需要参与运算,而且未来可能支持+86这种带符号的国际号码,数值类型根本存不了。第三层,在string里我选varchar而不是char。假设表用utf8mb4,char(11)会按最大字节数预留44字节,而varchar(11)只存11个数字加1字节长度前缀,总共12字节,20亿行光这一列就能省几十GB空间。而且varchar变长,未来扩张到varchar(32)去兼容国际号码,也比char更自然。如果业务确定只存国内号码,我会用varchar(11),并加唯一索引。

这段话有结论、有计算、有存储原理、有扩展性,基本能覆盖大部分追问。

5. 如果是20亿行数据,问题还没结束

5.1 这个量级先要解决分表问题

回到题干的20亿。就算你选了varchar(11),单表20亿行在InnoDB里也是不现实的。按每行100字节估算,20亿行光数据就是2TB级别,再加上二级索引,单机磁盘、内存、备份、运维全都扛不住。所以聊到这一步,一定要把分库分表补上。

手机号作为天然的业务标识,很适合做分片键。常见的做法是对手机号做hash,再模分片数,比如拆成1024张表,让同一个手机号的查询落到固定分片。

这里就能看到“一开始选对类型”的长期价值:如果一开始就把phone设计成bigint,分片时还可以勉强转换;但一旦字段定义是数值类型,而业务后来要求支持国际号码,你连分片逻辑都要跟着改,迁移成本是灾难级的。

所以很多老系统后期改造时,第一件事就是把手机号字段从bigint换成varchar,再加新数据同步,双写、校验、切流量,整个流程走下来以月为单位。面试中能提到这一段,会让面试官觉得你见过真实生产环境。

5.2 号码扩展和国际化的兼容设计

再往后想一步:手机号不是只有中国有。出海业务、跨境电商、海外用户注册,都会遇到E.164格式的号码,比如+8613800138000,最长可以达到15位左右。

如果你当初设计的是char(11),现在要改列长度,线上大表ALTER TABLE的代价非常高,不仅要拷数据,还可能锁表、影响线上写入。这类问题在面试里经常被用来考察“设计时有没有预留扩展性”。

所以我的习惯是:没出海的团队,手机号字段用varchar(11)没问题,但在DDL注释里写清楚“预留国际号码扩展”;如果公司有出海规划,直接用varchar(32)起步,多花不了多少空间,但省掉了未来一次大迁移。

还有一个容易被忽略的点:如果未来要支持国际号码,原来的唯一索引就不能简单建立在phone上,需要考虑国家码+手机号的联合唯一。字段设计上分开存country_code和phone会更干净,但这是另一个话题,面试时点到即可。

5.3 安全合规:手机号不是普通字段

最后,手机号属于个人信息,和用户名、昵称不一样。真实生产环境里,手机号这列通常要做脱敏展示、加密存储、访问审计,查询接口不能直接返回完整号码。

这会对字段设计产生影响。比如加密存储后密文长度可能会超过11个字符,如果当初用的是char(11),密文根本塞不下;但varchar天然变长,配合合理的上限值比如varchar(64),还能兼容密文存储。另外,如果需要对手机号做等值查询,又不想存明文,常见的做法是再加一个专门的hash列,用sha2之类的算法存定长hash值作为查询索引,明文列则加密存储。

这些点虽然超出了一道面试题的字面范围,但恰恰是“20亿手机号”真实项目中绕不开的环节。你能主动提到合规和脱敏,会让整段回答的工程分量完全不一样。

最后说个我自己的实际体会。这些年接触过的老系统里,把手机号设计成bigint的并不少见,表面看没出大问题,真到接国际号码、做号段分析、改分表方案的时候,个个都想骂当年拍板的人。类型选型这种事,不只是“能存不能存”,而是给未来三五年的业务留空间。你画表的时候多花两分钟想清楚,后面能帮运维和业务省下几个月的时间。

另外分享一个排查小技巧:不确定某个字段的行为时,先SHOW CREATE TABLE看字段类型和字符集,再EXPLAIN看查询是否走索引,这两步能解决大部分和存储选型相关的线上问题。

返回列表