- 减少数据冗余:想象一下,每次客户下订单时都要存储一次他们的地址。如果地址发生变化,你就得在多个地方更新!规范化帮助你把信息存储在一个地方,并在其他地方引用它,从而尽量减少重复。
- 提高数据完整性:更少的冗余意味着更少的不一致性。如果你在一个地方更新了地址,它会在所有需要的地方同步更新。
- 防止异常:规范化有助于防止以下问题:
- 插入异常:因为缺少相关信息而难以添加新数据。
- 更新异常:不得不在多行中更新相同的信息。
- 删除异常:在删除看似无关的内容时,意外丢失有价值的信息。
- 更易理解和维护:规范化后的数据库通常结构更合理,也更容易理解、查询和修改。
Drizzle 关系基础
在数据库的世界里,尤其是关系型数据库中,关系的概念绝对是基础性的。 把“关系”想象成不同数据片段之间的连接和联系。就像现实生活中, 人们彼此之间有关系,或者物体与类别相关联一样,数据库使用关系来建模不同 类型的信息是如何相互连接并协同工作的。
规范化
规范化是对数据库中的数据进行组织,以减少冗余(重复)并提高数据完整性 (准确性和一致性)的过程。可以把它想象成整理一个杂乱的文件柜。与其把各种文件 都塞进一个文件夹里,不如把它们按逻辑归类到不同的文件夹和类别中,这样一切都更容易查找和管理。
为什么规范化很重要?
规范化通常用“范式”来描述(1NF、2NF、3NF 以及更高范式)。虽然细节可能会变得相当技术化,但核心思想很简单:
1NF(第一范式):原子值
目标:每一列都应该只包含一个不可再分的值。单个单元格中不能有重复组数据
示例:不要使用单个 address 列来存储 123 Main St, City, USA,而是将其
拆分为多个独立列:street_address、city、state、zip_code。
-- 未规范化(违反 1NF)
CREATE TABLE `Customers_Unnormalized` (
`customer_id` INT PRIMARY KEY,
`name` VARCHAR(255),
`address` VARCHAR(255) -- 问题:一列中包含多个信息片段
);
-- 规范化到 1NF
CREATE TABLE `Customers_1NF` (
`customer_id` INT PRIMARY KEY,
`name` VARCHAR(255),
`street_address` VARCHAR(255),
`city` VARCHAR(255),
`state` VARCHAR(255),
`zip_code` VARCHAR(10)
);2NF(第二范式):消除依赖于主键部分的冗余数据
目标:适用于具有复合主键的表(由两列或多列组成的主键)。 2NF 确保所有非键属性都完全依赖于整个复合主键,而不仅仅是其中一部分。
假设我们有一个名为 order_items 的表。这个表用于跟踪订单中的商品,我们使用复合主键(order_id、product_id)
因为一个订单可以包含多个相同的商品(不过在这个简化示例中,为了便于理解,假设每个商品在每个订单中只出现
一次,但复合键逻辑仍然适用)。
展开查看可视化示例
CREATE TABLE `OrderItems_Unnormalized` (
`order_id` INT,
`product_id` VARCHAR(10),
`product_name` VARCHAR(100),
`product_price` DECIMAL(10, 2),
`quantity` INT,
`order_date` DATE,
PRIMARY KEY (`order_id`, `product_id`) -- 复合主键
);
INSERT INTO `OrderItems_Unnormalized` (`order_id`, `product_id`, `product_name`, `product_price`, `quantity`, `order_date`) VALUES
(101, 'A123', 'Laptop', 1200.00, 1, '2023-10-27'),
(101, 'B456', 'Mouse', 25.00, 2, '2023-10-27'),
(102, 'A123', 'Laptop', 1200.00, 1, '2023-10-28'),
(103, 'C789', 'Keyboard', 75.00, 1, '2023-10-29');+------------------------------------------------------------------------------------+
| OrderItems_Unnormalized |
+------------------------------------------------------------------------------------+
| PK (order_id, product_id) | product_name | product_price | quantity | order_date |
+------------------------------------------------------------------------------------+
| 101, A123 | Laptop | 1200.00 | 1 | 2023-10-27 |
| 101, B456 | Mouse | 25.00 | 2 | 2023-10-27 |
| 102, A123 | Laptop | 1200.00 | 1 | 2023-10-28 |
| 103, C789 | Keyboard | 75.00 | 1 | 2023-10-29 |
+------------------------------------------------------------------------------------+问题:注意当相同的 product_id 出现在不同订单中时,product_name 和 product_price 会重复。
这些属性只依赖于 product_id,而 product_id 只是复合主键(order_id、product_id)的一部分,而不是整个主键。
这就是部分依赖。
要实现 2NF,我们需要移除部分依赖的属性(product_name、product_price),并将它们放入一个单独的表中,使它们
完全依赖于新表的主键。
规范化到 2NF:可视化说明
+-------------------+ 1:M +---------------------------+
| Products | <---------- | OrderItems_2NF |
+-------------------+ +---------------------------+
| PK product_id | | PK (order_id, product_id) |
| product_name | | quantity |
| product_price | | order_date |
+-------------------+ | FK product_id |
+---------------------------+CREATE TABLE `Products` (
`product_id` VARCHAR(10) PRIMARY KEY,
`product_name` VARCHAR(100),
`product_price` DECIMAL(10, 2)
);
CREATE TABLE `OrderItems_2NF` (
`order_id` INT,
`product_id` VARCHAR(10),
`quantity` INT,
`order_date` DATE,
PRIMARY KEY (`order_id`, `product_id`), -- 复合主键保持不变
FOREIGN KEY (`product_id`) REFERENCES `Products`(`product_id`) -- 指向 Products 的外键
);
-- 将数据插入 Products
INSERT INTO `Products` (`product_id`, `product_name`, `product_price`) VALUES
('A123', 'Laptop', 1200.00),
('B456', 'Mouse', 25.00),
('C789', 'Keyboard', 75.00);
-- 将数据插入 OrderItems_2NF(引用 Products)
INSERT INTO `OrderItems_2NF` (`order_id`, `product_id`, `quantity`, `order_date`) VALUES
(101, 'A123', 1, '2023-10-27'),
(101, 'B456', 2, '2023-10-27'),
(102, 'A123', 1, '2023-10-28'),
(103, 'C789', 1, '2023-10-29');3NF(第三范式):消除依赖于非键属性的冗余数据
目标:移除依赖于其他非键属性的数据。这是为了消除传递依赖。
问题:假设我们有一个 suppliers 表。我们存储供应商信息,包括他们的 zip_code、city 和 state。supplier_id 是主键。
CREATE TABLE `suppliers` (
`supplier_id` VARCHAR(10) PRIMARY KEY,
`supplier_name` VARCHAR(255),
`zip_code` VARCHAR(10),
`city` VARCHAR(100),
`state` VARCHAR(50)
);
INSERT INTO `suppliers` (`supplier_id`, `supplier_name`, `zip_code`, `city`, `state`) VALUES
('S1', 'Acme Corp', '12345', 'Anytown', 'NY'),
('S2', 'Beta Inc', '67890', 'Otherville', 'CA'),
('S3', 'Gamma Ltd', '12345', 'Anytown', 'NY');+---------------------------------------------------------------+
| suppliers |
+---------------------------------------------------------------+
| PK supplier_id | supplier_name | zip_code | city | state |
+---------------------------------------------------------------+
| S1 | Acme Corp | 12345 | Anytown | NY |
| S2 | Beta Inc | 67890 | Otherville | CA |
| S3 | Gamma Ltd | 12345 | Anytown | NY |
+---------------------------------------------------------------+解决方案:为了实现 3NF,我们移除依赖于非键属性的属性(city、state 依赖于 zip_code),并将
它们放入一个以该非键属性本身(zip_code)为键的单独表中。
规范化到 3NF:可视化说明
+-------------------+ 1:M +--------------------+
| zip_codes | <---------- | suppliers |
+-------------------+ +--------------------+
| PK zip_code | | PK supplier_id |
| city | | supplier_name |
| state | | FK zip_code |
+-------------------+ +--------------------+CREATE TABLE `zip_codes` (
`zip_code` VARCHAR(10) PRIMARY KEY,
`city` VARCHAR(100),
`state` VARCHAR(50)
);
CREATE TABLE `suppliers` (
`supplier_id` VARCHAR(10) PRIMARY KEY,
`supplier_name` VARCHAR(255),
`zip_code` VARCHAR(10), -- 指向 zip_codes 的外键
FOREIGN KEY (`zip_code`) REFERENCES `zip_codes`(`zip_code`)
);
-- 将数据插入 zip_codes
INSERT INTO `zip_codes` (`zip_code`, `city`, `state`) VALUES
('12345', 'Anytown', 'NY'),
('67890', 'Otherville', 'CA');
-- 将数据插入 suppliers(引用 zip_codes)
INSERT INTO `suppliers` (`supplier_id`, `supplier_name`, `zip_code`) VALUES
('S1', 'Acme Corp', '12345'),
('S2', 'Beta Inc', '67890'),
('S3', 'Gamma Ltd', '12345');还有其他范式,例如 4NF、5NF、6NF、EKNF、ETNF 和 DKNF。这里我们不会涵盖这些内容,但我们会在指南和教程部分为它们创建一套专门的教程。
数据库关系
一对一
在一对一关系中,table A 中的每条记录最多只与 table B 中的一条记录相关联,而 table B 中的每条记录也最多只与 table A 中的一条记录相关联。这是一种非常直接、独占的配对关系。
使用场景与示例
- 用户资料与用户账户详情:想象一个网站。每个用户账户(在 Users 表中)可能恰好有一个用户资料(在 UserProfiles 表中),其中包含更详细的信息。
- 员工与停车位:Employees 表和 ParkingSpaces 表。每位员工最多可以被分配一个停车位,而每个停车位最多只分配给一位员工。
- 为了组织而拆分表:有时,你可能会为了更好的组织或安全性原因,将一个非常宽的表拆分成两个表,并保持它们之间的一对一关系。
Table A (One Side) Table B (One Side)
+---------+ +---------+
| PK (A) | <---------> | FK (A) | (Foreign Key referencing Table A)
| ... | | ... |
+---------+ +---------+一对多
在一对多关系中,table A 中的一条记录可以与 table B 中的多条记录相关联,但 table B 中的每条记录最多只与 table A 中的一条记录相关联。可以把它看作一种“父子”关系。
使用场景与示例
- 客户与订单:一个客户可以下很多订单,但每个订单只属于一个客户。
- 作者与书籍:一位作者可以写很多书,但(为了简化,这里假设)每本书只由一位主要作者撰写。
- 部门与员工:一个部门可以有很多员工,但每个员工只属于一个部门。
Table A (One Side) Table B (Many Side)
+---------+ +---------+
| PK (A) | ----------> | FK (A) | (Foreign Key referencing Table A)
| ... | | ... |
+---------+ +---------+
(One) (Many)多对多
在多对多关系中,table A 中的一条记录可以与 table B 中的多条记录相关联,而 table B 中的一条记录也可以与 table A 中的多条记录相关联。这是一种更复杂、双向的关系。
使用场景与示例
- 学生与课程:一个学生可以选修很多课程,而一门课程也可以有很多学生选修。
- 产品与分类:一个产品可以属于多个分类(例如,一个“T-shirt”可以同时属于“Clothing”和“Summer Wear”分类),而一个分类可以包含很多产品。
- 作者与书籍:一本书可以由多位作者撰写,而一位作者也可以写多本书。
Table A (Many Side) Junction Table Table B (Many Side)
+---------+ +-------------+ +---------+
| PK (A) | -------->| FK (A) | <----| FK (B) |
| ... | | FK (B) | | ... |
+---------+ +-------------+ +---------+
(Many) (Junction) (Many)多对多关系不会直接通过两个主表之间的外键来实现。
相反,你需要一个 junction 表(也称为关联表或桥接表)。
这个表充当中介,用来连接来自两个表的记录。
-- 学生表(多的一方)
CREATE TABLE `students` (
`id` INT PRIMARY KEY,
`name` VARCHAR(255)
);
-- 课程表(多的一方)
CREATE TABLE `courses` (
`id` INT PRIMARY KEY,
`name` VARCHAR(255),
`credits` INT
);
-- 连接表:enrollments(连接学生和课程 - M-M 关系)
CREATE TABLE `enrollments` (
`id` INT PRIMARY KEY AUTO_INCREMENT, -- 可选,但对于连接表来说是个好做法
`student_id` INT,
`course_id` INT,
`enrollment_date` DATE,
-- 复合外键(通常是复合主键或唯一约束的一部分)
FOREIGN KEY (`student_id`) REFERENCES `students`(`id`),
FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`),
UNIQUE KEY (`student_id`, `course_id`) -- 防止同一学生和课程重复选课
);为什么要使用外键?
你可能会把外键约束简单地看作一种验证数据的方式——确保当你在外键列中输入一个值时,这个值确实存在于另一张表的主键列中。这样理解有一部分是对的!外键所使用的值检查机制就是如此。
但关键在于要理解,这种验证并不是最终目标,而只是实现更大目的的手段。外键约束本质上是关于:
1. 明确定义并强制关系
我们已经讨论过 Customers 和 Orders 之间像 One-to-Many 这样的关系。外键是 SQL 语言告诉数据库的方式:
嘿,数据库,我想在这里强制一个 1-M 关系。Orders 表中 customer_id 列的每个值都必须对应 Customers 表中一个有效的 customer_id。
这不仅仅是一个建议;它是数据库会主动执行的约束。因为外键,数据库才会“感知”到这种关系。
2. 维护引用完整性
- 这是关系语境中“数据完整性”的核心。引用完整性意味着表与表之间的关系会随着时间的推移保持一致且有效。
- 外键可以防止孤儿记录。什么是孤儿记录?在我们的 Customer-Order 示例中,Orders 表中存在但在 Customers 表中没有对应客户的订单,就是一个孤儿记录。外键可以防止这种情况发生(或者在你尝试删除仍有订单的客户时控制会发生什么——通过 CASCADE、SET NULL 等)。
- 为什么防止孤儿记录很重要?孤儿记录会破坏数据的逻辑结构。如果你有一个没有客户的订单,你就失去了关键上下文。查询会变得不可靠,报表会变得不准确,你的应用逻辑也可能失效。
示例:
如果没有外键,你可能会在 Customers 表中意外删除某个客户,而他们的订单仍然存在于 Orders 表中。突然之间,你就有了指向一个已不存在客户的订单!外键约束可以防止这种数据不一致。3. 促进数据库设计与理解
- 外键不仅仅是技术上的强制手段;它们也是数据库设计文档中至关重要的一部分。
- 当你在数据库模式中看到一个外键时,它会立刻告诉你:
表 'X' 以这种方式与表 'Y' 相关联。这是关系的一个清晰的视觉和结构指示。 - 这让数据库更容易理解、维护,并随着时间推移不断演进。新开发者可以迅速掌握数据库不同部分之间是如何连接的。
总之,外键约束不仅仅是检查值;它们还意味着:
- 定义数据关系的规则
- 在数据库层面主动强制执行这些规则
- 保证这些关系中的数据完整性和一致性
- 让你的数据库更加健壮、可靠且易于理解
为什么不使用外键?
虽然外键非常有益,但在某些场景下,你可能需要重新考虑,或者谨慎使用外键。
这些通常是边缘情况,并且往往涉及权衡。
1. 高写入环境中的性能开销
- 场景:极高吞吐量的事务系统(例如,实时日志记录、超高频交易平台、大规模 IoT 数据摄取)。
- 说明:每次向带有外键的表中插入或更新数据时,数据库系统都需要执行检查以确保引用完整性。在极端高写入场景下,这些检查可能会带来 небольшой但可能可察觉的性能开销。
2. 分布式数据库系统和跨节点外键:
- 场景:数据分布在多个数据库节点或集群中的系统(在分片数据库、云环境和微服务中很常见)。
- 说明:跨节点外键会带来显著的复杂性和性能开销。验证引用完整性需要节点之间通信,从而增加延迟。为维护一致性所需的分布式事务也更复杂,并且可能比本地事务性能更差。在此类架构中,可以考虑使用应用层数据完整性检查或最终一致性模型作为替代方案。
3. 遗留系统以及与非关系型数据的数据集成:
- 场景:将关系型数据库与较旧的遗留系统或非关系型数据存储(例如 NoSQL、平面文件、外部 API)集成。
- 说明:遗留系统或非关系型数据可能无法始终遵循外键所强制执行的引用完整性规则。在这种情况下强加外键可能会导致数据导入问题、数据不一致,并且可能需要复杂的数据转换或改为由应用层管理完整性。你可能需要仔细评估外部来源的数据质量和一致性,并可能依赖应用逻辑或 ETL 流程来确保数据完整性,而不是在数据库层面严格强制外键。
你也可以查看 PlanetScale 团队在他们的 文章 中给出的一些很棒的解释
多态关系
多态关系是一个更高级的概念,它允许单个关系指向不同类型的实体或表。当你有一些具有共同点但数据类型不同的数据时,它能创建更灵活、更适应变化的关系。
想象一下你有一个 activities 日志。一次活动可以是 comment、like 或 share。每种 activity 类型都有不同的细节。与其为每种活动类型及其关联对象分别创建独立的表和关系,不如使用多态方式。
常见场景与示例
- 评论/评价:一个 “Comment” 可能关联不同类型的内容:文章、产品、视频等。
与其在 Comments 表中分别设置 article_id、product_id、video_id 列,不如使用多态关系。
+---------------------+
| **Comments** |
+---------------------+
| PK comment_id |
| commentable_type | ------> [多态关系]
| commentable_id | -------->
| user_id |
| comment_text |
| ... |
+---------------------+
^
|
+---------------------+ +---------------------+ +---------------------+
| **Articles** | | **Products** | | **Videos** |
+---------------------+ +---------------------+ +---------------------+
| PK article_id | | PK product_id | | PK video_id |
| ... | | ... | | ... |
+---------------------+ +---------------------+ +---------------------+- 通知:一个通知可能关联用户、订单、系统事件等。
+----------------------+
| **Notifications** |
+----------------------+
| PK notification_id |
| notifiable_type | ------> [多态关系]
| notifiable_id | -------->
| user_id |
| message |
| ... |
+----------------------+
^
|
+---------------------+ +---------------------+ +-----------------------+
| **Users** | | **Orders** | | **System Events** |
+---------------------+ +---------------------+ +-----------------------+
| PK user_id | | PK order_id | | PK event_id |
| ... | | ... | | ... |
+---------------------+ +---------------------+ +-----------------------+多态关系更复杂,通常在应用层处理,或者使用更高级的数据库特性来处理(取决于具体的数据库系统)。标准 SQL 并不直接内置支持像普通外键那样强制执行多态外键约束。