数据类型只有两条规矩:装得下、不浪费。
装不下会报错或静默截断,浪费会一直摊在磁盘和内存上。
数字走整型,钱走 DECIMAL,字符看长度变不变,时间看要不要跨时区。
VARCHAR(10) 的 10 是字符不是字节;这条规矩救过很多存 emoji 的项目。
5.1 讲的是「表用什么引擎存」,这一篇讲「每一列用什么类型装」。 它是第 6 章建表的最后一环:表名、列名、类型、约束,四样凑齐才能建。 这一篇的坑都很贵:钱用 FLOAT 存、时间用字符串存、主键用 VARCHAR 存,改起来都得动全表。
一、数字:整数、定点、浮点三路
MySQL 的数字类型分成三路,走错路的代价不一样。 整数那一路最安全,浮点那一路最容易出「差一分钱」。
| 路 | 类型 | 存什么 | 准不准 |
|---|---|---|---|
| 整数 | TINYINT/SMALLINT/MEDIUMINT/INT/BIGINT | 没有小数点的数 | 精确 |
| 定点 | DECIMAL | 钱、必须精确的小数 | 精确 |
| 浮点 | FLOAT/DOUBLE | 测量值、坐标、科学计数 | 近似 |
五种整数的范围不用背,记住「n 个字节就是 8n 位」就能推。
有符号要砍掉一位当符号位,所以 TINYINT 是 -128 到 127。
UNSIGNED 不是「加个保险」,它会改变运算规则。
两个无符号数相减得到负数时,MySQL 8.0 直接报错,而不是给你一个负值。
所以「年龄、数量这些肯定非负的字段加 UNSIGNED」这个习惯要谨慎。
还有一个 8.0 的变化:INT(11) 这种括号里的数字,已经不代表长度了。
它只是历史遗留的「显示宽度」,8.0 起除 ZEROFILL 外一律忽略,写不写都一样。
看到老教程说「INT(11) 比 INT(10) 存得多」,那是错的,两者都是 4 字节。
二、小数:DECIMAL 求准,FLOAT 求快
先说清楚为什么会不准:二进制浮点存不了 0.1。 0.1 在二进制里是无限循环小数,只能存一个最近的近似值。 Demo 里真算了一次:0.1 加 0.2 得到 0.30000000000000004441。 换成十进制算就是 0.3,一位不差。
| 类型 | 字节 | 精度 | 用在哪 |
|---|---|---|---|
| FLOAT | 4 | 约 7 位有效数字 | 传感器读数、评分 |
| DOUBLE | 8 | 约 15 位有效数字 | 经纬度、科学计算 |
| DECIMAL(M,D) | 按 M 定 | 精确到第 D 位 | 金额、单价、税率 |
DECIMAL(8,2) 的两个数字要读对:一共 8 位,其中 2 位在小数点后。
所以整数部分只剩 6 位,最大 999999.99。
这个上限很容易踩:单价字段定成 DECIMAL(8,2),一开始卖几百块没问题,
等有一天要录一台几十万的设备,就报 1264 了。
还有一条顺序要知道:MySQL 是先按小数位四舍五入,再判断整数部分放不放得下。
所以 999999.999 不会安静地存进去,它会先进位成 1000000.00,然后因为越界报错。
三、字符串:CHAR、VARCHAR、TEXT 三家
这三家的分工很清楚:定长、变长、超长。 选错的代价从「浪费一点空间」到「整张表慢下来」都有。
| 类型 | 长度怎么写 | 实际占用 | 适合 |
|---|---|---|---|
| CHAR(n) | 0~255,固定 | 总是 n 个字符 | 长度几乎不变的:编码、省份、性别 |
| VARCHAR(n) | 最长 65535 字节 | 实际长度+1 或 2 字节 | 变化大的:姓名、标题、邮箱 |
| TEXT | 不用写长度 | 单独存放,行里留指针 | 几百字以上、不常整篇查的 |
第二行的 65535 是字节不是字符,而且它还要跟别的列抢同一行的空间。
一行的总长度有上限(约 65535 字节),所以不能「每列都写 VARCHAR(20000)」。
第三行那个「单独存放」是 TEXT 的关键。
长文本不跟行数据存在一起,所以 SELECT id, title FROM t 不会把正文一起读出来。
但如果 SELECT *,那就全读出来了——这也是「别写 SELECT *」的原因之一。
长度单位是字符,不是字节,这一条从 MySQL 4.1 起就成立了。
VARCHAR(10) 能存 10 个汉字,也能存 10 个 emoji,只是后者占 40 个字节。
前提是字符集得是 utf8mb4——三字节的 utf8 存不下 emoji。
还有一对容易混的:TEXT 和 BLOB。
TEXT 存字符,有字符集和排序规则,能按大小写不敏感比较;BLOB 存字节,没有字符集,只按字节比。
存图片、压缩包用 BLOB,存文章用 TEXT。
四、日期和时间:五种,各有各的边界
这一族最容易出错的地方不是格式,是范围和时区。 教材 5.2.3 列的五种,每一个都有一条硬边界。
| 类型 | 范围 | 用在哪 |
|---|---|---|
| DATE | 1000-01-01 ~ 9999-12-31 | 生日、营业日 |
| TIME | -838:59:59 ~ 838:59:59 | 时长、班次 |
| DATETIME | 1000-01-01 ~ 9999-12-31 | 本地时间、预约时间 |
| TIMESTAMP | 1970-01-01 ~ 2038-01-19 | 创建时间、更新时间 |
| YEAR | 1901 ~ 2155 | 年份,几乎不用 |
TIME 那个范围值得停一下:它能到 838 小时,一天多。
因为它存的是「时长」不是「时刻」,所以可以表示一个月的工时。
TIMESTAMP 的 2038 不是传说,是一个确定的日期。
它内部是 32 位有符号整数,存「距 1970 年多少秒」,上限 2147483647 秒,
换成时间就是 2038-01-19 03:14:07 UTC。
TIMESTAMP 和 DATETIME 的真正分水岭是时区。
前者存 UTC 秒数,读出来按会话时区换算;后者存什么读什么。
所以同一行数据,北京的人看到 20:00,伦敦的人看到 12:00,那是 TIMESTAMP 的正常表现。
精度也要显式要。DATETIME 默认只到秒,多出来的小数会被丢掉;
要毫秒就写 DATETIME(3),要微秒就写 DATETIME(6)。
五、还有几种不常用的:ENUM、SET、BIT、JSON
这几种用得少,但见到了要认识,不然会以为是字符串。
| 类型 | 实际怎么存 | 坑在哪 |
|---|---|---|
| ENUM | 内部存 1、2、3 这样的序号 | 加选项要 ALTER,序号会变 |
| SET | 内部存位图,最多 64 个成员 | 一个字段装多个值,查起来要 FIND_IN_SET |
| BIT(n) | 存二进制位 | 读写要 b'1010' 这种字面量 |
| JSON | 8.0 起是真正的二进制格式 | 能直接取字段,但改整段要重写 |
ENUM 那一行是最容易吃亏的:看着像字符串,其实是整数。
ORDER BY 一个 ENUM 列,排的是序号不是字母序,很多人第一次见会以为是 bug。
JSON 那一行是 8.0 的新能力:->> 取标量、JSON_EXTRACT 取片段,还能对某个路径建函数索引。
但它不适合替代正规的表设计——查询、约束、统计都会变麻烦。
六、怎么选:五条判断
教材 5.2 三个小节讲完类型,最后落到「怎么挑」上。五条够用了。
| 场景 | 选什么 | 为什么 |
|---|---|---|
| 金额、单价、税率 | DECIMAL(M,2) |
浮点会差一分钱 |
| 数量、年龄、状态码 | 整型,最小够用即可 | 精确、索引快、占得少 |
| 长度几乎固定的短串 | CHAR(n) |
定长不用记长度前缀 |
| 长度变化大的串 | VARCHAR(n) |
按实际长度占空间 |
| 文章、评论、日志正文 | TEXT |
不跟行存在一起,查列表不受拖累 |
「最小够用」这句要补一句:留出增长余量。
用 TINYINT 存年龄没问题,用 TINYINT 存库存就会在 127 那天出事。
还有一条不在这张表里但很重要:索引列要短。
主键、外键、经常用来查的列,尽量用整型或短的 VARCHAR。
VARCHAR(255) 的列建索引,索引本身就很大——这就是「前缀索引」存在的理由。
最后一句判断标准:如果这一列以后要参与计算或比较,就别用字符串存。
日期存成字符串、金额存成字符串、数量存成字符串,都是「今天省事、明天改全表」的写法。
七、动手 Demo:数据类型的每一条边界都是实的
这一课把五类类型的边界都算一遍、存一遍:整数越界、小数精度、字符字节、时间范围、类型转换。 能在这个沙箱里跑出来的都跑了,跑不出来的在正文里明说。 最后一段会看到一件有意思的事:同一条比较表达式,MySQL 和 SQLite 给出不同答案。
# 一、整型:范围现算 + 真插一个越界值
for name, nb in INTS:
bits = nb * 8
print(name, -(2 ** (bits - 1)), 2 ** (bits - 1) - 1, 2 ** bits - 1)
c.execute("INSERT INTO tb_tiny (n) VALUES (?)", (128,)) # IntegrityError
# 二、小数:浮点 vs 十进制
print(0.1 + 0.2) # 0.30000000000000004441
print(Decimal("0.1") + Decimal("0.2")) # 0.3
# 三、字符串:字符数 vs UTF-8 字节数
c.execute("SELECT length(?), length(CAST(? AS BLOB))", (s, s))
# 四、日期:2038 与精度截断
datetime.fromtimestamp(2 ** 31 - 1, timezone.utc)
# 五、类型转换:文本与数字谁大
c.execute("SELECT '9' > 10") # SQLite 给 1,MySQL 给 0
实跑输出:
--------------------------------------------------------------------
第二十四课:数据类型的每一条边界都是实的
--------------------------------------------------------------------
**数据类型不是「写个大概就行」的东西。**
**选窄了存不进,选宽了白占空间,选错了一年后数据全是错的。**
**这一课把五类类型的边界都算一遍、存一遍。**
**凡是能在这个沙箱里跑出来的,都跑;跑不出来的,明说跑不出来。**
一、整型:先记字节数,范围都是算出来的
--------------------------------------------------------------------
类型 字节 有符号范围 无符号上限
---------------------------------------------------------------------------------
TINYINT 1 -128 ~ 127 255
SMALLINT 2 -32768 ~ 32767 65535
MEDIUMINT 3 -8388608 ~ 8388607 16777215
INT 4 -2147483648 ~ 2147483647 4294967295
BIGINT 8 -9223372036854775808 ~ 9223372036854775807 18446744073709551615
**不用背,记住两件事就能推:n 个字节是 8n 位,有符号砍掉一位当符号。**
**所以 TINYINT 是 -2 的 7 次方到 2 的 7 次方减一。**
**无符号那一列不是「多一点」,是「翻倍再减一」。**
**`INT` 从 21 亿变成 42 亿,很多人以为只多一倍——确实是只多一倍,但对存不下的那一行就是救命的。**
**边界两头的数能进去,越界就进不去。**
**下面用 CHECK 约束装成 TINYINT 的样子,真插了三个值:**
插入的值 结果
---------------------------------------------------------------
-128 成功
127 成功
128 被拒:CHECK constraint failed: n BETWEEN -128 AND 127
**真 MySQL 报的是 1264:`Out of range value for column 'n'`。**
**注意:如果 SQL 模式不严(老版本默认),它会静默截到 127 而不报错。**
**这就是「数据莫名其妙变了」的来源。**
**再看一个存不下的:`BIGINT UNSIGNED` 的上限是 18446744073709551615。**
**这个数超过 2 的 63 次方,而 SQLite 的整数最多到 2 的 63 次方减一。**
**我试着把它插进去,结果比预想的还干脆:**
要存的值 结果
---------------------------------------------------------------------------------------
18446744073709551615 绑定阶段就被拒:Python int too large to convert to SQLite INTEGER
9223372036854775807 这才存得进去
**不是存进去变形,是连参数都绑不进去。**
**因为 SQLite 全库只有一种整数类型,上限就是 8 字节有符号。**
**真 MySQL 的 `BIGINT UNSIGNED` 能存到 18446744073709551615。**
**这也是「换引擎要照实标差异」的一个实例:同一条 INSERT,一个存进去了,一个连门都进不了。**
**实践上的意思是:真要用到千万亿这个量级,得先确认目标库支持不支持无符号 8 字节。**
**换成 `DECIMAL(20,0)` 存更保险,代价是不能再做位运算。**
二、小数:DECIMAL 求准,FLOAT 求快
--------------------------------------------------------------------
**先说最经典的那一道:0.1 加 0.2 到底等于几。**
**浮点数存不了 0.1 这个值,它只能存一个最近的近似。**
**这一次真算出来的结果:0.30000000000000004441**
**换成十进制算:0.3**
**所以判断「两个钱数相等」永远不能用 `=`。**
**要么用 DECIMAL 存,要么两边都减一下看差是否小于一个阈值。**
**再累加一百次 0.01,这一项更能说明问题:**
算法 一百次之后
--------------------------------
浮点累加 1.000000000000000666
十进制累加 1.00
正确答案 1.00
**`DECIMAL(8,2)` 的意思是「一共 8 位,其中 2 位在小数点后」。**
**所以整数部分只剩 6 位,最大 999999.99。四个值挨个试:**
要存的值 先舍入到两位 结果
---------------------------------------------
1234.5678 1234.57 存成这个值
0.005 0.01 存成这个值
999999.999 1000000.00 舍入后越界,报 1264
1000000.00 1000000.00 舍入后越界,报 1264
**第二行的 0.005 被舍成了 0.01,这是正常的四舍五入。**
**后两行才是坑:999999.999 舍进来刚好破一千万,1000000.00 本来就在外面。**
**注意顺序:MySQL 是先按小数位四舍五入,再判断整数部分放不放得下。**
**所以「差一点就存下」的值不会安静地进去,它会先进位,再因为进位越界而报错。**
**一句话的选法:钱用 `DECIMAL`,坐标用 `DOUBLE`。**
**`FLOAT` 只在「省空间且不在乎末位」时才考虑。**
三、字符串:字符数和字节数不是一回事
--------------------------------------------------------------------
**`VARCHAR(10)` 里的 10 是「字符」不是「字节」,这是 4.1 之后才成立的规矩。**
**四字节的字符在 `utf8mb4` 下占 4 个字节,但仍然只算 1 个字符。**
**四个样本,逐个数一遍字符和字节:**
内容 字符数 UTF-8 字节数 三字节的 utf8 装得下吗
---------------------------------------------------
abc 3 3 能
张三 2 6 能
😀 1 4 不能,要 utf8mb4
中a😀 3 8 不能,要 utf8mb4
**第三行那个笑脸占 4 个字节,三字节的 `utf8` 装不下——这就是「存 emoji 报 1366」的全部原因。**
**换成 `utf8mb4` 就好,MySQL 8.0 的默认字符集已经是它了。**
**CHAR 和 VARCHAR 的差别在「定长还是变长」,这一条在沙箱里量不到准确的字节数。**
**能讲清的是规则:`CHAR(10)` 存 `abc` 会在 InnoDB 里补足到定长。**
**`VARCHAR(10)` 只存 3 个字符加一个长度前缀。**
类型 存 abc 实际占 适合存什么
------------------------------------------------------------------------------------------
CHAR(10) 定长,补空格(读回时 MySQL 会去掉尾部空格) 长度几乎不变的:性别、省份、编码
VARCHAR(10) 3 个字符 + 1 或 2 字节长度前缀 长度变化大的:姓名、标题
TEXT 单独存放,行里只留指针 几百字以上、不常整篇查的
**TEXT 那一行是关键:长文本不跟行存在一起,所以「查列表」时不会把正文一起拖出来。**
**四个 TEXT 兄弟的上限是算出来的:**
类型 上限字节 大概能装
------------------------------------
TINYTEXT 255 短备注
TEXT 65535 文章正文
MEDIUMTEXT 16777215 一本书
LONGTEXT 4294967295 一整个资料库
**它们不是「性能不同」,是「长度上限不同」。**
**中文字按三字节算,所以 `TEXT` 的 65535 字节大约只装得下两万字。**
四、日期时间:五种,各有各的范围和精度
--------------------------------------------------------------------
类型 字节 范围 精度
--------------------------------------------------------------------------
DATE 3 1000-01-01 ~ 9999-12-31 天
TIME 3 -838:59:59 ~ 838:59:59 秒(可带小数)
DATETIME 5~8 1000-01-01 00:00:00 ~ 9999-12-31 23:59:59 秒起,可到微秒
TIMESTAMP 4~7 1970-01-01 00:00:01 ~ 2038-01-19 03:14:07 秒起,可到微秒
YEAR 1 1901 ~ 2155 年
**第二行的时间范围是「一天以上」——`TIME` 存的是时间段,不是时刻。**
**所以它能到 838 小时,够存一个多月的工时。**
**第四行的 `TIMESTAMP` 只有到 2038 年,这一条每次都要单独讲。**
**它内部就是一个 32 位有符号整数,存的是「距 1970 年多少秒」。**
**32 位有符号整数的上限是 2147483647 秒,换成时间是:2038-01-19 03:14:07 UTC。**
**那一刻之后再写 `TIMESTAMP`,就会溢出。**
**离今天还剩 4099 天,大约 11 年。**
**所以「什么时候会出事」这个问题有确切答案:2038 年 1 月 19 日。**
**`DATETIME` 没有这个问题,它存的是字面的年月日时分秒,上限到 9999 年。**
**精度这一项也能算清。同一个时刻,存进三种精度的列:**
列类型 存进去之后
-------------------------------------------------
DATETIME(默认 0 位) 2026-10-30 12:34:56
DATETIME(3) 2026-10-30 12:34:56.789
DATETIME(6) 2026-10-30 12:34:56.789123
**五位小数那部分是被丢掉的,不是四舍五入。**
**要存毫秒就必须把列声明成 `DATETIME(3)`,只写 `DATETIME` 会静默截断。**
**最后还有一条 `TIMESTAMP` 和 `DATETIME` 的分水岭:时区。**
**`TIMESTAMP` 存的是 UTC 秒数,读出来按会话时区换算;`DATETIME` 存什么读什么。**
存进去(UTC 12:00) 会话时区 +08:00 读回
-----------------------------------------
TIMESTAMP 2026-10-30 20:00:00
DATETIME 2026-10-30 12:00:00
**同一行数据,两个人用不同时区连进来,`TIMESTAMP` 读出不同的字面值,`DATETIME` 读出一样的。**
**哪个对?看业务:跨时区的下单时间该用 `TIMESTAMP`,本地门店的营业日期该用 `DATETIME`。**
五、类型转换:两个引擎在这里给出不同答案
--------------------------------------------------------------------
**最后这一节是本课最该记住的一节:同一条 SQL,MySQL 和 SQLite 答案不同。**
**原因不在谁对谁错,在「什么跟什么比、按什么规则比」。**
表达式 本次实测 MySQL 的结果 差在哪
-------------------------------------------------------------------------------
'10' > 9 1 1 两边都成立,但理由不同
'9' > 10 1 0 SQLite:文本永远大于数字;MySQL:转成数字比
'9' > '10' 1 1 都是字符串,按字典序比
'abc' > 0 1 0 MySQL 把 'abc' 转成 0,0 不大于 0
**第二行是关键:`'9' > 10` 在 MySQL 里是假,在这里是真。**
**因为 SQLite 定了一条死规矩——任何文本都排在所有数字前面。**
**MySQL 的规矩是「把字符串转成数字再比」。**
**`'abc'` 转数字得 0,所以第四行在 MySQL 里是假。**
**这两种规矩放一起,后果是:同一句 `WHERE`,换了库结果就变。**
**所以列类型要对齐:字符串列就跟字符串比,别指望数据库帮你转。**
**最狠的一种情况是隐式转换让索引失效。**
**`WHERE phone = 13800138000` 这种写法,如果 `phone` 是 `VARCHAR`,**
**MySQL 会把每一行都转成数字再比,索引直接废掉。**
第一段的表是现算的,所以每一格都是准的。
紧跟着的三行插入里,-128 和 127 成功,128 被 CHECK 拦下来。
真 MySQL 拦它的是 1264,不是 CHECK,但意思一样:越界就是不进。
第二段的 BIGINT UNSIGNED 那一行更有意思:它不是存进去变形,是连参数都绑不进去。
报的是 Python int too large to convert to SQLite INTEGER。
原因很简单:SQLite 只有一种整数类型,上限就是 8 字节有符号。
第三段那两张小数表,浮点累加一百次 0.01 得到 1.000000000000000666。
十进制累加是 1.00。这就是为什么钱的字段不能用 FLOAT——误差会累积。
第四段字符串那张表,看第三行那个笑脸:1 个字符、4 个字节。
三字节的 utf8 装不下它,这就是「存 emoji 报 1366」的全部原因。
第五段是这一课最值钱的地方:'9' > 10,这里给 1,MySQL 给 0。
因为 SQLite 定了一条死规矩——文本永远大于数字;MySQL 是把字符串转成数字再比。
同一句 WHERE,换了库结果就变,这不是 bug,是两套语义。
30 秒实验
- 把
DECIMAL(8,2)改成DECIMAL(10,2),再算一遍整数部分能装多大。 8 位变 10 位,小数还是 2 位,整数部分从 6 位变成 8 位,上限到 99999999.99。 - 把
DATETIME(3)换成TIMESTAMP(3),想一想北京和伦敦的人读同一行会看到什么。 看到的时间不一样,因为TIMESTAMP存 UTC、按会话时区换算。 - 试一下
SELECT CAST('abc' AS INTEGER),再想想为什么「手机号存成整型」是个坏主意。 得到 0,因为转不动就归零。手机号存整型会丢前导零,也存不了带 +86 的格式。
常见误区
- 「金额用 FLOAT 存,反正只显示两位小数。」 显示的时候可以舍,累加的时候不能舍。 一百笔 0.01 加完是 1.000000000000000666,对账时就是差一分。
- 「
INT(11)比INT(10)存得大。」 括号里是显示宽度,8.0 起已经废弃,两者都是 4 字节。 要更大的范围得换类型或用UNSIGNED。 - 「时间存成字符串最省事,什么格式都能写。」 省的是写入时的校验,赔的是范围查询、日期计算和索引。 字符串比日期,比的是字面不是时间。
- 「
VARCHAR(255)是万能长度,全写它就行。」 行有总长上限,而且VARCHAR越长,索引越大、临时表越占内存。 长度按业务定,别一律 255。
自测三问(点开对答案)
1. `DECIMAL(8,2)` 最大能存多少? —— **999999.99。** **8 位里 2 位给了小数,整数部分只剩 6 位。** 2. 为什么 `TIMESTAMP` 只能到 2038 年,`DATETIME` 能到 9999 年? —— **`TIMESTAMP` 内部是 32 位有符号整数,存的是距 1970 年的秒数,** **2147483647 秒就是 2038-01-19。** **`DATETIME` 存的是字面的年月日时分秒,不靠一个整数表示。** 3. `'9' > 10` 在 MySQL 里是真还是假? —— **假。MySQL 把字符串转成数字再比,9 不大于 10。** **SQLite 给的是真,因为它规定文本永远大于数字。**第 5 章到这儿结束。下一篇进第 6 章,讲数据表的创建、修改、复制和删除—— 引擎和类型都定了,接下来就该真把表建出来了。
- ☐ (本篇完,下一篇 6.1 创建数据表)