先问一个几乎所有刚学 MySQL 的人都会踩过的问题:为什么我把一个数存进表里,报了个 Out of range value?又或者:别人建表时写 int(11),int 后面那个 (11) 到底是什么意思,是说我最多只能存 11 位数吗?如果这两个问题你脑子里还没能脱口而出答案,那这篇文章就是为你准备的。
建表是数据库的第一步,而建表的核心就是"给每一列选对数据类型"。你可能听说过一句业内老话:数据库选错类型,比选错值得算错还要难受。因为数据类型一旦确定,存储空间定死了、取值范围定死了、能不能做索引、能不能比大小搜索,甚至未来要不要迁移重构,全都在这一刻被决定了。今天我们把 MySQL 里最常见的那几类数据类型翻个底朝天:整数、bit 位、小数(float 和 DECIMAL)、字符串(CHAR / VARCHAR / TEXT / BLOB)、日期时间,还有两个容易被忽略的枚举家族 ENUM 和 SET。
我会尽量掰开揉碎地讲,把那些"课件上一个字带过、实际开发里坑你最狠"的细节也一并讲清楚。文中出现的所有命令,都请你亲手在 MySQL 里敲一遍——有些坑是只看文字永远体会不到的,必须亲眼看到报错你才会记住。
数据类型的四大分类
MySQL 的数据类型大致可以分成这么几大家族,我们先把地图铺开:
- 数值类型:整数(TINYINT、SMALLINT、MEDIUMINT、INT、BIGINT)、BIT 位类型、小数(浮点 FLOAT/DOUBLE、定点 DECIMAL);
- 字符串类型:CHAR、VARCHAR,以及用于存大文本的 TEXT 系列、存二进制数据的 BLOB 系列;
- 日期时间类型:DATE、DATETIME、TIMESTAMP、TIME、YEAR;
- 枚举与集合类型:ENUM、SET。
这张分类图是我们要一直装在脑子里的索引,后面每一节都是往这个框架里填肉。也请你留意:不同的类型,占用的磁盘字节数不同、能表示的范围不同、支持的运算不同,这是所有"坑"的源头。
整数类型:从 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 |
先解释一个术语。有符号(SIGNED)表示这个数允许存负数,取值范围一半是负数一半是非负数;无符号(UNSIGNED)表示"只允许非负数",取值范围整体从 0 开始往上抬。默认情况下,MySQL 的整型是有符号的。怎么看出来?你会发现每种类型的有符号范围,负数端点正好是正数端点再减一,这是因为二进制里最高位被"符号位"占掉了一个,剩下的才能表示数的绝对值。一个字节能表示 256 个不同的值:有符号时是 -128 到 127,无符号时是 0 到 255。
TINYINT 的越界实验
我们用 TINYINT 来做第一个"亲手踩坑"实验。它只有 1 个字节,有符号范围是 -128 到 127:
-- 建一张只有一个 tinyint 列的表
create table tt1 ( num tinyint );
-- 第 1 步:插入 1,完全在范围内,成功
insert into tt1 values(1);
-- 第 2 步:插入 128,已经超出有符号上限 127,直接报"越界"
insert into tt1 values(128);
-- 报错:ERROR 1264 (22003): Out of range value for column 'num' at row 1
-- 看看表里现在是什么
select * from tt1;
-- +------+
-- | num |
-- +------+
-- | 1 |
-- +------+注意看报错的编号 ERROR 1264 (22003)。这个 (22003) 是 SQL 标准的错误码,22003 对应的就是 "numeric value out of range",翻译成人话就是"数值超出范围"。新手想排查"为什么插不进去",第一反应往往去查是不是 SQL 写错了,其实先看范围更高效。
无符号:用 UNSIGNED 关掉负数
如果你确定某个字段永远不需要负数,可以用 UNSIGNED 把它变成无符号的,从而把这块存储空间全部用来表示非负数:
-- 建一个无符号的 tinyint:范围 0 ~ 255
create table tt2 ( num tinyint unsigned );
-- 插入 -1:无符号不允许负数,越界,报错
insert into tt2 values(-1);
-- 报错:ERROR 1264 (22003): Out of range value for column 'num' at row 1
-- 插入 255:无符号上限正是 255,成功
insert into tt2 values(255);
select * from tt2;
-- +------+
-- | num |
-- +------+
-- | 255 |
-- +------+你看,同样是 TINYINT,加了 unsigned 后上限从 127 变成了 255。这个差别在真实场景里很有用,比如"年龄"字段,年龄不可能为负,用 tinyint unsigned 就能在用一个字节的前提下把范围上限从 127 抬到 255。
关于整数显示宽度 M 的经典误区
很多老教程里会这么建议:"ID 用 INT(11),因为要给负号留一位。"这其实是流传最广的误解之一。我们需要澄清一个重要的知识点:对整数类型来说,括号里的那个 M 只表示"显示宽度"(display width),它既不占用存储、也不限制能存多少位数、更不改变取值范围。
-- INT(1) 和 INT(11) 占的都是 4 字节,范围都是 -2147483648 ~ 2147483647
create table t_a ( a int(1) );
create table t_b ( b int(11) );
-- 往 int(1) 里插入一个 123456,照样存得进去,没有任何限制
insert into t_a values(123456);
select * from t_a; -- 输出 123456,完整显示也就是说,int(1) 里存 123456 毫无问题,它不会被截成 1 位数。这个 M 只在极少数界面上(配合 ZEROFILL 前导补零显示)才有意义,而且从 MySQL 8.0.17 开始,整数显示宽度属性已经被官方标记为废弃(deprecated),未来的版本会彻底移除。所以现代建表时,整数类型的括号通常直接省略,写成 INT、BIGINT 就好。如果你在旧项目的建表语句里看到 int(11),它通常只是历史习惯,不代表任何范围上的含义。(此条依据 MySQL 官方文档 13.1.6 节"数值类型属性"核对。)
UNSIGNED 的正确使用姿势
这里要专门讲一讲 UNSIGNED 这个决定,因为它是个"双刃剑"。课件里有一句非常值得动脑的话,我把它的逻辑完整展开给你:
尽量不使用 unsigned,对于 int 类型可能存放不下的数据,int unsigned 同样可能存放不下,与其如此,还不如设计时,将 int 类型提升为 bigint 类型。
这句话的推理是这样的:假设你要存一个数,它的绝对值可能超过 21 亿。这时你会想"那我用 INT UNSIGNED,范围到 42 亿多,应该够了吧"。但问题来了——无符号整数的范围是"从 0 开始往上",它不是把有符号的负数部分挪过来用,而是整体抬升。如果业务数据的绝对值接近但大于 21 亿,用 INT UNSIGNED 当然能塞下大正数;可一旦某个数据是负数,或者上限依然不够,INT UNSIGNED 照样手足无措。而且更隐蔽的是,无符号列去跟有符号列、或者跟普通变量做减法运算,很容易因为"一正一无符号"的类型混搭产生微妙的结果问题。
所以更"稳"的做法是:与其费劲地给 INT 加符号限制去抠那一丁点上限,不如直接升级成 BIGINT,把空间和心智都留足。 磁盘上一个字节的事,远没有"数据塞不进去导致线上事故"来得贵。除非你非常确信"这个列永远非负,且数值用不到太大",否则默认用有符号即可。另外提一句:从 MySQL 8.0.17 起,UNSIGNED 用在 FLOAT、DOUBLE、DECIMAL 上也被标记为废弃,官方建议改用 CHECK 约束来表达"非负"这类业务规则。
BIT 位字段类型
BIT(M) 是一个有点另类的类型,它按"位"来存,专门用来表示二进制位。语法是 bit(M),其中 M 表示每个值占多少位(二进制位,bit),范围是 1 到 64;如果省略 M,默认为 1。
-- 建一张表:一个整数 id,一个 8 位的 bit 字段 a
create table tt4 ( id int, a bit(8) );
-- 插入 (10, 10)
insert into tt4 values(10, 10);
-- 查询时你会觉得"见鬼了":a 明明是 10,怎么显示出来是空的?!
select * from tt4;
-- +------+------+
-- | id | a |
-- +------+------+
-- | 10 | | -- 这里的空,其实是"看不见的字符"
-- +------+------+这个"怪异现象"背后是一个必须记住的知识点:bit 字段在显示时,是按 ASCII 码对应出的字符来显示的。也就是说,a 里存的 10,MySQL 会把它当作 ASCII 码 10 去查表——ASCII 码 10 对应的恰好是一个看不见的控制字符(换行 LF)。所以你看到的"空",其实是一个不可见字符。
再往上加一个值,现象就清楚了:
-- 再插入 (65, 65)
insert into tt4 values(65, 65);
select * from tt4;
-- +------+------+
-- | id | a |
-- +------+------+
-- | 10 | | -- ASCII 10:不可见控制字符
-- | 65 | A | -- ASCII 65:大写字母 A,显示出来了
-- +------+------+因为 ASCII 码 65 正好是大写字母 A,所以 a 列显示成了 A。这个"显示被翻译成字符"的行为,让 bit 类型的 select 结果经常很反直觉——不是它坏了,是它本来就不太适合"拿来给人看"。
那 bit 到底在什么时候是真有用的?最常见的一个用法是布尔标志:当某个字段只存 0 或 1 时,可以定义为 bit(1),用 1 个二进制位表示"真/假",比用一个 tinyint 还省空间。但要注意,即使只存 0/1 的 bit(1),也别随便往里面塞 2、3 之类的值:
-- 用 bit(1) 建一个存储"性别/开关"的字段
create table tt5 ( gender bit(1) );
insert into tt5 values(0); -- 0:bit(1) 能表示 0
insert into tt5 values(1); -- 1:bit(1) 能表示 1
insert into tt5 values(2); -- 2 需要 2 个二进制位才能表示,bit(1) 装不下,越界!
-- 报错:ERROR 1406 (22001): Data too long for column 'gender' at row 1这里报的错误编号变成了 ERROR 1406 (22001)——22001 表示"字符串或二进制数据对于该列来说太长"。虽然是 bit,但它同样有自己"一咬牙就装不下"的上限。一句话总结:bit 按位存,显示按 ASCII 翻译,真正的用武之地是像 bit(1) 这样的布尔开关,别拿它存普通整数给用户看。
小数类型:FLOAT 浮点 与 DECIMAL 定点
讲到小数,MySQL 里有两个大方向:浮点(FLOAT、DOUBLE)和定点(DECIMAL)。这里的"浮"和"定"是一对非常重要的概念,我们先把术语立起来:
- FLOAT / DOUBLE 是浮点数,意思是小数点的位置是"浮动"的。它用二进制近似的思维来存一个数,能表示的范围很大,但精度有限——很多十进制小数无法用二进制精确表示,存进去、读出来会有微小的误差。
- DECIMAL 是定点数(fixed-point),意思是小数点固定在某一位。它模仿人类手算那样,把整数部分和小数部分分开精确存储,精度很高,而且速度也够用。
FLOAT(M, D)
FLOAT 的语法常见写法是 float[(m, d)]。这里的 M 表示该数的总位数(显示长度),d 表示小数部分的位数。它一共占用 4 个字节。
先看一个非常有代表性的实验:
-- 建一张表:一个 id,一个 salary,float(4,2)
-- float(4,2):总共 4 位,其中 2 位是小数,即整数部分最多 2 位
create table tt6 ( id int, salary float(4,2) );
-- 插入 -99.99:整数部分两位(99),小数部分两位(99),正好 4 位
insert into tt6 values(100, -99.99);
-- 插入 -99.991:超过 4 位了,多出来的那点小数被"拿掉"(四舍五入)
insert into tt6 values(101, -99.991);
select * from tt6;
-- +------+--------+
-- | id | salary |
-- +------+--------+
-- | 100 | -99.99 |
-- | 101 | -99.99 | -- -99.991 被四舍五入成 -99.99
-- +------+--------+这里抓两个重点:
float(4,2)表示的范围是 -99.99 ~ 99.99。为什么?因为总共 4 位里 2 位给了小数,剩下 2 位给整数,整数部分最多 99。别忘了它默认是有符号的,所以负方向能到 -99.99。- MySQL 保存 float 小数时会进行四舍五入。第二个插入的
-99.991因为超出精度,被舍入(这里正好是"多的这点被拿掉"变成了 -99.99)。这意味着 float 不会"编译报错"式地拒收,而是悄悄做了舍入,这种"静默处理"反而更容易让人忽略数据失真。
在前面 TINYINT 那一节我让你记过无符号的作用,这里 FLOAT 也有同样的玩法:如果定义的是 float(4,2) unsigned,因为它被指定成无符号,范围就变成了 0 ~ 99.99(负数部分被"没收")。无符号对小数类型的影响有一个和历史不同的点:跟整数不同,浮点/定点类型的 UNSIGNED 并不同时抬高正上限,它只是单纯禁止负数(整数无符号会把正上限翻倍,小数无符号不会)。这一点容易跟整型的"无符号"混淆,特别提醒一下。
来验证一下无符号浮点的行为:
-- 无符号的 float(4,2):范围 0 ~ 99.99
create table tt7 ( id int, salary float(4,2) unsigned );
-- 插入 -0.1:无符号,负数不该被存进去
-- 但因为它是"越界"(超出下界 0),MySQL 此时只给出警告,而不是硬报错
insert into tt7 values(100, -0.1);
-- 出现:1 warning,用 show warnings 可查看详情
show warnings;
-- +---------+------+--------------------------------------------------+
-- | Level | Code | Message |
-- +---------+------+--------------------------------------------------+
-- | Warning | 1264 | Out of range value for column 'salary' at row 1 |
-- +---------+------+--------------------------------------------------+
insert into tt7 values(100, -0); -- -0 等于 0,在范围内
insert into tt7 values(100, 99.99); -- 99.99 正好是上限
select * from tt7;注意到这里的差别了吗:整数越界是硬报错(ERROR 1264),浮点无符号在"负的小数越界"时可能退化成警告(Warning)而不是中止。这是因为越界值在小数舍入后落回了范围内,MySQL 判断"可以救回来",于是只给个警告。这种"报错 vs 警告"的差异,是越界坑里最容易被忽略的变种——在严格模式下看警告、在宽松模式下甚至可能连警告都没有,这就是为什么选错类型的影响是"润物细无声"的。
一个小小的范围推演
课件里留了一个思维题:float(4,2) 有符号时范围是 -99.99 ~ 99.99,那么 float(6,3) 的范围是多少?我们先记住方法,后面答案区再验证。
为什么浮点不能用来算钱:FLOAT 与 DECIMAL 的精度对决
前面铺垫了那么多,现在到重头戏了。先看一个教科书级的对比实验——同样存 23.12345612,FLOAT(10,8) 和 DECIMAL(10,8) 给出的结果天差地别:
-- 同一份数据,分别存进 float 和 decimal
create table tt8 (
id int,
salary float(10,8), -- 浮点:总共 10 位,8 位小数
salary2 decimal(10,8) -- 定点:同样 10 位、8 位小数,但精度不同
);
insert into tt8 values(100, 23.12345612, 23.12345612);
select * from tt8;
-- +------+-------------+-------------+
-- | id | salary | salary2 |
-- +------+-------------+-------------+
-- | 100 | 23.12345695 | 23.12345612 | -- float 变味了,decimal 精确
-- +------+-------------+-------------+看到没有:salary(float)把 23.12345612 存成了 23.12345695,小数位已经开始"漂移";而 salary2(decimal)原封不动地保存了 23.12345612。这就是浮点与定点最本质的差别——float 的精度大约只有 7 位有效数字,超过的部分就是二进制近似在捣乱;而 decimal 能精确表示小数,做金钱、汇率这类容不得一分钱误差的数据时,必须用 DECIMAL。
关于 DECIMAL 的可配置范围,有四个数字要记牢:
- DECIMAL(M, D) 中 M 是总位数(整数位+小数位),最大为 65;
- D 是小数位数,最大为 30;
- 如果 D 被省略,默认是 0(即默认没有小数部分);
- 如果 M 也被省略,默认是 10。
举个例子,decimal(5,2) 的总位数是 5、小数 2 位,所以整数部分最多 5-2=3 位,表示的范围是 -999.99 ~ 999.99;如果是 decimal(5,2) unsigned,范围则是 0 ~ 999.99。
给一个把整章串起来的选型建议:只要关系到钱、余额、费率、坐标精度这类"差了 0.01 都出事"的数据,坚定地选 DECIMAL。 而 FLOAT/DOUBLE 适合用在"对精度不敏感、但需要大范围或超高性能计算"的场景。数据表不是草稿纸,宁可选得精确一点,也别让系统在不知情的情况下帮你偷偷"四舍五入"。
字符串类型:CHAR 定长
字符串是字符数据的集装箱。MySQL 里最常用的是 CHAR 和 VARCHAR 这一对,它们俩的区别,是所有数据库面试题里出镜率最高的之一。
CHAR(M) 是定长字符串:CHAR(L) 中的 L 表示可以存储的长度,单位是"字符"(不是字节)。含义是"我固定给你预留 L 个字符的位置,不管你实际存多短,一个长长的格子已经划好了"。它允许的最大长度值是 255。
-- char(2):固定能放 2 个字符,无论是字母还是汉字都按"字符"计
create table tt9 (
id int,
name char(2)
);
insert into tt9 values(100, 'ab'); -- 2 个字母,OK
insert into tt9 values(101, '中国'); -- 2 个汉字,OK
select * from tt9;
-- +------+------+
-- | id | name |
-- +------+------+
-- | 100 | ab |
-- | 101 | 中国 | -- 汉字也是按"字符"计数
-- +------+------+这里最容易被新手误解的点是:CHAR 的 L 是"字符数",不是字节数,而且中英文一视同仁。 所以 char(2) 既可以放 ab,也可以放 中国——都是两个字符。
那 char(2) 能放 3 个字符吗?不能,会越界。而 char(256) 呢?直接建表就建不出来。来看这个经典报错:
-- 尝试建 char(256):超过 char 上限 255,报错
create table tt10(
id int,
name char(256)
);
-- 报错:ERROR 1074 (42000): Column length too big for column 'name'
-- (max = 255); use BLOB or TEXT insteadMySQL 很贴心地提示你"列长度过大,最大 255,请改用 BLOB 或 TEXT"。这个报错本身就点明了两个关键信息:CHAR 上限是 255,以及当你想存超长文本时,该转向 TEXT/BLOB 而不是死磕 CHAR。
字符串类型:VARCHAR 变长
VARCHAR(M) 是变长字符串:VARCHAR(L) 中的 L 表示"最大字符长度",但它是"用多少,占多少"——不像 CHAR 那样一开始就把整块空间划死。它最大的长度按字节算可以达到 65535 个字节。
-- varchar(6):最多放 6 个字符
create table tt11(
id int,
name varchar(6)
);
insert into tt11 values(100, 'hello'); -- 5 个字符,在 6 以内,OK
insert into tt11 values(101, '中国'); -- 2 个字符,OK
select * from tt11;
-- +------+-------+
-- | id | name |
-- +------+-------+
-- | 100 | hello |
-- | 101 | 中国 |
-- +------+-------+
-- 要是硬塞 7 个字符的 '我爱你,中国',会越界报错:
-- ERROR 1406 (22001): Data too long for column 'name' at row 1不过,varchar(6) 能放 6 个字符,不代表 varchar(n) 可以随意把 n 拉到 65535。VARCHAR 的"65535"单位是字节,而这个 L 是字符数,两者之差就是第一个坑。
VARCHAR 长度与字符集的三层算术
这里要引入一个纯粹而关键的概念:字符集(charset)。字符集决定了"一个字符到底占几个字节"。我们只记最常用的两个:
- utf8(准确说是 utf8mb3):一个字符最多占 3 个字节;
- utf8mb4:一个字符最多占 4 个字节(MySQL 8 的默认字符集,能存 Emoji 这类补充字符)。
VARCHAR(n) 里那个 n 是"最多能放的字符数",那么:
该字段最多能用的存储字节数 = n × 每字符最大字节数
而 MySQL 一行数据(所有列加起来)的总长度上限是 65535 字节(TEXT/BLOB 除外,它们的实际内容单独存放)。再加上 VARCHAR 变长字符串通常会用 1~2 个字节额外记录"这串数据实际有多长"。于是有效可用的最大字节数大约是 65532。把这三个数凑到一起:
| 表的字符集 | 每字符最大字节 | VARCHAR(n) 的 n 最大值 |
|---|---|---|
| utf8 / utf8mb3 | 3 | 65532 ÷ 3 = 21844 |
| utf8mb4 | 4 | 65532 ÷ 4 = 16383 |
| gbk | 2 | 65532 ÷ 2 = 32766 |
| latin1(单字节) | 1 | 65535 左右 |
课件里的推导是"utf8 下 n 最大 21844、gbk 下 32766",我们一起亲自验证一遍:
-- 尝试建一个 utf8 的 varchar(21845):超了,报错
create table tt12( name varchar(21845) ) charset=utf8;
-- 报错:ERROR 1118 (42000): Row size too large. The maximum row size
-- for the used table type, not counting BLOBs, is 65535.
-- 改成 varchar(21844) 就成功了:正好卡在 utf8 的上限
create table tt12( name varchar(21844) ) charset=utf8;
-- Query OK注意我再次强调一个现代环境的坑:上面演示的 21844 是对"utf8"(即 utf8mb3)而言的。 如果你用的是 MySQL 8 默认的 utf8mb4,同样求一个极限,varchar(n) 里 n 的最大值会缩到 65532 ÷ 4 = 16383。这也是很多老教程能建出来的表、你在新库上却怎么都建不出来的原因之一——别把"utf8 上限 21844"这个数盲目套到 utf8mb4 库上。当你真的需要超大文本、而 VARCHAR 的 65535 字节不够用时,就该升级到 TEXT 系列了(TINYTEXT / TEXT / MEDIUMTEXT / LONGTEXT),它们不受这一行 65535 字节的总限制。
还要补一个隐藏加分项:NULL 也是要占字节的。同一个 VARCHAR 列,NOT NULL 比允许 NULL 时能多给出一丁点实际可用的字符上限(大约多 1 个字节)。对日常使用影响不大,但当你卡在极限边界时,这是压死骆驼的最后一根稻草。(此条依据 MySQL 官方文档 13.3.1 节关于字符集与行大小上限的规则核对。)
CHAR 与 VARCHAR:一张表看清怎么选
把这两个概念放在一起对比,是理解它们的最快路径:
| 对比维度 | CHAR(定长) | VARCHAR(变长) |
|---|---|---|
| 含义 | 固定长度,提前把 M 个字符的空间全部开好 | 变长,用多少字节开多少 |
| 存储占用量 | 固定,总是占满声明长度 | 只占实际内容的字节 + 长度前缀 |
| 磁盘空间 | 相对浪费(总是预分配) | 相对节省 |
| 存取效率 | 高(位置固定,直接偏移定位) | 低一些(要按长度前缀去解析) |
| 典型适用 | 身份证、手机号、MD5、固定编码 | 姓名、地址、标题等长短不一的文本 |
怎么选,记住这一句就够:如果数据长度基本恒定(身份证号 18 位、手机号 11 位、MD5 固定 32 位),用定长 CHAR;如果长度本身波动很大(姓名、地址、简介),用变长 VARCHAR,但要保证最长的值也塞得进你声明的 M。 CHAR 的代价是"固定开辟空间、存短了也浪费",换来的是"直接按位置存取、效率高";VARCHAR 的代价是"要花点功夫按长度解析",但换来的是"按需分配、省空间"。
底层原因说透一层:CHAR 建表时就一次性把空间铺好,取数据时按固定偏移即可,所以它"效率高但浪费";VARCHAR 只在写入时按实际长度分配,所以它"省空间但要多一道长度解析的活"。
日期时间类型
保存时间,MySQL 主要有这几个类型,它们各有分工:
| 类型 | 占用字节 | 表示格式 | 范围 |
|---|---|---|---|
| DATE | 3 | yyyy-mm-dd | 1000-01-01 ~ 9999-12-31 |
| DATETIME | 8 | yyyy-mm-dd HH:mm:ss | 1000-01-01 00:00:00 ~ 9999-12-31 23:59:59 |
| TIMESTAMP | 4 | yyyy-mm-dd HH:mm:ss | 1970-01-01 00:00:01 UTC ~ 2038-01-19 03:14:07 UTC |
| TIME | 3 | HH:mm:ss | -838:59:59 ~ 838:59:59 |
| YEAR | 1 | yyyy | 1901 ~ 2155 |
逐个解释一下术语:
- DATE:只存"年月日",格式
'1997-07-01',占用 3 字节; - DATETIME:存"年月日时分秒",格式
'2008-08-08 12:01:01',占用 8 字节,能表达的范围从 1000 年一直到 9999 年; - TIMESTAMP(时间戳):字面意思是从某个"纪元时刻"开始计算经过的秒数,它的显示格式和 DATETIME 完全一致,但底层存的是 4 字节的秒级计数值,从 1970 年开始。它有个著名的 2038 年问题:4 字节有符号整数能数到 2038 年 1 月 19 日,之后就会溢出——超过这个时间的数据,用 TIMESTAMP 可能存不下,而 DATETIME 能一路用到 9999 年。所以"历史深远/未来远长"的数据,往往更适合 DATETIME。
先看插入:
-- 建一张表,三个时间列:date、datetime、timestamp
create table birthday (
t1 date,
t2 datetime,
t3 timestamp
);
-- 只显式插入前两个,t3 交给系统自动处理
insert into birthday(t1, t2) values('1997-7-1', '2008-8-8 12:1:1');
select * from birthday;
-- +------------+---------------------+---------------------+
-- | t1 | t2 | t3 |
-- +------------+---------------------+---------------------+
-- | 1997-07-01 | 2008-08-08 12:01:01 | 2017-11-12 18:28:55 |
-- +------------+---------------------+---------------------+注意两点:第一,输入 '1997-7-1' 这种"不标准的单数字月份",MySQL 会帮你规范化成 1997-07-01;第二,没有给值的 timestamp,会自动用"当前时间"补上,这是 TIMESTAMP 的一个自动特性。
再来看更新时的自动行为:
-- 更新 t1,观察 timestamp 是否跟着变
update birthday set t1 = '2000-1-1';
select * from birthday;
-- +------------+---------------------+---------------------+
-- | t1 | t2 | t3 |
-- +------------+---------------------+---------------------+
-- | 2000-01-01 | 2008-08-08 12:01:01 | 2017-11-12 18:32:09 |
-- +------------+---------------------+---------------------+看到 t3 从 18:28:55 变成了 18:32:09 吗?这就是 TIMESTAMP 的另一个自动行为:当这一行数据被更新时,timestamp 会自动刷新成当前时间。 很多表设计利用这个特性来做"更新时间(auto-update)"字段,等于一个免费的"最近改动时间"记录器。
DATETIME 和 TIMESTAMP 怎么选? 记住三条就够:都既能存年月日时分秒;TIMESTAMP 占 4 字节更省空间、还能自动维护"这一行最后被修改的时间";但 TIMESTAMP 只到 2038 年,而且涉及时区换算(存的是 UTC 相对秒数),如果你的数据可能跨越 2038 或者强依赖"originally 存进去的字符串值原样不差",用 DATETIME(8 字节)更稳妥。
ENUM 枚举:单选
ENUM(枚举)是一种"单选"类型:它在一组给定的选项里,最终一个字段只存储其中某一个选项。语法如下:
enum('选项1', '选项2', '选项3', ...)它的取名字面就是"从这一串里枚举其一"。这里有一个很重要的内部原理:出于效率考虑,ENUM 实际在磁盘上存储的是"数字"而不是字符串——你声明的那些选项,会依次被映射成序号 1、2、3……直到最多 65535 个选项。也就是说,你看到的"男 / 女"背后,对应的是 1 / 2 两个数字,存下去的是一个很小的整数,取出来再翻译回文本。
这样的设计给 ENUM 带来一个特殊的能力,也带来一个大坑:插入枚举时,除了写文本,还可以直接按下标数字来写:
-- 建一张投票调查表
create table votes (
username varchar(30), -- 用户名
hobby set('登山','游泳','篮球','武术'), -- 爱好:多条,用 set(后面讲)
gender enum('男','女') -- 性别:单条,用 enum
);
-- 方式一:显式写文本选项
insert into votes values('雷锋', '登山,武术', '男');
-- 方式二:直接用数字!gender 那里写 2,而 enum 的序号从 1 开始,
-- 1 对应'男',2 对应'女'。想表达'女'就可以写 2。
insert into votes values('Juse', '登山,武术', 2);
-- 查询紧跟 gender 为 2(即'女')的人
select * from votes where gender = 2;
-- +----------+---------------+--------+
-- | username | hobby | gender |
-- +----------+---------------+--------+
-- | Juse | 登山,武术 | 女 |
-- +----------+---------------+--------+注意 ENUM 是标准的"数组下标"思维:序号从 1 开始,1 是第 1 个选项,2 是第 2 个选项,以此类推,最多 65535 个选项。上面的 gender = 2 就精准命中了"女"。
但这里我必须给出一个和课件一致的忠告:不建议在插入枚举/集合时用数字编号。 因为数字编号对人和代码都不够友好,你看到 insert ... (2) 时第一反应是"2 是啥",还得去翻表定义才能对上号;一旦选项顺序调整,数字的意思就会跟着变,极易埋雷。程序员自己读得懂的文本选项,比那些"少打几个字"的数字要可靠得多。能在建表时写 '男','女',就不要在插入时写 1,2。
SET 集合:多选 与 find_in_set
SET(集合)是一种"多选"类型:它允许你在一组给定的选项里,同时存储任意多个选项。语法:
set('选项值1', '选项值2', '选项值3', ...)回到刚才那张投票表,hobby set('登山','游泳','篮球','武术') 就是多选——一个人的爱好可以同时是"登山"和"武术"。
SET 底层存储数字的方式和 ENUM 不一样,这是理解它的钥匙:SET 用"位图"来描述。它的选项依次对应二进制某一位上的数字:1、2、4、8、16、32……最多 64 个选项。想想 Linux 权限里的 rwx——把每个爱好当成"一个开关位",同时开的几个位,它们的数字加起来就是这个 SET 实际存进磁盘的数。所以 SET 是"多位叠加",而 ENUM 是"单点取一"。
这也解释了为什么 SET 不能用简单的等值查询去筛选。 我们面临一个需求:想找出所有爱好包含"登山"的人。直觉会先想到:
-- 直觉写法:直接比较 hobby 是否等于 '登山'
-- 但这样只会匹配出"唯一爱好就是登山"的人
select * from votes where hobby = '登山';
-- +----------+--------+--------+
-- | username | hobby | gender |
-- +----------+--------+--------+
-- | LiLei | 登山 | 男 |
-- +----------+--------+--------+看出来了吗?凡是"登山和其他爱好组合在一起"的人(比如雷锋是 登山,武术),全被漏掉了。 因为 hobby = '登山' 是"整列精确等于",而 SET 里存的是多种组合。这是 SET 最著名的坑:对 SET 用等值比较,是筛不全的。
正确的筛选姿势是使用 MySQL 内置函数 find_in_set。它的语法是 find_in_set(sub, str_list):如果 sub 恰好是 str_list 中以逗号分隔的某一项,就返回它的下标(从 1 开始);如果不在,则返回 0。
-- 看 find_in_set 的基本行为
select find_in_set('a', 'a,b,c'); -- 'a' 在第 1 位,返回 1
-- +---------------------------+
-- | find_in_set('a', 'a,b,c') |
-- +---------------------------+
-- | 1 |
-- +---------------------------+
select find_in_set('d', 'a,b,c'); -- 'd' 不在列表里,返回 0
-- +---------------------------+
-- | find_in_set('d', 'a,b,c') |
-- +---------------------------+
-- | 0 |
-- +---------------------------+于是,正确地"找出所有爱好包含登山的人"应该这样写:
-- 用 find_in_set 判断:'登山' 是否是 hobby 列中参与逗号切分的某一项
select * from votes where find_in_set('登山', hobby);
-- +----------+---------------+--------+
-- | username | hobby | gender |
-- +----------+---------------+--------+
-- | 雷锋 | 登山,武术 | 男 |
-- | Juse | 登山,武术 | 女 |
-- | LiLei | 登山 | 男 |
-- +----------+---------------+--------+这次,雷锋、Juse(都是"登山,武术")以及 LiLei(单独登山)都被查出来了。一句总结:对多选的 SET,用 find_in_set(a, hobby) 来判断"包含某选项",用 hobby = '登山' 只能匹配"恰好等于"。
最后再统一回答一个概念级的问题,帮你把两个熟悉又容易搞混的类型彻底分清:
| 对比维度 | ENUM 枚举 | SET 集合 |
|---|---|---|
| 语义 | 单选,一件东西 | 多选,可多件 |
| 底层存数字方式 | 数组下标 1,2,3… | 位图 1,2,4,8… |
| 最大选项数 | 65535 | 64 |
| 查询"等于某个选项" | where col = '选项' | where find_in_set('选项', col) |
| 与 Linux 权限类比 | 相当于"只能选一种权限" | 相当于 rwx 叠加 |
总结:选型一句话
整篇文章其实都在回答一个问题:给字段选类型时看图说话,别闭着眼猜。
把全章要点浓缩成一张速查表,供你回忆和做判断:
| 你面对的数据 | 首选类型 | 关键理由 |
|---|---|---|
| 自增主键 / 很大整数 | BIGINT | 留足空间,避免将来溢出 |
| 年龄、标志、小整数 | TINYINT / SMALLINT | 按真实范围,不过度不外扩 |
| 布尔开关 0/1 | bit(1) | 最省空间的真/假 |
| 金额、汇率、满分付费 | DECIMAL | 定点,精度不丢 |
| 百分比、范围粗糙的数 | FLOAT / DOUBLE | 精度不敏感时用浮点 |
| 长度固定的标识 | CHAR | 定长,效率高 |
| 长度不定的文本 | VARCHAR | 变长,省空间 |
| 超长文本 / 二进制 | TEXT / BLOB | 突破 65535 字节行上限 |
| 时间点 | DATETIME / TIMESTAMP | 看时区与 2038 需求 |
| 单选固定选项 | ENUM | 单选、省空间 |
| 多选固定选项 | SET | 多选、位图存储 |
选型的底层心法只有两条,却足以解决 90% 的纠结:
- 看范围:预估这个数据最大能到多大、能不能为负,选适配的字节数,宁大勿小,因为它决定你将来会不会
Out of range; - 看精度:计算类、金额类数据秒选 DECIMAL 定点,千万别用浮点悄悄"舍入"掉真金白银。
别忘了那些"看不见的坑":整数括号里的 M 只是显示宽度、时间戳会自己动、SET 查询要配 find_in_set、VARCHAR 的长度要按字符集换算字节、越界可能是报错也可能是警告。把这些刻进脑子里,你建出来的表就不会在运行半年后突然给你凹一个线上事故。
思考题与详解答案
思考题 1:float(6,3) 有符号时的取值范围是多少?推演方法与结论都要给出。
模型:float(m, d) 中 m 是总位数,d 是小数位数,整数位数 = m - d,且默认有符号。已知 float(4,2) 范围是 -99.99 ~ 99.99,正负对称。方法一:整数位 = 4 - 2 = 2 位,即最多 99,故 ±99.99。方法二:关键是"整数位最多 m-d=2 位能表示的最大数是 99"。套用到 float(6,3):整数位 = 6 - 3 = 3 位,整数部分最多 999;小数 3 位,最多三位 9。因此范围是 -999.999 ~ 999.999。口诀:(m-d)个 9 决定整数上限,d 个 9 决定小数末尾,符号默认可负。
思考题 2:用 int(1) 建一个表,插入 123456,结果会是什么?为什么?
能正常存进去,查询时输出 123456。因为整数类型括号里的 M 只是"显示宽度",不影响存储范围,更不会把值截断成 1 位。int(1) 和 int(11) 占同样 4 字节、范围同样是 -2147483648 ~ 2147483647。显示宽度属性自 MySQL 8.0.17 起已被官方标记废弃,现代建表直接写 INT 即可。
思考题 3:一个 decimal(5,2) unsigned 的列,最大能存多少?想存 1000.00,这个列合适吗?
decimal(m,d) 有符号默认范围是 -(10^整数位 - 0.01) ~ (10^整数位 - 0.01)。decimal(5,2):整数位 = 5-2 = 3,即最大 999,总范围 -999.99 ~ 999.99。加 unsigned 后(对定点数)禁止负数,范围缩为 0 ~ 999.99。想存 1000.00 会越界(超出 999.99 上限),所以不合适,需要把 m 加大,例如 decimal(6,2)(整数位 4,可到 9999.99)才装得下。
思考题 4:一张 utf8 库的表里,单列 varchar(20000) 能建出来吗?如果是 utf8mb4 呢?
不能。utf8(utf8mb3)每个字符最多 3 字节,有效可用字节约 65532,单列最大字符数 = 65532 ÷ 3 = 21844,小于 20000,因此 varchar(20000) 已经超过 21844,会报 ERROR 1118 "Row size too large"。utf8mb4 更狠:65532 ÷ 4 = 16383,所以 utf8mb4 下 varchar(20000) 同样建不出来(实际上 20000 连 utf8 都过不了,utf8mb4 下极致也就 16383)。这道题真正的考点是 VARCHAR 的 L 是字符数、但受"每字符字节数 × 行大小 65535"双重约束。
思考题 5:select * from votes where hobby = '登山'; 查出的人,一定都是"只喜欢登山"的人吗?
是,而且这恰恰是坑。hobby = '登山' 是"整列字符串精确等于'登山'",所以只命中"唯一爱好就是登山"的行。像雷锋的 登山,武术 这种"登山和其他组合在一起"的数据会被漏掉。想"找出所有爱好包含登山的人",必须用 find_in_set('登山', hobby),因为它按逗号切分后精确匹配"某项是否包含某子选项",这才是多选 SET 的正确筛选姿势。
思考题 6:想让 TIMESTAMP 列在"每次该行被修改时"自动更新为当前时间,需要写什么代码吗?它直观表现是什么?
默认情况下,TIMESTAMP 列就具备"自动维护"的特性:插入未赋值时自动填当前时间;该行被 update 时自动刷新成当前时间(前面实验中 t3 从 18:28:55 变成 18:32:09 正是这个机制)。也就是说做一个"最近修改时间"字段,用 TIMESTAMP 通常连显式代码都不用写。但如果要更精细地控制(例如"只被显式更新时刷新、还是任意修改都刷新"),可以用 DEFAULT CURRENT_TIMESTAMP 和 ON UPDATE CURRENT_TIMESTAMP 这组特性显式声明。选择时记得权衡:TIMESTAMP 只到 2038 年、涉及时区,跨 2038 或希望绝对稳定原样的场景用 DATETIME 更稳。
到此,我们把 MySQL 里最常用的一整片数据类型地皮都翻了一遍:从 TINYINT 到 BIGINT 那一串"字节决定范围"的整数阶梯,到显示宽度 M 的迷思与 UNSIGNED 的双刃剑;从 BIT 位字段按 ASCII 显示的怪脾气,到 FLOAT 与 DECIMAL 那场精度对决;再走进 CHAR 与 VARCHAR 的定长与变长之争,看懂了 VARCHAR 长度里那笔字符集算账;接着跨进时间的世界,认识 DATE、DATETIME、TIMESTAMP 各自的脾气与那个绕不开的 2038;最后打了一个漂亮的多选与单选,用 ENUM 的数组下标和 SET 的位图,还有专门为 SET 而生的 find_in_set。
资料上常说"数据库是程序员和数据结构之间最真诚的关系",因为建表那一刻,你为每一个字段做的选择都会被诚实地兑现——也可能被诚实地报复。希望你把这堂课里那些"报错还是警告""能建还是不能建""查得出还是查不出"都亲手敲一遍,让 MySQL 用它的报错和结果,把这些坑烙进你的肌肉记忆。
学会了数据类型,下一站自然是真正让它运转起来:MySQL 的库表操作与字符集之谜。当你能熟练地建库、建表、增删改查,并且心里对每一列"该是什么类型、能存多大"都有数时,你那张表就既跑得动,也经得起时间。准备好了吗?
还没有评论 — 第一条由你来留。