做 MySQL 开发这些年,我见过太多因为数据类型没选对而返工的表结构。有人用 varchar 存日期,导致后面想做月份聚合只能字符串截取;有人拿 float 存金额,月底对账差出几毛钱怎么都查不明白;还有人把全部数字字段都设成 bigint,一张表愣是多出几倍的存储空间。数据类型看起来是建表时随手一填的小事,实际上却是整个 MySQL 体系里最不该马虎的地基。这篇东西不打算复读官方文档,我直接把常用的整数、小数、字符串、日期这四大类数据类型掰开揉碎讲一遍,顺手把每个类型在真实业务里最容易踩的坑也一起说了,适合正在建表、写 SQL,或者准备优化表结构的人。
1. 整数类型:能扛住多大的数据,比你想的更讲究
整数是 MySQL 里最基础、也最容易被忽视的类型。大多数人的习惯是“不管什么数字都上 int”,但 int 真的是万能解吗?显然不是。我们先把五种整数类型的存储空间和取值范围拉出来看一遍,你就知道该怎么选了。
1.1 从 tinyint 到 bigint:存储空间与取值边界
MySQL 提供五种整数类型,区别只在于占用的字节数不同,进而决定了能存的数据范围。我用一张表把它们列清楚:
| 类型 | 字节数 | 有符号范围 | 无符号范围 |
|---|---|---|---|
| tinyint | 1 | -128 ~ 127 | 0 ~ 255 |
| smallint | 2 | -32768 ~ 32767 | 0 ~ 65535 |
| mediumint | 3 | -8388608 ~ 8388607 | 0 ~ 16777215 |
| int | 4 | -2147483648 ~ 2147483647 | 0 ~ 4294967295 |
| bigint | 8 | -9223372036854775808 ~ 9223372036854775807 | 0 ~ 18446744073709551615 |
选型的基本逻辑就一句话:根据业务数据的上界,留出足够的增长余量,选择一个字节数尽量小的类型。比如用户年龄,tinyint 足够了;订单状态码,tinyint 或者 smallint 都行;主键 ID 这种会持续增长且无法预估终点的字段,直接上 bigint 才是最稳的选择。
这里有一个常见的认知误区:很多人以为“小类型更快”,于是把 id 设成 mediumint,觉得省空间、性能好。实际上在 InnoDB 引擎下,单行数据的查询瓶颈很少出现在一个字段占三字节还是四字节上,反而是在数据量达到千万级之后,主键类型的容量耗尽才是真问题。你可以想象一下,一张千万级订单表的主键 int 到了 21 亿上限,你要 ALTER TABLE 换主键类型,那个锁表时间足够让业务告警打爆手机。所以主键这种字段,宁可一开始就 bigint,也别后面折腾。
1.2 int(11) 的老误区:显示宽度不是存储上限
早期经常能看到建表语句里写int(11)、tinyint(1)这种写法。很多人以为括号里的数字代表“最大能存多少位”,这是 MySQL 使用中最经典的以讹传讹之一。
括号里的数字其实是“显示宽度”,它和 ZEROFILL 属性配合使用时才有一点意义:比如int(5)配合ZEROFILL,存 12 会显示成 00012,纯属为了对齐展示。它并不会限制你存入的数值大小,你写int(2)照样可以存 100000。从 MySQL 8.0 开始,显示宽度语法已经被移除,官方也彻底不推荐这种写法了。如果你在维护老项目,看到int(11)也不用慌,把它当成普通 int 就行,存储行为和取值范围没有半点区别。
另外要提醒的是ZEROFILL这个属性本身,它会在你查询时自动补零,但这只是展示层的加工,存储值依然是数字。很多人被这种“假象”迷惑,以为存进去的是字符串,实际上是多虑了。
1.3 数字字段的隐藏陷阱:unsigned 用不好反而添乱
无符号unsigned在某些场景下很顺手,比如 AGE、数量这种天然不可能为负数的字段,设成tinyint unsigned能让取值范围翻倍到 0~255。但问题也随之而来:两个无符号字段相减,结果如果为负,MySQL 会报错,或者给出一个非常大的无符号数。
我记得有一次排查报表数据,有个字段是“上次库存减去本次库存”算差值,公式里的两个字段都是int unsigned,一旦本次库存大于上次库存,结果变成负数,SQL 直接抛错,导致整张报表跑不出来。当时查了半天才发现是这个原因。所以如果你有字段需要参与加减运算,结果可能出现负数,就不要轻易用 unsigned。另外,在 Java 等后端语言里,unsigned 类型的值如果超过有符号上限,客户端驱动还可能出现数值溢出的问题,解析出来变成负数。为了减少跨语言的沟通成本,我的建议是:业务主键不要用 unsigned,让类型走有符号的正数范围;只有那些我确定“永远不需要负数且不做减法”的状态字段,才考虑 unsigned。
还有一个偏门但很实用的小知识:MySQL 里没有独立的布尔类型。BOOLEAN只是tinyint(1)的别名,存 0 表示假、1 表示真。所以查布尔字段时WHERE flag = 1才是正确的姿势,别用= true,有些驱动能识别,有些不能,纯属给自己埋坑。
2. 小数存储:float 的精度账,decimal 才算得清
小数是另一个事故高发区。很多人写金额字段时随手就是一个float,因为这个类型写起来短、看着也顺眼。但 float 和 double 都是浮点数,存储原理决定了它们天生就无法精确表示大多数十进制小数。
2.1 float 精度丢失的底层逻辑
解释一下为什么 float 存钱会出错。计算机用二进制存储小数,而二进制只能通过 1/2、1/4、1/8 这种分数相加来逼近十进制的值。0.1 在二进制里就是一个无限循环小数,float 只能用有限的精度去截断它,所以存进去之后,再读出来就不是完整的 0.1 了。
举个最直观的例子:你用 float 存 0.1,再加上 0.2,理论上得到 0.3,但实际计算结果是 0.30000000000000004。单条记录看这个偏差无所谓,但数据库里存的是海量订单,每单差一点点,月底一汇总,几百上千万的流水总额就会差出几毛钱。很多财务对不上账的问题,根子就在这里。
我和别人讨论过为什么很多老系统里会有这种设计,得到的答案往往是“当年图省事”。这个代价真的不值得——为了少写几个字符,后面要对账、要改表、要迁移数据,工作量翻几倍。
2.2 decimal 是定点数:精度和标度怎么设
如果有精确计算的需求,正确选择是DECIMAL(M, D)。M 是精度,代表总共最多能存多少位数字;D 是标度,代表小数部分保留多少位。比如DECIMAL(10, 2),整数部分是 8 位,小数部分是 2 位,最多能存到 99999999.99。
这里补充一个网上容易搜到的误区:在 MySQL 5.0 之后的版本里,decimal 的存储方式已经调整为每 9 位数字占用 4 个字节,但在语义上它依然是“精确十进制”,不会像 float 那样出现精度漂移。所以金额场景下,decimal 才是诚实可靠的类型。
那 M 和 D 到底怎么设?我的建议是:看业务里单笔金额的最大值,再加上两到三年的增长余量。比如普通电商订单单笔撑死几万块,用DECIMAL(10, 2)完全够;如果是进销存系统,涉及单价乘以数量,可能出现大整数场景,建议DECIMAL(14, 2)起步,整数部分 12 位,基本覆盖绝大多数企业的业务规模。还需要注意一点:如果数据超出 D 的小数位,MySQL 会四舍五入(5.7 及之前版本部分模式会直接报错或告警),所以在写入层就应该做好金额精度的校验,别把脏数据交给数据库处理。
2.3 金额字段的最佳实践和 float 的适用场景
总结下来,我处理金额字段的定型方案是:所有涉及钱的字段一律DECIMAL,应用层计算时也要注意,各大语言的浮点数运算同样有精度问题,应该用整数的“分”做计算,再把结果转为“元”。数据库层只负责存储,不要在 SQL 里做SUM(price * quantity)这类浮点乘法,能转成 decimal 的字段就不该让 float 掺和进来。
那 float 和 double 是不是就该被拉黑?也不是。它们适用于科学计算、坐标位置、比率、百分比这类“允许极小误差”的场景。比如存经纬度,用 double 既能保证足够的有效位数,存储效率也高。这里头的原则就是:能不能接受误差,决定了你用 float 还是 decimal。
3. 字符串类型:varchar 长度的学问和 text 的暗坑
字符串绝对是建表时决策最多、坑也最密集的类型。平时大家用得最多的是 varchar,但真的追问起来,能答清楚“为什么 varchar 里经常写 255”“text 和 varchar 到底差在哪”的人并不多。这一节我把它们彻底讲透。
3.1 char、varchar 与 text 的本质区别
先看存储层面的差异。char 是定长字符串,长度范围 0~255 字符;varchar 是变长字符串,最大长度 65535 字节;text 是专门存长文本的类型,可以存到 65535 字节以上。
既然是定长,char 在存储时会用空格填充到指定长度,取出时再把尾部空格去掉(MySQL 8.0 以前的行为要小心)。因此 char 适合存长度基本固定的短数据:手机号、身份证号、MD5 校验值、固定位数的订单号。变长 varchar 则会在数据前额外记录长度信息,短数据用 varchar 时每行多一两字节开销,但整体更省空间。
varchar 和 text 的界限以前很清晰,但随着 MySQL 版本迭代,两者的物理存储已经非常接近了。真正的区别在于使用感受:text 不能设置默认值(MySQL 8.0.13 之前),而且无法在无前缀的情况下直接建普通索引,排序和分组时行为也很别扭。所以我经常和团队说一句话:能用 varchar 解决的问题,不要指望让 text 来帮你兜底。
3.2 varchar 长度里的“字符”和“字节”之争
varchar(255)里的 255,指的是 255 个字符,而不是 255 个字节。这个细节直接决定了你为什么偶尔会遇到“字段过长插入失败”。
一个字符占几个字节,取决于表的字符集。utf8mb4 是目前的主流,每个字符最多占 4 个字节。也就是说varchar(255)在 utf8mb4 下最多可能占用 255 × 4 = 1020 字节。再加上 InnoDB 的索引限制(一个索引列最大字节数,旧版本是 767 字节,8.0 放宽到了 3072 字节),你在老版本上给varchar(255)的 utf8mb4 字段建普通索引,可能直接报 “Specified key was too long” 的错。
所以这里有一个很实用的经验法则:如果要给长字符串建索引,不要把长度卡在 255。要么老老实实用前缀索引,比如INDEX idx_name(name(20));要么根据实际业务缩短列长度。另外,网上流传的“varchar(255) 是性能最优”也是一句没头没尾的结论。它可能来自早期某些版本的存储引擎优化逻辑,但放到现在的 InnoDB + utf8mb4 环境里,盲目 255 反而会带来行变大、索引变宽的问题。正确思路是:根据该字段的真实业务长度上限定值,不要猜,去量。
3.3 别把 text 当万能筐:排序、去重和临时表的连锁反应
text 用得多了,问题会从多个方向冒出来。
第一个是排序问题。text 字段如果参与 GROUP BY 或 ORDER BY,MySQL 只能使用前缀排序(默认取前 1024 字节做排序键),排序结果可能和你的直觉不一致。明明按字母排的序,长文本字段却只比了对前几百个字符,剩下的内容完全没参与。
第二个是临时表问题。当查询需要用到内部临时表时,text 字段可能会导致临时表无法使用内存暂存,只能落到磁盘,查询性能断崖式下跌。这在做复杂聚合报表时特别明显,同样一批数据,把 text 改成 varchar 后,速度能差出好几倍。
第三个是默认值问题。老版本里 text 不允许设置默认值,很多 ORM 框架在迁移时专门为 text 字段绕路处理,非常麻烦。包括现在使用 8.0 高版本,text 的默认值支持也是有限制的,远不如 varchar 省心。
一句话总结这个章节:短而规整的数据用 char,一般业务字段用 varchar,大段文章、JSON 原始串、日志快照这些才考虑 text。不要把 text 当成“什么都往里塞”的后备字段。
4. 日期时间类型:timestamp 的 2038 问题和 datetime 的正确姿势
日期类型出错不像字符编码那样立刻爆出“乱码”,它的坑往往是延迟引爆的,等你想按月份汇总、按小时分析时才发现,当初的存储选择让你只能用一串字符串做截取。
4.1 三种日期类型的基本对比
MySQL 常见的日期时间类型就三个:date、datetime、timestamp。
date 占用 3 字节,只存日期,范围 1000-01-01 到 9999-12-31。datetime 占用 8 字节,存日期加时间,范围同上。timestamp 占用 4 字节,存的是从 1970-01-01 00:00:00 UTC 到当前时刻的秒数,范围也就是 1970 年到 2038 年。
这里值得展开的是 timestamp 和 datetime 的时区特性。timestamp 在存储时会把当前会话时区转换成的 UTC 时间存进去,读取时再转回当前时区。意思就是,如果你的业务是全球化的,或者服务器的时区发生过调整,timestamp 字段的值会跟着会话时区变化而变化。而 datetime 没有这个机制,你存进去什么,查出来就是什么,不受时区影响。
很多老项目里喜欢用 timestamp,理由是它占空间小。但它最大的诅咒就是 2038 年问题:因为使用 4 字节存储从 1970 年开始计数的秒数,它在 2038 年 1 月 19 日就会溢出。虽然看着还有十几年,但对于一些生命周期长的系统——比如银行、保险、政府项目——2038 年并不是遥不可及的事。
4.2 设计日期字段的几个决定性细节
先说默认值和自动更新。建表时给创建时间字段设置DEFAULT CURRENT_TIMESTAMP,给更新时间字段设置ON UPDATE CURRENT_TIMESTAMP,这样后续 INSERT、UPDATE 都不用手动维护时间,减少出错概率。需要注意,5.6 之前的版本不支持这种写法,老库迁移到新版本时确认一下表结构的 DDL 就清楚了。
其次是格式之争。我见过大量把日期存成varchar的“野路子”,因为业务方最初传了个字符串进来,程序员图省事直接扔进库里。等到后面要查“最近三个月的数据”,SQL 只能写WHERE substr(create_time, 1, 7) = '202401',字段上套了函数,索引直接失效,数据量一大就是全表扫描。同样的需求,datetime 字段写WHERE create_time >= '2023-11-01' AND create_time < '2024-02-01',索引走得好好的。所以不要用 varchar 存日期,这是我这篇文章里最想强调的一点。
再补充一个小知识:8.0.19 之后 MySQL 支持用表达式设置字段默认值,比如DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d')),但实际项目中没几个人这么玩。创建一个时间字段,保持原生的 datetime/timestamp 类型,查询时再用 DATE_FORMAT 格式化,才是最省心的方案。
4.3 实际选型建议:业务上优先 datetime
很多人会纠结 timestamp 省空间优点,但我的建议非常明确:新业务统一用 datetime,把时区问题交给应用层处理。理由有三层:第一,2038 问题直接绕开;第二,datetime 的行为简单可预期,不会因为 DBA 调整了服务端时区导致历史数据全部“变了样”;第三,现在存储成本远没有十年前那么敏感,8 字节换省心,划算。
如果业务确实要面向多时区用户,比如海外业务,我建议你在应用层统一按 UTC 时间生成并传入,数据库用 datetime 存储,查询展示时再按用户时区转换。这样数据库这一层的行为是确定性的,排查问题时少一层变量。
5. 特殊数据类型与最容易翻车的使用方式
把基础四类讲完之后,MySQL 里还有几个“看着简单但用起来处处是坑”的点位:布尔、枚举、JSON,以及开发中极其常见的隐式类型转换。这些不搞清楚,前面学的类型选择知识可能在你写 SQL 的时候全部白费。
5.1 布尔、枚举:方便背后的维护性代价
先说明 MySQL 里没有真正的布尔类型,上一节我提过它实际是 tinyint(1)。但很多人会为了可读性把字段设计成enum('Y','N')或enum('yes','no'),这就要说到枚举的维护成本了。
enum 本质上是一个隐藏的整数索引,字段存的是值在枚举列表里的位置。它有两个大问题:一是加枚举值需要 ALTER TABLE,在表数据量大的时候锁表风险非常高;二是枚举的排序不是按照字母或拼音,而是按照你在定义时写的顺序。如果你在一个 enum 里加了新值,老数据的位置可能不变,但新老数据的对比排序会出现让人摸不着头脑的结果。
还有一个小坑:如果你用 enum 存状态,比如enum('待支付','已支付','已取消'),业务后续要增加一个“退款中”的状态,这就要改表。而状态枚举在真实业务里是会不断增加、调整的。与其这样,不如直接用 tinyint 存状态码,状态含义放在代码里的常量类或配置表中维护,可扩展性和可读性都会好很多。
5.2 JSON 类型:不是洪水猛兽,也不是万能药
MySQL 5.7 开始支持原生 JSON 类型。它最大的优点是:可以在 SQL 里直接操作 JSON 字段中的某个属性,比如WHERE json_extract(info, '$.age') > 20,8.0 版本甚至支持用生成列给 JSON 里的某个 key 建索引。
我的看法是:JSON 适合存“结构不固定、只是用于展示”的配置数据,比如用户扩展信息、商品动态属性。它不适合承载核心业务查询条件,更不适合做 JOIN 的关联键。很多人碰到“字段不够用”就想塞 JSON,这其实是表设计没想清楚的表现。如果某个 JSON 里的属性值需要频繁作为查询条件,应该抽出来单独建列并建索引,而不是在 JSON 里“挖”着查。
我们团队的真实教训是:早期为了快速上线,把一批商品参数都塞进 JSON,后来运营要做筛选排序,每个条件都要用 JSON_EXTRACT 包一层,索引基本废掉。最后我们还是老老实实做了字段拆分,把那几个高频属性变成了普通列,查询速度直接回到毫秒级。这个经验分享给在座各位:JSON 可以用,但它不是让你偷懒不设计表结构的理由。
5.3 隐式类型转换:索引失效的最常见元凶
最后讲一个和数据类型强相关、但经常被人忽略的问题:隐式类型转换。它指的是 SQL 中比较的两个值类型不一致时,MySQL 会悄悄把其中一个转换成另一个再比较。转换一旦发生,索引就可能失效或行为异常。
最常见的场景就是字符串字段和数字比较。假设 user 表的 mobile 列是 varchar(11),你写WHERE mobile = 13812345678,MySQL 会把字符串列转换成数字再比较,等于在每一行上执行了 CAST,索引直接失效。更危险的情况是:如果手机号里有前导零,或者长度超过数字类型的精确范围,你甚至查不出正确数据。
同样的道理也适用于日期字段。datetime 和字符串比较时,MySQL 会尝试把字符串转成日期,大多数情况下没问题,但如果字符串格式不规范,或者比较的对象本身是函数结果,也会带来隐藏的扫描代价。
这里我给一个自查的习惯清单:每次写查询条件时,先问自己“比较的字段是什么类型,右边传的值是什么类型”。两边类型不一致,就该主动修正,要么改 SQL 参数,要么用 CAST 显式转换。显式转换虽然也可能导致索引失效,但至少行为是明确的,你能看到问题在哪里,而不是被隐式转换悄无声息地坑掉。
写 SQL 的人往往一上来就关注 join、子查询、索引,但真正让一张表跑不动的,常常就是这些数据类型层面的“小问题”。我自己在给团队做 code review 时,看表结构的时间远多过看业务逻辑的时间。字段选对了,索引建得才有意义;索引有意义了,SQL 优化才算真正落地。希望这篇文章能帮你在下一次建表时,多想一层“这个字段到底该用什么类型、以后会不会成为查询条件”,而不是顺手敲一个回车了事。如果真要往回改一张已经存了千万行数据的表,那种痛苦,谁改谁知道。