AIHub

数据库(上):SQL、建模与范式

入门20 分钟读完2026-08-06#后端#数据库#SQL#数据建模
数据库(上):SQL、建模与范式

很多后端新人的第一道坎不是写 SQL,而是设计表。查询语法背一周就会了,但「这个字段该放哪张表」这个问题,决定了你的系统三个月后是好改还是想推倒重构。这一篇不追求罗列 SQL 语法,而是建立一种建模直觉:为什么关系型数据库要「拆表」,拆到什么程度合适,什么时候又该故意不拆。

从 Excel 到关系表:先理解「一张表」的边界

几乎所有新手的第一版设计都是一张 Excel 式的大表。比如做博客,直觉上你会建一张 posts 表:文章标题、正文、作者名字、作者邮箱、分类名、标签……全塞一行里。

能用吗?能。但很快你会遇到三件事:

  • 改一处,要改一片:作者换了邮箱,你得更新他写过的几百篇文章的每一行,漏改一条数据就不一致了;
  • 删一条,丢一片:删掉某个作者唯一的文章,这个作者的信息也跟着消失了;
  • 插不进去:想先录入一个还没写过文章的作者?对不起,表里每一行都必须是一篇文章,没文章就没法有作者。

这三种毛病在教科书里叫更新异常、删除异常、插入异常。它们的共同根源是:一张表里混装了多种「东西」。文章是一种东西,作者是另一种东西,它们的生命周期不同、变化频率不同,就不该住在同一张表里。

关系型数据库的核心思想就一句话:一种实体一张表,表与表之间用「引用」连接。这个「引用」,就是主键和外键。

主键与外键:数据的身份证和指针

主键(Primary Key) 是每一行的唯一标识,相当于身份证号。它的要求是:唯一、非空、稳定(最好永远不变)。实务上的建议:

  • 用和业务无关的代理主键(自增整数或 UUID),别拿邮箱、手机号当主键——业务字段会变,主键一变,所有引用它的地方都得跟着改;
  • 每张表都老老实实有主键,别图省事省略。没有主键的表,连「准确删除某一行」都做不到。

外键(Foreign Key) 是一张表指向另一张表主键的字段,相当于存了一个指针。posts 表里存一个 author_id,指向 users 表的主键,文章和作者就关联起来了。数据库会帮你保证引用完整性:你不能插入一个 author_id = 999 的文章,如果 999 号用户根本不存在。

有一个业界惯例要知道:很多互联网公司在应用层维护外键关系,而不在数据库里建物理外键约束,为的是分库分表和高并发写入时的灵活性。但作为学习者,先把外键约束用起来——它能替你挡住大量脏数据,等你理解为什么要去掉它的时候再去掉。

三大范式:用一张订单表讲明白

把混杂的大表拆成结构清晰的多张小表

范式(Normal Form)听起来玄乎,其实就是三条「拆表到什么程度」的经验法则。用一张糟糕的订单表来演示:

orders_bad
| order_id | user_name | user_email   | products          | total |
|----------|-----------|--------------|-------------------|-------|
| 1001     | 张三      | zhang@a.com  | 键盘x1, 鼠标x2    | 399   |
| 1002     | 张三      | zhang@a.com  | 显示器x1          | 1299  |

第一范式(1NF):字段不可再分。看 products 列,一格塞了「键盘x1, 鼠标x2」——这是个列表,不是原子值。后果是:你没法用 SQL 回答「键盘一共卖了多少个」,只能把字符串抠出来在代码里解析。解法:一行一个订单项,把订单项拆出去。

第二范式(2NF):非主键字段必须依赖整个主键,而不是主键的一部分。拆出订单项表 (order_id, product_id, quantity, product_name),主键是联合主键 (order_id, product_id)。问题来了:product_name 只依赖 product_id,跟 order_id 无关——同一商品的名字在几百个订单里重复几百遍。解法:商品信息独立成 products 表,订单项里只留 product_id 和当时的成交价。

第三范式(3NF):非主键字段之间不能互相依赖。回到用户列:user_email 依赖 user_name,而 user_name 依赖 order_id——email 隔着一层挂在订单上。张三改邮箱,所有历史订单都得改。解法:用户独立成 users 表,订单只存 user_id

走完三步,一张大表变成了四张小表:usersordersorder_itemsproducts。每种实体各归其位,冗余消失了。

反范式:知道规则,才有资格打破规则

范式化是有代价的:数据拆得越散,查的时候拼得越多。一个典型的性能场景是「文章列表页要显示每篇文章的评论数」——严格范式下你得每次对 comments 表做 COUNT(*) 再 GROUP BY,数据量大时很浪费。

这时可以故意冗余:在 posts 表里加一个 comment_count 字段,发评论时顺手 +1。这违反了范式(评论数其实可以从 comments 表推导出来),但换来的是列表页查询的极致简单。

记住取舍原则:范式化是为了不写错,反范式是为了读得快。默认先做到 3NF;只有当你有明确的性能证据(实测慢的查询,而不是想象),并且愿意为冗余数据写维护一致性的代码(更新、补偿、对账),才做反范式。顺序反过来,就是给自己挖坑。

JOIN 的本质:把拆开的表拼回来

拆表的代价是要在查询时拼回去,JOIN 就是干这个的。它的数学本质其实很简单:先做笛卡尔积(两张表所有行的两两组合),再按连接条件过滤

SELECT p.title, u.nickname
FROM posts p
JOIN users u ON p.author_id = u.id;

postsusers 的每一行两两配对,只保留 author_id = id 的组合——于是每篇文章配上了它真正的作者。理解了「组合再过滤」,几个常见困惑就自然消解了:

  • LEFT JOIN:左表的行即使在右表找不到匹配也要保留,匹配不上的列填 NULL。「列出所有用户及其文章数,包括没发过文的」就必须用它;
  • 一对多 JOIN 会让行变多:一个用户 JOIN 十篇文章,结果就是这个用户出现十次。所以 JOIN 之后再 COUNT(*) 前,想清楚你要数的是什么;
  • 实际执行时数据库并不会真的生成完整笛卡尔积(那就太慢了),它会用索引直接定位匹配行——这是下一篇索引的内容,这里先记住逻辑语义即可。

实战:博客系统的完整建表

把前面的原则串起来,给一个博客系统设计 schema。需求:用户发文章,文章有标签(一篇文章多个标签,一个标签多篇文章),读者发评论。

CREATE TABLE users (
  id          BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  email       VARCHAR(255) NOT NULL UNIQUE,
  nickname    VARCHAR(64)  NOT NULL,
  password_hash CHAR(60)   NOT NULL,   -- 只存哈希,永远不存明文
  created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE posts (
  id          BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  author_id   BIGINT UNSIGNED NOT NULL,
  title       VARCHAR(255) NOT NULL,
  content     MEDIUMTEXT   NOT NULL,
  status      VARCHAR(16)  NOT NULL DEFAULT 'draft', -- draft/published
  comment_count INT UNSIGNED NOT NULL DEFAULT 0,     -- 反范式的冗余计数
  created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (author_id) REFERENCES users(id)
);

CREATE TABLE tags (
  id   BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(64) NOT NULL UNIQUE
);

-- 多对多关系必须靠中间表表达
CREATE TABLE post_tags (
  post_id BIGINT UNSIGNED NOT NULL,
  tag_id  BIGINT UNSIGNED NOT NULL,
  PRIMARY KEY (post_id, tag_id),
  FOREIGN KEY (post_id) REFERENCES posts(id),
  FOREIGN KEY (tag_id)  REFERENCES tags(id)
);

CREATE TABLE comments (
  id         BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  post_id    BIGINT UNSIGNED NOT NULL,
  user_id    BIGINT UNSIGNED NOT NULL,
  content    TEXT NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (post_id) REFERENCES posts(id),
  FOREIGN KEY (user_id) REFERENCES users(id)
);

几个设计决策值得说道:多对多关系(文章和标签)没有捷径,必须用中间表 post_tags,联合主键天然防止重复打标;comment_count 是我们有意识留下的反范式冗余;密码只存哈希(60 字符正好放下 bcrypt 输出);时间字段交给数据库默认值,少一个应用层出错的点。

趁热打铁:三条高频查询怎么写

表建好了,马上用它回答三个真实业务问题,体会「好设计让查询变简单」:

-- 1. 首页:最新发布的 10 篇文章,带上作者昵称
SELECT p.id, p.title, u.nickname, p.created_at
FROM posts p
JOIN users u ON p.author_id = u.id
WHERE p.status = 'published'
ORDER BY p.created_at DESC
LIMIT 10;

-- 2. 作者榜:每个用户的文章数,零文章的新人也要出现
SELECT u.nickname, COUNT(p.id) AS post_count
FROM users u
LEFT JOIN posts p ON p.author_id = u.id AND p.status = 'published'
GROUP BY u.id, u.nickname
ORDER BY post_count DESC;

-- 3. 标签页:「AI」标签下的所有文章标题
SELECT p.title
FROM posts p
JOIN post_tags pt ON pt.post_id = p.id
JOIN tags t ON t.id = pt.tag_id
WHERE t.name = 'AI';

注意第二条里的两个细节:LEFT JOIN 保证没发过文章的用户也出现在榜单上;统计条件 status = 'published' 写在 ON 里而不是 WHERE 里——写在 WHERE 会把没匹配上的行过滤掉,LEFT JOIN 就白用了。这是初学者最常踩的 JOIN 陷阱。第三条则展示了多对多查询的标准姿势:中间表做桥,两次 JOIN 走通。

常见建模错误清单

  • 一张大宽表:几十上百列的表,一半列对一半行是 NULL。本质是多种实体没拆开,回到范式的思路上重做;
  • 用 JSON 字段逃避设计:MySQL 支持 JSON 列不等于应该到处用。JSON 里的字段没法建索引、没法加约束、没法 JOIN。它适合存「结构不固定、只整存整取」的数据(比如商品扩展属性),不适合存要查询、要关联的核心数据;
  • 多值塞进一个字段:标签存成 "科技,AI,教程" 逗号分隔字符串——这是违反 1NF 的经典症状,查询和统计全是灾难;
  • 外键类型对不上:主键是 BIGINT,外键列建成 INT 或 VARCHAR,轻则隐式转换拖累性能,重则插不进去;
  • 给每张表都加 create_by/update_by 等「审计字段」却从不使用:字段不是免费的,加之前想清楚谁会用。

最后送一个判断标准:当你给一个字段找位置犹豫不决时,问自己三个问题——它描述的是哪种实体?这个实体有自己的生命周期吗?改它的时候要连带改多少行? 三个问题答完,字段该去哪张表基本就有答案了。建模没有银弹,但这套追问方式能替你挡掉八成的坏设计。

小结

建模的完整心智模型是:一种实体一张表,主键做身份证,外键做指针;按三大范式拆到没有冗余,再基于实测的性能需求有选择地冗余回来;查询时用 JOIN 把拆散的数据拼回去。SQL 语法只是工具,这套「拆与合」的直觉才是数据库学习的地基。动手建议:把博客系统的建表语句在本地数据库里跑一遍,再试着给它加一个「文章分类」实体——自己设计一遍分类和文章的关系,比再读十遍教程都有用。地基打好了,下一篇我们往深处走:索引为什么快、事务隔离到底隔离了什么。


系列导航:上一篇《吃透语言运行时:以 Node.js 事件循环为例》(/content/backend-expert-04-nodejs-runtime) · 下一篇《数据库(下):B+ 树索引、事务隔离与慢查询优化》(/content/backend-expert-06-database-advanced)

相关教程

数据库(上):SQL、建模与范式 | AIHub