去线上库捞表结构时,你会发现一个有趣现象:INT 几乎成了 MySQL 整数类型的默认配置。状态位用 INT,计数用 INT,连只需要 0/1 的标记也用 INT。我早年在项目里也这么干,因为模板里面主键默认就是 int(11),字段拿来就往上套。可等到表量级上来、慢查询变多、自增主键逼近上限、接口返回大整数被前端截断之后,我才意识到:整数类型选得好不好,根本不只是"省几个字节"的问题。这篇文章不聊安装、不堆概念,只把 TINYINT、INT 和 BIGINT 的范围、空间、属性组合、场景选型和常见坑一次讲清楚,适合刚接触 MySQL 的新手,也适合正在做表结构评审、改表或者接口对接的开发者。
1. 先搞清楚家底:TINYINT、INT、BIGINT 的存储边界
先把最基础的东西刻在脑子里。MySQL 的整数类型按字节数从小到大排列,常见的有 TINYINT、SMALLINT、MEDIUMINT、INT、BIGINT。日常开发中 TINYINT、INT、BIGINT 三兄弟出现频率最高,所以要理解这三者到底能存多少数据、各自占多少空间。
1.1 一张表看懂取值范围和内存开销
| 类型 | 占字节 | 有符号范围 | 无符号范围 |
|---|---|---|---|
| TINYINT | 1 | -128 到 127 | 0 到 255 |
| INT | 4 | -2147483648 到 2147483647 | 0 到 4294967295 |
| BIGINT | 8 | -9223372036854775808 到 9223372036854775807 | 0 到 18446744073709551615 |
记忆方法也很简单:1 字节等于 8 个 bit,n 字节能表达的数值总量是 2 的 8n 次方。有符号类型需要拿出最高位做符号位,所以 TINYINT 正数最大是 2 的 7 次方减 1,也就是 127;负数最小是负的 2 的 7 次方,也就是 -128。无符号类型把所有位都用来表达数值,所以 TINYINT 最大是 255。INT 是 4 字节,BIGINT 是 8 字节,把 n 分别替换成 4 和 8 就能推出上面的范围。
如果觉得数字太抽象,可以换个角度理解:TINYINT 就是一张小纸条,适合写"0、1、2"这种一眼能数完的状态;INT 是一张 A4 纸,能够写平时绝大多数数量;BIGINT 是一本厚笔记本,主要给那种大到无法预期的流水和 ID 使用。用一张小纸条能搞定的事情,没必要掏出一本笔记本;反过来,明知道内容会写满笔记本,就别为了省事赖在小纸条上。
1.2 显示宽度和 ZEROFILL 的坑,十个人里有八个理解错
再说一个高频误区:很多人看到 int(11),以为括号里的 11 是"最多只能存 11 位数字"。这是错的。INT 本身能存的上限 2147483647 只有 10 位,但你在 MySQL 中把字段写成 int(11),依然可以存 21 亿不到的数字,也可以存负数,根本不会被 11 限制住。括号里的数字叫"显示宽度",它只有在结合 ZEROFILL 属性时才有视觉上的作用。
举个例子,字段定义为 INT(5) ZEROFILL,当插入 123 时,查询结果会显示为 00123。如果没有 ZEROFILL,这个宽度完全不影响实际存储和计算。而 ZEROFILL 本身又会自动把字段变成无符号类型,导致负数无法写入,所以现在新版本 MySQL 已经逐步把整数显示宽度属性淡出。日常建表,直接写 INT、BIGINT 就够了,完全不需要在括号里填一堆没有语义的数字。
这个坑的实际影响在于很多旧工具生成的建表语句都是 int(11)、tinyint(1)。看到 tinyint(1) 时,又有人会以为它只能存 0 或 1,于是拿它当布尔类型用。其实 TINYINT(1) 默认有符号范围还是 -128 到 127,只是展示时最多显示 1 位。某些 ORM 框架确实会把 tinyint(1) 特殊处理成布尔值,但这是框架行为,不是 MySQL 本身的性质,别混为一谈。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 实际业务怎么选:状态位、计数、主键各有各的逻辑
知道了存储范围,下一步就是选型。选型不能单独看"这个字段现在要存什么",还要看它未来几年可能变成什么。我习惯把整数字段分成三类:状态位、普通计数和主键 ID。
2.1 能用 TINYINT 的字段,别让 INT 来凑热闹
最常见的 TINYINT 使用场景是订单状态、支付状态、逻辑删除标记、渠道来源这类枚举值。订单状态通常就那几种:待支付、已支付、已发货、已取消、已退款,最多再加十来个细分状态,1 个字节的 TINYINT 完全够用。逻辑删除标记只有"未删除"和"已删除",用 TINYINT DEFAULT 0 也是最标准的选择。
有些团队习惯把所有整数字段都定义成 INT,理由是"反正以后可能要加状态,INT 保险"。这句话听上去没毛病,实际代价被你忽略了。每个 INT 字段比 TINYINT 字段多 3 个字节,一张表如果有 5 个本应使用 TINYINT 的字段,1000 万行数据就会多出 150MB 左右的裸数据。如果这几个字段还建了索引,那么 InnoDB 存索引时也要多占用空间,扫描数据时从磁盘读到内存的数据量也会变大。
我并不是说所有状态都一定用 TINYINT。如果状态枚举多到超过 127 个,或者你明确要兼容第三方回传的 200 多个状态值,那当然要换 SMALLINT 或者 INT。反过来,如果确认业务状态只有 2 到 3 个,就老老实实用 TINYINT。一个足够小的字段类型,能让表更紧凑,索引更快,代码里也更难传错值。
2.2 普通计数:INT 依然是主流,但也别把"时间线"忘了
业务里的计数、排序值、积分、次数、短时间内的限流值,这类字段绝大多数用 INT 就够了。INT 能存 21 亿多,一天新增 10 万条数据,一年也就 3600 多万条,21 亿够你跑 50 年。所以只要不是主键和外键,普通计数我倾向于用 INT,不会轻易上 BIGINT。
但有一个反例需要注意,叫"时间累加型"字段。比如一张埋点事件表,每天百万级写入,你只要统计未来三年的总量,可能已经到 10 亿以上。虽然 21 亿这个上限看起来还有距离,但三年后再做一次大促可能就超过 20 亿。更关键的是,存量表里已经有大量数据,如果才跑了两年就发现 20 亿上限不够,那时候改表会非常痛苦。所以我们判断 INT 是否够用时,不能只盯着当前数据量,还要估算业务峰值和增长趋势。如果每天高频写入,或者字段会不断累加且永远不清理,直接选 BIGINT 更省心。
2.3 自增主键用 INT 还是 BIGINT,得看前端怎么接
主键是重灾区。很多系统早期都把主键设计成 INT AUTO_INCREMENT,等到一天写入几百万订单流水时,才发现 21 亿并不遥远。主键一旦耗尽,表现是:插入时提示主键重复,或者自增值溢出。你无法通过简单调整自增值来解决,因为那一行的最大 ID 已经撑到类型极限。
更稳的做法是:只要这张表可能是大表、长期表和流水表,主键直接上 BIGINT。BIGINT 的有符号最大值 9223372036854775807,这个数字大到了九千亿亿,日常业务几乎不可能靠自增耗尽。而且用 BIGINT 后还能兼容雪花 ID、号段发号器生成的分布式 ID,这对后续分库分表非常重要。
这里有一个很现实的前端坑:MySQL BIGINT 传到 Java 后端一般映射为 Long,但如果你直接通过 JSON 返回给浏览器,JavaScript 能精确表达的最大整数只有 Number.MAX_SAFE_INTEGER,也就是 9007199254740991。一旦 BIGINT 值超过这个数,JS 的 Number 类型就会丢失精度,可能出现"最后几位变成 0"或者"两个 ID 看起来一样"的诡异问题。后端的 Long 和数据库的 BIGINT 再匹配,也架不住前端解析出错。解决方案很简单:对外接口只要是 BIGINT 主键,统一把它序列化成字符串返回;前端需要把某个 ID 传回服务端时,也请用字符串类型参数接收,不要在 JS 里用 Number 去算。
3. 属性组合与操作细节:UNSIGNED、溢出和隐式转换
选完基本类型之后,真正让新人翻车的往往是属性组合。同样一个 INT,加不加 UNSIGNED、会不会超范围、比较时用什么类型,结果完全不同。
3.1 UNSIGNED 确实能翻倍上限,但也会带来隐藏麻烦
很多人一看 UNSIGNED 能让正数范围扩大一倍,就喜欢给主键或者 ID 加 UNSIGNED。INT UNSIGNED 上限是 42 亿多,看着很踏实。但实际工程里,我不太建议为 INT 或 BIGINT 加 UNSIGNED,尤其是主键。
原因很简单。第一,BIGINT 有符号范围已经大到用不完,再加 UNSIGNED 没有收益。第二,UNSIGNED 以后很容易出现"运算减出负数"的问题。比如一个字段是无符号的,执行 UPDATE t SET num = num - 1 时如果 num 已经是 0,MySQL 会报 "BIGINT UNSIGNED value is out of range" 之类的错误,而不是简单把结果变成负数。这种错误在排查时很容易被忽略,因为 SQL 本身看着没有任何问题。
第三,如果表里有外键或者不同表字段需要做关联,一边是 UNSIGNED 另一边是 SIGNED,MySQL 在做 JOIN 时往往会把有符号的强转成无符号,一旦遇到负数条件,可能直接触发转换异常。所以我的建议是:只有非常明确的"我存的数值永远不可能为负数,而且有符号范围确实不够用"时,才考虑给整数列加 UNSIGNED。最典型的就是 TINYINT UNSIGNED,用来存 128 到 255 之间的状态码,这种场景有明确收益。
这里再补充一个判断习惯:看别人设计的表时,如果看到 int(10) unsigned 这种写法,我会先问三个问题——这个字段是否有负数计算?这个字段是否参与 JOIN?这个字段未来会不会超过 INT 有符号上限?如果答案都是否,加 UNSIGNED 还说得过去;如果你答不上来,就别加。让字段保持有符号,是最省心、最不容易在边界条件下出错的选择。
3.2 溢出截断和隐式转换,是线上 SQL 问题的常见元凶
MySQL 对超出范围的整数处理,和 SQL 模式强相关。5.7 及以后默认开启 STRICT_TRANS_TABLES 等严格模式,插入一条超出 TINYINT/INT 范围的数据时,会直接报 "Out of range value for column",整个 SQL 失败,不会糊弄你。但如果有人把 SQL 模式改成了宽松模式,MySQL 会选择把超范围的值截断到边界值后插入,比如给 TINYINT 插入 128,它会截成 127,插入成功后只给一个 warning。这种静默截断最坑,因为业务代码发过去的 128,最后落库却变成了 127,数据悄无声息就错了。
除了溢出,类型转换也是一个隐蔽问题。整数字段与字符串比较时,MySQL 一般会尝试把字符串转换成数字。WHERE id = '123' 这种写法通常没问题,因为 id 是 INT,MySQL 会把字符串 '123' 转成 123,再走索引,效率不受影响。反过来,如果字符串列和数字比较,比如 WHERE user_code = 12345,MySQL 会把每一行的字符串都转成数字再比较,那字符串列上的索引就大概率失效,全表扫描就来了。
还有一个典型的副作用是表达式套函数。WHERE id + 5 = 20 看着只是给 id 加了 5 再比较,但因为 id 列被包进了表达式,MySQL 没法直接使用这个字段的索引。这类 SQL 在数据量小的时候看不到问题,等表到了几百万行才开始慢。建议不管是不是整数类型,查询条件都要保持字段本身不被运算,要计算的话尽量先把值算好再传入 SQL。
3.3 AUTO_INCREMENT 和 DDL 写法:一次写对,少走弯路
自增列在 MySQL 里有一个硬性要求:必须定义在某个索引上,最常见的就是主键索引。每张表只能有一个 AUTO_INCREMENT 列,而且它必须是整数类型。从 InnoDB 的存储行为来说,自增主键最好单调递增,所以不要把 VARCHAR 或者 UUID 拿来做主键,更不要用业务编号做自增主键。
DDL 推荐写成这样:
sql复制CREATE TABLE user_account (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
account_name VARCHAR(64) NOT NULL COMMENT '账号名',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用 1正常',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_account_name (account_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户账号表';
注意这里的主键没有写 UNSIGNED,没有写显示宽度,直接写 BIGINT NOT NULL AUTO_INCREMENT。好处是 Java 的 Long 类型能完全对齐,前端序列化成字符串也不存在边界问题。如果一张表数据量很小,比如字典表、配置表,那 INT 作为主键也完全可以,没必要统一用 BIGINT。
4. 实操案例:从建表到改表,我把完整方案走一遍
理论讲再多,不如看一个能直接落地的例子。下面我用一张订单表来演示不同类型应该怎么落位、怎么处理主键、以及上线后主键不够改 BIGINT 的全过程。
4.1 订单和用户表,一张可以直接抄的建表方案
假设我们要设计一张订单记录表。表的写入量大,未来可能达到千万甚至亿级。基于这个前提,我的定义思路如下:
- 主键用
BIGINT AUTO_INCREMENT,不直接暴露给前端做业务传参。 - 业务订单号用
BIGINT,适合放发号器或雪花算法生成的整数 ID。 - 用户 ID 用
BIGINT,因为用户主键如果用了雪花 ID 或未来分表,INT 放不下。 - 订单状态用
TINYINT,0、1、2、3 这类状态码完全够用。 - 渠道来源用
TINYINT,App、H5、小程序几种情况。 - 逻辑删除标记用
TINYINT,只存 0 和 1。 - 金额这种涉及小数和精度的,不属于整数类型讨论范围,后面单独用 DECIMAL。
建表 SQL 可以这样做:
sql复制CREATE TABLE `order_record` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID,只做内部关联,不直接给前端',
`order_no` BIGINT NOT NULL COMMENT '业务订单号',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态:0待支付 1已支付 2已取消 3退款中',
`source` TINYINT NOT NULL DEFAULT 1 COMMENT '渠道:1App 2H5 3小程序',
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0未删除 1已删除',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';
这套结构放在大多数业务系统里都很稳。有人会问,为什么不给 order_no 也加唯一索引?这要看业务需求。如果订单号本来就必须唯一,那直接加 UNIQUE KEY uk_order_no (order_no);如果只是为了普通查询走索引,用普通二级索引也可以。对于 status 这种区分度很低的字段,单独建索引效果一般,通常要和 create_time 组合使用,所以这里没有单独给 status 建索引。区分度字段本身用 TINYINT 再配合组合索引,整体占用就会小很多。
4.2 线上表从 INT 主键改成 BIGINT,应该怎么动
如果建表时没想清楚,表已经跑了一两年,主键还是 INT AUTO_INCREMENT,现在要改成 BIGINT,该怎么办?这要分两种场景。
第一种:表比较小,数据量在百万级以内,可以直接在低峰期执行:
sql复制ALTER TABLE `order_record`
MODIFY COLUMN `id` BIGINT NOT NULL AUTO_INCREMENT;
如果这条 ALTER 报了和 ALGORITHM=INPLACE 相关的错误,原因不是主键不够大,而是 MySQL 在修改列类型时,可能需要重建表。重建表不仅会临时占用磁盘空间,还会拷贝数据,所以执行前一定要确认空间充足。用 SHOW TABLE STATUS LIKE 'order_record' 看当前数据量和表空间占用,预留至少 1 倍空间更稳。
第二种:表很大,比如超过千万行甚至亿级,就不要直接在业务高峰期跑裸 ALTER 了。裸 ALTER 会长时间锁表或产生大量主从延迟,一般用 pt-online-schema-change 或 gh-ost 这种工具在线改。即使在线工具,也要提前检查:从库延迟是否正常、有没有长事务、有没有外键关联、磁盘是否够用。改完以后还要去确认 max(id) 没有异常,自增列能正常往下走。
我在生产环境见过一种更隐蔽的问题:只改了主表的主键类型,却没改子表的外键字段类型。两个表关联时一个 INT 一个 BIGINT,虽然 MySQL 允许 JOIN,但类型不一致容易让优化器对转换产生误判,并且后续如果要重建表或做数据迁移,类型不匹配会带来一堆麻烦。所以改主键类型时,要把所有引用这张主键的关联字段一并查出来,统一改成 BIGINT。
4.3 ORM 映射和接口传值:Java、Python、前端各怎么说
数据库类型定了之后,代码层面的映射同样不能忽略。Java 后端最常用的是 MyBatis 和 Spring Data JPA,对应关系大致如下:
| MySQL 整数类型 | Java 类型推荐 | 说明 |
|---|---|---|
| TINYINT | Integer | 如果用 Byte,存入 128 可能溢出,不建议 |
| SMALLINT | Integer | Java 没有对应 SMALLINT 的基础包装类型 |
| INT / INTEGER | Integer | 常规整数映射 |
| INT UNSIGNED | Long | 最大 42 亿,Integer 放不下 |
| BIGINT | Long | 主流映射方式 |
| BIGINT UNSIGNED | BigInteger 或避开 | 最大超过 Long,普通 JDBC 映射容易出问题 |
Python 因为 int 类型不限制位数,所以绝大多数 MySQL 整数类型都能直接用 Python int 接收,这个倒是省心。Node.js 则要特别注意,BIGINT 如果被识别成 JavaScript number,就可能丢精度。所以现在不少 Node.js 的 ORM 都会把 BIGINT 字段映射成 string,或者要求你在查询结果里做转换。
回到 Java 后端,最常见的问题是 BIGINT 对应的 Long 在 JSON 序列化时默认会输出成数字。前端一旦解析超过 2 的 53 次方减 1 的数值,精度就会丢。解决方式有几种:全局配置 Jackson 把 Long 和 Long 包装类序列化为字符串;在字段上加 @JsonSerialize(using = ToStringSerializer.class);或者在 VO 对象里直接把这个字段定义为 String。哪种方案都可以,关键是团队要形成统一约定。我的建议是:所有和数据库主键相关的 Long 字段,对外一律转成字符串,不要在接口层输出裸 Long。
5. 常见问题与排查技巧实录
做数据库相关的活,最怕的不是不会建表,而是线上出问题时不知道怎么定位。下面把高频问题整理成速查表,每个问题都是我平时看群聊和排障时反复遇到的类型。
5.1 高频问题速查表格
| 现象 | 可能原因 | 处理思路 |
|---|---|---|
| 插入 128 到 TINYINT 字段报 Out of range | TINYINT 有符号上限只有 127 | 改字段为 TINYINT UNSIGNED 或 SMALLINT |
int(11) 明明写了 11 位,还是插不进去更大的数字 |
括号内只是显示宽度 | 检查真实类型是否为 INT,必要时改 BIGINT |
| 主键到了 2147483647 后新增报主键重复 | INT 已满 | 尽早改 BigInt,别无符号自增硬撑 |
| 从数据库查 BIGINT 到前端,ID 后几位变成 0000 | JS Number 精度不够 | 后端 JSON 序列化时把 Long 转成字符串 |
| 同一张表和字符列比较时快时慢 | 隐式类型转换导致字符索引失效 | 避免数字与字符串列直接比较,统一字段类型 |
| 做减法时出现 BIGINT UNSIGNED value is out of range | UNSIGNED 字段不能得出负数 | 能不加 UNSIGNED 就不加,或把字段改成 SIGNED |
tinyint(1) 查询结果返回 true/false |
某些驱动按 bit/boolean 处理 | 确认驱动配置;正常用 TINYINT 0/1,别依赖显示宽度 |
这张表里的内容并不深奥,但每个问题背后都可能牵扯一两个小时排障时间。尤其"隐式转换"和"UNSIGNED 减法"这两类,SQL 本身能执行,错误提示却常常让人摸不着头脑,需要重点防范。
5.2 一个真实案例:TINYINT 被塞进 128 之后的排查
前阵子一个朋友的项目突然报错,错误信息类似:Out of range value for column 'type' at row 1。一开始他以为是数据太长,确认后发现 type 字段是 TINYINT,而代码里新增了一个状态,值是 128。问题就出在他根本没想过加状态会上限。
这个例子最能说明选型思维。他用 TINYINT 存状态是对的,但因为定义类型时没有考虑有符号范围,导致上限只有 127。其实当时业务明确不会出现负数状态,只需要把字段改成 TINYINT UNSIGNED,上限就能到 255,完全没必要改成 SMALLINT。后来这条 ALTER 在生产环境执行时很快,因为 TINYINT 改 TINYINT UNSIGNED 对表结构的影响相对可控,才算把问题平息。
我把这个过程写出来,是为了提醒大家:选型时要看"业务值域"和"未来扩展余量"两层。状态码如果明确从 0 开始,而且永远不可能小于 0,那无符号是合理的;但如果你只是照抄模板,写了个 TINYINT 就把状态码当成有符号数,等到 127 之后再加新状态,就会遇到同样的尴尬。
5.3 排查整数字段时的三个高效技巧
第一,先看字段真实定义,不要只看实体类。用下面这条 SQL 把库里的整数类型字段一次性摸出来:
sql复制SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'your_database'
AND DATA_TYPE IN ('tinyint', 'int', 'bigint')
ORDER BY TABLE_NAME, ORDINAL_POSITION;
这个查询特别适合做数据库巡检。你一眼就能发现哪些表的状态字段用了 INT、哪些表主键已经快逼近上限、哪些字段加了 UNSIGNED 但代码里根本没有负数概念。真正上线前做一次这种扫描,能避免很多半夜告警。
第二,查当前最大自增值,预判是否快满:
sql复制SELECT MAX(id), COUNT(*) FROM order_record;
如果 MAX(id) 已经超过 INT 上限的七成,也就是 15 亿左右,就该准备修改字段类型了。别等告警才动,因为大表 ALTER 需要排期,一旦主键真的写满,业务马上停摆,临时处理会被动很多。
第三,用 EXPLAIN 看有没有隐式转换。当 EXPLAIN 结果里 5.7 版本出现 type = ALL,8.0 里出现 Using where 且查询条件有明显类型问题时,就去检查字段定义和传入参数类型。整数字段和字符串参数比较多数情况没问题,但如果两个关联表的关联字段一个 INT 一个 VARCHAR,那基本就是灾难,最好直接统一列类型。
5.4 这个内容后续还能怎么扩展
掌握了 TINYINT、INT、BIGINT,其实你已经把 MySQL 整数类型的绝大部分基础拿下了。更进阶的方向有两个:一个是和 DATETIME/TIMESTAMP 配合做 range 分区,评估主键是不是 BIGINT 对分表路由的影响;另一个是深入了解 InnoDB 中整数主键与二级索引的关系,这对优化大表查询和索引设计帮助很大。等你有机会接触千万级表之后,回头再看这一篇里的"省字节"思路,感受会深很多。
最后分享一个我自己的习惯:每次建表前,我会把表的未来三年数据量先估一遍。状态、标记这类低基数字段坚持用 TINYINT;普通计数用 INT;任何主键和长期流水表,默认用 BIGINT。不要被老模板里的 int(11) 牵着鼻子走,也不要为了炫技把所有字段都放大一号。一个字段类型改起来不难,但如果等到数据量大了才发现选错,那代价就是凌晨两点的 ALTER 和全链路联调。该省的时候省,该宽的时候宽,这才是整数类型选型最朴素也最实用的原则。
