Files
test-repo/docs/zh-cn/appendix/5-data/data-models.md
T
2026-02-24 00:18:09 +08:00

83 KiB
Raw Blame History

数据模型:设计的"骨架"

::: 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:外键唯一约束(推荐)

-- 用户表
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       |

同样的问题:查询慢、无法建立约束、无法添加额外信息。

方案三:使用中间表(正确!)

创建三张表:

  1. students 表(学生)
  2. courses 表(课程)
  3. 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 💡 多对多关系的判断标准 问自己:

  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

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

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_idid 重复了(这是 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_idproduct_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_nameunit_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 依赖 BB 依赖 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_leveldiscount

问题 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_namedept_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_nameunit_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
-- 或用复杂的 SQLMySQL 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 的场景

  1. 非结构化数据

    -- 用户的扩展配置信息(每个用户配置不同)
    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. 低频查询的数据

    -- 日志、埋点数据(查询频率低)
    CREATE TABLE events (
      id BIGINT PRIMARY KEY,
      user_id BIGINT,
      event_data JSON,  -- 事件详情(字段不固定)
      created_at TIMESTAMP
    );
    
  3. 动态属性

    -- 商品的动态属性(不同品类属性不同)
    -- 手机:屏幕尺寸、电池容量
    -- 衣服:尺码、材质、颜色
    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 核心模块

用户模块

-- 用户表
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 手把手:从零设计一个数据模型

让我们用一个图书馆管理系统的例子,从头到尾演示数据模型设计过程。

第一步:识别实体(识别"对象")

问题:这个系统需要管理哪些"东西"?

需求分析

  • 图书馆有很多书
  • 书有不同的类别(小说、科技、历史)
  • 读者可以借书、还书
  • 每本书可以被多次借阅

识别出的实体

  1. 图书Book):书的基本信息
  2. 读者Reader):借书的人
  3. 借阅记录Borrow):谁借了哪本书
  4. 分类Category):书的分类

第二步:确定属性(识别"特征")

问题:每个实体有哪些信息需要记录?

图书的属性

  • 书名(title
  • 作者(author
  • ISBNisbn- 唯一标识
  • 出版社(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

-- 图书表
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_titlereader_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 💡 数据模型设计是个迭代过程

  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 输出示例

-- 用户表
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 → CC 传递依赖 A
部分依赖 Partial Dependency 非主键字段只依赖复合主键的一部分
原子性 Atomicity 字段不可再分的最小数据单元
宽表 Wide Table 字段数量特别多的表
索引 Index 加速查询的数据结构
约束 Constraint 保证数据完整性的规则(如 NOT NULL、UNIQUE