起名三条底线:别用关键字、别用数字开头、别在大小写上玩花样。 中文名能建,但不建议。 三范式一句话各自记住:1NF 一格一个值、2NF 非主键列依赖整个主键、 3NF 别绕一道弯。 约束是最后一道闸,但 SQLite 的外键开关默认是关的——不开等于没写。
这一节没有一条新语法,全是「建表之前要想清楚的事」。 它比前面任何一节都更影响后面:表建错了,后面每一句 SQL 都在还债。 演示方式: 每个范式先造一张反例表,故意弄出脏数据,再拆成规范的三张表,同一个操作重做一遍。 脏数据不是「可能出问题」,是当场就出问题——这样才看得出范式不是理论洁癖。
一、名字怎么起
Demo 里试了九种写法,六种能建、三种被拦。
中文表名、中文列名、带空格加双引号的,SQLite 全放行——它很宽容。
但「能建」不等于「该这么干」。 三条硬规矩:别用关键字(order、group、
select 这些,不加引号就报语法错);别用数字开头(1abc 直接认不出来);
别在大小写上玩花样。
大小写这条值得单独说:t_v 和 T_V 在 SQLite 里是同一张表,
建第二张会报「已存在」。 而 MySQL 在 Linux 上默认区分、Windows 上不区分——
同一份建表脚本在两台机器上表现可能不一样。 全小写加下划线,是最不容易出事的选择。
还有一个不成文的规矩:表名用复数或单数都行,但一本书里要统一;
列名别用 data、info、value 这种谁都像的名字。
created_at 比 time 好,student_id 比 sid 好。 名字是给人看的,多打几个字不亏。
二、第一范式:一格只放一个值
反例很常见:一个格子里塞两个电话号,用分号隔开。 看着省事,麻烦在后面。
第一个麻烦是查不准。 想查「谁有 139 这个号」,只能 like '%139%' 模糊匹配——
like '%13%' 会把 137 也捞出来,得靠猜边界。 而且这种查询用不上索引,数据一多就全表扫。
第二个麻烦是改不对。 Demo 里用 replace 改号,这次刚好改对了——
但它改的是「所有匹配的片段」。 哪天有个号是 1139,一样会被改成 11390。
字符串里的子串替换,永远不知道边界在哪。
第一范式的要求就一句话:每个格子只放一个「原子值」,
不可再分。 做不到就拆表——
一个号一行,主键用「学生 + 号码」联合起来。
查「谁有 139」就是一句等值查询,改号就是一行 update。
三、第二范式:联合主键里别只依赖一半
反例:成绩表的主键是「学号 + 科目」,但表里还存了学生姓名。
CREATE TABLE bad2(
stu_id INT, course TEXT, stu_name TEXT, score INT,
PRIMARY KEY(stu_id, course));
问题在 stu_name 这一列:
它只依赖学号,跟科目没关系。 于是张三的名字存了两遍(语文一行、数学一行)。
Demo 里只改了其中一行,
同一个学号立刻出现两个名字——
一个叫张三丰、一个还叫张三。 数据库不会拦你,它只是老老实实存下这个矛盾。
第二范式的判据一句话:
非主键列必须依赖「整个主键」,不能只依赖其中一部分。
满足这个条件叫「完全依赖」,不满足就是「部分依赖」。 部分依赖只可能出现在联合主键的表里——
单列主键的表天然满足第二范式。
四、第三范式:别绕一道弯
反例:学生表里同时存了「班级号」和「班级名」。
CREATE TABLE bad3(stu_id INT PRIMARY KEY, stu_name TEXT,
cls_id INT, cls_name TEXT);
依赖链是这样的:
cls_name 依赖 cls_id,cls_id 又依赖 stu_id——
从主键到 cls_name 绕了一道弯,这叫传递依赖。
后果和上一个范式一模一样:
一班改名,Demo 里只改了一行,另一行还是旧的。 一个班三十个人,就得改三十行,漏一行就错。
而且错得毫无提示——查「一班有多少人」会查出两个数字。
第三范式的判据:
非主键列只能直接依赖主键,不能依赖另一个非主键列。
换句话说:表里不该出现「A 决定 B、B 决定 C」这种链子。
五、拆成三张表之后
按范式拆开是这样的:
| 表 | 存什么 | 主键 |
|---|---|---|
cls |
班级号、班级名 | id |
stu |
学号、姓名、班级号 | id |
sc |
学号、科目、分数 | (stu_id, course) |
同一个「一班改名」操作,Demo 里只改了一行,两处都对。
这就是范式的全部收益:同一个事实只存一份。 存一份就不会自相矛盾——
不是靠程序员记得同时改两个地方,是靠结构上只存在一个地方。
代价也很清楚:查「谁、在哪个班、考了多少」得连三张表,
Demo 里那句 join 就是这笔账。 存的时候多一步,查的时候多一句,
这笔账要在设计表的时候认,不要等上线了才后悔。
顺带说一句「反范式」:
有时候为了查得快,会故意在表里冗余一个字段(比如订单表里存一份当时的商品名)。
这是允许的,但前提是「你清楚自己在冗余什么、由谁来保证它同步」。
没有同步机制的冗余,就是下一个 bug。
六、约束是最后一道闸
五种约束一次看清楚:主键(唯一且非空)、非空(not null)、外键(references)、
检查(check)、唯一(unique)。
Demo 里六条故意违规的语句,五条被拦下、一条放行(放行的那条是正常插入)。
拦下来的报错信息把原因写得很清楚:FOREIGN KEY constraint failed、
CHECK constraint failed、NOT NULL constraint failed、UNIQUE constraint failed。
看一眼报错前缀就知道是哪类约束,不用猜。
但这里有个大坑,Demo 专门演示了两遍:pragma foreign_keys 默认是 0。
开关关着的时候,同一条「外键指向不存在的班」的数据会一路放行。
Demo 最后那张表里就躺着一条 cls_id = 9 的记录,
而 cls 里只有 1 号班。 数据库里就这么放着一条指向空气的记录,谁也不报错。
还有第二个坑:
pragma foreign_keys 在事务里是空操作。 所以「先 commit 再开开关」的顺序不能反,
反了就是白写。 这一点极容易漏——
代码里明明写了 PRAGMA foreign_keys = ON,可它就是不生效。
MySQL 的外键不用手动开,建表时写了 foreign key 就生效;
但引擎必须是 InnoDB——
MyISAM 写了外键也是白写,它根本不认。
所以「有没有外键约束」这件事,在两边都得亲手验一次,不能看建表语句就下结论。
七、类型:两个容易踩的坑
坑一:金额别用浮点。
Demo 里存 99.9,取出来打印是 99.90,看着挺干净——
但把小数位全展开,实际存的是 99.90000000000000568。
0.1 + 0.2 更明显,存进去是 0.30000000000000004。
求和会把误差累起来:
Demo 里两行加起来是 100.20000000000000284,不是 100.2。 金额对不上账,就是这么来的。
原因是浮点用二进制存小数,而 0.1 在二进制里是无限循环小数,
只能截断,截断就有误差。 这不是 SQL 的问题,是所有用 IEEE 754 浮点的语言共同的问题——
Python 里 0.1 + 0.2 也是这个结果。
MySQL 里该用 decimal(10,2),
它按字符串存定点数,不做二进制换算。 本沙箱没有 decimal 类型——
写上去不报错,但存进去还是浮点,
这个「静默降级」比报错更危险。 所以在 SQLite 里存金额,要么用整数存「分」,要么存字符串。
坑二:日期别指望数据库帮你算「今天」。
Demo 里 date('now') 给的是 UTC 日期,datetime('now') 给的是 UTC 时刻。
打印出来比本地时间慢整 8 小时。 在东八区,早上八点之前 date('now') 会比本地日期小一天。
这一点在跨天统计里非常容易出错:
「今天注册了多少人」这种查询,如果按 UTC 算,每天的头八小时都算到昨天去了。
而且它不会报错,只是数字对不上。
MySQL 的 now() 给的是会话时区的当前时间,
这一点两边不一样。 跨时区的项目必须显式写时区,
不能靠「数据库默认应该知道我在哪」。 它不知道。
八、动手 Demo:三张反例表 + 一次拆表 + 六条违规
Demo 分四段: 起名试九种写法、三个范式各造一张反例表、拆成三张表重做一遍、最后用六条违规语句压一遍约束。 反例表是故意造脏的—— 脏数据不是「以后可能出问题」,是当场就出问题。 这样才看得出范式不是理论洁癖,是止血。
import sqlite3
c = sqlite3.connect(":memory:")
c.execute("PRAGMA foreign_keys = ON") # 无事务时才生效
c.execute("CREATE TABLE cls(id INTEGER PRIMARY KEY, name TEXT UNIQUE)")
c.execute("CREATE TABLE stu(id INTEGER PRIMARY KEY, name TEXT NOT NULL, "
"cls_id INT NOT NULL REFERENCES cls(id), "
"score REAL CHECK(score BETWEEN 0 AND 100), phone TEXT UNIQUE)")
c.execute("INSERT INTO cls(name) VALUES ('一班')")
c.commit()
CASES = [
("正常插入", "INSERT INTO stu(name,cls_id,score,phone) "
"VALUES (?,?,?,?)", ("张三", 1, 88.5, "138")),
("外键指向不存在的班", "INSERT INTO stu(name,cls_id,score,phone) "
"VALUES (?,?,?,?)", ("李四", 9, 70, "139")),
("分数 120", "INSERT INTO stu(name,cls_id,score,phone) "
"VALUES (?,?,?,?)", ("李四", 1, 120, "139")),
("电话重复", "INSERT INTO stu(name,cls_id,score,phone) "
"VALUES (?,?,?,?)", ("李四", 1, 70, "138")),
]
for label, sql, args in CASES:
try:
c.execute(sql, args)
c.commit()
print("放行", label)
except sqlite3.Error as e:
print("拦下", label, e)
print(c.execute("SELECT id, name, cls_id FROM stu").fetchall())
实跑输出:
--------------------------------------------------------------------
第八十三课:表的设计——先想清楚再动手建
--------------------------------------------------------------------
名字怎么起
**可以** 中文表名
**可以** 中文列名
**可以** 空格加双引号
**不行** 关键字当列名 —— near "order": syntax error
**可以** 关键字加双引号
**可以** 下划线 user_name
**可以** 驼峰 UserName
**不行** 大小写不同重名 —— table T_V already exists
**不行** 数字开头 —— unrecognized token: "1abc"
**中文表名、中文列名、带空格的名字都能建**,**SQLite 很宽容。**
**但「能建」不等于「该这么干」。** **三条硬规矩**:**别用关键字**、**别用数字开头**、
**别在大小写上玩花样。**
**`t_v` 和 `T_V` 是同一张表**,
**再建一次报「已存在」**——**表名在 SQLite 里不区分大小写。** **全小写是最省事的写法。**
第一范式:一格只放一个值
SQL> SELECT * FROM bad1
stu phones
-----------------
张三 138;139
李四 137
(2 行)
**张三有两个号,塞进同一个格子里用分号隔开。** **看着省事,麻烦在后面:**
SQL> SELECT stu FROM bad1 WHERE phones LIKE '%139%'
stu
-----
张三
(1 行)
**想查「谁有 139 这个号」,只能 `like` 模糊匹配。** **`like '%13%'` 会把 137 也捞出来**,
**得靠猜边界。**
SQL> UPDATE bad1 SET phones = replace(phones, '139', '1390')
影响 2 行
SQL> SELECT * FROM bad1
stu phones
------------------
张三 138;1390
李四 137
(2 行)
**这次刚好改对了**:**`replace` 只碰了「139」这个片段。**
**但它改的是「所有匹配的片段」**——**哪天有个号是 `1139`,一样会被改成 `11390`。**
**第一范式的要求**:**每个格子只放一个「原子值」。** **做不到就拆表**,**一个号一行。**
第二范式:联合主键里别只依赖一半
SQL> SELECT * FROM bad2
stu_id course stu_name score
--------------------------------------------
1 语文 张三 88
1 数学 张三 92
2 语文 李四 75
(3 行)
**主键是「学号 + 科目」**,**但 `stu_name` 只依赖学号,跟科目没关系。**
**于是张三的名字存了两遍。** **只要有人只改其中一行,数据就自相矛盾:**
SQL> UPDATE bad2 SET stu_name = '张三丰' WHERE stu_id = 1 AND course = '语文'
影响 1 行
SQL> SELECT * FROM bad2
stu_id course stu_name score
--------------------------------------------
1 语文 张三丰 88
1 数学 张三 92
2 语文 李四 75
(3 行)
**同一个学号出现了两个名字**,**这就是第二范式要解决的问题。** **判据一句话**:
**非主键列必须依赖「整个主键」,不能只依赖一部分。**
第三范式:别绕一道弯
SQL> SELECT * FROM bad3
stu_id stu_name cls_id cls_name
--------------------------------------------
1 张三 1 一班
2 李四 1 一班
(2 行)
**`cls_name` 依赖 `cls_id`,`cls_id` 依赖 `stu_id`**——**绕了一道弯,这叫传递依赖。**
SQL> UPDATE bad3 SET cls_name = '一年级一班' WHERE cls_id = 1 AND stu_id = 1
影响 1 行
SQL> SELECT * FROM bad3
stu_id stu_name cls_id cls_name
----------------------------------------------
1 张三 1 一年级一班
2 李四 1 一班
(2 行)
**一班改名,改了一行,另一行还是旧的。** **一个班三十个人,就得改三十行,漏一行就错。**
拆成三张表,同样的操作
SQL> UPDATE cls SET name = '一年级一班' WHERE id = 1
影响 1 行
SQL> SELECT * FROM cls
id name
------------------
1 一年级一班
(1 行)
**一班改名只改了一行,两处都对。** **这就是范式的全部收益:同一个事实只存一份。**
**拆开之后要查「谁、在哪个班、考了多少」,得连三张表:**
SQL> SELECT s.name AS 学生, c.name AS 班级, sc.course AS 科目, sc.score AS 分数 FROM sc
JOIN stu s ON s.id = sc.stu_id JOIN cls c ON c.id = s.cls_id ORDER BY s.id, sc.course
学生 班级 科目 分数
--------------------------------------
张三 一年级一班 数学 92
张三 一年级一班 语文 88
李四 一年级一班 语文 75
(3 行)
**代价就是这句 `join`**——**存的时候多一步,查的时候多一句。** **这笔账在设计表的时候就要认。**
约束是最后一道闸
**先把外键开关打开**,**不然外键等于没写:**
SQL> PRAGMA foreign_keys
foreign_keys
-------------
1
(1 行)
**注意这里有个坑**:**`pragma foreign_keys` 在事务里是空操作**,
**必须先 `commit` 才生效。** **上面那句 `commit` 就是为这个加的。**
**五种约束一次看清楚**:**主键、非空、外键、检查、唯一。**
**下面六条逐条试,每条都故意违反一次:**
**放行** 正常插入
**拦下** 外键指向不存在的班 —— FOREIGN KEY constraint failed
**拦下** 分数 120 超范围 —— CHECK constraint failed: score BETWEEN 0 AND 100
**拦下** 名字给空 —— NOT NULL constraint failed: s2.name
**拦下** 电话重复 —— UNIQUE constraint failed: s2.phone
**拦下** 删掉还有学生的班 —— FOREIGN KEY constraint failed
**六条里五条被拦,只有第一条正常插入放行。**
**别急着走**——**把开关关掉再试一次,同一条外键数据会一路放行:**
**放行** 外键指向不存在的班 cls_id = 9
SQL> SELECT id, name, cls_id FROM s2
id name cls_id
-----------------------
1 张三 1
2 王五 9
(2 行)
**这条数据的 `cls_id` 是 9,而 `cls` 里只有 1 号班。**
**数据库里就这么躺着一条指向空气的记录。**
**`pragma foreign_keys` 默认是 `0`**,**每条连接都要自己打开一次**——**这是最容易忘的一步。**
**MySQL 的外键不用手动开**,**建表时写了 `foreign key` 就生效**;
**但引擎必须是 InnoDB**——**MyISAM 写了外键也是白写,它不认。**
类型:两个容易踩的坑
**坑一:金额别用浮点。** **下面两行看着挺干净:**
SQL> SELECT amount, typeof(amount) AS 存储类型 FROM ord
amount 存储类型
--------------------
99.90 real
0.30 real
(2 行)
**把小数字位全展开就是另一回事了**:
**第 1 行实际存的是 99.90000000000000568**
**第 2 行实际存的是 0.30000000000000004**
**屏幕上显示「99.90」只是格式化得好**,**值本身不干净。**
**求和会把误差累起来**:
**两行加起来是 100.20000000000000284**,**不是 100.2。**
**MySQL 里该用 `decimal(10,2)`**,
**它按字符串存定点数,不做二进制换算。** **本沙箱没有 `decimal` 类型**——
**写上去不报错,但存进去还是浮点。**
坑二:日期别指望数据库帮你算「今天」
SQL> SELECT date('now') AS 函数给的今天, datetime('now') AS 函数给的时刻
函数给的今天 函数给的时刻
-------------------------------------
2026-10-30 2026-10-30 00:28:24
(1 行)
**这台机器的本地时间是 2026-10-30 08:28:24。**
**`datetime('now')` 比它慢整 8 小时**——**因为 `now` 是 UTC,不是北京时间。**
**`date('now')` 同理**,
**在东八区,早上八点之前它会比本地日期小一天。** **跨天统计最容易在这栽。**
**MySQL 的 `now()` 给的是会话时区的当前时间**,
**这一点两边不一样**——**跨时区的项目必须显式写时区,不能靠默认值。**
**这一节没有新语法**,**讲的全是建表之前要想清楚的事。** **表建错了,后面每一句 SQL 都在还债。**
起名那九条,六种能建、三种被拦。 被拦的是「关键字当列名」(order)、
「大小写不同重名」(T_V 撞 t_v)、「数字开头」(1abc)。
order 加上双引号就能建,但那是绕路,不是解法。
第二范式的反例里,只改一行的后果是「同一个学号两个名字」。
1 语文 张三丰 和 1 数学 张三 并存,数据库一句错都不报。
第三范式的反例里,一班改名改了一行,另一行还是「一班」。 三十个人的班就得改三十行。
拆成 cls/stu/sc 三张表之后,同样一次改名只动一行,两处都对。 代价是查明细要连三张表——
Demo 最后那句 join 就是代价本身。
约束那六条:五条拦下、一条放行。 放行的是正常插入;
外键那两条(插错班、删有学生的班)都能拦住。
但前提是开头那句 pragma foreign_keys = on 真的生效了——
所以 Demo 里在它前面加了一句 commit。
关掉开关再插一次,同一条坏数据就进去了。 最后那张 s2 表里 cls_id = 9 那行,
就是「外键没开」的物证。
30 秒实验
- 把
s2表的check(score between 0 and 100)改成check(score >= 0),再插一条 120 分的记录, 会不会被拦? - 给
sc表加一列stu_name并把姓名填进去,想想这算不算退回了第二范式。 - 把 Demo 里
pragma foreign_keys = on那句挪到commit之前,再跑一遍那六条, 看外键还拦不拦得住。
常见误区
- 用中文或关键字当表名列名。 中文能建但不通用,关键字不加引号直接语法错, 老老实实用小写字母加下划线。
- 以为写了
references就有外键约束。 SQLite 的外键开关默认是关的, 而且它在事务里设了也不生效。 - 用
real/float存金额。 二进制浮点存不下 0.1,求和会攒出误差,该用定点数或整数存分。
自测三问(点开对答案)
1. 三个范式各用一句话说清楚? —— 1NF:每个格子只放一个原子值;2NF:非主键列必须依赖整个主键(联合主键时不能只依赖一部分); 3NF:非主键列不能依赖另一个非主键列(不能有传递依赖)。 2. 为什么 SQLite 里明明写了外键约束却不生效? —— 因为 `pragma foreign_keys` 默认是 0,而且它在事务里设置是空操作。 必须先 `commit`、再打开开关,外键才真正起作用。 3. 金额为什么不能用 `real` 存? —— 二进制浮点表示不了 0.1 这类十进制小数,存的时候就被截断了,求和会把误差累起来。 该用 `decimal` 定点数,或者用整数存「分」。第 18 章完了: 系统库、分页、表的设计,三件都过了。 下一篇进第 19 章——经典案例:19.1 案例准备。
- ☐ (本篇完,下一篇 19.1 案例准备)