0.1 + 0.2 ≠ 0.3。你在 MySQL 里用 FLOAT 存金额,对账永远差这一分钱。
这篇把建表时最常纠结的四种类型——整数 INT、文本 VARCHAR、时间 DATETIME、金额 DECIMAL——一次讲清,看完直接照着建表。
先把选型原则钉死
所有类型选择背后就四条原则,后面每一节都是它们的展开:
- 最小存储:能放下业务范围的最小类型。年龄不用 BIGINT,状态不用 INT。
- 精准匹配:是什么类型就用什么类型。数值用数值、文本用字符串、日期用日期,别全塞 VARCHAR。
- 精度优先:金额、汇率必须定点数,浮点绝对不行。
- 兼容拓展:自增主键预见到后期数据量,直接 BIGINT,别等溢出再改表。
知道了这四条,我们从一张订单表的第一个字段开始选。
记住:选型不是背类型表,是对着业务字段一个个问"这一列存什么"。
主键 ID:INT 还是 BIGINT?INT(11) 又是什么?
订单表第一个字段必然是order_id。选 INT 还是 BIGINT?
INT 是 32 位有符号整数,固定 4 字节,有符号范围 -21 亿 ~ 21 亿;加UNSIGNED翻一倍到 0 ~ 42 亿。中小体量业务 42 亿够你写到公司倒闭;但分布式、雪花 ID、海量流水,直接上 BIGINT(8 字节,上限 9.2×10^18)。
业务里 99% 的整数字段都是非负的——用户 ID、订单数量、浏览量、年龄、排序号,这种字段统一加UNSIGNED。一举两得:容量翻倍,负数脏数据根本写不进去。只有真会出现负数的场景(差值、温度、盈亏统计)才保留有符号。
整数查询快不是玄学——固定 4 字节长度,数据库比较、排序、走索引都是按定长算,不用像 VARCHAR 那样先读长度前缀再定位。这也是"能整型就别字符串"的底层原因。
这里有个几乎人人踩过的坑:INT 后面的括号数字,不是存储位数。
💡要点:INT(11)、INT(5)、INT(20)占用空间和取值范围完全一样——都是 4 字节。括号里的数字叫显示宽度(Display Width),只在配ZEROFILL时才生效,用于左侧补零。前端本来就会管展示格式,数据库这层零填充现在没人用。
CREATETABLEt(aINT(5)ZEROFILL,bINT(11));INSERTINTOtVALUES(12,12);SELECTa,bFROMt;-- 预期输出: a=00012, b=12看到区别没?a被补成 5 位,b没有。但两个字段占的空间一模一样。
⚠️坑:我见过有人为了"省空间"把主键从 INT(11) 改成 INT(5),上线后发现查询结果多了前导零,前端把00012当字符串拼了。这事改起来简单,但暴露了他根本不知道显示宽度是干嘛的。
整型家族怎么挑?对照下表:
| 类型 | 字节 | 有符号范围 | 无符号范围 | 典型场景 |
|---|---|---|---|---|
| TINYINT | 1 | -128 ~ 127 | 0 ~ 255 | 状态、性别、评分 |
| SMALLINT | 2 | -3.2 万 ~ 3.2 万 | 0 ~ 6.5 万 | 年份、小计数 |
| INT | 4 | ±21 亿 | 0 ~ 42 亿 | 用户 ID、订单 ID |
| BIGINT | 8 | ±9.2×10^18 | 0 ~ 1.8×10^19 | 雪花 ID、海量主键 |
主键选完,状态字段别再用 INT。订单状态就 0/1/2 三个值,用TINYINT UNSIGNED只占 1 字节,比 INT 省 3 倍。大表上这个差距肉眼可见。
顺便把状态字段的默认值也定了:别用INT DEFAULT NULL。两个问题——INT 浪费空间,NULL 会让WHERE status != 1这种查询直接漏掉 NULL 行。统一TINYINT UNSIGNED NOT NULL DEFAULT 0,业务里没有"未赋值"这一说。
记住:主键预见数据量,状态用 TINYINT,INT(M) 的 M 别当回事。
手机号字段:INT 能存,为什么不能用?
下一个字段是phone。11 位数字,看着像整数,很多人第一反应是 INT。
这是个生产事故级别的错误。手机号看着像数字,用 INT 存会有三个问题:
- 前导 0 直接丢——手机号本身没有前导 0,但卡号、身份证、工号有。
0012345存进 INT 变成12345,对不上。 - 超范围溢出——身份证 18 位,早就爆 INT 的 42 亿上限,存进去就是错的。
- 失去字符串语义——手机号不做加减乘除,你要的是它"长什么样",不是"值多大"。
⚠️坑:我刚工作时见过一张用户表用 BIGINT 存身份证号,数据迁移才发现有几个人的号开头是 0,导出来全少一位。这种脏数据事后根本查不回来。
所以短文本、编号、手机号、邮箱,统一用VARCHAR。
VARCHAR(M) 的 M 是字符数,不是字节数——这是第二个新手坑。一个汉字在 utf8mb4 下最多占 4 字节,但VARCHAR(50)就是按 50 个字符算,不管你存的是汉字还是字母。
长度按"够用且最小"挑:
- 昵称、用户名、手机号:
VARCHAR(32)或VARCHAR(50) - 地址、简介、标题:
VARCHAR(128)或VARCHAR(255) - 文章正文、商品详情:别用 VARCHAR,换 TEXT
为什么?因为 MySQL 单行有 65535 字节的硬上限,所有列共享。你把一堆 VARCHAR(2000) 堆进去,建表直接报错。长文本交给 TEXT,别硬塞。
还有个细节:VARCHAR 的长度前缀占 1 字节还是 2 字节,分界正好在 255。定义 ≤255 时长度前缀 1 字节,>255 时 2 字节——这也是为什么很多人习惯把 VARCHAR 卡到 255,不是迷信,是有底层原因的。但别为了这 1 字节硬把短文本设成 255,按需选就好。
另外密码字段也是 VARCHAR——存的是加密后的哈希串,比如 bcrypt 输出 60 位,VARCHAR(60)就够,别去存明文。
💡要点:CHAR和VARCHAR的区别——CHAR 固定长度、尾补空格,适合"长度完全固定"的字段(比如定长编码);VARCHAR 按需存储,90% 场景用它。手机号虽然长度固定 11 位,但"前导 0 + 不参与运算"这两点,还是选 VARCHAR。
VARCHAR 还有两个硬规矩:字符集统一utf8mb4,不然 emoji 存不进去;字段默认NOT NULL DEFAULT '',别让字符串字段为 NULL——NULL 会让索引效率下降,WHERE name != 'a'这种条件还会漏行。
新手最省事的写法是"所有字段都用 VARCHAR"——图省事,其实是灾难。一旦金额列是 VARCHAR,ORDER BY amount DESC就变成字典序排(9排在10前面),SUM(amount)直接报错或按字符串拼,WHERE age > 18也走不了数值比较。类型混用,索引和计算全部失效。
VARCHAR 建索引还有个细节:字段太长时别整列建索引,指定前缀长度。比如标题列VARCHAR(255),索引只取前 50 个字符:INDEX title(title(50))。整列建索引会让索引文件暴涨,B+ 树层级变深,查询反而慢。
记住:编号类字段哪怕全是数字,只要带前导 0、不运算、长度超 INT,就用 VARCHAR。
时间字段:DATETIME 还是 TIMESTAMP?
订单表第三个关键字段是create_time。教科书里教过两个:DATETIME 和 TIMESTAMP,选哪个?
直接给结论:新项目一律 DATETIME,别碰 TIMESTAMP。
先说版本前提:MySQL 5.6 及以上把 DATETIME 改成了二进制压缩存储,固定 5 字节;更早版本是字符串存,又慢又占地方。现在没人用 5.6 以前的版本了,这条可以直接跳过,但你要是接手老项目,先SELECT version()看一眼。
| 对比项 | DATETIME | TIMESTAMP |
|---|---|---|
| 空间 | 5 字节 | 4 字节 |
| 范围 | 1000 ~ 9999 年 | 1970 ~ 2038 年 |
| 时区 | 无关,存绝对值 | 依赖时区,改时区就乱 |
| 自动更新 | 可控 | 默认强制 |
那省下来的 1 字节,换一个 2038 年就溢出的炸弹,值不值?
⚠️坑:2038 问题不是吓唬人。TIMESTAMP 底层是 32 位时间戳,到 2038-01-19 03:14:07 UTC 就翻转。现在写的代码,几年后可能就在这个坑上炸。而且 TIMESTAMP 会随数据库时区自动转换——服务器从东八区迁到新加坡,老数据"看起来"全变了。
DATETIME 存的是绝对值,不管服务器时区怎么改,2026-09-27 22:30:00就是这个数。
实际建表时,create_time和update_time是标配:
create_timeDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMPCOMMENT'创建时间',update_timeDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMPCOMMENT'更新时间'create_time插入时自动填当前时间,以后不改;update_time在行被 UPDATE 时自动刷新。不用业务代码操心。
精度上,普通业务DATETIME(0)精确到秒就够;秒杀、日志、高频排序场景上DATETIME(3)精确到毫秒。两个延伸:只存日期不存时间(比如生日)用DATE,只存时长(比如视频时长)用TIME,别全用 DATETIME 浪费。
❌错误:用VARCHAR存时间字符串,或者用INT存时间戳。前者没法用DATEDIFF、DATE_FORMAT这些函数,范围索引也走不了;后者人眼看不出是几点,排查问题全靠转。
还有一条容易被忽略的:服务器时区、MySQL 时区、应用时区三层必须统一设成Asia/Shanghai。不然你在本地看到的时间和线上差 8 小时,排查问题时先怀疑数据错了,绕一大圈才发现是时区没对齐。
记住:新项目时间字段用 DATETIME,TIMESTAMP 的 2038 炸弹和时区坑别去接。
字段:为什么 FLOAT 永远对不平账?
回到开头那个反常识——0.1 + 0.2 到底等多少?
在 MySQL 里实测一下:
SELECT0.1+0.2;-- 预期输出: 0.30000000000000004SELECTCAST(0.1ASDECIMAL(10,2))+CAST(0.2ASDECIMAL(10,2));-- 预期输出: 0.30FLOAT 和 DOUBLE 是二进制浮点数,底层用二进制近似存小数,0.1 在二进制里是无限循环,存进去就已经不是 0.1 了。这不是 bug,是 IEEE 754 的设计。FLOAT 大约 6-7 位有效数字,DOUBLE 大约 15-16 位——别被"DOUBLE 更准"骗了,它只是误差更小,累加十笔百笔之后照样飘。
✅验证:你把 0.1 存 FLOAT、0.2 存 FLOAT、加起来再 SUM,十笔交易下来对账差几分钱是常态。财务找过来的时候,你跟他解释"这是二进制浮点"——没用。
DECIMAL 是十进制定点数,按十进制字符串存,0.1 就是 0.1,0.2 就是 0.2,加起来严格 0.3。
语法DECIMAL(M, D):M 是总位数,D 是小数点后位数。常用配置:
- 订单金额、余额、商品价格:
DECIMAL(10,2),最大 99999999.99 - 大额交易、企业账务:
DECIMAL(16,2) - 汇率、税率、利率:
DECIMAL(8,4),留 4 位小数
⚠️坑:别把 M 设得过大。DECIMAL(65,30)听着"安全",实际占的存储是按 M 算的,每多一位都在浪费。DECIMAL 上限是 M=65、D=30,但没人真用到那么大。按业务最大值选,单价封顶 9999 元的业务,DECIMAL(10,2)完全够。
金额字段的默认值是0.00,不是NULL,也不是整数0——后者会让小数位丢精度。温度、身高、体重这种不要求精确的数用 DOUBLE 是对的;但把它用在金额上,就是事故。
还有一层兜底:数据库存 DECIMAL 不代表业务代码可以躺平。入库前用BigDecimal.setScale(2, RoundingMode.HALF_UP)截一下,不然传进来一个0.123456,数据库按 D=2 静默截断成0.12,业务层完全无感知,对账单又差一分。
记住:资金字段一律 DECIMAL(10,2),FLOAT/DOUBLE 留给科学计算,别碰钱。
一张标准订单表长什么样
把前面四个选择拼起来,一张能直接上线的订单表大致是这样:
CREATETABLEorders(order_idBIGINTUNSIGNEDNOTNULLAUTO_INCREMENT,user_idBIGINTUNSIGNEDNOTNULL,statusTINYINTUNSIGNEDNOTNULLDEFAULT0,phoneVARCHAR(18)NOTNULLDEFAULT'',amountDECIMAL(10,2)NOTNULLDEFAULT0.00,create_timeDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMP,update_timeDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP,PRIMARYKEY(order_id))ENGINE=InnoDBDEFAULTCHARSET=utf8mb4;对照一下前面讲的每一条:主键order_id用 BIGINT 预见未来,状态status用 TINYINT 省空间,手机号phone用 VARCHAR 容纳前导 0,金额amount用 DECIMAL 保精度,create_time/update_time双字段 DATETIME 自动维护,全表NOT NULL+ 默认值,没有 NULL 字段。
这就是一套能跑的最小规范。
结尾速查与常见坑
业务字段选型对照:
| 业务字段 | 推荐类型 | 标准写法 |
|---|---|---|
| 用户 ID、订单 ID | BIGINT | BIGINT UNSIGNED NOT NULL AUTO_INCREMENT |
| 状态、是否删除 | TINYINT | TINYINT UNSIGNED NOT NULL DEFAULT 0 |
| 昵称、地址 | VARCHAR | VARCHAR(50) NOT NULL DEFAULT '' |
| 手机号、身份证 | VARCHAR | VARCHAR(18) NOT NULL DEFAULT '' |
| 创建/更新时间 | DATETIME | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP |
| 订单金额 | DECIMAL | DECIMAL(10,2) NOT NULL DEFAULT 0.00 |
| 汇率、税率 | DECIMAL | DECIMAL(8,4) NOT NULL DEFAULT 0.0000 |
| 生日 | DATE | DATE NOT NULL DEFAULT '1970-01-01' |
常见坑清单(按踩中频率排序):
- 全表用 VARCHAR——数值没法排序、没法运算、索引效率掉。
- 金额用 FLOAT/DOUBLE——对账永远差几分钱。
- TIMESTAMP 用在新项目——2038 溢出 + 时区错乱。
- 手机号/身份证用 INT——前导 0 丢失、超长溢出。
- 纠结 INT(11) 还是 INT(5)——显示宽度而已,别浪费时间。
- 字段默认 NULL——
WHERE != x漏行、索引效率下降。 - VARCHAR 一律 255——短文本浪费空间,长文本又截断。
- 用字符串存时间——时间函数全废,范围查询慢。
下一步你可以做的:找一张你最近建的表,跑一句SHOW CREATE TABLE 表名\G,把字段类型拉出来,对照上面这张表逐字段过一遍。我赌至少有一个字段要么用大了、要么用错了类型。改完再跑一次EXPLAIN看看索引有没有变化。
参考链接:
- MySQL 官方文档 - 数据类型
- MySQL 官方文档 - Fixed-Point Types (Exact Value)
- MySQL 官方文档 - The DATE, DATETIME, and TIMESTAMP Types