- 减少数据冗余:想象一下,每次客户下订单时都保存一次他们的地址。如果地址变了,你就得在多个地方更新!规范化可以帮助你把信息存放在一个地方,并在其他地方引用它,从而尽量减少重复。
- 提高数据完整性:冗余越少,出现不一致的可能性就越低。如果你在一个地方更新了地址,那么所有需要它的地方都会同步更新。
- 防止异常:规范化有助于避免以下问题:
- 插入异常:由于缺少相关信息,难以添加新数据。
- 更新异常:不得不在多行中更新同一信息。
- 删除异常:在删除看似无关的内容时,意外丢失有价值的信息。
- 更易理解和维护:规范化后的数据库通常具有更清晰的逻辑结构,也更容易理解、查询和修改。
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 表。每个员工最多可能被分配一个停车位,而每个停车位最多只分配给一个员工。
- 为组织而拆分表:有时,为了更好的组织或安全原因,你可能会把一个非常宽的表拆分成两个表,并保持它们之间的 1-1 关系。
Table A (One Side) Table B (One Side)
+---------+ +---------+
| PK (A) | <---------> | FK (A) | (引用 Table A 的外键)
| ... | | ... |
+---------+ +---------+一对多
在一对多关系中,table A 中的一条记录可以与 table B 中的多条记录相关联,但 table B 中的每条
记录至多只与 table A 中的一条记录相关联。可以把它看作一种“父子”关系。
用例与示例
- 客户与订单:一个客户可以下很多订单,但每个订单只属于一个客户。
- 作者与书籍:一个作者可以写很多书,但(为了简化,我们暂且认为)每本书都由一位主要作者撰写。
- 部门与员工:一个部门可以有很多员工,但每个员工只属于一个部门。
Table A (One Side) Table B (Many Side)
+---------+ +---------+
| PK (A) | ----------> | FK (A) | (引用 Table A 的外键)
| ... | | ... |
+---------+ +---------+
(One) (Many)多对多
在多对多关系中,table A 中的一条记录可以与 table B 中的多条记录相关联,而 table B 中的一条
记录也可以与 table A 中的多条记录相关联。这是一种更复杂的双向关系。
用例与示例
- 学生与课程:一个学生可以选修很多课程,而一门课程可以有很多学生选修。
- 产品与分类:一个产品可以属于多个分类(例如,“T 恤”可以属于“服装”和“夏季服饰”分类),而一个分类可以包含很多产品。
- 作者与书籍:一本书可以由多位作者撰写,而一位作者可以写多本书。
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 IDENTITY(1,1) PRIMARY KEY, -- 可选,但对连接表来说是良好实践
[student_id] INT,
[course_id] INT,
[enrollment_date] DATE,
-- 复合外键(通常是复合主键或唯一约束的一部分)
FOREIGN KEY ([student_id]) REFERENCES [students]([id]),
FOREIGN KEY ([course_id]) REFERENCES [courses]([id]),
UNIQUE ([student_id], [course_id]) -- 防止同一学生和课程重复选课
);为什么需要外键?
你可能会把外键约束简单地看作一种验证数据的方法——确保当你在外键列中输入一个值时,这个值实际上存在于另一张表的主键列中。你这样理解也部分正确!这种值检查正是外键所使用的机制。
但关键是要明白,这种验证并不是最终目标,它只是通向更大目标的手段。外键约束从根本上来说是为了:
1. 明确定义并强制执行关系
我们已经讨论过 Customers 和 Orders 之间的 一对多 关系。外键是 SQL 语言告诉数据库的方式:
嘿,数据库,我想在这里强制执行一个 1-M 关系。Orders 表中 customer_id 列里的每个值,都必须对应 Customers 表中一个有效的 customer_id。
这不只是一个建议;这是数据库会主动强制执行的约束。
正因为有了外键,数据库才具备了关系感知能力。
2. 维护引用完整性
- 这是关系语境中“数据完整性”的核心。引用完整性意味着表之间的关系在一段时间内始终保持一致和有效。
- 外键可以防止孤儿记录。什么是孤儿记录?在我们的 Customer-Order 例子中,如果 Orders 表中存在一条订单记录,但 Customers 表中没有对应的客户记录,那么这条订单就是孤儿记录。外键可以防止这种情况发生(或者在你尝试删除有订单的客户时控制其行为——通过 CASCADE、SET NULL 等方式)。
- 为什么防止孤儿记录很重要?孤儿记录会破坏数据的逻辑结构。如果一张订单没有对应的客户,你就失去了关键上下文。查询会变得不可靠,报表会变得不准确,你的应用逻辑也可能随之失效。
示例:
如果没有外键,你可能会在 Customers
表中不小心删除一个客户,而他们的订单仍然存在于 Orders 表中。突然之间,你就有了指向一个不再存在的客户的订单!外键约束可以防止这种数据不一致。3. 促进数据库设计与理解
- 外键不仅仅是技术层面的约束;它们也是数据库设计文档中至关重要的一部分。
- 当你在数据库模式中看到一个外键时,它会立刻告诉你:
Table 'X' 与 Table 'Y' 以这种方式相关联。这是一种清晰的视觉和结构化关系指示。 - 这让数据库更容易理解、维护,并随着时间推移不断演进。新的开发者可以快速掌握数据库不同部分之间是如何连接的。
本质上,外键约束不仅仅是检查值;它们还在于:
- 定义数据关系的规则
- 在数据库层面主动强制执行这些规则
- 保证这些关系中的数据完整性和一致性
- 让你的数据库更健壮、更可靠、也更容易理解
为什么不使用外键?
尽管外键非常有益,但在某些场景下,你可能会重新考虑,或者谨慎使用外键。
这些通常属于边缘情况,并且往往涉及权衡取舍。
1. 高写入环境中的性能开销
- 场景:极高吞吐量的事务系统(例如:实时日志记录、超高频交易平台、大规模 IoT 数据摄取)。
- 说明:每次在带有外键的表中插入或更新数据时,数据库系统都需要执行检查以确保引用完整性。在极高写入量的场景下,这些检查可能会带来一小部分但可能会比较明显的性能开销。
2. 分布式数据库系统与跨节点外键:
- 场景:数据分布在多个数据库节点或集群中的系统(在分片数据库、云环境和微服务中很常见)。
- 说明:跨节点外键可能会引入显著的复杂性和性能开销。验证引用完整性需要节点之间通信,从而导致延迟增加。为了保持一致性而需要的分布式事务也更加复杂,且性能可能不如本地事务。在这种架构中,可以考虑使用应用层数据完整性检查或最终一致性模型作为替代方案。
3. 遗留系统以及与非关系型数据的集成:
- 场景:将关系型数据库与较旧的遗留系统或非关系型数据存储(例如 NoSQL、平面文件、外部 API)进行集成。
- 说明:遗留系统或非关系型数据可能并不总是遵循外键所强制执行的引用完整性规则。在这种场景下强制使用外键,可能会导致数据导入问题、数据不一致,并且可能需要复杂的数据转换或改为由应用层来管理完整性。你可能需要仔细评估外部数据源的数据质量和一致性,并可能依赖应用逻辑或 ETL 流程来确保数据完整性,而不是在数据库层面严格强制外键。
你也可以查看 PlanetScale 团队在他们的 文章 中提供的一些很棒的解释
多态关系
多态关系是一种更高级的概念,它允许单个关系指向不同类型的实体或表。当你有多种具有某些共同点的数据时,它能帮助你创建更灵活、更具适应性的关系。
想象一下你有一个 activities 日志。一个活动可以是 comment、like 或 share。
每一种 activity 类型都有不同的细节。与其为每种活动类型及其关联对象分别创建独立的表和关系,不如使用多态方式。
常见场景与示例
- 评论/评价:一个“评论”可能与不同类型的内容相关:文章、产品、视频等。
与其在 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 并不直接内置支持以与常规外键相同的方式来强制执行多态外键约束。