你有没有因为MySQL数据类型没选对,半夜被线上报警电话叫醒?一张表数据量才几十万,查询却慢得让人怀疑人生;或者建表时随手选了INT,几年后数据溢出直接报错;又或者存金额用了FLOAT,月底对账差了三分钱,查了两天最后才发现是浮点精度问题。这些我全都经历过,而且每次排查到最后,根因都指向同一个起点——MySQL数据类型。
MySQL数据类型是建表时最先做、却最容易被敷衍的决定。它决定了存储引擎怎么给这行数据分配空间,决定了索引怎么组织,也决定了优化器能不能用上你辛辛苦苦建的索引。往大了说,表结构设计里一半的坑都藏在类型里;往小了说,一个字段多占几个字节,千万行数据跑起来差距就是天壤之别。这篇文章把我这些年建表、改表、排查慢查询踩过的坑整理成一份实战笔记,覆盖数值、字符串、日期时间三大类,再加上类型转换和选型清单。适合刚入门想少走弯路的同学,也适合写惯了CRUD、却从没认真审视过表结构的老后端。
1. 类型选错有多痛:先聊聊为什么数据类型是第一设计决策
先说结论:MySQL里的每一个类型,本质上是一套存储协议和一套比较规则。类型选对,存储、索引、排序、连接都顺;类型选错,后面DBA和运维就得一直给你擦屁股。
1.1 每个字段都在替你记账
数据库里每一行数据的每个字段,最终都要落盘。InnoDB的行格式(比如COMPACT、DYNAMIC)对每种类型都有固定的存储约定。CHAR(255)哪怕你只存一个"是",它也要按255个字符占空间;VARCHAR按实际长度存,但额外要花1到2字节记录长度;INT固定4字节,BIGINT固定8字节,DECIMAL按位数动态计算占用。这些账在每一行里都会重复计算。
举个例子,一个用户状态字段,用TINYINT占1字节,用INT占4字节。1000万行的表,光这一个字段就差了大约30MB的裸数据。30MB看着不多,但如果这个字段建了二级索引,差距就被放大了。InnoDB的二级索引叶子节点会保存主键值和索引列值,字段类型越长,每个索引页能容纳的索引项越少,扫描的页数就越多,IO次数成倍上涨。所以类型选型不是洁癖,是实打实的性能设计。
再往深一层说,主键类型的选择对整张表的空间影响是全局性的。如果主键用VARCHAR(36)存UUID,二级索引每一条都要完整存一遍这个36字符的字符串;如果换成BIGINT自增主键,二级索引的体积会小一大截。这也是为什么我一直强调,能自增整数主键就不要用UUID字符串,尤其在高并发写入场景下,UUID的随机性还会带来页分裂,写入性能进一步恶化。
1.2 索引、JOIN、排序全都看类型
类型还决定了MySQL怎么比较数据。数字有数字大小,字符串有字符序,日期有日期先后。如果业务上是个数字,却用VARCHAR存,很容易踩到字符串排序的坑:'9'比'10'大,因为按字典序比较时字符'9'排在字符'1'后面。我在线上见过一个版本号字段,存成VARCHAR,结果排序排出了1.10在1.9前面的闹剧。
JOIN连接也一样,两边字段类型不一致时,MySQL往往会把一边隐式转成另一边,转换之后索引大概率失效。这是慢查询里最隐蔽的一类问题,因为SQL语句看着完全正常,EXPLAIN一出来却显示全表扫描。
类型还直接影响优化器对成本的估算。索引选择、是否走覆盖索引、是否生成临时表排序,这些都建立在类型信息之上。所以我说,类型是第一设计决策,一点都不夸张。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数值型:从TINYINT到DECIMAL,别再盲选INT了
数值类型是业务表里最常用的类型,但很多人建表时只有一个习惯:遇到数字就用INT。这不算错,但远远不够好。整数、小数、定点数、位类型,各有各的适用场景。
2.1 整数族:一张范围表记清楚
MySQL的整数类型一共有五种:TINYINT、SMALLINT、MEDIUMINT、INT、BIGINT。它们的区别就是字节数和范围,我直接给一张表:
| 类型 | 字节数 | 有符号范围 | 无符号范围 |
|---|---|---|---|
| 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 | -2^63 ~ 2^63-1 | 0 ~ 2^64-1 |
选型原则很简单:能用小的不用大的,但要留足余量。状态码、布尔值、年龄、性别这类枚举和短数值,用TINYINT UNSIGNED完全够,只要不是强迫症,都不需要动用INT。订单金额、商品库存、计数器这些未来可能增长的量,直接上BIGINT也行,等INT溢出再改类型,那才是真的痛。
我印象最深的一次事故,是某个老系统的积分字段用了INT,业务做了几年后积分总量超过21亿,写入直接报Out of range,当天晚上紧急改表,把INT改成BIGINT,几百行的迁移还好说,几千万行的表ALTER TABLE花了将近一个小时,期间IO打满,业务不得不降级。从那以后,所有可能累加的计数类字段,我一律BIGINT起步。
2.2 别被int(11)骗了:显示宽度不是存储限制
不少初学者以为int(11)最多只能存11位的数,这是流传很广的误解。INT就是4字节,不管括号里写几,存储范围和占用空间都不变。括号里的数字在老版本MySQL里配合ZEROFILL用来做前导零填充显示,跟能存多少位完全无关。而且MySQL 8.0.17之后,整数类型的显示宽度已经被废弃,INT(11)和INT写出来效果一样。
顺带回应一个网上经常被搜到的问题:"mysql中int+5是什么意思"。这就是普通的数值运算,INT类型字段加数字5,跟"数据类型强制转换"不是一回事。如果你在SQL里写int_col + 5,那就是简单的加法表达式;如果你想在查询结果里转换类型,那是CAST和CONVERT函数的事,后文会展开讲。
2.3 浮点与定点:FLOAT、DOUBLE、DECIMAL的恩怨
FLOAT占4字节,DOUBLE占8字节,它们都是二进制浮点数,存储的是近似值;DECIMAL是定点数,以十进制格式存储,精确无误差。为什么FLOAT会有精度问题?因为0.1在二进制里是无限循环小数,FLOAT和DOUBLE只能存一个近似值,累计运算误差就会逐渐放大。
金额、税率、积分、费率这类和钱有关的字段,一律用DECIMAL,没有任何商量余地。DECIMAL(M,D)里,M是总位数,D是小数位数。比如DECIMAL(10,2),表示整数部分最多8位,小数2位,最大能存99999999.99。我见过有人用DECIMAL(10,4)存折扣率,也有人用DECIMAL(20,2)存大额交易,都可以,核心是先估算清楚业务规模,再留出缓冲。
这里有个实际建议:DECIMAL的总位数要一次给够,尽量在项目初期就定到业务极限以上。因为线上表ALTER TABLE改DECIMAL精度,在大数据量下代价很高,而且一旦数据超过已有精度,改表还会碰上数据截断风险。宁可前期多给两位,也不要中期来回折腾。
2.4 BIT、BOOL和那些容易混的小类型
MySQL里BOOL和BOOLEAN都是TINYINT(1)的别名,存TRUE和FALSE就是1和0,查出来也是1和0。BIT(M)是真正的位字段,最多64位,一般用于按位存储的权限标记。实际项目里,布尔字段直接用TINYINT(1)就够了,配合字段注释写清楚0和1的含义,不要再用BIT去炫技,因为在应用程序和ORM框架里,BIT类型的映射经常出幺蛾子。
3. 字符串型:CHAR、VARCHAR、TEXT到底怎么选
字符串是另一大主战场。CHAR、VARCHAR、TEXT、BLOB、ENUM、SET,选错的表现不像数值类型那样直接报错,更多是空间浪费、性能劣化和各种诡异的比较行为。
3.1 CHAR和VARCHAR:定长与变长的博弈
CHAR(N)是定长字符串,存不满N个字符时,尾部用空格填充,读取时通常会去掉尾随空格。VARCHAR(N)是变长字符串,保存实际内容再加上1到2字节的长度前缀。长度前缀的字节数取决于内容最大字节数:如果最大长度不超过255字节,用1字节存长度;超过则用2字节。
选择上,长度基本固定的字段用CHAR:MD5加密串是32位定长,UUID去掉横线是32位,手机号11位,身份证18位,这些用CHAR非常合理。长度浮动明显的字段用VARCHAR:用户名、邮箱、文章标题、备注,这些没有一个固定长度的,用VARCHAR能省下大量空间。
一个关键细节:VARCHAR(N)的N是字符数,不是字节数。在utf8mb4字符集下,一个字符最多占4字节,所以VARCHAR(255)理论上最多占1020字节。如果一张表里多个VARCHAR字段长度都很大,累加超过65535字节的行大小上限,InnoDB会直接报错,提示行太大。遇到这种问题,要么拆表,要么把部分大字段改成TEXT。
3.2 TEXT和BLOB家族:能拆就拆,别跟高频字段挤一起
TEXT家族按最大字节数分为TINYTEXT、TEXT、MEDIUMTEXT、LONGTEXT,对应255字节、64KB、16MB、4GB。BLOB是二进制版本,区别只在于字符集和排序规则不适用于BLOB。TEXT和BLOB适合存文章正文、JSON大串、文件内容这类大对象。
但这类大字段有几个硬伤。第一,不能直接设置默认值,给业务代码带来不便。第二,排序和去重时可能用到临时表,大文本在内存放不下就会落盘,慢查询的风险很高。第三,索引不能整列覆盖,必须指定前缀长度,比如INDEX idx_content(content(100)),查询时只有前缀匹配可能走索引,范围查询和排序能力很弱。
我的习惯是:大文本一律拆到独立的附属表,主表只保留业务主键、摘要或外键关联。这样高频访问的小字段都集中在主表的连续页里,缓冲区命中率高很多;需要读大文本时再按主键去附属表取,对绝大多数读多写少的业务是更优解。
3.3 字符集和排序规则:统一,统一,还是统一
字符集决定了字符怎么编码,排序规则决定了字符怎么比较和排序。MySQL里最常用的字符集是utf8mb4,它是完整的UTF-8实现,支持emoji和绝大多数Unicode字符。老项目里常见的utf8其实是utf8mb3,最多3字节,存emoji会报错,这也是很多老系统突然写入emoji失败的原因。
排序规则常见的有utf8mb4_general_ci和utf8mb4_unicode_ci,MySQL 8.0默认是utf8mb4_0900_ai_ci。不同排序规则之间的字符串类型在做比较时,可能触发隐式转换,进而影响索引使用。所以建库、建表、建字段时字符集和排序规则要全链路统一,尤其是JOIN两边的关联字段,一定要一致,不然优化器很容易放弃索引。
长字符串建索引时,如果长度太长,会超过InnoDB索引键的最大限制,所以必须用前缀索引。比如对用户姓名字段建索引,可以写CREATE INDEX idx_name ON user(name(10)),只取前10个字符进索引。前缀索引的问题是,无法用覆盖索引完成查询优化,ORDER BY name也可能因为索引里只有前缀而无法使用。能用短字段做主键、关联键,就别用长字符串去硬扛。
3.4 ENUM和SET:看起来方便,改起来要命
ENUM是枚举类型,内部用数字存储,SET是集合类型,可以存一个或多个值。ENUM的好处是直观、省空间,但坑也明显:排序按定义顺序而不是字母序;插入不在枚举列表里的值,严格模式直接报错,非严格模式可能存成空字符串;修改枚举成员需要ALTER TABLE,对线上表不友好。SET类似,灵活性更低。
业务状态字段,我强烈建议用TINYINT UNSIGNED加代码层字典,比如0待支付、1已支付、2已退款。这个方案的扩展性比ENUM好太多,加状态不用改表,查询条件也清晰。ENUM只适合那种几乎永不变化的固定清单,真要用了,记得在字段注释里写清楚每个值的含义。
4. 日期时间型:DATETIME、TIMESTAMP和时区的爱恨纠葛
日期时间的坑通常不是立即爆发的,而是等到跨时区部署、数据迁移、或者写入远日期时才显现。最能体现"类型选错"的长期代价的,就是时间类型。
4.1 三种日期类型怎么选
MySQL的日期时间类型主要有DATE、DATETIME、TIMESTAMP、TIME、YEAR。最常用的是前三种:
| 类型 | 字节数 | 支持范围 | 时区处理 |
|---|---|---|---|
| DATE | 3 | 1000-01-01 ~ 9999-12-31 | 无时区概念,存日期 |
| DATETIME | 8 | 1000-01-01 00:00:00 ~ 9999-12-31 23:59:59 | 不转换时区,存什么是什么 |
| TIMESTAMP | 4 | 1970-01-01 00:00:01 UTC ~ 2038-01-19 03:14:07 UTC | 内部存UTC时间戳,显示时按会话时区转换 |
核心区别就一条:TIMESTAMP存的是UTC时间戳,查询时会根据数据库连接的time_zone设置转成对应的本地时间;DATETIME则是纯文本式存储,你写入什么值,读出来就是什么值,不涉及时区转换。
选型建议:创建时间、更新时间、操作日志时间,用TIMESTAMP还是DATETIME都行,关键是配合好默认值和自动更新;如果业务要存远期日期,比如合同到期日、保险期限、孩子出生日期,或者做历史数据归档,直接用DATETIME,别让2038年问题在几十年后反噬你。
4.2 TIMESTAMP的2038年问题,别觉得离自己很远
TIMESTAMP只占4字节,以秒为单位存储UTC时间戳,上限正好是2038年1月19日03:14:07 UTC。这个时间看着很远,但对一些长期系统来说,真的是一颗定时炸弹。养老金、保险、大额存单这些动辄几十年的业务,完全有可能写入2040年的日期,如果用了TIMESTAMP,到时就真的踩雷了。
MySQL 8.0在TIMESTAMP超范围时的行为是报错,这比老版本直接写进去更安全,但依然不能解决问题。所以在设计新表时,没有特殊理由,我直接用DATETIME。空间上DATETIME比TIMESTAMP多4字节,但换来的近8000年的时间范围和一个不被时区搞晕的心,很值。
4.3 默认值、自动更新和精度选择
MySQL 5.6.5之后,DATETIME也支持默认当前时间和ON UPDATE自动更新了,写法如下:
sql复制CREATE TABLE t_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
这样写,插入时created_at自动取当前时间,update时updated_at自动刷新,业务代码里就不用手动维护这两个字段了。TIMESTAMP在5.6之前是唯一支持自动更新的类型,这也是老项目里大量TIMESTAMP的历史原因,新项目完全可以把默认选项交还给DATETIME。
精度方面,DATETIME和TIMESTAMP都支持小数秒,DATETIME(3)存毫秒,DATETIME(6)存微秒。需要精确计时、流水排序的场景,用带精度的DATETIME,但要注意精度越高,存储和比较开销越大,没必要全表统一上微秒。
4.4 时区:一个让我排查了一整天的经典问题
时间类型最隐蔽的坑是时区。我接手过一个项目,数据库里存的是北京时间,但JDBC连接串时区参数设成了UTC,结果所有时间字段查询出来都慢了8小时。反馈到接口,用户看到的创建时间是凌晨,而实际是早上8点,查了半天才怀疑到连接时区。
正确的做法是:数据库服务器、应用服务器、连接串参数三处的时区口径完全统一。要么全用UTC,存储层不关心业务时区,展示层再转换;要么全用北京时间,连接串明确指定serverTimezone=Asia/Shanghai。最怕的是两边都设了一半,靠代码里东拼西凑的补偿逻辑去补,那迟早会漏。跨时区业务建议用TIMESTAMP存UTC,让数据库替你做时区换算;单一时区业务直接用DATETIME,简单可靠,时间逻辑完全由代码掌控。
5. 实战选型清单:这些场景我踩过的类型坑
前面把三大类类型讲完,这一章直接给结论。很多字段类型的问题,是业务建模时就能想清楚的,我把自己这些年常用的选型清单整理出来,可以直接抄作业。
5.1 布尔、状态、枚举
布尔字段:用TINYINT(1),只存0和1,字段名叫is_xxx,注释写明含义。状态字段:用TINYINT UNSIGNED,0到255足够绝大多数业务状态,配合代码里的枚举类做映射。不要为了省那一个字节把多个布尔字段压进一个BIT字段再用位运算,代码可读性会变得极差,排查问题时的痛苦远超省下的那点空间。
5.2 手机号、身份证、银行卡号
手机号固定11位,用CHAR(11)。身份证固定18位,用CHAR(18)。银行卡号虽然现在大多是16到19位,但不同卡组织规则不完全一样,而且有的场景会预留扩展位,用VARCHAR(32)更稳妥。
这里特别强调:不要用BIGINT去存这些号码。虽然手机号恰好是数字,看起来能放下,但身份证18位已经超过BIGINT的精度上限了,BIGINT存身份证会丢精度,取出后最后几位变成0,这个坑我在面试题里经常看到,也在真实项目里见过。正确思路是:凡是"看起来是数字、但你不打算对它做加减乘除"的标识符,都按字符串处理。
5.3 金额、数量、百分比
金额用DECIMAL,整数位和小数位都留足。普通交易订单,DECIMAL(10,2)够用;大额转账、企业结算,DECIMAL(20,2)更稳。数量、库存、计数器这类字段,用BIGINT,自增累加不用担心溢出。百分比、折扣、利率,用DECIMAL(5,2)或DECIMAL(10,4),看精度要求,但别用FLOAT。
用FLOAT存金额是我最想劝退的行为,没有之一。哪怕只是一分钱的误差,到了对账环节也会变成灾难。数据库里钱相关字段的精确性,是任何性能优化都换不回来的。
5.4 IP地址和其他杂项
IPv4建议用VARCHAR(15),IPv6用VARCHAR(45),这样可读性好,排查日志方便。如果日志表数据量极大、对空间敏感,IPv4也可以转换为INT UNSIGNED存储,MySQL提供INET_ATON和INET_NTOA函数做整数和IP的互转,但会牺牲一部分可读性,需要权衡。
JSON字段在MySQL 5.7之后可用,8.0已经比较成熟。适合存储结构不固定、不常作为查询条件的半结构化数据,比如第三方接口回调的原始报文。但别把核心查询字段只放在JSON里,JSON字段上的索引能力比普通列弱很多,需要经常按JSON内部字段过滤的业务,应该把该字段抽成普通列并建索引。
5.5 顺带回应几个高频搜索词
网上经常有人搜"modbus数据类型长度默认为多长"、"威纶通如何新增local hmi数据类型",这些是工业组态和PLC通信里的概念,跟MySQL数据类型完全不是一回事。遇到这些问题时,不要把Modbus寄存器类型和数据库类型的长度概念混在一起,它们分别是两套完全独立的体系。
还有一句"firedac phys mysql client does not support authentication protocol requested",这是Delphi的FireDAC连接MySQL时的认证协议不匹配问题,常见于MySQL 8.0默认的caching_sha2_password认证插件和旧版客户端不兼容。解决办法通常是调整MySQL用户的认证插件为mysql_native_password,或者升级客户端驱动。这不是数据类型问题,但很多人在搜类型问题时误入,顺便提醒一句。
6. 类型转换与隐式转换陷阱:索引失效的隐形杀手
最后一个大主题,也是慢查询排查里最容易被忽略的一环:类型转换。很多SQL语句看起来没有任何问题,EXPLAIN一执行却走全表扫描,原因就是触发了隐式转换。
6.1 什么是隐式转换,MySQL会怎么做
当SQL表达式中出现了不同类型的数据,MySQL会根据规则自动把一方转成另一方。最典型的是字符串和数字的比较:字符串会被转换成数字。这种转换对结果的影响很隐蔽,因为即使字段是VARCHAR,里面存的也是纯数字,WHERE mobile = 13812345678照样能查出正确结果,问题在于这个比较让索引失效了。
为什么索引会失效?因为当列类型是VARCHAR、比较值是数字时,MySQL被迫对列应用类型转换后再比较,相当于在列上套了一个函数。一旦列被函数包裹,B+树索引就无法按原顺序定位,优化器只能放弃索引,选择全表扫描。
6.2 经典翻车现场:VARCHAR字段用数字查询
看这个例子:
sql复制-- 假设mobile是VARCHAR类型,建了索引
SELECT * FROM user WHERE mobile = 13812345678;
执行计划大概率是ALL,全表扫描。正确写法是:
sql复制SELECT * FROM user WHERE mobile = '13812345678';
字符串字面量和VARCHAR字段类型匹配,索引正常使用。这一个单引号的差别,可能就是毫秒和秒的差别。相似的情况还有:字段是DATETIME,你传了'2024-01-01',字符串能正常转成日期,索引能用;但如果你传的是20240101这种整数,或者对DATE字段做DATE_FORMAT再比较,索引又会失效。
我在联表JOIN里也踩过同样的坑。A表的id是BIGINT,B表的user_id是VARCHAR,JOIN条件写成ON A.id = B.user_id,MySQL会把VARCHAR转成数字,B表的user_id索引全废,驱动表和被驱动表的数据量一大,查询直接卡死。解决办法就一条:让关联字段的类型保持完全一致,字符集排序规则也一致。
6.3 显式转换:CAST和CONVERT怎么用
需要主动转换类型时,用CAST或CONVERT。语法分别是:
sql复制SELECT CAST('123' AS SIGNED); -- 结果是整数123
SELECT CONVERT('2024-01-01', DATE); -- 结果是日期类型
SELECT CAST(amount AS CHAR); -- 数字转字符串
显式转换的意义在于,让写SQL的人明确知道类型变换的目标,避免产生歧义。但要注意:显式转换用在索引列上,同样会让索引失效。比如WHERE CAST(create_time AS DATE) = '2024-01-01',就是把create_time列套了CAST,索引没法用。正确做法是改成范围查询:
sql复制SELECT * FROM t_order
WHERE create_time >= '2024-01-01 00:00:00'
AND create_time < '2024-01-02 00:00:00';
这样create_time列本身没有函数操作,索引才能正常发挥。
6.4 排序规则不一致也是一种隐式转换
除了数值和字符串之间的转换,字符集排序规则不一致也会触发隐式转换。两个表,一个字段是utf8mb4_general_ci,另一个是utf8mb4_unicode_ci,JOIN比较时MySQL需要把其中一方的排序规则转成兼容形式,这个过程同样可能导致索引失效。
所以统一字符集和排序规则,不只是为了避免乱码,更是为了索引优化。建库、建表、建字段时,把character set和collation写到建表语句里,不要依赖默认值在各环境下漂移,这是成本最低又最有效的预防措施。
结尾的几句大实话
我现在建表已经形成了固定习惯,拿到任何一个字段先问自己四个问题:这个字段最大可能的值是多少?它需要参与加减乘除吗?它会不会被拿来排序、过滤或JOIN?这张表预期要活多少年?四个问题想清楚了,类型基本不会选错。
前阵子接手一个老项目,里面用VARCHAR存日期、用FLOAT存金额、用INT存状态码,还都加了索引,乱成一锅粥。改类型又不敢动线上数据,只能靠不断打补丁的SQL去兼容,成本高得离谱。如果你正好在设计一张新表,趁数据量小,花两分钟把类型定准,这是回报率最高的一次设计决策。等数据上千万再想改,无论你多小心,都要脱一层皮。
