最近一次代码评审我看到同事在那张用户表里给手机号字段定义的是bigint。我问了一句为什么不用varchar他愣了一下说手机号不就是一串数字吗11 位bigint最大能存 19 位刚好装得下而且数字比字符串省空间、查询也快。这个回答其实代表了相当一部分开发的直觉。但直觉归直觉在生产环境里用bigint存手机号后面会给你挖出一串坑前导零丢不丢先不说国际区号、模糊查询、格式校验、加密脱敏这些需求一出来bigint基本就废了。所以「mysql存储手机号为什么不用bigint」这个问题其实不是个存储空间问题而是个建模问题。这篇文章就把背后的逻辑、代价、还有我实际踩过的坑一次说清楚。1. 先搞清楚手机号到底是个数字还是个「号」1.1 为什么很多人的第一反应是 bigint这个第一反应很好理解。手机号是 11 位全是 0-9 的数字看起来跟年龄、订单金额没什么区别。而且很多刚入门的朋友又刚好知道一个知识点——int最大才 21 亿也就是 10 位11 位手机号用int存不下会溢出。这个时候bigint跳出来8 字节最大 19 位11 位手机号绰绰有余。于是「用bigint存手机号」这个方案看起来又合理又「懂底层」。这里还藏着一个很经典的误区有不少人以为int(11)的11表示能存 11 位数字。这是完全错误的。int的括号里的数字叫显示宽度不决定存储范围。int的范围永远是 -2147483648 到 2147483647最大 10 位不算负号。哪怕你写int(11)它照样存不了 11 位数字。MySQL 8.0 已经彻底废弃了整数显示宽度这个语法以后这种写法会直接报警告。所以「用 bigint」这个方案能解决一个问题让 11 位手机号物理上「装得下」。但从「装得下」到「应该这么存」中间隔着一整个业务建模的思考过程。1.2 手机号的本质标识编码不是参与运算的数值我见过太多人在这个问题上栽跟头核心原因就是把「长得像数字」和「是数字」混为一谈。手机号虽然由数字组成但它本质上是一个标识编码类似于身份证号、银行卡号、订单号、车牌号。你什么时候见过手机号参与加减乘除13800138000 1有意义吗完全没意义。我们存储一个数据选什么类型第一个要问的问题不是「能不能放进去」而是「这个数据在业务里的角色是什么」。手机号的角色是唯一标识一个用户的通信实体是查询条件是匹配键是消息发送目标。它没有任何数值运算语义。这种「编码」型数据标准做法就是字符串。另外一个更感性的判断方式如果你需要给这个字段加「正则校验」「前缀匹配」「格式化展示」那它就一定是字符串。手机号要做1[3-9]\d{9}的正则校验用bigint怎么做先转成字符串再正则那不是自己给自己找事吗。2. 用 bigint 存储的真实代价从格式到扩展性2.1 一个加号和一个空格就能让 bigint 崩盘先别谈那些复杂场景就说最基本的一件事手机号不是只有 11 位纯数字这一种形态。国际贸易、跨境电商、海外用户、物联网卡……这些场景下的手机号几乎必然带国际区号甚至带空格和短横线。举个例子英国号码是44 20 7946 0958日本号码可能是81 90-1234-5678就算是中国大陆的号码用户从通讯录导出的格式也可能是138 0013 8000。这些格式你没法用bigint存。有人会狡辩那我在应用层把符号去掉再存只存纯数字部分呗。好那我问你存英国的号码02079460958如果用户就是想在 UI 上显示44 20 7946 0958你从bigint里读出来之后怎么还原你根本不知道原始格式。更麻烦的是有些国家的号码本身就以0开头比如法国的06 12 34 56 78如果你存成bigint开头的0直接被丢弃号码直接变形。我实际处理过一个海外用户系统里面手机号字段就是bigint结果英国那一批用户的号码全错了因为本地号码的0前缀在入库时被抹掉了。后来排查到凌晨两点原因就是建表时那句云淡风轻的phone bigint。2.2 号码段和长度未来一定会变手机号码不只是一个静态的「11 位数字」它会随着电信行业演进不断变化。国内目前是 11 位但物联网卡、虚拟运营商、卫星电话这些新业务已经让号段变得很丰富了比如1349、1749开头的卫星电话。国际标准 E.164 允许电话号码最长 15 位。你用一个固定bigint去存一个「最长 15 位、可能带前缀符号、可能以 0 开头」的东西从根上就把扩展性锁死了。正确的做法是用varchar给未来留余地。国内标准 11 位那我建议varchar(11)或者为了兼容国际号码统一varchar(20)。多出来的这 9 个字符顶多也就是几个字节的空间成本换来的是整个系统对号码格式的兼容能力。这笔账怎么算都划算。2.3 语义不清晰SQL 可读性一塌糊涂你自己写代码的时候可能没感觉但过三个月再看或者换一个新的同事来维护差别就出来了。WHERE phone 13800138000这行 SQL你能一眼看出它查的是什么吗能看出13800138000是手机号吗不能。它看起来像在查一个数字等于某个值。而WHERE phone 13800138000引号一加所有人都知道这是在查一个字符串编码。这就是代码可读性的问题。数据库表结构本身是一种文档字段类型就是文档的一部分。你把手机号定义成bigint相当于在文档上写了一句话这个字段是数值类型可以拿来计算。但实际上你不应该也不会对它做任何计算。我见过更夸张的有人真的写了WHERE phone 0 13800138000这种 SQL原因竟然是「这样能加速隐式转换」。我只能说能写出这种 SQL 的表设计一开始就病根种下了。3. 性能和索引字符串真的更差吗3.1 存储空间对比bigint 确实小但这不是决策重点先把账算明白。bigint是固定 8 字节。varchar(11)呢取决于字符集。如果表的默认字符集是utf8mb4一个数字字符也要占 4 字节11 个字符就是 44 字节再加上变长字段的长度标记 2 字节总共约 46 字节。这么一比bigint确实省了 38 字节左右。但问题是这几十字节的差距在一个真实的业务系统里能带来多少性能差异MySQL InnoDB 的索引页默认是 16KB一页里能装多少个手机号索引条目差 40 字节也就是单页条目数从几百降到几十的差别树高可能增加一层也可能不增加。而真正决定性能的是查询是否走索引、是否回表、是否发生隐式转换导致索引失效。这些跟字段用bigint还是varchar的关系远没有大家想象的那么大。实际生产环境里手机号字段一定会建唯一索引而且查询基本都是等值查询。一个varchar(20)的唯一索引和一个bigint的唯一索引在等值查询场景下性能差异基本可以忽略不计。如果你纠结这几十字节说明你的瓶颈根本不在这一层。3.2 等值查询varchar 唯一索引和 bigint 基本持平很多人担心字符串索引比整数索引慢。这个担心可以理解但现实里MySQL 对等值查询的优化做得很好。字符串走 BTree 索引时是逐字符比较听起来比整数逐位比较慢但这个差异只在极端大型数据集上才可能体现出来而且通常被磁盘 IO 的时间掩盖掉了。真正需要注意的是参数类型。如果你把phone定义成varchar查询时却写WHERE phone 13800138000这个时候 MySQL 会对phone字段做隐式类型转换——把手机号字符串转成数字去比较。问题来了一旦对索引列做了函数或类型转换这个索引就废了MySQL 会直接走全表扫描。我拿一个十万行的表做过实验正确写法WHERE phone 13800138000走索引耗时毫秒级。错误写法WHERE phone 13800138000全表扫描耗时一下子多了两个数量级。这就是为什么「用字符串存然后查询时也老老实实传字符串」才是正经做法。你用bigint存应用层查询时往往传进去的还是一个字符串13800138000MySQL 又要做一次字符串转整数的隐式转换纯粹是在给自己埋雷。3.3 模糊查询和号段统计字符串的独享福利业务里除了等值查询还经常有按号段统计的需求比如「统计所有 139 开头的用户数量」。用字符串类型一句话就搞定SELECT COUNT(*) FROM user WHERE phone LIKE 139%;如果字段是varchar(11)并且建立了普通索引这个查询可以利用最左前缀原则走索引范围扫描。可如果你存的bigint那查询就变成了一件非常尴尬的事你要么把它CAST(phone AS CHAR)再做 LIKE索引直接失效要么就只能用phone 13900000000 AND phone 14000000000这种数值范围写法又绕又丑。我遇到过一个大屏展示项目需要把运营商号段分布按前缀分组拉出来。字符串字段一条GROUP BY LEFT(phone, 3)就完事。如果当初用了bigint这步操作大概率要先把所有数据拉出来到应用层再用字符串处理白白消耗一堆内存和 IO。4. 落地实践MySQL 表结构到底该怎么设计4.1 国内 11 位手机号的推荐建表方式如果你只需要存中国大陆的 11 位手机号我推荐这么建CREATE TABLE user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, phone VARCHAR(11) NOT NULL COMMENT 手机号E.164去掉国际区号, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci;这里用VARCHAR(11)有几个理由手机号严格 11 位但不是所有业务都需要固定长度填充varchar更灵活。很多人问为什么不用char(11)其实在 InnoDB 里char和varchar的存储处理差异已经很小varchar对 NULL 和空字符串的处理更友好而且 MySQL 8.0 对varchar的变长存储做了不少优化。纠结这两个不如纠结校验和索引。唯一索引加在phone上保证系统里不会出现两个用户共用同一个手机号。如果你用的是 MySQL 8.0.16 以上版本还可以加一个 CHECK 约束从数据库层拦住非法格式CONSTRAINT chk_phone_format CHECK (phone REGEXP ^1[3-9][0-9]{9}$)注意MySQL 8.0.16 之前 CHECK 约束是摆设不生效。如果你在 5.7 及以下版本只能靠应用层正则校验兜底。4.2 需要存国际号码时怎么办业务一旦涉及海外用户手机号设计就要重新考虑。我见过两种主流方案。方案一一个字段存 E.164 标准格式。E.164 是国际电信联盟定义的电话号码格式标准统一以开头国家码加国内有效号码总长度最长 15 位。比如中国8613800138000英国442079460958。这个时候字段直接定义成VARCHAR(20)校验规则用^\[1-9]\d{1,14}$就行。这种方案简单直接查询、去重、展示都方便。方案二拆成两个字段country_code和national_number。适用于需要按国家维度统计分析、或者不同国家号码长度差异很大的场景。ALTER TABLE user ADD COLUMN country_code VARCHAR(4) NOT NULL DEFAULT 86; ALTER TABLE user ADD COLUMN national_number VARCHAR(15) NOT NULL;这两种方案都比bigint合理一万倍。统一原则是你存标准格式展示时再格式化。不要在数据库里存用户输入的原生乱格式比如夹杂着空格和短横线的号码否则后面做归一化和去重的时候会想哭。4.3 加密与脱敏场景下字符串几乎是唯一解手机号属于个人信息在现在的隐私合规要求下很多系统需要对手机号做加密存储或者脱敏展示。这两个需求bigint一个都接不住。加密存储有两种常见做法对称加密后存密文。比如用 AES 加密后转成十六进制或 Base64密文字段至少需要VARCHAR(64)或VARBINARY(64)。你要是用bigint密文根本塞不进去。加盐哈希后存摘要。像 SHA-256 哈希结果固定 64 位十六进制字符妥妥的CHAR(64)。bigint在这里毫无存在感。再说脱敏。前端展示用户信息时手机号通常要显示成138****8000这种掩码格式。字符串字段直接用字符串函数处理SELECT CONCAT(LEFT(phone, 3), ****, RIGHT(phone, 4)) AS masked_phone FROM user;这个操作在 SQL 层一步完成。而如果你存的是bigint你至少得先CAST(phone AS CHAR)然后才能做字符串裁剪。这还没完如果手机号不小心存成了8613800138000的国际格式你的脱敏逻辑还得跟着改写。字符串字段的容错空间显然更大。5. 常见问题与避坑清单5.1 已经用了 bigint怎么低成本迁移如果线上表已经用bigint存了手机号别慌迁移是可以平滑做的。推荐步骤是这样的第一步新增一个可空字符串字段ALTER TABLE user ADD COLUMN phone_str VARCHAR(20) NULL COMMENT 手机号-字符串格式;第二步把原有bigint字段的数据转过去。这里要特别小心如果原来已经因为bigint丢失了前导零那就没法挽回了只能靠业务侧重新纠正。正常数据直接CAST转换UPDATE user SET phone_str CAST(phone AS CHAR) WHERE phone_str IS NULL;第三步数据校验通过后给phone_str建唯一索引然后把查询、写入都在应用层切到新字段。第四步观察一段时间没问题再删除或归档掉老字段。这个过程中最痛的还不是 SQL而是应用层所有涉及到phone的地方。如果你当初在代码里到处写着user.Phone 1之类的怪逻辑这会是一次不错的代码净化机会。5.2 隐式转换varchar 字段被数字参数坑的全过程前面说过隐式转换会导致索引失效这里再给一个实际的排查案例。某次线上慢查询SQL 长这样SELECT * FROM user WHERE phone 13800138000;执行计划显示typeALL十万行的表扫了整整一遍。起初大家以为是索引坏了后来单独跑explain才发现字段是varchar查询参数却是数值MySQL 不得不把每一行的手机号都转成数字再比较索引自然用不上。修法也简单SELECT * FROM user WHERE phone 13800138000;加个引号完事。这个坑在 ORM 里也容易踩。很多 ORM 框架如果你把实体的手机号属性定义成了Long类型那它生成的 SQL 就是数字参数照样触发隐式转换。所以在 ORM 里手机号属性必须定义为String这一点要从数据模型一路贯彻到代码模型。5.3 关于 int(11) 显示宽度的陈年老谣这个坑值得单独拿出来说。MySQL 里int(11)的11不是存储长度而是显示宽度。什么叫显示宽度配合ZEROFILL才有意义。比如INT(4) ZEROFILL你存一个12查询显示出来是0012。只是显示补零不影响存储容量。INT类型的存储范围永远是那 4 字节最大值 2147483647。一个典型的 11 位手机号 13800138000 远超这个值用int存必然报错或溢出。bigint虽然能装下但它解决的是「容量」问题没解决「语义」问题。MySQL 8.0.17 之后整数显示宽度语法已经被移除INT(11)这种写法在新版本里会直接报错或忽略。如果你还在维护旧代码看到INT(11)存手机号的地方趁早改。5.4 「长得像数字」但千万别用数字类型的清单我自己后来总结了一条经验法则只要这个字段不会参与任何算术运算、不需要比较大小、不需要按数值排序它就不应该是数字类型。按这个标准一过滤下面这类字段全部归入字符串阵营手机号、座机号、传真号身份证号、护照号、社保号银行卡号、信用卡号订单号、流水号、业务编号哪怕它叫 No.邮编、区号版本号1.2.10和1.2.9的数值排序是反的这些字段的背后共性是它们是「标识符」不是「数值」。标识符偶尔需要做前缀匹配、格式校验、分段提取这些全部是字符串函数的适用范围。顺带提一个真实业务场景银行卡号如果存bigint超过 19 位直接溢出就算没超过很多第三方渠道给的卡号本身就带空格清洗阶段你没转成字符串后面全是坑。身份证号就更不用说了18 位含Xbigint连存都存不下。6. 一次完整的表设计评审实录前阵子我给一个内部项目做表结构设计评审其中一张user_contact表的第一版设计是这样的字段类型说明idbigint主键user_idbigint用户IDphonebigint手机号backup_phonebigint备用手机号我没有直接说「改成 varchar」而是问设计的人三个问题第一这两个字段存的是「号码」还是「数字」他说是号码。第二用户如果从海外注册号码是44 20 7946 0958你的bigint怎么存他沉默了。第三运营后台要按前三位号段做统计报表你的bigint怎么写 SQL他说可以用BETWEEN写范围。我再追问如果号段未来新增一个长度不一样的格式呢他最终承认字符串才是这个字段应该有的形态。后来这张表改成了这样CREATE TABLE user_contact ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, phone VARCHAR(20) NOT NULL COMMENT 主联系方式优先存E.164格式, backup_phone VARCHAR(20) DEFAULT NULL COMMENT 备用联系方式, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_phone (user_id, phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这个升级过程没有动任何索引结构没有改任何查询逻辑只改了字段类型就把系统从一个「只能存中国大陆 11 位手机号」的窄模型变成一个「能存全球号码、能校验、能脱敏、能统计」的通用模型。代价只是多占几十字节收益是未来所有号码相关的需求都不用再推翻表结构。这件事之后我更喜欢在评审时问一句这个字段你的查询会对它做加减乘除吗如果答案是不会那它就大概率不该是数字类型。手机号 100% 属于这一类。所以回到标题那个问题mysql存储手机号为什么不用bigint不是不能用是不该用。用varchar存手机号不是「退而求其次的妥协」而是从语义、扩展、查询、合规几个维度综合对比之后的最优解。我个人的建议就是新表直接用varchar(20)兼容 E.164老表尽早迁移代码里手机号字段一律String。在这一点上多花十分钟设计能帮你后面少熬十个晚上的排障。
阅读完成 · 觉得有帮助?