码上问学问代码之理,学编程之道

第 9 章 · 项目数据库设计实战

前八章你学会了「查、改、建、约束、视图」,但那些都是在别人建好的表上操作。本章把主动权交给你:从一张白纸开始,设计出「古典文学网」的整个数据库。流程是 需求分析 → 实体与 ER 图 → 三范式检验 → 物理建表 → 落成脚本——这套方法论,换任何一个项目都照样能用。

为什么先设计再动手

表是地基,不是草稿纸
先讲为什么盖楼可以改装修、换家具,但很少有人愿意拆承重墙。数据库的表结构就是承重墙——它一改,上面的 ORM 模型、接口字段、前端页面、已经录进去的数据,全都要跟着动。
新手最典型的翻车方式是这样的:先急着写 Flask,写着写着发现「作品还要支持多个标签」,于是给 article 加个字段凑合;又发现「标签要能筛选」,于是发现那个字段根本没法用……最后只能推倒重来。先花两小时想清楚,能省你两天重写。

需求分析:从功能倒推实体

名词是表,动词是关系
从功能倒推实体:把需求里的「名词」圈出来,一个名词就是一张表功能清单· 用户注册 / 登录· 发作品、看作品· 作品分类浏览· 发表书评、回复书评· 收藏作品· 点赞作品· 首页轮播图圈名词user 用户article 作品category 分类comment 书评favorite 收藏user_like 点赞banner 轮播动词变成「关系」:发→article.user_id 指向 user;评→comment.article_id 指向 article「收藏/点赞」这种谁对谁的关系,单独建关系表(user_id + article_id 成对出现)
动手做别一上来就写 CREATE TABLE。先把「古典文学网」要做的事一条条列出来,再问自己:这件事需要「存」哪些东西?把需求里出现的名词圈出来,每个名词大概率就是一张表。
要做的功能需要存什么落到哪张表
浏览 / 筛选 / 搜索作品作品本身(标题、作者、朝代、正文…)article
按分类看作品分类名、分类简介、排序category
写书评、回复书评谁、在哪篇作品下、写了什么、回复的是哪条comment
收藏作品谁收藏了哪篇、什么时候favorite
给作品或书评点赞谁、点了哪一类对象的哪一个user_like
用户中心(注册/登录/资料)账号、密码哈希、昵称、头像、简介user
首页轮播图图片、标题、跳转链接、排序banner
讲透
  • 「谁、什么、什么时候」三问法:凡是有「谁做了某事」的功能(书评、收藏、点赞),都要多一张表——因为「人」和「物」是多对多关系,中间必须落一张表。
  • 表名用单数:article 而不是 articles。这是本项目的统一约定,也是 Laravel、部分 ORM 的默认风格;关键是全项目一致,不要一半单数一半复数。
  • 两种「多」要分清:分类和作品是 1 : N(一篇作品只属于一个分类),所以分类 id 直接做字段放在 article 里就行;用户和作品是 N : N(一个人收藏多篇、一篇被多人收藏),必须拆中间表。
  • 轮播图为什么单独一张表:它跟别的实体没有任何关系,就是一组「首页要展示的图片」,独立成表最省事。
避坑绝对不要用「一个字段存多个值」来表达多对多。比如想在 user 里加一列 favorites = '1,3,5' —— 这样写看起来省了一张表,实际上:查「这篇作品被谁收藏」得用 LIKE '%3%'(还会把 13、23 一起命中)、没法建索引、没法加外键、删收藏要拼接字符串。这就是下一节要讲的违反第一范式。多对多,一定拆中间表。

ER 图:把关系画出来

眼睛比文字更靠得住
先讲为什么七张表、六组关系,光看文字描述你很难发现「谁忘了关掉哪根线」。ER 图(实体关系图)就是把表画成方框、把关系画成连线,一眼就能看出哪张表是孤岛、哪条线漏了、有没有形成环。写建表语句之前,先把图画出来。
古典文学网 · 7 张表关系(ER 图) category 文学分类 article 文学作品 banner 首页轮播(独立) favorite 收藏 comment 书评 · parent_id 自关联 user_like 点赞记录(多态目标) user 用户 1:N 1:N 1:N 1:N 1:N 1:N 实线 = 外键约束(1 : N)  虚线 = 多态引用,MySQL 无法建外键,靠应用层保证 banner 无外键,是独立表;comment.parent_id 自关联,实现「楼中楼」回复
讲透:怎么读这张图
  • 每个圆角方框是一张表,连线表示外键。1:N 的读法是「一个 category 对应多个 article」,箭头的方向指向 N 的那一头(子表)。
  • 三类关系的落法完全不同:1:N 把外键放在 N 的那张表里(article.category_id);N:N 必须拆中间表(favorite、user_like);1:1 也可以把字段并到同一张表。
  • comment 的自关联是这张图里唯一的一根「回头线」:parent_id 指向同一张表的 id。有了它,一条书评可以挂在另一条书评下面,二级回复就这样实现了。
  • 虚线为什么不是实线:user_like 用 target_type(文章 or 书评)+ target_id 来表示「点了谁」。它可能指向 article,也可能指向 comment——而 MySQL 的外键只能指向一张确定的表,所以这里建不了外键约束,只能靠应用层保证数据正确。这叫「多态引用」。
  • 为什么点赞要设计成通用表:如果给作品点赞和给书评点赞各建一张表,那「我点赞过的所有内容」就得 UNION 两张表。user_like 用 target_type 区分对象类型,一张表搞定,代价是失去外键约束——这是一个有意识的取舍。

三范式:别把同一件事存两遍

1NF / 2NF / 3NF
三范式:三级递进,每级消灭一种「重复存储」1NF列不可再分「作者」拆成 author 与 dynasty 两列,而不是一格塞「李白·唐」2NF消除部分依赖非主键列必须依赖整个主键(关系表用联合主键时最易踩)3NF消除传递依赖分类名不存进 article(它依赖 category_id,间接依赖主键),要查就 JOIN反范式:有时为速度故意冗余(如 article 存一份 author 名冗余)——先守范式,慢了再优化
先讲为什么假设 article 表里既存了 category_id = 1,又存了 category_name = '唐诗'。现在运营把分类改名叫「唐代诗歌」——你得把 6 篇作品里的 category_name 全改一遍。只改了一部分,数据就不一致了:同一个分类 id,名字却有三种。
范式(Normal Form)就是用来防止这种「一个事实存了多处」的一套规则。它一共三级,逐级加严。
动手做用「作品表该不该存分类名」这一个例子,把三级范式一次讲清。
反例与正解.sqlsql可运行
-- ✗ 反例:把分类名也塞进 article(违反 3NF)
-- article 里 category_id 和 category_name 同时存在,
-- category_name 实际依赖的是 category_id,不是 article.id → 传递依赖
CREATE TABLE article_bad (
  id            INT UNSIGNED NOT NULL AUTO_INCREMENT,
  title         VARCHAR(150) NOT NULL,
  category_id   INT UNSIGNED,
  category_name VARCHAR(30), -- ✗ 多余,且会和 category 表打架
  PRIMARY KEY (id)
);

-- ✓ 正解:分类名只存在 category 表里
-- article 只留一个「指针」category_id,名字要用时 JOIN 出来
SELECT a.title, c.name AS category_name
FROM article a
LEFT JOIN category c ON a.category_id = c.id
WHERE a.id = 1;
查询结果SQL 执行结果
运行结果: title | category_name 静夜思 | 唐诗 -- 共 1 行
讲透:comment.parent_id 就是范式的产物
  • 换个笨办法存回复会怎样:如果为了省事,在子回复那行里直接写上「被回复的内容」,那么父评论一旦被作者编辑,所有子回复里存的都是旧文本——又要批量改。这正是 3NF 要禁止的传递依赖。
  • parent_id 只存一个 id(一条指针),需要显示父评论内容时,用第 5 章的自连接临时拼出来:FROM comment r JOIN comment p ON r.parent_id = p.id。数据只存一份,永远一致。
  • 范式带来的「代价」是要多写 JOIN——这正是第 8 章视图存在的理由:把常用的 JOIN 固化成一个名字,规整的数据结构 + 便利的读取口径,两头都占。
  • 要会用「依赖谁」来判断:写下一个字段时问一句「它到底依赖本表的主键,还是依赖另一个字段?」只依赖主键 → 留在本表;依赖别的字段 → 搬出去独立成表。
避坑范式是默认策略,不是教条——刻意反范式是正当的工程手段。本项目的 article.likes 和 comment.likes 就是活生生的例子:点赞明细全都记在 user_like 里,这两列只是「缓存下来的计数」,严格说违反了 3NF。为什么可以接受?因为它们要出现在列表页的每一行上,如果每渲染一篇作品都去 COUNT(*) 一遍 user_like,性能会很难看。冗余必须有同步机制(点赞接口里同时更新计数),而且要清楚自己在冗余什么。

物理建表(上):user / category / article

把图变成 DDL
先讲为什么ER 图只告诉你有哪几张表、怎么连,还没落到「列是什么类型、有没有索引」。物理建表这一步,要把每个字段的类型、约束、索引都定死——顺序上先建被引用的表(category),再建引用它的表(article),否则外键找不到目标。
动手做先建用户表。它是被引用最多的表,放最前面。
01_user.sqlsql可运行
CREATE TABLE `user` (
  `id`         INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID,主键自增',
  `username`   VARCHAR(50)  NOT NULL                COMMENT '登录名(唯一)',
  `password`   VARCHAR(255) NOT NULL                COMMENT '密码(后端哈希后存储,切勿明文)',
  `nickname`   VARCHAR(50)      DEFAULT NULL        COMMENT '昵称',
  `email`      VARCHAR(100)     DEFAULT NULL        COMMENT '邮箱(唯一,可空)',
  `avatar`     VARCHAR(255)     DEFAULT ''          COMMENT '头像图片路径',
  `intro`      VARCHAR(255)     DEFAULT NULL        COMMENT '个人简介',
  `gender`     TINYINT          DEFAULT 0           COMMENT '0未知 1男 2女',
  `status`     TINYINT      NOT NULL DEFAULT 1      COMMENT '1正常 0禁用',
  `created_at` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_username` (`username`),
  UNIQUE KEY `uk_user_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
查询结果SQL 执行结果
运行结果: Query OK, 0 rows affected -- 表 user 已创建
动手做再建分类表。它很简单,但有个关键约束:分类名不能重复。
02_category.sqlsql可运行
CREATE TABLE `category` (
  `id`          INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '分类ID',
  `name`        VARCHAR(30)  NOT NULL                COMMENT '分类名(如 唐诗、宋词)',
  `description` VARCHAR(255)     DEFAULT NULL        COMMENT '分类简介',
  `sort`        INT          NOT NULL DEFAULT 0      COMMENT '排序值,越小越前',
  `status`      TINYINT      NOT NULL DEFAULT 1      COMMENT '1显示 0隐藏',
  `created_at`  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_category_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文学分类表';
查询结果SQL 执行结果
运行结果: Query OK, 0 rows affected -- 表 category 已创建
动手做最后建主角 article。它身上有 1 个外键、2 个普通索引、1 个全文索引——这是本章最需要细看的一张表。
03_article.sqlsql可运行
CREATE TABLE `article` (
  `id`          INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '作品ID',
  `title`       VARCHAR(150) NOT NULL                COMMENT '标题',
  `author`      VARCHAR(50)      DEFAULT NULL        COMMENT '作者(如 李白)',
  `dynasty`     VARCHAR(20)      DEFAULT NULL        COMMENT '朝代(如 唐)',
  `category_id` INT UNSIGNED     DEFAULT NULL        COMMENT '所属分类',
  `cover`       VARCHAR(255)     DEFAULT ''          COMMENT '封面图路径',
  `summary`     VARCHAR(500)     DEFAULT NULL        COMMENT '内容摘要',
  `content`     TEXT                                 COMMENT '正文',
  `views`       INT UNSIGNED NOT NULL DEFAULT 0      COMMENT '浏览量',
  `likes`       INT UNSIGNED NOT NULL DEFAULT 0      COMMENT '点赞数',
  `is_recommend` TINYINT     NOT NULL DEFAULT 0      COMMENT '1首页推荐 0普通',
  `status`      TINYINT      NOT NULL DEFAULT 1      COMMENT '1已发布 0草稿',
  `created_at`  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at`  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_article_category` (`category_id`),
  KEY `idx_article_recommend` (`is_recommend`, `status`),
  FULLTEXT KEY `ft_article_title_summary` (`title`, `summary`),
  CONSTRAINT `fk_article_category` FOREIGN KEY (`category_id`)
    REFERENCES `category` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文学作品表';
查询结果SQL 执行结果
运行结果: Query OK, 0 rows affected -- 表 article 已创建
讲透:每一处设计都有理由
  • password VARCHAR(255):密码绝不存明文,存的是后端 generate_password_hash() 生成的哈希串,长度远超 50,所以要留 255。种子数据里那一串 PUT_HASH_HERE 就是占位符,提醒你必须自己生成后替换。
  • UNIQUE KEY uk_user_email:邮箱要能当登录凭据,就不能重复。但它允许 NULL——MySQL 的唯一索引允许多个 NULL,正好适配「邮箱可以不填」。
  • ON UPDATE CURRENT_TIMESTAMP:只在「第一次插入」写 created_at 好办,但「每次改动自动刷新 updated_at」如果靠应用层写,总有接口会忘。交给数据库,一劳永逸。
  • fk_article_category 用 ON DELETE SET NULL:分类被删掉时,作品不该跟着消失(那是内容资产)。所以把 category_id 置成 NULL,作品变成「未分类」——这也正是第 8 章 v_article_list 必须用 LEFT JOIN 的原因。
  • idx_article_category:外键列在 InnoDB 里最好显式建索引,因为「按分类筛作品」是最高频的查询之一。
  • idx_article_recommend (is_recommend, status) 是联合索引:首页推荐位的条件恰好就是这两个字段一起用,联合索引比两个单列索引更贴合实际查询。
  • FULLTEXT ft_article_title_summary (title, summary):为「按关键词搜索」准备的全文索引。注意:它只在 title 和 summary 上,content(正文)故意没放进去——这是有意识的取舍。

物理建表(下):comment / favorite / user_like / banner

关系表才是设计难点
先讲为什么前三张表是「实体」,接下来这四张里有两张是「关系」——它们没什么自己的字段,存在的意义就是把两个实体连起来并约束住。关系表的设计难点不在字段,而在联合唯一键:它决定了「不能重复」这条业务规则能不能被数据库强制保证。
动手做先建书评表,它有三个外键,其中一个指向自己。
04_comment.sqlsql可运行
CREATE TABLE `comment` (
  `id`         INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '书评ID',
  `user_id`    INT UNSIGNED NOT NULL                COMMENT '评论人',
  `article_id` INT UNSIGNED NOT NULL                COMMENT '被评作品',
  `parent_id`  INT UNSIGNED     DEFAULT NULL        COMMENT '父评论ID(回复时填,否则 NULL)',
  `content`    VARCHAR(1000) NOT NULL               COMMENT '书评内容',
  `likes`      INT UNSIGNED NOT NULL DEFAULT 0      COMMENT '该条书评点赞数',
  `status`     TINYINT      NOT NULL DEFAULT 1      COMMENT '1正常 0已删除',
  `created_at` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_comment_article` (`article_id`, `status`),
  KEY `idx_comment_user` (`user_id`),
  KEY `idx_comment_parent` (`parent_id`),
  CONSTRAINT `fk_comment_user`    FOREIGN KEY (`user_id`)    REFERENCES `user` (`id`)    ON DELETE CASCADE,
  CONSTRAINT `fk_comment_article` FOREIGN KEY (`article_id`) REFERENCES `article` (`id`) ON DELETE CASCADE,
  CONSTRAINT `fk_comment_parent`  FOREIGN KEY (`parent_id`)  REFERENCES `comment` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='书评表';
查询结果SQL 执行结果
运行结果: Query OK, 0 rows affected -- 表 comment 已创建(自关联外键允许指向本表)
动手做再建收藏表。它的全部意义就在那个联合唯一键上:同一用户对同一作品只能收藏一次。
05_favorite.sqlsql可运行
CREATE TABLE `favorite` (
  `id`         INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `user_id`    INT UNSIGNED NOT NULL COMMENT '用户',
  `article_id` INT UNSIGNED NOT NULL COMMENT '作品',
  `created_at` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_fav_user_article` (`user_id`, `article_id`),
  KEY `idx_fav_article` (`article_id`),
  CONSTRAINT `fk_fav_user`    FOREIGN KEY (`user_id`)    REFERENCES `user` (`id`)    ON DELETE CASCADE,
  CONSTRAINT `fk_fav_article` FOREIGN KEY (`article_id`) REFERENCES `article` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收藏表';
查询结果SQL 执行结果
运行结果: Query OK, 0 rows affected -- 表 favorite 已创建

这个唯一键真的能挡住重复吗?种子里已经有用户 2 收藏作品 1 这条记录,再插一次试试:

验证唯一键.sqlsql可运行
INSERT INTO favorite (user_id, article_id) VALUES (2, 1);
查询结果SQL 执行结果
运行结果: ERROR 1062 (23000): Duplicate entry '2-1' for key 'favorite.uk_fav_user_article' -- 被联合唯一键拦下:用户 2 已经收藏过作品 1(种子数据里就有这条)
动手做点赞表要「一张表管作品和书评两种对象」,靠的是 ENUM 加三列联合唯一键。
06_user_like.sqlsql可运行
CREATE TABLE `user_like` (
  `id`          INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `user_id`     INT UNSIGNED NOT NULL,
  `target_type` ENUM('article','comment') NOT NULL COMMENT '点赞对象类型',
  `target_id`   INT UNSIGNED NOT NULL                COMMENT '对象ID',
  `created_at`  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_like` (`user_id`, `target_type`, `target_id`),
  KEY `idx_like_target` (`target_type`, `target_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='点赞记录表';
查询结果SQL 执行结果
运行结果: Query OK, 0 rows affected -- 表 user_like 已创建(注意:这里没有 FOREIGN KEY)
动手做最后一张,轮播图表,简单收尾。
07_banner.sqlsql可运行
CREATE TABLE `banner` (
  `id`         INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `title`      VARCHAR(100) NOT NULL COMMENT '标题',
  `image`      VARCHAR(255) NOT NULL COMMENT '图片路径',
  `link`       VARCHAR(255) DEFAULT ''   COMMENT '点击跳转链接',
  `sort`       INT          NOT NULL DEFAULT 0,
  `status`     TINYINT      NOT NULL DEFAULT 1,
  `created_at` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='首页轮播图表';
查询结果SQL 执行结果
运行结果: Query OK, 0 rows affected -- 表 banner 已创建
讲透:这几处设计的「为什么」
  • comment 的 ON DELETE CASCADE 和 article 的 SET NULL 不同:作品被删了,它的书评就没有存在的意义 → 级联删掉;分类被删了,作品还要留下 → 置空。同一件事,两种删法,取决于「谁是附庸、谁是主体」。
  • uk_like (user_id, target_type, target_id) 是「三列联合唯一」:它保证「同一个用户、对同一个类型的同一个对象,只能点一次赞」。业务上的「再点一次 = 取消点赞」,在这种约束下就变成了 DELETE 那一行,逻辑很干净。
  • idx_like_target (target_type, target_id):查「这篇作品被点了多少个赞」时,WHERE target_type = 'article' AND target_id = 1 正好走这个联合索引;列的顺序也讲究——先 target_type 再 target_id,与查询条件一致。
  • idx_comment_article (article_id, status):详情页要的是「这篇作品下、状态正常的书评」,两列一起进索引,一次命中。
  • favorite 的 idx_fav_article:唯一键 (user_id, article_id) 已经能服务「某用户收藏了哪些」;但反过来「这篇作品被多少人收藏」用不到它,所以要单独给 article_id 建索引。唯一键与普通索引各管一个方向,这是关系表的常见套路。
  • banner 干净利落:没有外键、没有关系,只有 sort 排序和 status 开关。不是每张表都要有关系——能简单就简单。
避坑user_like 里故意没有写 FOREIGN KEY,这不是漏了。target_id 有时指向 article.id、有时指向 comment.id,而 MySQL 的外键只能指向一张确定的表,没法表达这种「多态引用」。代价是:删作品或删书评时,对应的点赞记录不会被自动清理,得由应用层负责删;target_id 也可能因为代码 bug 指向一条不存在的数据。这类「数据库保证不了」的规则,必须在后端代码里补上——认识到哪些约束该由谁负责,比背语法重要得多。

从设计到脚本:hiwen.sql

让别人一条命令就能跑起来
先讲为什么你的设计只写在纸上,同学没法用。把它整理成一个脚本,任何人一条命令就能得到和你完全一样的数据库——这才是可交付的设计。这也是为什么项目里要做数据迁移(migration):数据库结构本身,也是要进版本管理的代码。
动手做脚本 pyweb/sql/hiwen.sql 的顺序是固定的,照着这个骨架写就不会错:
① DROP DATABASE IF EXISTS + CREATE DATABASE(保证可重复执行);
② SET FOREIGN_KEY_CHECKS = 0;
③ 七张表按「被引用者优先」的顺序 CREATE;
④ SET FOREIGN_KEY_CHECKS = 1;
⑤ 建视图 v_article_list;
⑥ 插入种子数据。
导入数据库.shbash可运行
# 在 pyweb 目录下执行:把整个脚本导进本机 MySQL
mysql -uroot -p < sql/hiwen.sql
终端输出命令行执行结果
运行结果: Enter password: -- 无报错即导入成功(脚本开头会先删掉同名库再重建)
动手做用 information_schema 查一下,确认七张表和一个视图都在。
验收.sqlsql可运行
SELECT table_name, table_type
FROM information_schema.tables
WHERE table_schema = 'hiwen'
ORDER BY table_type, table_name;
查询结果SQL 执行结果
运行结果: table_name | table_type article | BASE TABLE banner | BASE TABLE category | BASE TABLE comment | BASE TABLE favorite | BASE TABLE user | BASE TABLE user_like | BASE TABLE v_article_list | VIEW -- 共 8 行
讲透:脚本里那几个「怪地方」都要有理由
  • 开头为什么敢 DROP DATABASE:因为这是教学/开发用的初始化脚本,要的就是「一条命令回到干净状态」。生产库绝对不能这么写——脚本头部那句注释「慎用于生产环境」就是这个意思。
  • 为什么要临时 SET FOREIGN_KEY_CHECKS = 0:建表、清表时如果外键互相牵制(comment 引用 article,而 article 未必先建好),顺序稍有不慎就会报外键错。先关掉检查、建完再打开,是最省心的做法。
  • 为什么表用 DROP TABLE IF EXISTS 而视图用 CREATE OR REPLACE VIEW:两者都能让脚本重复执行,但视图用 OR REPLACE 更简洁——下一行的 SELECT 直接覆盖旧定义。
  • CREATE DATABASE 时就要定字符集:utf8mb4 + utf8mb4_general_ci。中文、emoji 都能存;忘了设,后面所有表都跟着错。
  • 种子数据和建表要放在同一个脚本里:只有结构没有数据,别人导进去也没法练查询。种子里那 5 个分类、6 篇作品,就是为了让每一章的示例都能直接跑出结果。

小结:一套可复用的设计方法

需求 → ER → 范式 → 建表
动手做把这五步固化下来。以后不管做「古典文学网」还是「红影赏析」,都照这个顺序走一遍。
步骤要产出的东西本例的结果
① 需求分析功能清单 → 实体清单7 个实体:user / category / article / comment / favorite / user_like / banner
② 画 ER 图方框 + 关系线,标出 1:N / N:N6 组外键关系 + 1 个自关联 + 1 个多态引用
③ 范式检验逐个字段问「它依赖谁」分类名只留 category;N:N 拆成 favorite / user_like
④ 物理建表字段类型、约束、索引、外键7 张 InnoDB / utf8mb4 表,含 2 个联合唯一键、1 个全文索引
⑤ 落成脚本一个可重复执行的 .sqlhiwen.sql:建库 + 建表 + 视图 + 种子数据
讲透:三个「不要」
  • 不要跳过需求直接建表。表结构是需求的镜像;需求没想清,表一定漏字段或冗余。
  • 不要死守范式到影响性能。先按范式设计,遇到明确的热点再做「有意识的冗余」(比如 article.likes),并且写清同步逻辑。
  • 不要让设计只留在脑子里。ER 图 + .sql 脚本一起进 Git,这才是别人能接手的东西。
动手练习
1需求分析:「古典文学网」要新增「阅读历史」——记录用户看过哪些作品、最近一次是什么时候看的(同一个人看同一篇只记一条)。请说明需要新增几张表、和现有表是什么关系,并写出建表语句。
2范式判断:article 表里有一个刻意的「反范式」字段,是哪个?它为什么可以接受?如果去掉它,查询会付出什么代价?
3迁移练习:「红影赏析」要给电影加「标签」(一部电影可以有多个标签,一个标签也能贴到多部电影上)。请说明这是几对几的关系、需要新增哪几张表,并写出建表语句。
参考答案
练习答案.sqlsql可运行
-- 第 1 题:阅读历史 → 新增 1 张表(user 与 article 的「多对多 + 时间属性」)
CREATE TABLE `read_history` (
  `id`         INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `user_id`    INT UNSIGNED NOT NULL COMMENT '用户',
  `article_id` INT UNSIGNED NOT NULL COMMENT '作品',
  `read_at`    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最近阅读时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_read_user_article` (`user_id`, `article_id`),
  KEY `idx_read_user_time` (`user_id`, `read_at`),
  CONSTRAINT `fk_read_user`    FOREIGN KEY (`user_id`)    REFERENCES `user` (`id`)    ON DELETE CASCADE,
  CONSTRAINT `fk_read_article` FOREIGN KEY (`article_id`) REFERENCES `article` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='阅读历史表';

-- 第 2 题:article.likes 是反范式字段(点赞明细记在 user_like 里)
--   可以接受:列表页每一行都要显示点赞数,逐行 COUNT(user_like) 代价过高;
--   去掉它就得每页多跑 10 次聚合查询,或改用冗长的 JOIN + GROUP BY。
--   代价是:必须由「点赞接口」在同一事务里同步维护这一列。

-- 第 3 题(红影赏析):电影 ↔ 标签 是 N:N,必须拆成三张表
CREATE TABLE `tag` (
  `id`   INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `name` VARCHAR(30)  NOT NULL COMMENT '标签名',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_tag_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='标签表';

CREATE TABLE `movie_tag` (
  `id`       INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `movie_id` INT UNSIGNED NOT NULL,
  `tag_id`   INT UNSIGNED NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_movie_tag` (`movie_id`, `tag_id`),
  KEY `idx_mt_tag` (`tag_id`),
  CONSTRAINT `fk_mt_movie` FOREIGN KEY (`movie_id`) REFERENCES `movie` (`id`) ON DELETE CASCADE,
  CONSTRAINT `fk_mt_tag`   FOREIGN KEY (`tag_id`)   REFERENCES `tag` (`id`)   ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电影-标签关联表';
查询结果SQL 执行结果
运行结果: Query OK, 0 rows affected -- 第 1 题:新增 read_history 一张表即可(user 1:N read_history,article 1:N read_history) Query OK, 0 rows affected Query OK, 0 rows affected -- 第 3 题:tag + movie_tag。movie_tag 是典型的 N:N 中间表, -- uk_movie_tag 防止同一部电影重复贴同一个标签

第 1 题和第 3 题其实是同一个套路:只要出现「多对多」,就多一张中间表;中间表上用「两列联合唯一键」防重复。区别只在于 read_history 比 movie_tag 多带了一个业务字段(阅读时间)。想通这一层,你以后设计任何关系表都不会慌。

本章小结