83 KiB
数据模型:设计的"骨架"
::: tip 🎯 核心问题 如何设计合理的数据结构? 这就像问:盖房子前怎么画图纸?仓库怎么摆放货物最高效?家族谱系怎么记录最清晰?数据模型解决的就是"数据如何组织"的问题。 :::
0. 先问一个问题:你有没有经历过这些噩梦?
场景一:表设计混乱
users 表:
| id | name | address | order_1 | order_1_amount | order_2 | order_2_amount | ... |
订单字段重复 100 次,每次下单都要改表结构。
场景二:数据冗余严重
orders 表:
| id | user_name | user_email | user_phone | product_name | product_price |
用户信息、商品信息全部复制到订单表,修改用户邮箱需要更新所有历史订单。
场景三:关系处理错误
posts 表:
| id | title | tags |
| 1 | Vue入门 | vue,frontend,javascript |
用逗号分隔存储标签,无法查询"有哪些文章包含 vue 标签"。
好的数据模型就像建筑蓝图——结构清晰、扩展灵活、关系明确。
0.5 从零开始:什么是数据?
在深入了解数据模型之前,我们需要先搞清楚几个最基础的问题。
0.5.1 什么是数据?
数据就是对事物的记录。
生活中的例子:
- 你的购物车里的商品:商品名称、价格、数量、图片链接
- 你的通讯录:姓名、电话、邮箱、地址
- 你的书架:书名、作者、出版社、出版年份
- 你的游戏存档:等级、经验值、装备、金币
这些都是"数据"——它们记录了某种信息。
0.5.2 数据为什么要组织?
想象一下这些场景:
场景一:杂乱的通讯录
张三 13800138000 北京朝阳区
李四 13900139000 上海浦东新区
王五 13700137000 lisi@example.com
赵六 13600136000 广州天河区 zhaoliu@example.com
所有信息混在一起,你想:
- 找"李四"的电话号码 → 需要逐行扫描
- 找所有"广州"的人 → 需要逐行判断
- 添加一个人的邮箱 → 格式不统一,不知道放哪里
场景二:有组织的通讯录
姓名 电话 邮箱 城市
张三 13800138000 zhangsan@example.com 北京朝阳区
李四 13900139000 lisi@example.com 上海浦东新区
王五 13700137000 wangwu@example.com 深圳南山区
赵六 13600136000 zhaoliu@example.com 广州天河区
现在:
- 找"李四"的电话 → 在"姓名"列查找 → 快速定位
- 找所有"广州"的人 → 在"城市"列筛选 → 一目了然
- 添加新信息 → 按列填写 → 格式统一
结论:数据需要组织才能高效使用!
0.5.3 一个简单的例子:记录你的书架
让我们用一个最简单的例子——"记录你的书架"——来理解数据模型是如何一步步形成的。
第一步:原始想法(用一张纸记录)
我的书架:
《JavaScript高级程序设计》 Matt Frisbie 2020年 人民邮电出版社
《Vue.js设计与实现》 霍春阳 2022年 人民邮电出版社
《深入浅出Node.js》 朴灵 2013年 人民邮电出版社
问题:
- 想找"2022年"出版的书 → 需要逐行看
- 想找"人民邮电出版社"的所有书 → 需要逐行判断
- 想按"出版年份"排序 → 需要重新整理
第二步:用表格组织(这就是数据模型的雏形)
| 书名 | 作者 | 出版年份 | 出版社 |
|---------------------------|-----------|---------|--------------|
| JavaScript高级程序设计 | Matt Frisbie | 2020 | 人民邮电出版社 |
| Vue.js设计与实现 | 霍春阳 | 2022 | 人民邮电出版社 |
| 深入浅出Node.js | 朴灵 | 2013 | 人民邮电出版社 |
改进:
- 每列有明确的含义(书名、作者、出版年份、出版社)
- 可以按任意列排序
- 可以按任意列筛选(比如找出所有"人民邮电出版社"的书)
第三步:发现新需求(多本书有多个作者)
问题:《重构:改善既有代码的设计》有两个作者:Martin Fowler 和 Kent Beck
怎么记录?
错误做法:把两个名字塞进一个字段
| 书名 | 作者 |
|------|------------------------|
| 重构 | Martin Fowler, Kent Beck |
问题:
- 无法单独查询"Martin Fowler 写了哪些书"
- 无法统计"每个作者写了几本书"
- 修改作者名字时需要字符串操作
正确做法:拆分成两张表,用"关系"连接
书籍表:
| 书名 | 出版年份 |
|--------------|---------|
| 重构 | 2019 |
| JavaScript高级程序设计 | 2020 |
作者表:
| 作者姓名 |
|------------------|
| Martin Fowler |
| Kent Beck |
| Matt Frisbie |
书籍-作者关联表:
| 书名 | 作者姓名 |
|--------------|---------------|
| 重构 | Martin Fowler |
| 重构 | Kent Beck |
| JavaScript高级程序设计 | Matt Frisbie |
这就是数据模型设计的核心:
- 识别实体(书、作者)
- 设计属性(书名、出版年份、作者姓名)
- 建立关系(一本书可以有多个作者)
0.5.4 什么是数据模型?
数据模型(Data Model)就是对"数据如何组织、存储、关联"的设计方案。
用盖房子来类比:
| 盖房子 | 数据模型 |
|---|---|
| 蓝图设计 | 数据模型设计 |
| 客厅、卧室、厨房 | 不同的表(用户表、订单表、商品表) |
| 承重墙、水电管线 | 主键、外键(关系) |
| 房屋的格局 | 表的结构(字段、类型) |
::: tip 💡 为什么要设计数据模型? 想象一下:
- 没有蓝图盖房子 → 房子可能会倒、功能混乱、无法扩建
- 没有数据模型写程序 → 数据冗余、查询缓慢、难以维护
好的数据模型让你:
- 数据不重复(节省空间)
- 查询快速(性能好)
- 易于扩展(新增功能方便)
- 避免错误(数据一致性强) :::
1. 数据模型的重要性
数据模型(Data Model)是对现实世界的抽象,描述数据如何存储、组织和关联。
1.1 用建筑来类比
| 建筑概念 | 对应概念 | 说明 |
|---|---|---|
| 蓝图 | 数据模型 | 设计的"骨架"和结构 |
| 承重墙 | 主键/外键 | 保证结构稳固的核心 |
| 房间布局 | 表结构 | 各个功能单元的设计 |
| 水电管线 | 关系 | 连接各个部分的数据流 |
1.2 数据模型的层次
| 层次 | 内容 | 示例 |
|---|---|---|
| 概念模型 | 业务对象和关系 | 用户、订单、商品 |
| 逻辑模型 | 表结构、关系类型 | users 表 1:N orders 表 |
| 物理模型 | 具体存储实现 | 字段类型、索引、分区 |
2. ER 图:实体关系建模
2.0 为什么需要画图?
想象你和朋友聊天,描述你的家庭关系:
用文字描述:
"我有一个爸爸、一个妈妈、一个哥哥。我爸爸的父母是我爷爷奶奶,我妈妈的父母是我外公外婆。我哥哥结婚了,有一个嫂子。"
听的人可能会困惑:
- 你哥哥和你嫂子是什么关系?
- 你和你嫂子是什么关系?
- 如果你哥哥又有孩子,那孩子和你是什么关系?
用图画出来:
爷爷 -- 奶奶
|
爸爸 -- 妈妈 -- 外公 -- 外婆
| |
你 哥哥 -- 嫂子
瞬间就清晰了!
ER 图的作用:
- 把复杂的"数据关系"可视化
- 帮助设计者理清思路
- 方便团队沟通讨论
- 在写代码前发现问题
ER 图(Entity-Relationship Diagram,实体关系图)就是专门用来画"数据关系"的工具。
2.1 手把手:从零画出你的第一个 ER 图
让我们用一个家庭关系的例子,一步步画出 ER 图。
第一步:识别"实体"
实体(Entity)就是现实世界中的"对象"或"事物"。
在家庭关系中,实体有:
- 人(Person)
- 家庭(Family)
问题:我们把"人"作为核心实体,"家庭"可以暂时忽略。
第二步:确定"属性"
属性(Attribute)就是实体的"特征"或"信息"。
"人"这个实体的属性:
- 姓名(name)
- 性别(gender)
- 出生日期(birth_date)
- 身份证号(id_card)
第三步:识别"关系"
关系(Relationship)就是实体之间的"联系"。
家庭中的关系:
- 结婚(丈夫 ↔ 妻子)
- 亲子(父母 → 孩子)
- 兄弟姐妹(兄弟姐妹之间)
第四步:确定"关系类型"
每种关系都有一个"基数"(Cardinality):
一对一(1:1):
- 一个丈夫 ↔ 一个妻子(假设一夫一妻制)
丈夫 1 ---- 1 妻子
一对多(1:N):
- 一个父母 → 多个孩子
父母 1 ---- N 孩子
多对多(M:N):
- 多个兄弟姐妹 ↔ 多个兄弟姐妹
兄弟姐妹 M ---- N 兄弟姐妹
第五步:画出完整的 ER 图
ER 图的基本符号:
| 符号 | 名称 | 含义 | 生活类比 |
|---|---|---|---|
| ▭ | 矩形 | 实体(表) | 一个"箱子"装一类数据 |
| ▭ | 椭圆 | 属性(字段) | 箱子里的"物品" |
| ◇ | 菱形 | 关系 | 箱子之间的"连线" |
| — | 线条 | 连接 | 把东西串起来的"绳子" |
家庭关系的 ER 图示例:
┌─────────┐
│ 人 │
└─────────┘
│ 姓名 │
│ 性别 │
│ 出生日期│
└─────────┘
│
│
┌────┴────┐
│ │
结婚 亲子
1:1 1:N
::: tip 💡 为什么要画 ER 图?
- 设计前理清思路:画图比改代码容易得多
- 团队沟通:一张图胜过千言万语
- 发现遗漏:画图时容易发现"咦,这个关系好像没考虑"
- 文档留存:新人看图就能理解系统结构 :::
2.2 核心概念详解
实体(Entity)
实体就是你想记录的"对象"或"事物"。
示例:
- 用户(User)
- 订单(Order)
- 商品(Product)
- 文章(Article)
如何识别实体? 问自己:"我需要记录什么信息?"
- 我需要记录用户 → 用户实体
- 我需要记录订单 → 订单实体
- 我需要记录商品 → 商品实体
属性(Attribute)
属性就是实体的"特征"或"详细信息"。
示例:
- 用户实体的属性:用户名、邮箱、电话、注册时间
- 订单实体的属性:订单号、金额、状态、创建时间
如何识别属性? 问自己:"这个实体有哪些信息需要记录?"
关系(Relationship)
关系就是实体之间的"联系"。
示例:
- 用户下订单(下单关系)
- 订单包含商品(包含关系)
- 用户购买商品(购买关系)
如何识别关系? 问自己:"这些实体之间有什么关联?"
2.3 用一个简单例子练习
让我们画一个班级管理系统的 ER 图。
第一步:识别实体
在这个系统中,有哪些实体?
- 学生(Student)
- 课程(Course)
- 老师(Teacher)
第二步:确定属性
每个实体有哪些属性?
学生:学号、姓名、班级 课程:课程编号、课程名称、学分 老师:工号、姓名、职称
第三步:识别关系
- 学生选修课程(多对多:一个学生可以选多门课,一门课可以被多个学生选)
- 老师教授课程(一对多:一个老师可以教多门课,一门课通常由一个老师教)
第四步:画出 ER 图
┌─────────┐
│ 学生 │
└─────────┘
│ 学号 │
│ 姓名 │
│ 班级 │
└─────────┘
│
│ 选修 (M:N)
│
┌─────────┐
│ 课程 │
└─────────┘
│ 课程编号│
│ 课程名称│
│ 学分 │
└─────────┘
│
│ 教授 (1:N)
│
┌─────────┐
│ 老师 │
└─────────┘
│ 工号 │
│ 姓名 │
│ 职称 │
└─────────┘
2.4 核心概念总结表
| 符号 | 含义 | 示例 | 通俗理解 |
|---|---|---|---|
| 矩形 | 实体(表) | 用户、订单、商品 | 一个"箱子"装一类数据 |
| 椭圆 | 属性(字段) | 用户名、邮箱、电话 | 箱子里的"物品" |
| 菱形 | 关系 | 下单、支付、评论 | 箱子之间的"连线" |
| 线条 | 连接 | 表与表的关联 | 把东西串起来的"绳子" |
| 符号 | 含义 | 示例 |
|---|---|---|
| 矩形 | 实体(表) | 用户、订单、商品 |
| 椭圆 | 属性(字段) | 用户名、邮箱、电话 |
| 菱形 | 关系 | 下单、支付、评论 |
| 线条 | 连接 | 表与表的关联 |
2.5 完整的 ER 图示例
👇 动手试试看:探索用户-订单-商品的实体关系模型:
3. 关系类型:一对一、一对多、多对多
关系类型决定了表之间如何关联,是数据模型设计的核心。
3.1 一对一(One-to-One)
3.1.1 什么是"一对一"?
定义:A 表的一条记录对应 B 表的一条记录,反过来也成立。
生活类比:
- 一个人 ↔ 一个身份证号码
- 一个丈夫 ↔ 一个妻子(一夫一妻制)
- 一个国家 ↔ 一个首都
3.1.2 用生活场景理解
想象你在做一个用户系统:
场景:用户基本信息 + 详细资料
你发现用户信息太多了:
- 基本信息:用户名、邮箱、密码(经常用)
- 详细资料:昵称、头像、个人简介、生日、地址、手机号(不常用)
问题:要不要把所有信息放一张表?
方案一:全放一张表
users 表:
| id | username | email | password | nickname | avatar | bio | birthday | address | phone |
缺点:
- 表太宽,字段太多(超过 20 个)
- 查询"登录"时只需要基本字段,但会加载所有字段(浪费性能)
- 详细资料可能很长(bio、avatar),拖慢查询速度
方案二:拆分成两张表(一对一关系)
users 表(基本信息):
| id | username | email | password_hash |
| 1 | zhangsan | zhangsan@qq.com | xxxxx... |
user_profiles 表(详细资料):
| user_id | nickname | avatar | bio | birthday |
| 1 | 小张 | ... | 热爱编程... | 1995-06-15 |
优点:
- 登录时只查 users 表,快速
- 查看资料时再 JOIN user_profiles 表
- 分离敏感信息(password)和非敏感信息
关系表示:
users (1) ---- (1) user_profiles
| |
id (主键) user_id (主键 + 外键)
3.1.3 SQL 实现
方式 1:外键唯一约束(推荐)
-- 用户表
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 用户详细资料表
CREATE TABLE user_profiles (
user_id BIGINT PRIMARY KEY, -- user_id 同时是主键和外键
nickname VARCHAR(50),
avatar VARCHAR(255),
bio TEXT,
birthday DATE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- 插入数据
INSERT INTO users (id, username, email, password_hash) VALUES
(1, 'zhangsan', 'zhangsan@qq.com', 'hashed_password');
INSERT INTO user_profiles (user_id, nickname, bio) VALUES
(1, '小张', '热爱编程,热爱生活');
-- 查询:获取用户完整信息
SELECT u.*, p.nickname, p.bio, p.avatar
FROM users u
LEFT JOIN user_profiles p ON u.id = p.user_id
WHERE u.id = 1;
方式 2:在主表添加外键(不推荐)
-- 详细资料表
CREATE TABLE user_profiles (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
nickname VARCHAR(50),
bio TEXT
);
-- 用户表(引用 profile)
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50),
profile_id BIGINT UNIQUE, -- 唯一约束
FOREIGN KEY (profile_id) REFERENCES user_profiles(id)
);
为什么方式 1 更好?
- 用户是"主实体",资料是"从属信息"
- 先有用户,再有资料
- 删除用户时,资料也应该删除(CASCADE)
3.1.4 数据样子
users 表:
┌────┬──────────┬─────────────────┬───────────────┐
│ id │ username │ email │ password_hash │
├────┼──────────┼─────────────────┼───────────────┤
│ 1 │ zhangsan │ zhangsan@qq.com │ xxxxx... │
│ 2 │ lisi │ lisi@qq.com │ yyyyy... │
└────┴──────────┴─────────────────┴───────────────┘
│
│ 一对一关系
│
user_profiles 表:
┌──────────┬──────────┬─────────┬──────────────┐
│ user_id │ nickname │ avatar │ bio │
├──────────┼──────────┼─────────┼──────────────┤
│ 1 │ 小张 │ avatar1 │ 热爱编程... │
│ 2 │ 小李 │ avatar2 │ 喜欢读书... │
└──────────┴──────────┴─────────┴──────────────┘
3.1.5 什么时候用一对一?
适合使用的场景:
- 字段数量过多(超过 20 个字段)
- 需要分离敏感信息(密码、支付信息)
- 部分字段很少查询(登录时不需要加载简介、头像)
- 需要独立扩展(用户资料可能有多种类型:个人、企业)
不适合使用的场景:
- 字段不多(少于 15 个),直接放一张表即可
- 所有字段都会频繁查询(拆分反而需要 JOIN,降低性能)
::: tip 💡 一对一关系的判断标准 问自己:"A 的一条记录能对应 B 的多条记录吗?"
- 如果能 → 一对多或多对多
- 如果不能 → 可能是一对一
再问:"B 的一条记录能对应 A 的多条记录吗?"
- 如果能 → 一对多
- 如果不能 → 一对一 :::
users 表: user_profiles 表:
| id | username | | user_id | bio | avatar |
| 1 | 张三 | | 1 | ... | ... |
实现方式:
-- 方式 1:外键唯一约束
CREATE TABLE user_profiles (
user_id BIGINT PRIMARY KEY,
bio TEXT,
avatar VARCHAR(255),
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 方式 2:直接在主表扩展
CREATE TABLE users (
id BIGINT PRIMARY KEY,
username VARCHAR(50),
profile_id BIGINT UNIQUE,
FOREIGN KEY (profile_id) REFERENCES user_profiles(id)
);
使用场景:
- 用户表 + 详细资料表(分离敏感信息)
- 订单表 + 支付信息表(分离支付数据)
- 商品表 + 库存表(分离库存管理)
::: tip 💡 一对一关系的判断标准 问自己:"A 的一条记录能对应 B 的多条记录吗?"
- 如果能 → 一对多或多对多
- 如果不能 → 可能是一对一
再问:"B 的一条记录能对应 A 的多条记录吗?"
- 如果能 → 一对多
- 如果不能 → 一对一 :::
3.2 一对多(One-to-Many)
3.2.1 什么是"一对多"?
定义:A 表的一条记录可以对应 B 表的多条记录,但 B 的一条记录只能对应 A 的一条记录。
生活类比:
- 一个母亲 → 多个孩子
- 一个班级 → 多个学生
- 一个作者 → 多篇文章
这是最常见的关系!约占数据库关系的 70%。
3.2.2 用生活场景理解
想象你在做一个电商系统:
场景:用户和订单
需求:
- 一个用户可以下多个订单
- 一个订单只能属于一个用户
怎么设计数据库?
方案一:全放一张表(错误)
users 表:
| id | username | order_1 | order_1_amount | order_2 | order_2_amount | ... |
| 1 | 张三 | 100 | 500 | 101 | 300 | ... |
问题:
- 一个用户最多能下多少单?10 个?100 个?
- 字段数量爆炸,表结构混乱
- 无法查询"某个订单属于哪个用户"
方案二:拆分成两张表(一对多关系)
users 表:
| id | username | email |
| 1 | 张三 | zhangsan@qq.com |
orders 表:
| id | user_id | amount | status |
| 1 | 1 | 500 | paid |
| 2 | 1 | 300 | shipping |
| 3 | 1 | 800 | completed |
关系表示:
users (1) ---- (N) orders
| |
id (主键) user_id (外键)
关键点:在"多"的那张表(orders)添加"外键"(user_id),指向"一"的那张表(users)。
3.2.3 SQL 实现
-- 用户表("一"的那一方)
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 订单表("多"的那一方)
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL, -- 外键,指向 users 表
amount DECIMAL(10, 2) NOT NULL, -- 订单金额
status VARCHAR(20) DEFAULT 'pending', -- 订单状态
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- 插入数据
INSERT INTO users (id, username, email) VALUES
(1, '张三', 'zhangsan@qq.com'),
(2, '李四', 'lisi@qq.com');
INSERT INTO orders (user_id, amount, status) VALUES
(1, 500.00, 'paid'),
(1, 300.00, 'shipping'),
(1, 800.00, 'completed'),
(2, 200.00, 'pending');
-- 查询 1:查询用户的所有订单
SELECT o.*
FROM orders o
WHERE o.user_id = 1;
-- 查询 2:查询订单及对应的用户信息
SELECT o.*, u.username, u.email
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.id = 1;
-- 查询 3:统计每个用户的订单数量
SELECT u.username, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id;
3.2.4 数据样子
users 表("一"的一方):
┌────┬──────────┬─────────────────┐
│ id │ username │ email │
├────┼──────────┼─────────────────┤
│ 1 │ 张三 │ zhangsan@qq.com │
│ 2 │ 李四 │ lisi@qq.com │
└────┴──────────┴─────────────────┘
│
│ 一对多关系
│
orders 表("多"的一方):
┌────┬─────────┬────────┬───────────┐
│ id │ user_id │ amount │ status │
├────┼─────────┼────────┼───────────┤
│ 1 │ 1 │ 500.00 │ paid │ ← 张三的订单
│ 2 │ 1 │ 300.00 │ shipping │ ← 张三的订单
│ 3 │ 1 │ 800.00 │ completed │ ← 张三的订单
│ 4 │ 2 │ 200.00 │ pending │ ← 李四的订单
└────┴─────────┴────────┴───────────┘
3.2.5 什么时候用一对多?
适合使用的场景:
- 父子关系:用户 → 订单、分类 → 商品
- 包含关系:文章 → 评论、部门 → 员工
- 层级关系:菜单 → 子菜单
设计原则:
- 在"多"的那张表添加外键
- 外键指向"一"的那张表的主键
- 使用 LEFT JOIN 可以查询"即使没有订单的用户"
::: tip 💡 一对多关系的判断标准 问自己:"A 的一条记录能对应 B 的多条记录吗?"
- 如果能 → 可能是一对多
- 再问:"B 的一条记录能对应 A 的多条记录吗?"
- 如果不能 → 确定是一对多
- 如果能 → 是多对多 :::
3.3.1 什么是"多对多"?
定义:A 表的多条记录可以对应 B 表的多条记录,反过来也成立。
生活类比:
- 多个学生 ↔ 多门课程(一个学生可以选多门课,一门课可以被多个学生选)
- 多个顾客 ↔ 多个商品(一个顾客可以买多个商品,一个商品可以被多个顾客买)
- 多个作者 ↔ 多篇文章(一篇文章可以有多个作者,一个作者可以写多篇文章)
这是最复杂的关系,需要使用"中间表"!
3.3.2 用生活场景理解
想象你在做一个学生选课系统:
场景:学生和课程
需求:
- 一个学生可以选多门课程
- 一门课程可以被多个学生选
怎么设计数据库?
方案一:在学生表添加课程字段(错误)
students 表:
| id | name | courses |
| 1 | 小明 | 1,2,3 |
| 2 | 小红 | 1,2 |
问题:
- 无法查询"选了数学 1 的所有学生"(需要字符串匹配,很慢)
- 无法建立外键约束
- 无法给"选课"添加额外信息(比如:选课时间、成绩)
方案二:在课程表添加学生字段(同样错误)
courses 表:
| id | title | students |
| 1 | 数学 | 1,2,3,4,5 |
同样的问题:查询慢、无法建立约束、无法添加额外信息。
方案三:使用中间表(正确!)
创建三张表:
- students 表(学生)
- courses 表(课程)
- enrollments 表(选课记录,中间表)
关系表示:
students (M) ---- (M) courses
| |
| enrollments(中间表)
| | student_id | course_id |
3.3.3 SQL 实现
-- 学生表
CREATE TABLE students (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
student_no VARCHAR(20) UNIQUE NOT NULL, -- 学号
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 课程表
CREATE TABLE courses (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(100) NOT NULL,
credits DECIMAL(3, 1) NOT NULL, -- 学分
teacher VARCHAR(50)
);
-- 选课记录表(中间表)
CREATE TABLE enrollments (
student_id BIGINT NOT NULL,
course_id BIGINT NOT NULL,
enrolled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 选课时间
grade DECIMAL(5, 2), -- 成绩(可选)
PRIMARY KEY (student_id, course_id), -- 联合主键,防止重复选课
FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE,
FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE
);
-- 插入数据
INSERT INTO students (id, name, student_no) VALUES
(1, '小明', '2021001'),
(2, '小红', '2021002'),
(3, '小刚', '2021003');
INSERT INTO courses (id, title, credits, teacher) VALUES
(1, '数学', 4.0, '王老师'),
(2, '英语', 3.0, '李老师'),
(3, '物理', 4.0, '张老师');
-- 学生选课
INSERT INTO enrollments (student_id, course_id) VALUES
(1, 1), -- 小明选了数学
(1, 2), -- 小明选了英语
(2, 1), -- 小红选了数学
(2, 3), -- 小红选了物理
(3, 2), -- 小刚选了英语
(3, 3); -- 小刚选了物理
-- 查询 1:查询小明选了哪些课程
SELECT c.*
FROM courses c
JOIN enrollments e ON c.id = e.course_id
WHERE e.student_id = 1;
-- 查询 2:查询数学课有哪些学生选了
SELECT s.*
FROM students s
JOIN enrollments e ON s.id = e.student_id
WHERE e.course_id = 1;
-- 查询 3:查询每个学生选了多少门课
SELECT s.name, COUNT(e.course_id) as course_count
FROM students s
LEFT JOIN enrollments e ON s.id = e.student_id
GROUP BY s.id;
-- 查询 4:录入成绩
UPDATE enrollments
SET grade = 90.5
WHERE student_id = 1 AND course_id = 1;
-- 查询 5:查询小明的选课及成绩
SELECT c.title, e.grade, e.enrolled_at
FROM courses c
JOIN enrollments e ON c.id = e.course_id
WHERE e.student_id = 1;
3.3.4 数据样子
students 表:
┌────┬──────┬───────────┐
│ id │ name │ student_no│
├────┼──────┼───────────┤
│ 1 │ 小明 │ 2021001 │
│ 2 │ 小红 │ 2021002 │
│ 3 │ 小刚 │ 2021003 │
└────┴──────┴───────────┘
│ ┌──────────────┐
│ │ courses 表 │
│ ├────┬─────────┬────────┬────────┐
│ │ id │ title │ credits│ teacher│
│ ├────┼─────────┼────────┼────────┤
│ │ 1 │ 数学 │ 4.0 │ 王老师 │
│ │ 2 │ 英语 │ 3.0 │ 李老师 │
│ │ 3 │ 物理 │ 4.0 │ 张老师 │
│ └────┴─────────┴────────┴────────┘
│ ▲
│ │
│ │ 多对多关系
│ │
│ ┌───────────────────┐
│ │ enrollments(中间表)│
│ ├──────────┬─────────┬──────────────┬───────┐
│ │student_id│course_id│ enrolled_at │ grade │
│ ├──────────┼─────────┼──────────────┼───────┤
│ │ 1 │ 1 │ 2024-09-01 │ 90.5 │ ← 小明选数学
│ │ 1 │ 2 │ 2024-09-01 │ NULL │ ← 小明选英语
│ │ 2 │ 1 │ 2024-09-01 │ NULL │ ← 小红选数学
│ │ 2 │ 3 │ 2024-09-01 │ NULL │ ← 小红选物理
│ │ 3 │ 2 │ 2024-09-01 │ NULL │ ← 小刚选英语
│ │ 3 │ 3 │ 2024-09-01 │ NULL │ ← 小刚选物理
│ └──────────┴─────────┴──────────────┴───────┘
3.3.5 中间表的设计要点
要点 1:联合主键
PRIMARY KEY (student_id, course_id)
作用:防止同一个学生重复选同一门课
要点 2:外键约束
FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE,
FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE
作用:保证数据完整性
- 删除学生时,自动删除该学生的选课记录
- 删除课程时,自动删除该课程的选课记录
要点 3:中间表可以添加额外字段
enrolled_at TIMESTAMP, -- 选课时间
grade DECIMAL(5, 2), -- 成绩
status VARCHAR(20), -- 状态:选课中、已退课、已完成
这是多对多关系的一大优势!
3.3.6 什么时候用多对多?
适合使用的场景:
- 标签系统:文章 ↔ 标签
- 权限系统:用户 ↔ 角色
- 社交关系:用户 ↔ 用户(关注、好友)
- 购物车:用户 ↔ 商品
设计原则:
- 必须创建中间表(也叫关联表、桥接表)
- 中间表包含两个外键,分别指向两张表
- 使用联合主键防止重复关联
- 可以在中间表添加额外属性(如:选课时间、成绩)
::: tip 💡 多对多关系的判断标准 问自己:
- "A 的一条记录能对应 B 的多条记录吗?" → 是
- "B 的一条记录能对应 A 的多条记录吗?" → 是
- "A 和 B 之间的关联需要额外信息吗?" → 可能需要
如果 1 和 2 都是"是",这就是多对多关系,需要用中间表! :::
4. 范式理论:从混乱到有序
4.0 什么是"范式"?
范式(Normalization,规范化)是数据库设计的"规范"或"标准"。
目的:
- 消除数据冗余(重复的数据)
- 避免数据异常(插入、更新、删除时的问题)
- 让数据结构更清晰
通俗理解: 就像整理你的衣柜:
- 乱七八糟的衣服堆在一起 → 不符合范式
- 按类别分开(上衣、裤子、袜子)→ 符合第一范式
- 再按颜色分开 → 符合第二范式
- 再按季节分开 → 符合第三范式
范式的级别:
- 1NF(第一范式):基础要求
- 2NF(第二范式):在 1NF 基础上进一步优化
- 3NF(第三范式):在 2NF 基础上再优化
- BCNF(BC 范式):3NF 的增强版
- 4NF、5NF:更高级的范式(很少用到)
实际开发中:
- 大部分系统做到 3NF 就够了
- 有时为了性能,会故意违反范式(反范式化,后面会讲)
4.1 第一范式(1NF):消除重复组
4.1.1 1NF 的规则
规则:每个字段都应该是"原子性"的,不可再分。
什么是不符合 1NF?
- 一个字段包含多个值
- 一个字段包含"组合数据"(如:地址 = 城市 + 区 + 详细地址)
什么是符合 1NF?
- 每个字段只存储一个值
- 每个字段都是"最小数据单元"
4.1.2 问题场景:订单信息重复
想象你在做一个订单管理系统:
场景:一个订单包含多个商品
错误的表设计(不符合 1NF):
orders 表:
| id | user_id | products | total |
| 1 | 100 | iPhone(2台),iPad(1台),AirPods(3台) | 50000 |
问题:
products字段包含多个值(不原子)- 无法查询"哪些订单包含 iPhone"
- 无法统计"卖了多少台 iPad"
- 修改商品数量需要字符串操作
- 无法对商品建立索引
4.1.3 应用 1NF 后
方案 1:拆分成多行(符合 1NF)
orders 表:
| id | user_id | product_id | product_name | quantity | price |
| 1 | 100 | 1 | iPhone | 2 | 5999 |
| 1 | 100 | 2 | iPad | 1 | 3999 |
| 1 | 100 | 3 | AirPods | 3 | 1299 |
改进:
- 每行只记录一个商品(原子性)
- 可以查询"哪些订单包含 iPhone"
- 可以统计"卖了多少台 iPad"
- 可以对
product_id建立索引
问题:user_id 和 id 重复了(这是 2NF 要解决的问题)
4.1.4 另一个例子:地址字段
不符合 1NF:
users 表:
| id | name | address |
| 1 | 张三 | 北京市朝阳区xxx街道 |
问题:address 包含多个信息(城市、区、详细地址),不原子。
符合 1NF:
users 表:
| id | name | city | district | detail_address |
| 1 | 张三 | 北京市 | 朝阳区 | xxx街道 |
改进:
- ���个字段只存储一个信息
- 可以按城市查询、按区统计
::: tip 💡 1NF 是基础 所有关系型数据库(MySQL、PostgreSQL、Oracle)默认都满足 1NF,因为字段本身就不能存储复杂对象(JSON 除外)。即使你违反了 1NF(如存储逗号分隔的字符串),数据库也不会报错,但会导致查询和维护困难。 :::
-- 用户和地址混在一起
| id | name | contact_info |
| 1 | 张三 | 北京朝阳区,13800138000 |
✅ 符合 1NF:
| id | name | city | district | phone |
| 1 | 张三 | 北京 | 朝阳区 | 13800138000 |
::: tip 💡 1NF 是基础 所有关系型数据库(MySQL、PostgreSQL、Oracle)默认都满足 1NF,因为字段本身就不能存储复杂对象(JSON 除外)。即使你违反了 1NF(如存储逗号分隔的字符串),数据库也不会报错,但会导致查询和维护困难。 :::
4.2 第二范式(2NF):消除部分依赖
4.2.1 2NF 的规则
前提:表必须先符合 1NF
规则:非主键字段必须完全依赖于主键,而不是只依赖主键的一部分。
什么是"部分依赖"?
- 当主键是"复合主键"(多个字段组成的主键)时才存在
- 某个非主键字段只依赖主键的一部分,而不是全部
什么是不符合 2NF?
- 主键是复合主键(如:order_id + product_id)
- 某个字段(如 product_name)只依赖 product_id,不依赖 order_id
什么是符合 2NF?
- 消除部分依赖
- 所有非主键字段都完全依赖主键
4.2.2 问题场景:订单明细表
想象你在设计订单明细表:
场景:记录每个订单的每个商品
表设计:
order_items 表(订单明细):
| order_id | product_id | product_name | quantity | unit_price | subtotal |
| 100 | 1 | iPhone | 2 | 5999 | 11998 |
| 100 | 2 | iPad | 1 | 3999 | 3999 |
主键:(order_id, product_id) —— 复合主键
问题分析:
| 字段 | 依赖关系 | 是否符合 2NF |
|---|---|---|
| quantity | 依赖 (order_id, product_id) | ✅ 符合 |
| subtotal | 依赖 (order_id, product_id) | ✅ 符合 |
| product_name | 只依赖 product_id | ❌ 不符合 |
| unit_price | 只依赖 product_id | ❌ 不符合 |
为什么不符合?
product_name只依赖product_id- 即使我改变
order_id,product_name也不会变 - 这就是"部分依赖"(只依赖主键的一部分)
4.2.3 会有什么问题?
问题 1:数据冗余
| order_id | product_id | product_name | quantity |
| 100 | 1 | iPhone | 2 |
| 101 | 1 | iPhone | 5 | ← 重复了
| 102 | 1 | iPhone | 1 | ← 又重复了
每次订购 iPhone,都要重复存储 product_name。
问题 2:更新异常 如果 iPhone 改名为 "iPhone 15",需要更新所有包含该产品的订单记录。
问题 3:插入异常 无法插入一个还没有被订购的新产品(因为 order_id 不能为空)。
4.2.4 应用 2NF 后
方案:拆分成两张表
-- 订单明细表(只依赖复合主键)
order_items 表:
| order_id | product_id | quantity | subtotal |
| 100 | 1 | 2 | 11998 |
| 100 | 2 | 1 | 3999 |
| 101 | 1 | 5 | 29995 |
-- 商品表(只依赖 product_id)
products 表:
| product_id | product_name | unit_price |
| 1 | iPhone | 5999 |
| 2 | iPad | 3999 |
改进:
product_name和unit_price只在 products 表存储一次- 修改商品信息时,只需更新 products 表
- 新商品可以先插入 products 表,即使还没人买
4.2.5 查询数据
查询订单 100 的所有商品详情:
SELECT
oi.order_id,
oi.product_id,
p.product_name,
oi.quantity,
oi.subtotal
FROM order_items oi
JOIN products p ON oi.product_id = p.product_id
WHERE oi.order_id = 100;
虽然需要 JOIN,但数据结构更合理,避免了冗余和异常。
::: tip 💡 2NF 针对复合主键 如果主键是单个字段,则自动满足 2NF。2NF 主要解决复合主键的部分依赖问题。
判断方法:
- 主键是单个字段? → 自动满足 2NF
- 主键是复合主键? → 检查是否有字段只依赖主键的一部分 :::
4.3 第三范式(3NF):消除传递依赖
4.3.1 3NF 的规则
前提:表必须先符合 2NF
规则:非主键字段不传递依赖于主键。
什么是"传递依赖"?
- A → B → C
- C 依赖 B,B 依赖 A
- 所以 C 传递依赖 A
什么是不符合 3NF?
- 非主键字段之间有依赖关系
- 比如:user_level 依赖 user_id,而 user_id 又依赖 id
什么是符合 3NF?
- 所有非主键字段只直接依赖主键
- 非主键字段之间没有依赖关系
4.3.2 问题场景:订单表包含用户等级
想象你在设计订单表:
场景:记录订单时,需要记录用户的等级(用于计算折扣)
表设计:
orders 表:
| id | user_id | total | user_level | discount | pay_amount |
| 1 | 100 | 500 | VIP | 0.9 | 450 |
| 2 | 101 | 300 | 普通 | 1.0 | 300 |
主键:id
问题分析:
| 字段 | 依赖关系 | 是否符合 3NF |
|---|---|---|
| total | 直接依赖 id | ✅ 符合 |
| user_id | 直接依赖 id | ✅ 符合 |
| user_level | 依赖 user_id,再依赖 id | ❌ 不符合(传递依赖) |
| discount | 依赖 user_level | ❌ 不符合(传递依赖) |
为什么不符合?
user_level依赖user_id(用户的等级存在用户表中)user_id依赖id(订单属于用户)- 所以
user_level传递依赖id - 同理,
discount依赖user_level,也传递依赖id
4.3.3 会有什么问题?
问题 1:数据冗余
| id | user_id | user_level | discount |
| 1 | 100 | VIP | 0.9 |
| 2 | 100 | VIP | 0.9 | ← 重复了
| 3 | 100 | VIP | 0.9 | ← 又重复了
同一用户的多个订单,重复存储 user_level 和 discount。
问题 2:更新异常 如果用户从"VIP"升级到"SVIP",需要更新该用户的所有历史订单。
问题 3:数据不一致
| id | user_id | user_level | discount |
| 1 | 100 | VIP | 0.9 |
| 2 | 100 | SVIP | 0.8 | ← 不一致!
同一用户的不同订单,等级可能不同(数据更新不一致)。
4.3.4 应用 3NF 后
方案:把用户等级放到用户表
-- 订单表(只存储订单相关字段)
orders 表:
| id | user_id | total | discount | pay_amount |
| 1 | 100 | 500 | 0.9 | 450 |
| 2 | 100 | 300 | 0.9 | 270 |
-- 用户表(存储用户等级)
users 表:
| id | username | level |
| 100 | 张三 | VIP |
改进:
user_level只在 users 表存储一次- 修改用户等级时,只需更新 users 表
- 订单表只存储
discount(下单时的折扣),作为历史记录
::: tip 💡 3NF 与历史数据 有些场景需要"冗余"历史数据:
- 订单的折扣(下单时的折扣,即使后来用户等级变了)
- 商品的快照(下单时的价格,即使后来商品改价了)
这不是违反 3NF,而是为了"历史准确性"故意保留的冗余。 :::
4.3.5 另一个例子:员工表
不符合 3NF:
employees 表:
| id | name | dept_id | dept_name | dept_location |
| 1 | 张三 | 10 | 技术部 | 北京 |
| 2 | 李四 | 10 | 技术部 | 北京 | ← 重复
| 3 | 王五 | 20 | 销售部 | 上海 |
| 4 | 赵六 | 20 | 销售部 | 上海 | ← 重复
问题:dept_name 和 dept_location 依赖 dept_id,再依赖 id(传递依赖)
符合 3NF:
-- 员工表
employees 表:
| id | name | dept_id |
| 1 | 张三 | 10 |
| 2 | 李四 | 10 |
| 3 | 王五 | 20 |
-- 部门表
departments 表:
| dept_id | dept_name | dept_location |
| 10 | 技术部 | 北京 |
| 20 | 销售部 | 上海 |
::: tip 💡 3NF 是最常见的范式 实际业务中,大部分表设计都遵循 3NF,它在数据冗余和查询性能之间取得了平衡。
判断方法:
- 非主键字段之间有依赖关系吗? → 有,可能不符合 3NF
- 这个依赖是"历史快照"还是"可以外键关联"? → 可以外键关联,应该拆分 :::
-- 订单明细表:(order_id, product_id) 是复合主键
| order_id | product_id | product_name | quantity | unit_price |
| 100 | 1 | iPhone | 2 | 5999 |
问题:product_name 和 unit_price 只依赖 product_id,不依赖 order_id。
✅ 符合 2NF:
-- 订单明细表
| order_id | product_id | quantity |
| 100 | 1 | 2 |
-- 商品表
| product_id | name | price |
| 1 | iPhone | 5999 |
::: tip 💡 2NF 针对复合主键 如果主键是单个字段,则自动满足 2NF。2NF 主要解决复合主键的部分依赖问题。 :::
4.3 第三范式(3NF):消除传递依赖
要求:非主键字段不传递依赖于主键。
❌ 不符合 3NF:
-- 订单表
| id | user_id | total | user_level | discount |
| 1 | 100 | 500 | VIP | 0.9 |
问题:user_level 依赖 user_id,再依赖 id(传递依赖)。
✅ 符合 3NF:
-- 订单表
| id | user_id | total | discount |
| 1 | 100 | 500 | 0.9 |
-- 用户表
| id | level |
| 100| VIP |
::: tip 💡 3NF 是最常见的范式 实际业务中,大部分表设计都遵循 3NF,它在数据冗余和查询性能之间取得了平衡。 :::
4.4 范式对比演示
👇 点击下方标签页,查看各范式的对比:
5. 反范式化:用空间换时间
范式化虽然能消除冗余,但查询时需要多次 JOIN,影响性能。
反范式化(Denormalization)是有意增加冗余,换取查询性能提升。
5.1 何时需要反范式化?
| 场景 | 说明 |
|---|---|
| 高频查询 | 每秒数百次查询,JOIN 成为瓶颈 |
| 大数据量 | 表数据超过千万级,JOIN 性能下降 |
| 报表统计 | 需要聚合计算,预先存储结果 |
| 分布式系统 | 跨库 JOIN 困难,需要冗余数据 |
5.2 反范式化实战
场景:电商订单查询
范式化设计:
-- 订单表
orders (id, user_id, total, status)
users (id, name, email)
-- 查询订单及用户信息
SELECT o.*, u.name, u.email
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.id = 123;
性能问题:每次查询都需要 JOIN,数据量大时慢。
反范式化设计:
-- 订单表(冗余用户信息)
orders (id, user_id, user_name, user_email, total, status)
-- 查询订单(单表查询)
SELECT * FROM orders WHERE id = 123;
性能提升:无需 JOIN,单表查询速度快 5-10 倍。
代价:
- 存储空间增加(每个订单多存用户名和邮箱)
- 更新成本增加(修改用户邮箱需更新所有历史订单)
5.3 反范式化的设计原则
| 原则 | 说明 |
|---|---|
| 冗余不经常变化的数据 | 如用户名、商品名称(很少修改) |
| 冗余查询频繁的字段 | 如订单列表展示的用户名、商品图片 |
| 保留原始表 | 范式化表作为"主表",反范式化表作为"查询表" |
| 数据同步策略 | 通过定时任务、消息队列同步冗余字段 |
::: warning ⚠️ 反范式化的风险
- 数据冗余:占用更多存储空间
- 更新异常:修改数据需同步多处
- 数据不一致:同步失败导致数据不匹配
建议:核心交易表保持范式化,查询表、统计表适当反范式化。 :::
6. 常见反模式及改进
反模式(Antipattern)是"看似正确但实际有害"的设计模式。
就像生活中的"误区":
- 为了省钱买便宜的鞋子 → 结果经常坏,花更多钱
- 为了省时间不睡觉 → 结果身体垮了,效率更低
数据库设计也有很多"反模式",看起来方便,实际上埋雷。
6.1 反模式 1:巨型宽表
6.1.1 错误的做法
错误设计:把所有数据塞进一张表
-- 巨型宽表(错误示例)
CREATE TABLE big_table (
id BIGINT,
-- 用户字段
user_name VARCHAR(50),
user_email VARCHAR(100),
user_phone VARCHAR(20),
-- 订单字段(重复 100 次!)
order_1_id BIGINT,
order_1_amount DECIMAL(10, 2),
order_1_status VARCHAR(20),
order_2_id BIGINT,
order_2_amount DECIMAL(10, 2),
order_2_status VARCHAR(20),
-- ...
order_100_id BIGINT,
order_100_amount DECIMAL(10, 2),
order_100_status VARCHAR(20)
);
-- 数据样子
| id | user_name | user_email | order_1_id | order_1_amount | order_2_id | order_2_amount | ... |
| 1 | 张三 | zhang@qq.com | 100 | 500.00 | 101 | 300.00 | ... |
6.1.2 为什么会这样做?
当时的需求:
- 产品经理说:"我们想在一个页面显示用户的所有订单"
- 开发者想:"少 JOIN 几次,查询更快"
- 时间紧:"先赶上线,以后再优化"
看起来很合理:
- 查询用户订单时,不需要 JOIN,单表查询快
- SQL 简单:
SELECT * FROM big_table WHERE id = 1
6.1.3 会有什么问题?
问题 1:字段数量爆炸
-- MySQL 对表字段数量有限制
-- InnoDB: 最多 1017 列
-- MyISAM: 最多 259 列
-- 如果用户下了 1000 个订单怎么办?
order_1_id, order_2_id, ..., order_1000_id ← 字段爆炸!
问题 2:大量空值,浪费存储
-- 大部分用户只有 5-10 个订单,其他 90-95 个字段都是 NULL
| id | order_1_amount | order_2_amount | order_3_amount | ... | order_100_amount |
| 1 | 500.00 | 300.00 | 800.00 | ... | NULL | ← 浪费 97 个字段
存储浪费:
- 假设每个字段占用 8 字节(DECIMAL(10,2))
- 100 个字段 × 8 字节 = 800 字节/行
- 实际只用 3 个字段 = 24 字节
- 浪费了 97% 的存储空间!
问题 3:新增订单需要修改表结构(DDL 操作)
-- 用户下了第 101 个订单,需要加字段
ALTER TABLE big_table ADD COLUMN order_101_id BIGINT;
ALTER TABLE big_table ADD COLUMN order_101_amount DECIMAL(10, 2);
-- 问题:
-- 1. DDL 操作会锁表,影响线上服务
-- 2. 大表加字段非常慢(可能需要几小时)
-- 3. 需要停机维护,用户体验差
问题 4:无法查询"某个订单的所有信息"
-- 想查询订单 100 的信息
-- 需要扫描所有 order_XX_id 字段
SELECT *
FROM big_table
WHERE order_1_id = 100
OR order_2_id = 100
OR order_3_id = 100
-- ...
OR order_100_id = 100; ← SQL 非常复杂,性能极差!
问题 5:无法统计订单数据
-- 想统计"所有订单的总金额"
-- 需要写复杂的 SQL
SELECT
SUM(order_1_amount) +
SUM(order_2_amount) +
-- ...
SUM(order_100_amount) as total ← 无法维护!
FROM big_table;
6.1.4 正确的做法
正确设计:拆分成多张表,用外键关联
-- 用户表
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
phone VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 订单表
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL, -- 外键,指向 users 表
amount DECIMAL(10, 2) NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- 创建索引(提升查询性能)
CREATE INDEX idx_orders_user_id ON orders(user_id);
6.1.5 数据样子
-- users 表
| id | name | email | phone |
| 1 | 张三 | zhang@qq.com | 13800138000 |
-- orders 表
| id | user_id | amount | status | created_at |
| 100 | 1 | 500.00 | paid | 2024-01-01 10:00:00|
| 101 | 1 | 300.00 | shipping | 2024-01-05 14:30:00|
| 102 | 1 | 800.00 | completed| 2024-01-10 09:15:00|
6.1.6 查询数据
查询用户的所有订单:
SELECT o.*
FROM orders o
WHERE o.user_id = 1; -- 利用索引,快速查询
查询订单及对应的用户信息:
SELECT o.*, u.name, u.email
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.id = 100; -- 只 JOIN 需要的字段
统计用户的订单总数和总金额:
SELECT
u.name,
COUNT(o.id) as order_count,
SUM(o.amount) as total_amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id;
6.1.7 优缺点对比
| 对比项 | 宽表(错误) | 分表(正确) |
|---|---|---|
| 存储空间 | 大量空值,浪费 90%+ | 紧凑存储,无浪费 |
| 扩展性 | 需要修改表结构(DDL) | 直接 INSERT,无需改表 |
| 查询性能 | 简单查询快,复杂查询极慢 | 利用索引,整体性能好 |
| 维护性 | 字段数量爆炸,无法维护 | 结构清晰,易于维护 |
| 数据完整性 | 无法建立外键约束 | 外键保证数据一致 |
结论:不要用宽表!用一对多关系 + 索引来解决!
6.2 反模式 2:逗号分隔值
6.2.1 错误的做法
错误设计:用逗号分隔存储多个值
-- 文章表(错误示例)
CREATE TABLE posts (
id BIGINT PRIMARY KEY,
title VARCHAR(200),
content TEXT,
tags VARCHAR(500) -- 用逗号分隔存储标签
);
-- 数据样子
| id | title | tags |
| 1 | Vue入门 | vue,frontend,javascript |
| 2 | React实战 | react,frontend |
| 3 | Node.js后端 | node,backend |
6.2.2 为什么会这样做?
当时的需求:
- 产品经理说:"一篇文章可以有多个标签"
- 开发者想:"用逗号分隔最简单,不用建中间表"
- 时间紧:"先赶上线,以后再优化"
看起来很合理:
- 存储简单:直接
'vue,frontend,javascript' - 查询简单:
SELECT * FROM posts WHERE id = 1 - 不用 JOIN:少一张表,少麻烦
6.2.3 会有什么问题?
问题 1:无法有效索引
-- 即使给 tags 字段建索引
CREATE INDEX idx_tags ON posts(tags);
-- 查询包含 "vue" 标签的文章
SELECT * FROM posts WHERE tags LIKE '%vue%';
-- 问题:
-- 1. LIKE '%xxx%' 无法使用索引(全表扫描)
-- 2. 查询 "vue" 会匹配 "javascript"(包含 "vue" 字符)
-- 3. 性能极差,数据量大时数据库卡死
问题 2:无法精确查询
-- 想查询"有哪些文章包含 frontend 标签"
SELECT * FROM posts WHERE tags LIKE '%frontend%';
-- 问题:会匹配到错误的记录
-- 'vue,frontend,javascript' ← 正确
-- 'frontendend' ← 错误!但会被匹配到
-- 'myfrontendapp' ← 错误!但会被匹配到
问题 3:无法统计查询
-- 想统计"每个标签有多少篇文章"
-- 几乎无法用 SQL 实现!
-- 需要:
-- 1. 查出所有文章
-- 2. 在应用层分割 tags 字符串
-- 3. 手动统计
-- 或者用复杂的 SQL(性能极差)
SELECT
SUBSTRING_INDEX(SUBSTRING_INDEX(tags, ',', n), ',', -1) as tag,
COUNT(*) as count
FROM posts
JOIN (
SELECT 1 as n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4
) numbers
ON CHAR_LENGTH(tags) - CHAR_LENGTH(REPLACE(tags, ',', '')) >= n - 1
GROUP BY tag;
问题 4:无法关联查询
-- 想查询"包含 vue 标签的文章 + 作者信息"
-- 需要字符串匹配,无法用 JOIN
SELECT p.*, u.name
FROM posts p
JOIN users u ON p.user_id = u.id
WHERE p.tags LIKE '%vue%'; ← 性能差,不准确
问题 5:修改标签需要字符串操作
-- 想给文章 1 添加一个 "tutorial" 标签
-- 需要分步操作
-- 1. 查询当前 tags
SELECT tags FROM posts WHERE id = 1; -- 'vue,frontend,javascript'
-- 2. 在应用层拼接字符串
new_tags = old_tags + ',tutorial' -- 'vue,frontend,javascript,tutorial'
-- 3. 更新数据库
UPDATE posts SET tags = 'vue,frontend,javascript,tutorial' WHERE id = 1;
-- 问题:
-- 1. 需要 3 步操作,代码复杂
-- 2. 并发修改时可能丢失数据
-- 3. 无法保证标签唯一性(可能添加重复标签)
6.2.4 正确的做法
正确设计:用中间表(多对多关系)
-- 文章表
CREATE TABLE posts (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(200) NOT NULL,
content TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 标签表
CREATE TABLE tags (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) UNIQUE NOT NULL -- 标签名称唯一
);
-- 文章-标签关联表(中间表)
CREATE TABLE post_tags (
post_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL,
PRIMARY KEY (post_id, tag_id), -- 联合主键,防止重复关联
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
-- 创建索引(提升查询性能)
CREATE INDEX idx_post_tags_tag_id ON post_tags(tag_id);
6.2.5 数据样子
-- posts 表
| id | title | content |
| 1 | Vue入门 | Vue 是一个渐进式框架... |
| 2 | React实战| React 是一个 UI 库... |
-- tags 表
| id | name |
| 1 | vue |
| 2 | frontend |
| 3 | javascript |
| 4 | react |
-- post_tags 表
| post_id | tag_id |
| 1 | 1 | ← 文章 1 有 vue 标签
| 1 | 2 | ← 文章 1 有 frontend 标签
| 1 | 3 | ← 文章 1 有 javascript 标签
| 2 | 2 | ← 文章 2 有 frontend 标签
| 2 | 4 | ← 文章 2 有 react 标签
6.2.6 查询数据
查询包含 "vue" 标签的所有文章:
SELECT p.*
FROM posts p
JOIN post_tags pt ON p.id = pt.post_id
JOIN tags t ON pt.tag_id = t.id
WHERE t.name = 'vue'; -- 利用索引,快速查询
查询文章的所有标签:
SELECT t.name
FROM tags t
JOIN post_tags pt ON t.id = pt.tag_id
WHERE pt.post_id = 1;
-- 结果
| name |
| vue |
| frontend |
| javascript |
统计每个标签的文章数量:
SELECT t.name, COUNT(pt.post_id) as post_count
FROM tags t
LEFT JOIN post_tags pt ON t.id = pt.tag_id
GROUP BY t.id
ORDER BY post_count DESC;
-- 结果
| name | post_count |
| frontend | 2 |
| vue | 1 |
| javascript | 1 |
| react | 1 |
给文章添加标签:
-- 先查询或创建标签
INSERT IGNORE INTO tags (name) VALUES ('tutorial');
-- 添加关联
INSERT INTO post_tags (post_id, tag_id)
VALUES (1, (SELECT id FROM tags WHERE name = 'tutorial'));
-- 完事!一条 SQL 搞定
6.2.7 优缺点对比
| 对比项 | 逗号分隔(错误) | 中间表(正确) |
|---|---|---|
| 查询性能 | 无法使用索引,全表扫描 | 利用索引,快速查询 |
| 查询精确度 | LIKE 匹配不准确 | 精确匹配 |
| 统计查询 | 几乎无法实现 | 简单的 GROUP BY |
| 数据完整性 | 无约束,可重复、格式混乱 | 外键 + 唯一约束 |
| 维护性 | 字符串操作,复杂 | 标准 SQL,简单 |
| 扩展性 | 难以扩展(如标签权重) | 易于扩展 |
结论:不要用逗号分隔!用多对多关系 + 中间表来解决!
6.3 反模式 3:滥用 JSON 字段
6.3.1 错误的做法
错误设计:把订单明细存为 JSON
-- 订单表(错误示例)
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT,
items JSON, -- 订单明细存为 JSON
total DECIMAL(10, 2),
created_at TIMESTAMP
);
-- 数据样子
| id | user_id | items | total |
| 1 | 100 | [{"pid":1,"qty":2},{"pid":2,"qty":1}] | 500 |
| 2 | 101 | [{"pid":3,"qty":5}] | 300 |
6.3.2 为什么会这样做?
当时的需求:
- 产品经理说:"订单明细结构复杂,字段不固定"
- 开发者想:"JSON 灵活,不用定义字段"
- 时间紧:"先赶上线,以后再优化"
看起来很合理:
- 灵活:可以存储任意结构的数据
- 简单:不需要建 order_items 表
- 查询快:单表查询,不需要 JOIN
6.3.3 会有什么问题?
问题 1:无法建立外键约束
-- 无法保证 JSON 中的 pid(商品 ID)真实存在
INSERT INTO orders (id, user_id, items, total) VALUES
(1, 100, '[{"pid":9999,"qty":1}]', 500); ← pid 9999 不存在,但能插入!
-- 问题:数据完整性无法保证
问题 2:无法有效索引(MySQL 5.7 之前)
-- MySQL 5.7+ 支持生成列 + 索引,但很复杂
-- MySQL 5.6 及之前版本,JSON 字段完全无法索引
-- 查询"购买了商品 1 的所有订单"
SELECT * FROM orders
WHERE JSON_CONTAINS(items, '{"pid": 1}'); ← 全表扫描!
问题 3:数据完整性差
-- 可以插入错误格式的 JSON
INSERT INTO orders (id, user_id, items, total) VALUES
(1, 100, '[{"pid":1}]', 500), -- 缺少 qty
(2, 100, '[{"qty":2}]', 300), -- 缺少 pid
(3, 100, 'not-a-json', 200); -- 不是 JSON
-- 问题:数据库无法检测 JSON 内容的正确性
问题 4:查询"某个商品的所有订单"需要全文扫描
-- 想查询"购买了 iPhone 的所有订单"
-- 需要扫描所有订单的 JSON 字段
SELECT * FROM orders
WHERE JSON_CONTAINS(items, '{"pid": 1}'); ← 全表扫描,性能极差!
问题 5:修改商品信息很困难
-- 想把商品 1 的名称从 "iPhone" 改为 "iPhone 15"
-- 需要更新所有包含该商品的订单的 JSON 字段
-- 但 JSON 无法用 UPDATE 语句部分更新,只能整体替换
-- 步骤:
-- 1. 查询订单
SELECT items FROM orders WHERE id = 1;
-- 2. 在应用层解析 JSON,修改,再序列化
items = JSON.parse(items)
items.forEach(item => {
if (item.pid === 1) {
item.name = "iPhone 15" -- 但订单表没存 name!
}
})
new_items = JSON.stringify(items)
-- 3. 更新数据库
UPDATE orders SET items = new_items WHERE id = 1;
-- 问题:操作复杂,性能差,容易出错
问题 6:无法统计商品销量
-- 想统计"每个商品卖了多少"
-- 需要在应用层遍历所有订单的 JSON
-- 或用复杂的 SQL(MySQL 5.7+)
SELECT
JSON_UNQUOTE(JSON_EXTRACT(items, '$[0].pid')) as pid,
SUM(JSON_EXTRACT(items, '$[0].qty')) as total_qty
FROM orders
GROUP BY pid; ← 假设每个订单只有一个商品,不通用!
6.3.4 正确的做法
正确设计:用关联表(一对多关系)
-- 订单表
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
total DECIMAL(10, 2) NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 订单明细表
CREATE TABLE order_items (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT NOT NULL, -- 外键,指向 orders 表
product_id BIGINT NOT NULL, -- 外键,指向 products 表
product_name VARCHAR(200), -- 冗余,保存下单时的商品名称
price DECIMAL(10, 2) NOT NULL, -- 冗余,保存下单时的价格
quantity INT NOT NULL,
subtotal DECIMAL(10, 2) NOT NULL, -- 小计 = price * quantity
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(id)
);
-- 创建索引(提升查询性能)
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
CREATE INDEX idx_order_items_product_id ON order_items(product_id);
6.3.5 数据样子
-- orders 表
| id | user_id | total | status | created_at |
| 1 | 100 | 500.00| paid | 2024-01-01 10:00:00|
-- order_items 表
| id | order_id | product_id | product_name | price | quantity | subtotal|
| 1 | 1 | 1 | iPhone | 5999.00| 2 | 11998.00|
| 2 | 1 | 2 | iPad | 3999.00| 1 | 3999.00 |
6.3.6 查询数据
查询订单的所有商品:
SELECT oi.*
FROM order_items oi
WHERE oi.order_id = 1; -- 利用索引,快速查询
查询"购买了商品 1 的所有订单":
SELECT DISTINCT o.*
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE oi.product_id = 1; -- 利用索引,快速查询
统计每个商品的销量:
SELECT
p.name,
SUM(oi.quantity) as total_qty,
SUM(oi.subtotal) as total_amount
FROM products p
JOIN order_items oi ON p.id = oi.product_id
GROUP BY p.id
ORDER BY total_qty DESC;
6.3.7 什么时候可以用 JSON?
JSON 不是"洪水猛兽",在以下场景可以使用:
适合使用 JSON 的场景:
-
非结构化数据
-- 用户的扩展配置信息(每个用户配置不同) ALTER TABLE users ADD COLUMN preferences JSON; -- 数据样子 | id | username | preferences | | 1 | zhangsan | {"theme":"dark","lang":"zh","fontSize":14} | | 2 | lisi | {"theme":"light","lang":"en"} | -
低频查询的数据
-- 日志、埋点数据(查询频率低) CREATE TABLE events ( id BIGINT PRIMARY KEY, user_id BIGINT, event_data JSON, -- 事件详情(字段不固定) created_at TIMESTAMP ); -
动态属性
-- 商品的动态属性(不同品类属性不同) -- 手机:屏幕尺寸、电池容量 -- 衣服:尺码、材质、颜色 CREATE TABLE products ( id BIGINT PRIMARY KEY, name VARCHAR(200), attributes JSON -- 动态属性 ); -- 数据样子 | id | name | attributes | | 1 | iPhone | {"screen":"6.1 inch","battery":"3000mAh"} | | 2 | T恤 | {"size":"L","material":"cotton","color":"blue"} |
不适合使用 JSON 的场景:
- 核心业务数据(订单、用户、商品)
- 需要频繁查询的字段
- 需要建立索引的字段
- 需要保证数据完整性的字段(外键约束)
::: tip 💡 JSON 的使用原则 能用关系表就用关系表,实在不行才用 JSON。
判断标准:
- 这个字段需要查询吗? → 需要,别用 JSON
- 这个字段需要建立索引吗? → 需要,别用 JSON
- 这个字段需要保证数据完整性吗? → 需要,别用 JSON
- 这个字段结构经常变化吗? → 是,可以考虑 JSON :::
7. 实战:电商系统数据模型
下面是一个完整的电商系统数据模型设计。
7.1 核心模块
用户模块:
-- 用户表
users (id, username, email, password_hash, created_at)
-- 用户地址表
user_addresses (id, user_id, province, city, district, detail, is_default)
-- 用户资料表
user_profiles (id, user_id, nickname, avatar, bio)
商品模块:
-- 分类表
categories (id, name, parent_id, level)
-- 商品表
products (id, category_id, name, description, created_at)
-- 商品 SKU 表
product_skus (id, product_id, specs, price, stock)
-- 库存表
product_inventory (sku_id, warehouse_id, quantity)
订单模块:
-- 订单表
orders (
id,
user_id,
user_name, -- 冗余,反范式化
total_amount,
discount_amount,
pay_amount,
status,
created_at
)
-- 订单明细表
order_items (
id,
order_id,
product_id,
product_name, -- 冗余,反范式化
product_sku_id,
price,
quantity
)
-- 支付记录表
payments (id, order_id, amount, method, status, transaction_id)
营销模块:
-- 优惠券表
coupons (id, name, type, discount, min_amount, stock)
-- 用户优惠券表
user_coupons (id, user_id, coupon_id, status, used_at)
-- 促销活动表
promotions (id, name, type, discount, start_time, end_time)
7.2 关系设计
| 关系 | 类型 | 说明 |
|---|---|---|
| users ↔ orders | 1:N | 一个用户有多个订单 |
| orders ↔ order_items | 1:N | 一个订单有多个明细 |
| products ↔ product_skus | 1:N | 一个商品有多个 SKU |
| users & coupons | M:N | 通过 user_coupons 中间表 |
| products & categories | M:N | 一个商品可属于多个分类 |
7.3 反范式化策略
| 字段 | 冗余位置 | 原因 |
|---|---|---|
| user_name | orders 表 | 避免查询订单时 JOIN users 表 |
| product_name | order_items 表 | 避免商品改名后历史订单显示问题 |
| product_price | order_items 表 | 保存下单时价格,避免价格变动影响 |
8. 数据模型设计流程
8.0 手把手:从零设计一个数据模型
让我们用一个图书馆管理系统的例子,从头到尾演示数据模型设计过程。
第一步:识别实体(识别"对象")
问题:这个系统需要管理哪些"东西"?
需求分析:
- 图书馆有很多书
- 书有不同的类别(小说、科技、历史)
- 读者可以借书、还书
- 每本书可以被多次借阅
识别出的实体:
- 图书(Book):书的基本信息
- 读者(Reader):借书的人
- 借阅记录(Borrow):谁借了哪本书
- 分类(Category):书的分类
第二步:确定属性(识别"特征")
问题:每个实体有哪些信息需要记录?
图书的属性:
- 书名(title)
- 作者(author)
- ISBN(isbn)- 唯一标识
- 出版社(publisher)
- 出版年份(publish_year)
- 库存数量(stock)
读者的属性:
- 姓名(name)
- 电话(phone)
- 邮箱(email)
- 注册日期(registered_at)
借阅记录的属性:
- 借阅日期(borrowed_at)
- 应还日期(due_date)
- 实还日期(returned_at)
- 状态(status):借阅中、已归还、逾期
分类的属性:
- 分类名称(name)
- 分类描述(description)
第三步:确定关系(识别"关联")
问题:这些实体之间有什么关系?
分析:
-
图书 ↔ 读者:多对多?
- 一个读者可以借多本书
- 一本书可以被多个读者借(不同时间)
- 但是:同一时间,一本书只能被一个读者借
- 所以:需要借阅记录来记录"谁借了哪本书"
-
图书 ↔ 分类:多对多
- 一本书可以属于多个分类(如:《Python编程》属于"编程"和"Python")
- 一个分类包含多本书
- 需要中间表:book_categories
-
读者 ↔ 借阅记录:一对多
- 一个读者可以有多条借阅记录
- 一条借阅记录只属于一个读者
-
图书 ↔ 借阅记录:一对多
- 一本书可以有多条借阅记录(被不同人借过)
- 一条借阅记录只针对一本书
第四步:画出 ER 图
┌─────────┐
│ 图书 │
└─────────┘
│ id │
│ title │
│ author │
│ isbn │
└─────────┘
│
│ 1:N
│
┌─────────┐ ┌─────────┐
│ 借阅记录│────│ 读者 │
└─────────┘ └─────────┘
│ book_id │ │ id │
│ reader_id│ │ name │
│ borrowed_at│ │ phone │
└─────────┘ └─────────┘
│
│ N:M
│
┌─────────┐
│ 分类 │
└─────────┘
│ id │
│ name │
└─────────┘
第五步:设计表结构(SQL)
-- 图书表
CREATE TABLE books (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(200) NOT NULL,
author VARCHAR(100) NOT NULL,
isbn VARCHAR(20) UNIQUE NOT NULL, -- ISBN 唯一
publisher VARCHAR(100),
publish_year INT,
stock INT DEFAULT 1, -- 库存数量
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 读者表
CREATE TABLE readers (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
phone VARCHAR(20) UNIQUE NOT NULL, -- 电话唯一
email VARCHAR(100) UNIQUE,
registered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 借阅记录表
CREATE TABLE borrows (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
book_id BIGINT NOT NULL,
reader_id BIGINT NOT NULL,
borrowed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
due_date TIMESTAMP NOT NULL, -- 应还日期
returned_at TIMESTAMP NULL, -- 实还日期(NULL 表示未还)
status VARCHAR(20) DEFAULT 'borrowed', -- borrowed: 借阅中, returned: 已归还, overdue: 逾期
FOREIGN KEY (book_id) REFERENCES books(id),
FOREIGN KEY (reader_id) REFERENCES readers(id)
);
-- 分类表
CREATE TABLE categories (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
description TEXT
);
-- 图书-分类关联表(多对多)
CREATE TABLE book_categories (
book_id BIGINT NOT NULL,
category_id BIGINT NOT NULL,
PRIMARY KEY (book_id, category_id), -- 联合主键
FOREIGN KEY (book_id) REFERENCES books(id),
FOREIGN KEY (category_id) REFERENCES categories(id)
);
-- 创建索引(提升查询性能)
CREATE INDEX idx_borrows_reader_id ON borrows(reader_id);
CREATE INDEX idx_borrows_book_id ON borrows(book_id);
CREATE INDEX idx_borrows_status ON borrows(status);
CREATE INDEX idx_book_categories_category_id ON book_categories(category_id);
第六步:应用范式(检查和优化)
检查 1NF:
- ✅ 所有字段都是原子的(没有字段包含多个值)
- ✅ 没有重复组
检查 2NF:
- ✅ 没有复合主键的情况(除了 book_categories 的联合主键)
- ✅ 所有非主键字段完全依赖于主键
检查 3NF:
- ✅ 没有传递依赖
- 例如:借阅记录中的
status不会因为reader_id的变化而变化
优化:
- 考虑是否需要冗余字段?
- 例如:在
borrows表中冗余book_title和reader_name,避免查询时 JOIN - 决策:不冗余,因为查询频率不高,保持数据一致性更重要
- 例如:在
第七步:设计查询(验证设计)
查询 1:查询读者当前借阅的所有图书
SELECT
b.title,
b.author,
bor.borrowed_at,
bor.due_date,
bor.status
FROM borrows bor
JOIN books b ON bor.book_id = b.id
WHERE bor.reader_id = 1
AND bor.status = 'borrowed'; -- 只查未还的
查询 2:查询某本书的所有借阅历史
SELECT
r.name,
bor.borrowed_at,
bor.returned_at,
bor.status
FROM borrows bor
JOIN readers r ON bor.reader_id = r.id
WHERE bor.book_id = 1
ORDER BY bor.borrowed_at DESC;
查询 3:查询逾期未还的图书
SELECT
r.name as reader_name,
r.phone as reader_phone,
b.title as book_title,
bor.due_date
FROM borrows bor
JOIN readers r ON bor.reader_id = r.id
JOIN books b ON bor.book_id = b.id
WHERE bor.status = 'borrowed'
AND bor.due_date < NOW(); -- 应还日期早于当前时间
查询 4:查询某个分类的所有图书
SELECT b.*
FROM books b
JOIN book_categories bc ON b.id = bc.book_id
JOIN categories c ON bc.category_id = c.id
WHERE c.name = '编程';
第八步:迭代优化(根据实际情况调整)
问题发现:
- 每次查询"读者借阅的图书"都需要 JOIN 3 张表(borrows、books、readers)
- 如果数据量大,查询可能慢
优化方案 1:添加冗余字段(反范式化)
-- 在 borrows 表添加冗余字段
ALTER TABLE borrows ADD COLUMN book_title VARCHAR(200);
ALTER TABLE borrows ADD COLUMN reader_name VARCHAR(50);
-- 修改插入逻辑
INSERT INTO borrows (book_id, reader_id, book_title, reader_name, ...)
VALUES (1, 2, (SELECT title FROM books WHERE id = 1),
(SELECT name FROM readers WHERE id = 2), ...);
-- 现在查询可以简化
SELECT book_title, borrowed_at, due_date, status
FROM borrows
WHERE reader_id = 1 AND status = 'borrowed'; -- 无需 JOIN
代价:
- 存储空间增加(每次借阅都存储书名和读者名)
- 更新复杂(修改书名时需要同步更新所有借阅记录)
优化方案 2:添加缓存
- 把热门读者的借阅记录缓存到 Redis
- 定期刷新缓存
优化方案 3:添加视图
- 创建视图简化查询
CREATE VIEW reader_borrows AS
SELECT
bor.id,
r.name as reader_name,
b.title as book_title,
bor.borrowed_at,
bor.due_date,
bor.status
FROM borrows bor
JOIN readers r ON bor.reader_id = r.id
JOIN books b ON bor.book_id = b.id;
-- 查询时直接用视图
SELECT * FROM reader_borrows WHERE reader_name = '张三';
::: tip 💡 数据模型设计是个迭代过程
- 第一步:先设计一个满足 3NF 的规范模型
- 第二步:根据实际查询需求,考虑反范式化
- 第三步:通过性能测试,找出瓶颈
- 第四步:优化(添加索引、缓存、冗余字段)
- 第五步:重复 2-4 步
不要一开始就过度优化!先保证正确性,再优化性能。 :::
8.1 数据模型设计流程总结
| 阶段 | 目标 | 产出 | 工具 |
|---|---|---|---|
| 需求分析 | 识别实体和关系 | 实体清单、关系描述 | 文档、脑图 |
| 概念设计 | 画出 ER 图 | ER 图 | draw.io、Workbench |
| 逻辑设计 | 设计表结构 | 表结构 SQL | 文本编辑器 |
| 范式检查 | 消除冗余 | 符合 3NF 的表 | 检查清单 |
| 物理设计 | 索引、分区 | 建表 SQL + 索引 | 数据库工具 |
| 性能优化 | 反范式化 | 冗余字段、缓存 | 慢查询日志 |
| 3. 数据归档:历史数据迁移到归档表 |
9. 用 AI 辅助设计数据模型
AI 可以帮你快速生成符合规范的数据模型。关键在于提供清晰的业务描述。
9.1 提示词模板
你是一位资深的数据库架构师,精通关系型数据库设计。请帮我设计数据模型。
## 业务背景
[描述你的业务场景,例如:电商系统、博客平台、任务管理系统]
## 核心实体
[列出主要的业务对象,例如:
- 用户:注册、登录、个人信息
- 订单:下单、支付、发货
- 商品:分类、库存、价格]
## 关系说明
[描述实体之间的关系,例如:
- 一个用户可以有多个订单
- 一个订单包含多个商品
- 商品属于分类,多级分类]
## 设计要求
1. 遵循 3NF 范式
2. 明确标注主键(PK)和外键(FK)
3. 说明表之间的一对一、一对多、多对多关系
4. 考虑反范式化策略(如果需要)
5. 提供完整的建表 SQL(MySQL 语法)
## 输出格式
请按以下格式输出:
### 表结构
- 表名:说明
- 字段定义
### 关系图
用文字描述表之间的关系
### 索引建议
列出需要建立的索引
### 反范式化建议
说明哪些字段需要冗余,为什么
9.2 实战示例:博客系统
输入提示词:
你是一位资深的数据库架构师。请帮我设计一个博客系统的数据模型。
## 业务背景
一个多用户博客平台,用户可以发布文章、评论、点赞。
## 核心实体
- 用户:昵称、邮箱、密码
- 文章:标题、内容、发布时间
- 评论:评论内容、评论时间
- 标签:文章可以打多个标签
## 关系说明
- 一个用户可以发表多篇文章
- 一篇文章可以有多个评论
- 一篇文章可以有多个标签
- 一个用户可以点赞多篇文章
## 设计要求
1. 遵循 3NF 范式
2. 标注 PK 和 FK
3. 说明关系类型
4. 提供建表 SQL
AI 输出示例:
-- 用户表
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
nickname VARCHAR(50) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 文章表
CREATE TABLE posts (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- 评论表
CREATE TABLE comments (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
post_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 标签表
CREATE TABLE tags (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) UNIQUE NOT NULL
);
-- 文章-标签关联表
CREATE TABLE post_tags (
post_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL,
PRIMARY KEY (post_id, tag_id),
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id)
);
-- 点赞表
CREATE TABLE likes (
user_id BIGINT NOT NULL,
post_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, post_id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
);
-- 索引建议
CREATE INDEX idx_posts_user_id ON posts(user_id);
CREATE INDEX idx_posts_created_at ON posts(created_at DESC);
CREATE INDEX idx_comments_post_id ON comments(post_id);
CREATE INDEX idx_post_tags_tag_id ON post_tags(tag_id);
9.3 AI 辅助设计的注意事项
| 注意点 | 说明 |
|---|---|
| 提供完整上下文 | 业务场景、数据量级、查询模式都要说明 |
| 明确关系类型 | 一对一、一对多、多对多要说清楚 |
| 要求解释原因 | 让 AI 说明为什么这样设计 |
| 检查约束条件 | 主键、外键、唯一索引是否合理 |
| 考虑扩展性 | 询问未来可能的扩展场景 |
| 人工审核 | AI 生成的内容需要人工检查是否符合业务需求 |
::: tip 💡 追问技巧
- "请说明这个设计遵循了哪些范式"
- "如果数据量达到千万级,如何优化"
- "哪些字段可以考虑反范式化"
- "请补充索引设计的理由" :::
名词速查表
| 名词 | 英文 | 解释 |
|---|---|---|
| 数据模型 | Data Model | 对现实世界的抽象,描述数据如何存储、组织和关联 |
| ER 图 | Entity-Relationship Diagram | 用图形化方式描述实体关系的工具 |
| 主键 | Primary Key | 唯一标识表中每条记录的字段 |
| 外键 | Foreign Key | 关联另一张表主键的字段 |
| 范式 | Normalization | 数据库设计的规范,消除数据冗余 |
| 反范式化 | Denormalization | 有意增加冗余,换取查询性能提升 |
| 一对一 | One-to-One | A 表一条记录对应 B 表一条记录 |
| 一对多 | One-to-Many | A 表一条记录对应 B 表多条记录 |
| 多对多 | Many-to-Many | A 表多条记录对应 B 表多条记录 |
| 中间表 | Junction Table | 实现多对多关系的关联表 |
| 冗余 | Redundancy | 数据重复存储 |
| 传递依赖 | Transitive Dependency | A → B → C,C 传递依赖 A |
| 部分依赖 | Partial Dependency | 非主键字段只依赖复合主键的一部分 |
| 原子性 | Atomicity | 字段不可再分的最小数据单元 |
| 宽表 | Wide Table | 字段数量特别多的表 |
| 索引 | Index | 加速查询的数据结构 |
| 约束 | Constraint | 保证数据完整性的规则(如 NOT NULL、UNIQUE) |