我在做后端开发的时候,最常被新人问倒的问题之一就是:建表的时候,用户ID到底用 INT 还是 BIGINT?一个状态字段用 TINYINT 会不会不够?明明都是整数,MySQL 为什么要分 TINYINT、INT、BIGINT 这么多种类?说实话,这些基础问题看起来简单,但选错了代价很大——轻则白占存储空间,重则字段溢出、数据写入失败,线上事故就是这么来的。这篇东西我就把自己这些年对 MySQL 整数类型的理解和实操经验梳理一遍,从存储原理讲到具体的业务选型,再聊几个容易踩的坑。适合刚接触数据库设计的同学通读一遍,也适合写过一阵子但没系统整理过类型体系的开发者用来查漏补缺。
1. 整数类型的基础认知:存储、范围与业务直觉
1.1 字节数才是类型的本质
MySQL 里整数类型最大的区别不在于“能存多大数”,而在于“占多少字节”。TINYINT 占 1 个字节,INT 占 4 个字节,BIGINT 占 8 个字节。这背后是计算机存储的基本单位:1 个字节等于 8 个比特位,能表达 2 的 8 次方也就是 256 种组合。
所以 TINYINT 理论上可以表示 0 到 255(无符号)或者 -128 到 127(有符号),它的取值范围直接由比特位数决定。同样地,INT 用 32 个比特位,BIGINT 用 64 个比特位。这些数字很多人在大学课本里见过,但真正理解它的人不多——类型选型的本质,就是在“够用”和“省空间”之间找平衡点。
举一个直观的例子:如果一个表有 1 亿行数据,某个字段从 INT 改成 TINYINT,每行省 3 个字节,总共省下约 286 MB 的存储空间。如果这张表还有几个二级索引,那节省的就不只是行数据本身,还包括索引占用的空间。反过来,如果一个字段用 TINYINT 但业务值可能会超过 127,那就等着线上写数据时报 Out of range 错误吧。
1.2 取值范围的计算方法,看懂就够了
我见过不少开发者记不住各类型的范围,每次都去查手册。其实只要记住一个公式:有符号范围负半轴是 -2^(n-1),正半轴是 2^(n-1)-1,无符号范围是 0 到 2^n-1,其中 n 是比特位数。
- TINYINT:8 位,有符号 -128 到 127,无符号 0 到 255
- INT:32 位,有符号 -2147483648 到 2147483647,无符号 0 到 4294967295
- BIGINT:64 位,有符号 -9223372036854775808 到 9223372036854775807,无符号 0 到 18446744073709551615
为什么正半轴要减 1?因为 0 占了其中一个位置。这个细节很多人在计算时容易漏掉,比如 INT 的正半轴很多口头描述说“21亿”,精确值其实是 2147483647。
我把这三类最常用的整数类型整理成一个速查对照表,方便建表时直接瞟一眼:
| 类型 | 字节数 | 有符号范围 | 无符号范围 | 典型用途 |
|---|---|---|---|---|
| TINYINT | 1 | -128 ~ 127 | 0 ~ 255 | 状态码、开关、性别、删除标记 |
| SMALLINT | 2 | -32768 ~ 32767 | 0 ~ 65535 | 小范围计数、枚举值扩展 |
| MEDIUMINT | 3 | -8388608 ~ 8388607 | 0 ~ 16777215 | 中等长度计数 |
| INT | 4 | -2147483648 ~ 2147483647 | 0 ~ 4294967295 | 常规ID、计数器、普通数值 |
| BIGINT | 8 | 约 -9.22e18 ~ 9.22e18 | 0 ~ 约 1.84e19 | 雪花ID、超大数据量主键、金额分 |
顺带说一句,SMALLINT 和 MEDIUMINT 也是很常用的,只是标题里重点讲 TINYINT、INT、BIGINT 这三兄弟。实际工作中 SMALLINT 适合放枚举值扩展场景,MEDIUMINT 相对冷门一些,我曾经在一个数据量巨大的日志表里用它存储请求耗时(毫秒级),效果也很好。
1.3 有符号还是无符号,别想当然
在 MySQL 中,整数类型默认是有符号的(SIGNED),但你可以显式声明为无符号(UNSIGNED)。什么场景下用 UNSIGNED?最常见的判断标准就是:业务值是否可能出现负数。
- 如果字段存的是“价格差额”,可能出现负数,必须用 SIGNED
- 如果字段存的是“库存数量”“点击次数”“IP地址段”,永远不会为负数,用 UNSIGNED 可以在同等字节下把正数上限翻倍
比如 TINYINT 有符号上限 127,改成 UNSIGNED 后上限变成 255。INT UNSIGNED 的上限是 42.9 亿,刚好是 INT 有符号上限的两倍,这对于一些不能轻易用 BIGINT(因为存储空间和索引宽度都更大)的场景很有吸引力。
但是我也要提醒:如果某天业务需求变化,原本非负的字段要支持负数,那么 UNSIGNED 就会成为限制。我在实际项目里见过一个教训:某个计数器字段用 INT UNSIGNED,后来产品要做“余额增减”,余额可能变成负数,结果数据库层直接写不进去,最后只能改字段类型加迁移脚本。所以,无符号的选择要考虑未来业务边界,不能只看当下。
另外,在 JOIN 关联时,两个表的关联字段类型最好完全一致,包括有符号无符号也必须一致。如果一张表是 INT UNSIGNED,另一张是 INT SIGNED,MySQL 要做隐式转换,往往会导致索引失效、全表扫描。这一点很多人写 join 语句时根本没注意,以为是相同类型就万事大吉了。
2. 别被 INT(11) 骗了:显示宽度与存储大小无关
2.1 括号里的数字到底有什么用
很多从 Navicat 或旧版建表脚本里看到INT(11)的人,会误以为 11 表示这个字段最多只能存 11 位数字。这是一个流传很广的误解,必须纠正。
INT(11)中的 11 是“显示宽度”(display width),它只在两个条件下有效果:字段设置了 ZEROFILL(零填充),并且查询结果需要按固定宽度显示。它不限制存储范围——INT 不管写 INT(11) 还是 INT(3),取值范围都是完全一样的 21.47 亿上限。也就是说,括号里的数字不影响你能存多少数据,也不影响存储空间。
比如一个字段定义为TINYINT(2) UNSIGNED ZEROFILL,你插入一个 5,查询出来显示为 05,但实际存储值仍然是 5。这只是展示层面的补零效果。
2.2 显示宽度的实际应用场景
那 ZEROFILL 在什么场景下有用?比较经典的是一个订单号场景:要求编号固定 8 位,不足 8 位前面补零。如果业务上允许用自增数字且不超过 99999999,那么定义INT(8) UNSIGNED ZEROFILL就能在查询时自动显示成00001234。
但我个人的建议是:除非有很强的展示需求,否则不要依赖数据库层的 ZEROFILL。原因有三个:
- ZEROFILL 会影响存储和比较,在 MySQL 中启用 ZEROFILL 的列会被隐式加上 UNSIGNED 属性,这可能导致负数场景出问题
- 展示格式化属于表现层逻辑,应该由后端代码处理,比如在 SQL 查询后用字符串补零函数,或直接在接口层处理
- 不同版本的 MySQL 对显示宽度的支持细节有差异,比如 8.0 已经弃用了部分显示宽度语法,后面维护容易踩坑
2.3 新版本建表时怎么写最稳妥
现在写 SQL 建表,我推荐直接用不带括号或带标准宽度的写法。比如:
CREATE TABLE `order_info` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态: 0待支付 1已支付 2已取消', `user_id` BIGINT NOT NULL COMMENT '用户ID', PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';关于括号数字,我的经验是:既然不限制存储长度,就没必要写得花里胡哨。MySQL 8.0 已经在逐步弱化整数显示宽度的语义,建议新项目在定义表结构时就统一风格,写出没有歧义的 DDL 脚本。
3. 实战选型指南:不同业务场景到底该选哪个
3.1 主键字段:从 INT 到 BIGINT 的抉择
自增主键是所有 MySQL 表中最常见的整数应用。我见过无数张表用INT AUTO_INCREMENT,一般业务下没问题,但有个隐患:INT 有符号上限 21.47 亿,无符号也才 42.9 亿。看着很大,但是对于日志流水、用户关系、交易记录这类高频插入的表,达到这个量级并不是不可能。
关键点在于:INT 主键涨到上限后,再插入数据就会主键溢出,整张表不可写入。这个事故一旦发生,扩容非常麻烦,不光是改字段类型,还要重建索引、处理缓存,业务中断时间你根本预估不出来。
所以我的建议是:凡是主键,直接上 BIGINT,不要犹豫。有人觉得 BIGINT 比 INT 多 4 个字节,存储和索引都变大;但在绝大多数业务下,这点空间换来的是几十年的增长余量,非常划算。尤其是 MySQL InnoDB 引擎中,二级索引都会包含主键列,所以主键越大,每个二级索引也会相应增大。可是相比于主键溢出的运维灾难,多出来的存储开销完全可接受。
执行 ALTER TABLE 修改主键类型时,有个细节要提醒:在线 DDL 虽然支持 MODIFY COLUMN,但修改主键类型往往意味着重建表,在亿级大表上执行时极可能锁表或产生巨大 IO 压力。我建议选择业务低峰期操作,或者用 gh-ost 这类工具做在线变更,并提前备份。
3.2 状态类字段:TINYINT 的好处比你想象的多
应用层最常见的枚举字段:订单状态、用户状态、逻辑删除标记、性别、消息是否已读。这类字段取值范围很小,可能就 0 到 10 的几种状态,用 TINYINT 是唯一的正解。
为什么不用 INT?原因很简单:TINYINT 只占 1 字节,INT 占 4 字节。一张订单表可能几千万行,三个状态字段加起来每行省 9 字节,整表省下几百 MB 的磁盘和内存(缓冲池缓存行数据时同样受益)。这段优化几乎是零成本的。
另一个容易被忽略的点:TINYINT 对代码规范有积极影响。因为它范围小,你在写后端枚举转换时,会下意识地去维护一个完整的映射表。如果字段是 INT,可能有人就把魔法数字散落在代码里。虽然这不是数据库本身的问题,但类型设计确实会影响团队的编码习惯。
建议在枚举字段上加上注释,把每个数字的含义写清楚,例如:
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-待支付 1-已支付 2-已退款 3-已关闭'这样其他同事接手时不用去翻代码就能明白业务含义,也避免了一个数字在不同表里代表不同含义的混乱。
3.3 中频数值与计数器:INT 的主场
当数值范围超过 255、但又明显远小于 21 亿时,INT 是最常见的选择。典型的场景包括:文章的阅读数、商品的库存量、用户的积分余额、某个资源的总下载次数、消息队列里的重试次数等。
这类字段的核心特点是:值会增长,可能到千万级甚至上亿,但很少超过 21 亿。如果你预期极限值可能在 10 亿这个量级,我建议认真评估一下是否应该用 BIGINT,因为 INT 无符号上限是 42.9 亿,而有符号上限才 21.47 亿。假设一个热门视频播放量能到 20 亿,你用的是 INT SIGNED,那就位于危险边缘了。
还有一个容易忽略的点:用 UPDATE 语句做自增时,一旦值溢出,语句会直接报错,而不是自动封顶。比如:
UPDATE video SET play_count = play_count + 1 WHERE id = 123;如果 play_count 已经接近 INT 上限,这个更新就可能失败。所以计数器字段在预估波动大时,宁可直接用 BIGINT,省得后面再改表。
3.4 超大数值与分布式ID:BIGINT 的专属场景
BIGINT 的典型应用场景包括:雪花算法生成的分布式 ID、超大数据量的主键、需要精确存储的大整数、某些系统时间戳等。
雪花 ID 是一个典型的必须用 BIGINT 的例子。雪花算法生成的结果通常是 64 位整数,往往会超过 INT 的 42.9 亿上限,如果建表时用 INT 去接,数据直接溢出报错。我见过有人把所有表主键统一设计成 BIGINT,就是为了给未来接入分布式 ID 留好余地。
另外一个容易被忽略的场景是时间戳。某些系统设计里,时间戳用毫秒级存储成一个整数,比如 2025 年的毫秒时间戳大约是 1700000000000 这个量级,远远超过 INT 的 21 亿。如果存到 INT 字段,结果就是写入失败。所以当你在建表时看到“time_value”“ts”这类字段,先确认它是秒级还是毫秒级再决定类型。
4. 容易踩坑的细节:类型转换、溢出与隐式陷阱
4.1 条件查询里的字符串与整型隐式转换
这是实战中出现频率最高的坑之一。假设某张表的user_id字段是 BIGINT,你在代码里拼 SQL 时不小心传了一个字符串,比如:
SELECT * FROM user WHERE user_id = '123abc';MySQL 会把字符串转换成数字再比较。如果这个字符串不是纯数字,转换时可能出现警告,而且更严重的是,如果字段本身是字符串类型,而传入的是数字,也可能会使索引失效。不过对于整型字段,更多的问题出在另一侧——你把字符串类型的字段和数字比较,就会导致字段上的索引失效。
这些坑虽然不属于 TINYINT、INT、BIGINT 内部的问题,但恰恰是整数类型使用中最常见的隐患。我建议所有写查询的同事,都要保持字段类型和传入参数类型的一致性。尤其在 MyBatis 之类的框架里,动态 SQL 容易因为参数类型没写对而触发隐式转换。出现慢查询时,用 EXPLAIN 看一下 key 列和 rows 列,如果明明建了索引却走了全表,优先排查类型是否一致。
4.2 数值溢出:静默与报错的边界
在 MySQL 的不同模式下,数值溢出的表现不太一样。默认情况下,如果给一个 TINYINT 字段插入 200,MySQL 会在非严格模式下把值截断为 127 或 255,并产生一个警告,而不是报错。在严格模式(sql_mode 中包含 STRICT_TRANS_TABLES)下,插入超出范围的值会直接报错。
实际生产中我强烈建议开启严格模式,默认的宽松模式会让数据悄悄变坏,等到查数时才发现不对,那时候已经很难追溯了。检查当前会话的 sql_mode 可以执行:
SELECT @@sql_mode;如果发现没有 STRICT_TRANS_TABLES,可以加上。修改方式可以写在配置文件或会话中执行:
SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION';注意这个修改只在当前会话有效,持久化要改配置文件并重启实例。这也是一个我面对新环境时首先会检查的项目。
4.3 整型与 DECIMAL、FLOAT 的边界意识
有些人会在金额字段上用 BIGINT,以“分”为单位存储整数金额,这是一种常见的做法,优点是不会出现浮点误差。但要注意,金额计算如果涉及汇率换算,结果可能是小数,用 BIGINT 存储时要么四舍五入,要么拆成两个字段。
与此相对的,不要用 FLOAT/DOUBLE 来存需要精确比较的数值,会有精度问题。如果确实要用小数,DECIMAL 才是合适的选择。这些内容虽然不是整型本身,但选型时要把它们放在同一张决策表里考虑。
我在一个项目中曾见过这样的设计:用户余额用 DECIMAL(10,2),积分用 INT,折扣用 FLOAT。结果折扣计算时因为 FLOAT 精度问题导致积分数值出现偏差,排查了很久。总结一句话:需要精确十进制运算时选 DECIMAL,需要连续数值计算时考虑 DOUBLE,但无论如何别把金额、积分这类关键业务数值让给浮点类型。
4.4 综合性避坑清单
把上面提到的坑整理成一张速查表,方便建表评审和排查问题时对照:
| 常见问题 | 典型表现 | 解决办法 |
|---|---|---|
| 整型字段溢出 | insert/update 报 Out of range value | 预估数据量,直接上 BIGINT |
| 关联字段类型不一致 | JOIN 慢、索引失效 | 统一两表字段类型和有符号属性 |
| 整数类型当字符串比较 | 查询结果异常或索引失效 | 保持参数类型与字段类型一致 |
| 状态字段用 INT | 存储空间浪费、枚举易乱 | 无脑用 TINYINT 就好 |
| 显示宽度误用 | 误以为限制长度 | 抛弃 INT(N) 思维,8.0 后更应简化写法 |
| 自增主键用 INT | 数据量大后溢出风险 | 新表主键全部用 BIGINT |
| 非严格模式截断 | 数据悄悄变小 | 开启 STRICT_TRANS_TABLES |
5. 代码层的类型映射:从数据库到应用的一一对应
5.1 各语言里 TINYINT、INT、BIGINT 对应的类型
建表选型只是第一步,应用层读取数据时要选用匹配的数据类型。这里如果映射错了,同样会踩坑。
在 Java 中,TINYINT 映射为 Byte 或 Short,INT 映射为 Integer,BIGINT 映射为 Long。如果数据库字段是 BIGINT,但你在实体类里用 Integer 接收,查询结果超过 21 亿就可能直接被 MyBatis 转成负数或报转换异常。另外,使用 ResultSet.getInt() 读取 BIGINT 字段也可能溢出,建议读取大整数时使用 getLong()。
在 Go 中,TINYINT 对应 int8(但为保险起见,通常扫描到 int 也没问题),INT 对应 int32 或 int,BIGINT 对应 int64。如果你在 GORM 里用 int 接 BIGINT 字段,在 32 位系统上会溢出。Python 的 int 是变长的,反而没有这些问题,但 ORM 内部仍可能对长度有约束。无论什么语言,原则很简单:应用层的数值类型范围,一定要能覆盖数据库字段的取值范围。
5.2 自增主键与 ORM 的配合
有的团队会在后端代码里自己生成主键,而不是依赖数据库自增。常见的做法是用雪花算法生成一个 BIGINT 主键,这种 ID 因为不是一个连续的递增序列,对 InnoDB 的索引页写入会产生一定随机性。所以很多系统会采用“BIGINT 自增主键 + 业务ID唯一索引”的设计,兼顾写入性能与业务查询需求。
如果你用的是 ORM 框架,插入后需要回填自增主键。在 MyBatis 中使用useGeneratedKeys="true"可以拿到自增 ID;JPA 则通过@GeneratedValue配合GenerationType.IDENTITY。这些场景下主键类型要和数据库定义完全一致,才能避免精度损失。比如数据库用 BIGINT,Java 实体用 Long,那么 MyBatis/JPA 回填的类型也要是 Long。
5.3 数据同步与跨库对比时不要丢精度
BIGINT 的最大值是 9223372036854775807,这个数字有 19 位,而 JavaScript 的 Number 安全整数上限是 9007199254740991(16 位)。所以如果你在前端页面直接展示后端传过来的 BIGINT 主键,就可能发生精度丢失。比如某个 ID 是 17612345678901234567,前端拿到后变成 17612345678901234000,再往后端传就完全对不上了。
这个问题在电商、社交等场景里很常见,解决办法有两个:一是把 BIGINT 主键序列化成字符串传给前端,二是干脆在应用中额外生成一个 String 类型的业务编号,比如订单号,作为前端交互的主键。我个人更推荐后者,因为字符串业务主键更灵活,而且可以内置业务规则,比如日期加序号。
另外一个场景是跨数据库同步。从 MySQL 同步到其他数据库或数据仓库时,如果目标库没有 BIGINT 类型,只能把这类字段定义为 DECIMAL 或字符串,否则就会丢精度。数据同步时最好加个校验环节,对比源和目标的最大值、最小值、总数,早发现问题早处理。
6. 实际表结构案例与设计思路复盘
6.1 一个完整的订单表设计案例
与其零散地讲,不如直接给一个案例。假设我们设计一张电商订单表,核心字段如下:
CREATE TABLE `trade_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键', `order_id` VARCHAR(32) NOT NULL COMMENT '业务订单号(对前端展示)', `user_id` BIGINT NOT NULL COMMENT '用户ID(雪花ID)', `shop_id` BIGINT NOT NULL COMMENT '店铺ID(雪花ID)', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态 0-待支付 1-已支付 2-已发货 3-已完成 4-已取消', `refund_status` TINYINT NOT NULL DEFAULT 0 COMMENT '退款状态 0-无 1-申请中 2-已退款', `total_amount` BIGINT NOT NULL COMMENT '订单总金额, 单位分', `discount_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '优惠金额, 单位分', `pay_amount` BIGINT NOT NULL COMMENT '实付金额, 单位分', `goods_count` INT NOT NULL DEFAULT 0 COMMENT '商品总件数', `province_code` INT UNSIGNED NOT NULL COMMENT '省份编码', `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`), UNIQUE KEY `uk_order_id` (`order_id`), KEY `idx_user_id` (`user_id`), KEY `idx_shop_id` (`shop_id`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易订单表';这个表结构里有几个值得复盘的点:
- 主键用 BIGINT:为海量订单数据留足了空间
- 用户ID、店铺ID用 BIGINT:因为上游系统生成的就是雪花ID,不能用 INT,强行用 INT 会在接入阶段出现溢出
- 状态用 TINYINT:取值范围小,节省空间,配合注释展示清晰语义
- 金额用 BIGINT 存“分”:避免浮点误差,同时为高额订单留出空间
- 省份编码用 INT UNSIGNED:因为编码本身是正数,用无符号类型多出一倍的可表达空间
这种“一看字段就知道业务边界”的表结构,是值得推荐的设计风格。
6.2 建表时需要反复问自己的几个问题
每次设计表结构时,我建议带着一套问题轮番过一遍,可以有效避免大部分类型选型失误:
- 这个字段存的是什么业务值?数值是否有界?最大可能是多少?
- 未来三年到五年,这个值有可能增长到什么量级?
- 这个字段是否要和另一张表的字段做 JOIN?两边类型是否完全一致?
- 这个字段在应用层会被映射成什么类型?是否有溢出风险?
- 这个字段会出现负数吗?用 UNSIGNED 会不会给未来埋坑?
- 是否存在格式化展示的需求?如果有,是否应该放在应用层解决?
这套问题比任何现成的规则都管用,因为你在真正问自己的过程中,会更深入地理解业务边界。数据库字段类型的选择没有银弹,永远是在具体业务约束下的权衡。
6.3 现有的表还能优化吗
如果你接手了一个老项目,发现里面有大量可以优化空间,不要贸然全局 ALTER TABLE。刚上线时听运维朋友讲过一句话:改表一时爽,锁表火葬场。MySQL 的 DDL 虽然支持在线 DDL,但代价是可能产生临时文件、占用大量 IO,大表可能拖垮主库。
我的建议是分三步走:
- 第一步,先对现有表做盘点,统计每个整数类型字段的当前最大值、最小值、空值率、增长率
- 第二步,筛选出真正需要调整的字段,优先处理有溢出风险的
- 第三步,在业务低峰期或用在线工具逐个变更,变更前备份,变更后对比前后数据
比如一个日志表的 time_value 字段存的是毫秒时间戳,当前用的是 INT,但实际上数据量已经接近 20 亿毫秒。尽早改成 BIGINT 是唯一正确的选择,拖得越久越难改。
结尾:个人经验与一段补充提醒
在我自己维护过的项目里,因为整数类型选错而导致的线上故障至少遇到过三次。一次是 INT 自增主键接近溢出,一次是 BIGINT 字段映射到 Java Integer 导致数据错乱,还有一次是状态字段塞进了超过 127 的值导致 TINYINT 报错。事后复盘的时候发现,这些故障只要在建表阶段多想五分钟,完全可以避免。
所以最后分享一个我个人养成的习惯:凡是新建表,主键一律 BIGINT;凡是状态、标志、类型类字段,一律 TINYINT;凡是计数、数量类字段,先想想最大可能值再决定 INT 还是 BIGINT。数据库表结构是变更成本最高的代码,一次设计失误,后面要花十倍的运维成本去修补。你要是能从这篇内容里带走一句话,我希望是这句话:字段类型不只是“能不能放得下”的问题,更是“将来能不能撑得住”的问题。