# 数据模型:设计的"骨架" ::: 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 | ``` **这就是数据模型设计的核心**: 1. **识别实体**(书、作者) 2. **设计属性**(书名、出版年份、作者姓名) 3. **建立关系**(一本书可以有多个作者) ### 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 图? 1. **设计前理清思路**:画图比改代码容易得多 2. **团队沟通**:一张图胜过千言万语 3. **发现遗漏**:画图时容易发现"咦,这个关系好像没考虑" 4. **文档留存**:新人看图就能理解系统结构 ::: ### 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:外键唯一约束(推荐)** ```sql -- 用户表 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:在主表添加外键(不推荐)** ```sql -- 详细资料表 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 的多条记录吗?" - 如果能 → 一对多 - 如果不能 → 一对一 ::: ```sql users 表: user_profiles 表: | id | username | | user_id | bio | avatar | | 1 | 张三 | | 1 | ... | ... | ``` **实现方式**: ```sql -- 方式 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 实现 ```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 | ``` **同样的问题**:查询慢、无法建立约束、无法添加额外信息。 **方案三:使用中间表(正确!)** 创建三张表: 1. students 表(学生) 2. courses 表(课程) 3. enrollments 表(选课记录,中间表) **关系表示**: ``` students (M) ---- (M) courses | | | enrollments(中间表) | | student_id | course_id | ``` #### 3.3.3 SQL 实现 ```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:联合主键** ```sql PRIMARY KEY (student_id, course_id) ``` 作用:防止同一个学生重复选同一门课 **要点 2:外键约束** ```sql FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE, FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE ``` 作用:保证数据完整性 - 删除学生时,自动删除该学生的选课记录 - 删除课程时,自动删除该课程的选课记录 **要点 3:中间表可以添加额外字段** ```sql enrolled_at TIMESTAMP, -- 选课时间 grade DECIMAL(5, 2), -- 成绩 status VARCHAR(20), -- 状态:选课中、已退课、已完成 ``` 这是多对多关系的一大优势! #### 3.3.6 什么时候用多对多? **适合使用的场景**: - 标签系统:文章 ↔ 标签 - 权限系统:用户 ↔ 角色 - 社交关系:用户 ↔ 用户(关注、好友) - 购物车:用户 ↔ 商品 **设计原则**: - 必须创建中间表(也叫关联表、桥接表) - 中间表包含两个外键,分别指向两张表 - 使用联合主键防止重复关联 - 可以在中间表添加额外属性(如:选课时间、成绩) ::: tip 💡 多对多关系的判断标准 问自己: 1. "A 的一条记录能对应 B 的多条记录吗?" → 是 2. "B 的一条记录能对应 A 的多条记录吗?" → 是 3. "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)**: ```sql orders 表: | id | user_id | products | total | | 1 | 100 | iPhone(2台),iPad(1台),AirPods(3台) | 50000 | ``` **问题**: 1. `products` 字段包含多个值(不原子) 2. 无法查询"哪些订单包含 iPhone" 3. 无法统计"卖了多少台 iPad" 4. 修改商品数量需要字符串操作 5. 无法对商品建立索引 #### 4.1.3 应用 1NF 后 **方案 1:拆分成多行(符合 1NF)** ```sql 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**: ```sql users 表: | id | name | address | | 1 | 张三 | 北京市朝阳区xxx街道 | ``` **问题**:`address` 包含多个信息(城市、区、详细地址),不原子。 **符合 1NF**: ```sql users 表: | id | name | city | district | detail_address | | 1 | 张三 | 北京市 | 朝阳区 | xxx街道 | ``` **改进**: - 每个字段只存储一个信息 - 可以按城市查询、按区统计 ::: tip 💡 1NF 是基础 所有关系型数据库(MySQL、PostgreSQL、Oracle)默认都满足 1NF,因为字段本身就不能存储复杂对象(JSON 除外)。即使你违反了 1NF(如存储逗号分隔的字符串),数据库也不会报错,但会导致查询和维护困难。 ::: ```sql -- 用户和地址混在一起 | id | name | contact_info | | 1 | 张三 | 北京朝阳区,13800138000 | ``` ✅ **符合 1NF**: ```sql | 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 问题场景:订单明细表 想象你在设计**订单明细表**: **场景**:记录每个订单的每个商品 **表设计**: ```sql 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:数据冗余** ```sql | 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 后 **方案:拆分成两张表** ```sql -- 订单明细表(只依赖复合主键) 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 的所有商品详情**: ```sql 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 问题场景:订单表包含用户等级 想象你在设计**订单表**: **场景**:记录订单时,需要记录用户的等级(用于计算折扣) **表设计**: ```sql 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:数据冗余** ```sql | 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:数据不一致** ```sql | id | user_id | user_level | discount | | 1 | 100 | VIP | 0.9 | | 2 | 100 | SVIP | 0.8 | ← 不一致! ``` 同一用户的不同订单,等级可能不同(数据更新不一致)。 #### 4.3.4 应用 3NF 后 **方案:把用户等级放到用户表** ```sql -- 订单表(只存储订单相关字段) 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**: ```sql 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**: ```sql -- 员工表 employees 表: | id | name | dept_id | | 1 | 张三 | 10 | | 2 | 李四 | 10 | | 3 | 王五 | 20 | -- 部门表 departments 表: | dept_id | dept_name | dept_location | | 10 | 技术部 | 北京 | | 20 | 销售部 | 上海 | ``` ::: tip 💡 3NF 是最常见的范式 实际业务中,大部分表设计都遵循 3NF,它在数据冗余和查询性能之间取得了平衡。 **判断方法**: - 非主键字段之间有依赖关系吗? → 有,可能不符合 3NF - 这个依赖是"历史快照"还是"可以外键关联"? → 可以外键关联,应该拆分 ::: ```sql -- 订单明细表:(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**: ```sql -- 订单明细表 | order_id | product_id | quantity | | 100 | 1 | 2 | -- 商品表 | product_id | name | price | | 1 | iPhone | 5999 | ``` ::: tip 💡 2NF 针对复合主键 如果主键是单个字段,则自动满足 2NF。2NF 主要解决复合主键的部分依赖问题。 ::: ### 4.3 第三范式(3NF):消除传递依赖 **要求**:非主键字段不传递依赖于主键。 ❌ **不符合 3NF**: ```sql -- 订单表 | id | user_id | total | user_level | discount | | 1 | 100 | 500 | VIP | 0.9 | ``` **问题**:`user_level` 依赖 `user_id`,再依赖 `id`(传递依赖)。 ✅ **符合 3NF**: ```sql -- 订单表 | 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 反范式化实战 **场景**:电商订单查询 **范式化设计**: ```sql -- 订单表 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,数据量大时慢。 **反范式化设计**: ```sql -- 订单表(冗余用户信息) 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 错误的做法 **错误设计**:把所有数据塞进一张表 ```sql -- 巨型宽表(错误示例) 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:字段数量爆炸** ```sql -- MySQL 对表字段数量有限制 -- InnoDB: 最多 1017 列 -- MyISAM: 最多 259 列 -- 如果用户下了 1000 个订单怎么办? order_1_id, order_2_id, ..., order_1000_id ← 字段爆炸! ``` **问题 2:大量空值,浪费存储** ```sql -- 大部分用户只有 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 操作)** ```sql -- 用户下了第 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:无法查询"某个订单的所有信息"** ```sql -- 想查询订单 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 -- 想统计"所有订单的总金额" -- 需要写复杂的 SQL SELECT SUM(order_1_amount) + SUM(order_2_amount) + -- ... SUM(order_100_amount) as total ← 无法维护! FROM big_table; ``` #### 6.1.4 正确的做法 **正确设计**:拆分成多张表,用外键关联 ```sql -- 用户表 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 数据样子 ```sql -- 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 查询数据 **查询用户的所有订单**: ```sql SELECT o.* FROM orders o WHERE o.user_id = 1; -- 利用索引,快速查询 ``` **查询订单及对应的用户信息**: ```sql SELECT o.*, u.name, u.email FROM orders o JOIN users u ON o.user_id = u.id WHERE o.id = 100; -- 只 JOIN 需要的字段 ``` **统计用户的订单总数和总金额**: ```sql 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 错误的做法 **错误设计**:用逗号分隔存储多个值 ```sql -- 文章表(错误示例) 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:无法有效索引** ```sql -- 即使给 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:无法精确查询** ```sql -- 想查询"有哪些文章包含 frontend 标签" SELECT * FROM posts WHERE tags LIKE '%frontend%'; -- 问题:会匹配到错误的记录 -- 'vue,frontend,javascript' ← 正确 -- 'frontendend' ← 错误!但会被匹配到 -- 'myfrontendapp' ← 错误!但会被匹配到 ``` **问题 3:无法统计查询** ```sql -- 想统计"每个标签有多少篇文章" -- 几乎无法用 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:无法关联查询** ```sql -- 想查询"包含 vue 标签的文章 + 作者信息" -- 需要字符串匹配,无法用 JOIN SELECT p.*, u.name FROM posts p JOIN users u ON p.user_id = u.id WHERE p.tags LIKE '%vue%'; ← 性能差,不准确 ``` **问题 5:修改标签需要字符串操作** ```sql -- 想给文章 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 正确的做法 **正确设计**:用中间表(多对多关系) ```sql -- 文章表 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 数据样子 ```sql -- 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" 标签的所有文章**: ```sql 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'; -- 利用索引,快速查询 ``` **查询文章的所有标签**: ```sql 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 | ``` **统计每个标签的文章数量**: ```sql 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 | ``` **给文章添加标签**: ```sql -- 先查询或创建标签 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 ```sql -- 订单表(错误示例) 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:无法建立外键约束** ```sql -- 无法保证 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 之前)** ```sql -- MySQL 5.7+ 支持生成列 + 索引,但很复杂 -- MySQL 5.6 及之前版本,JSON 字段完全无法索引 -- 查询"购买了商品 1 的所有订单" SELECT * FROM orders WHERE JSON_CONTAINS(items, '{"pid": 1}'); ← 全表扫描! ``` **问题 3:数据完整性差** ```sql -- 可以插入错误格式的 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:查询"某个商品的所有订单"需要全文扫描** ```sql -- 想查询"购买了 iPhone 的所有订单" -- 需要扫描所有订单的 JSON 字段 SELECT * FROM orders WHERE JSON_CONTAINS(items, '{"pid": 1}'); ← 全表扫描,性能极差! ``` **问题 5:修改商品信息很困难** ```sql -- 想把商品 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:无法统计商品销量** ```sql -- 想统计"每个商品卖了多少" -- 需要在应用层遍历所有订单的 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 正确的做法 **正确设计**:用关联表(一对多关系) ```sql -- 订单表 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 数据样子 ```sql -- 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 查询数据 **查询订单的所有商品**: ```sql SELECT oi.* FROM order_items oi WHERE oi.order_id = 1; -- 利用索引,快速查询 ``` **查询"购买了商品 1 的所有订单"**: ```sql SELECT DISTINCT o.* FROM orders o JOIN order_items oi ON o.id = oi.order_id WHERE oi.product_id = 1; -- 利用索引,快速查询 ``` **统计每个商品的销量**: ```sql 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 的场景**: 1. **非结构化数据** ```sql -- 用户的扩展配置信息(每个用户配置不同) ALTER TABLE users ADD COLUMN preferences JSON; -- 数据样子 | id | username | preferences | | 1 | zhangsan | {"theme":"dark","lang":"zh","fontSize":14} | | 2 | lisi | {"theme":"light","lang":"en"} | ``` 2. **低频查询的数据** ```sql -- 日志、埋点数据(查询频率低) CREATE TABLE events ( id BIGINT PRIMARY KEY, user_id BIGINT, event_data JSON, -- 事件详情(字段不固定) created_at TIMESTAMP ); ``` 3. **动态属性** ```sql -- 商品的动态属性(不同品类属性不同) -- 手机:屏幕尺寸、电池容量 -- 衣服:尺码、材质、颜色 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。** 判断标准: 1. 这个字段需要查询吗? → 需要,别用 JSON 2. 这个字段需要建立索引吗? → 需要,别用 JSON 3. 这个字段需要保证数据完整性吗? → 需要,别用 JSON 4. 这个字段结构经常变化吗? → 是,可以考虑 JSON ::: --- ## 7. 实战:电商系统数据模型 下面是一个完整的电商系统数据模型设计。 ### 7.1 核心模块 **用户模块**: ```sql -- 用户表 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) ``` **商品模块**: ```sql -- 分类表 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) ``` **订单模块**: ```sql -- 订单表 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) ``` **营销模块**: ```sql -- 优惠券表 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 手把手:从零设计一个数据模型 让我们用一个**图书馆管理系统**的例子,从头到尾演示数据模型设计过程。 #### 第一步:识别实体(识别"对象") **问题**:这个系统需要管理哪些"东西"? **需求分析**: - 图书馆有很多书 - 书有不同的类别(小说、科技、历史) - 读者可以借书、还书 - 每本书可以被多次借阅 **识别出的实体**: 1. **图书**(Book):书的基本信息 2. **读者**(Reader):借书的人 3. **借阅记录**(Borrow):谁借了哪本书 4. **分类**(Category):书的分类 #### 第二步:确定属性(识别"特征") **问题**:每个实体有哪些信息需要记录? **图书的属性**: - 书名(title) - 作者(author) - ISBN(isbn)- 唯一标识 - 出版社(publisher) - 出版年份(publish_year) - 库存数量(stock) **读者的属性**: - 姓名(name) - 电话(phone) - 邮箱(email) - 注册日期(registered_at) **借阅记录的属性**: - 借阅日期(borrowed_at) - 应还日期(due_date) - 实还日期(returned_at) - 状态(status):借阅中、已归还、逾期 **分类的属性**: - 分类名称(name) - 分类描述(description) #### 第三步:确定关系(识别"关联") **问题**:这些实体之间有什么关系? **分析**: 1. **图书 ↔ 读者**:多对多? - 一个读者可以借多本书 - 一本书可以被多个读者借(不同时间) - **但是**:同一时间,一本书只能被一个读者借 - **所以**:需要**借阅记录**来记录"谁借了哪本书" 2. **图书 ↔ 分类**:多对多 - 一本书可以属于多个分类(如:《Python编程》属于"编程"和"Python") - 一个分类包含多本书 - **需要中间表**:book_categories 3. **读者 ↔ 借阅记录**:一对多 - 一个读者可以有多条借阅记录 - 一条借阅记录只属于一个读者 4. **图书 ↔ 借阅记录**:一对多 - 一本书可以有多条借阅记录(被不同人借过) - 一条借阅记录只针对一本书 #### 第四步:画出 ER 图 ``` ┌─────────┐ │ 图书 │ └─────────┘ │ id │ │ title │ │ author │ │ isbn │ └─────────┘ │ │ 1:N │ ┌─────────┐ ┌─────────┐ │ 借阅记录│────│ 读者 │ └─────────┘ └─────────┘ │ book_id │ │ id │ │ reader_id│ │ name │ │ borrowed_at│ │ phone │ └─────────┘ └─────────┘ │ │ N:M │ ┌─────────┐ │ 分类 │ └─────────┘ │ id │ │ name │ └─────────┘ ``` #### 第五步:设计表结构(SQL) ```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:查询读者当前借阅的所有图书** ```sql 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:查询某本书的所有借阅历史** ```sql 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:查询逾期未还的图书** ```sql 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:查询某个分类的所有图书** ```sql 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:添加冗余字段(反范式化)** ```sql -- 在 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:添加视图** - 创建视图简化查询 ```sql 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 💡 数据模型设计是个迭代过程 1. **第一步**:先设计一个满足 3NF 的规范模型 2. **第二步**:根据实际查询需求,考虑反范式化 3. **第三步**:通过性能测试,找出瓶颈 4. **第四步**:优化(添加索引、缓存、冗余字段) 5. **第五步**:重复 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 输出示例**: ```sql -- 用户表 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) |