前八章你学会了「查、改、建、约束、视图」,但那些都是在别人建好的表上操作。本章把主动权交给你:从一张白纸开始,设计出「古典文学网」的整个数据库。流程是 需求分析 → 实体与 ER 图 → 三范式检验 → 物理建表 → 落成脚本——这套方法论,换任何一个项目都照样能用。
| 要做的功能 | 需要存什么 | 落到哪张表 |
|---|---|---|
| 浏览 / 筛选 / 搜索作品 | 作品本身(标题、作者、朝代、正文…) | article |
| 按分类看作品 | 分类名、分类简介、排序 | category |
| 写书评、回复书评 | 谁、在哪篇作品下、写了什么、回复的是哪条 | comment |
| 收藏作品 | 谁收藏了哪篇、什么时候 | favorite |
| 给作品或书评点赞 | 谁、点了哪一类对象的哪一个 | user_like |
| 用户中心(注册/登录/资料) | 账号、密码哈希、昵称、头像、简介 | user |
| 首页轮播图 | 图片、标题、跳转链接、排序 | banner |
-- ✗ 反例:把分类名也塞进 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;
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='用户表';
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='文学分类表';
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='文学作品表';
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='书评表';
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='收藏表';
这个唯一键真的能挡住重复吗?种子里已经有用户 2 收藏作品 1 这条记录,再插一次试试:
INSERT INTO favorite (user_id, article_id) VALUES (2, 1);
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='点赞记录表';
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='首页轮播图表';
# 在 pyweb 目录下执行:把整个脚本导进本机 MySQL
mysql -uroot -p < sql/hiwen.sql
SELECT table_name, table_type
FROM information_schema.tables
WHERE table_schema = 'hiwen'
ORDER BY table_type, table_name;
| 步骤 | 要产出的东西 | 本例的结果 |
|---|---|---|
| ① 需求分析 | 功能清单 → 实体清单 | 7 个实体:user / category / article / comment / favorite / user_like / banner |
| ② 画 ER 图 | 方框 + 关系线,标出 1:N / N:N | 6 组外键关系 + 1 个自关联 + 1 个多态引用 |
| ③ 范式检验 | 逐个字段问「它依赖谁」 | 分类名只留 category;N:N 拆成 favorite / user_like |
| ④ 物理建表 | 字段类型、约束、索引、外键 | 7 张 InnoDB / utf8mb4 表,含 2 个联合唯一键、1 个全文索引 |
| ⑤ 落成脚本 | 一个可重复执行的 .sql | hiwen.sql:建库 + 建表 + 视图 + 种子数据 |
-- 第 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='电影-标签关联表';
第 1 题和第 3 题其实是同一个套路:只要出现「多对多」,就多一张中间表;中间表上用「两列联合唯一键」防重复。区别只在于 read_history 比 movie_tag 多带了一个业务字段(阅读时间)。想通这一层,你以后设计任何关系表都不会慌。