🦏 第18章 其他内容
📄 一句话概括

起名三条底线:别用关键字、别用数字开头、别在大小写上玩花样。 中文名能建,但不建议。 三范式一句话各自记住: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 秒实验

  1. 把 s2 表的 check(score between 0 and 100) 改成 check(score >= 0),再插一条 120 分的记录, 会不会被拦?
  2. 给 sc 表加一列 stu_name 并把姓名填进去,想想这算不算退回了第二范式。
  3. 把 Demo 里 pragma foreign_keys = on 那句挪到 commit 之前,再跑一遍那六条, 看外键还拦不拦得住。

常见误区

  1. 用中文或关键字当表名列名。 中文能建但不通用,关键字不加引号直接语法错, 老老实实用小写字母加下划线。
  2. 以为写了 references 就有外键约束。 SQLite 的外键开关默认是关的, 而且它在事务里设了也不生效。
  3. 用 real/float 存金额。 二进制浮点存不下 0.1,求和会攒出误差,该用定点数或整数存分。
自测三问(点开对答案) 1. 三个范式各用一句话说清楚? —— 1NF:每个格子只放一个原子值;2NF:非主键列必须依赖整个主键(联合主键时不能只依赖一部分); 3NF:非主键列不能依赖另一个非主键列(不能有传递依赖)。 2. 为什么 SQLite 里明明写了外键约束却不生效? —— 因为 `pragma foreign_keys` 默认是 0,而且它在事务里设置是空操作。 必须先 `commit`、再打开开关,外键才真正起作用。 3. 金额为什么不能用 `real` 存? —— 二进制浮点表示不了 0.1 这类十进制小数,存的时候就被截断了,求和会把误差累起来。 该用 `decimal` 定点数,或者用整数存「分」。

第 18 章完了: 系统库、分页、表的设计,三件都过了。 下一篇进第 19 章——经典案例:19.1 案例准备。

相关笔记