Drizzle 关系基础

在数据库的世界里,尤其是关系型数据库中,关系的概念绝对是基础性的。 把“关系”想象成不同数据片段之间的连接和联系。就像在现实生活中, 人们彼此之间存在关系,或者对象与类别相关联一样,数据库使用关系来建模不同 类型的信息是如何相互连接并协同工作的。

规范化

规范化是组织数据库中数据的过程,目的是减少冗余(重复)并提高数据完整性(准确性和一致性)。可以把它想象成整理一个杂乱的文件柜。不是把各种各样的文件都塞进一个文件夹里,而是把它们整理到有逻辑的文件夹和分类中,让查找和管理都更容易。

为什么规范化很重要?

  • 减少数据冗余:想象一下,每次客户下订单时都要存储他们的地址。如果地址变了,你就得在多个地方更新!规范化帮助你把信息存放在一个地方,并从其他地方引用它,从而尽量减少重复。
  • 提高数据完整性:冗余越少,不一致的可能性就越低。如果你在一个地方更新了地址,它在所有需要的地方都会同步更新。
  • 防止异常:规范化有助于防止以下问题:
    1. 插入异常:由于缺少相关信息,导致难以添加新数据。
    2. 更新异常:不得不在多行中更新同一信息。
    3. 删除异常:当你删除某个看似不相关的内容时,意外丢失了有价值的信息。
  • 更易理解和维护:规范化后的数据库通常在逻辑上更有结构,也更容易理解、查询和修改。

规范化通常用“范式”(1NF、2NF、3NF 以及更高)来描述。虽然细节可能会变得相当技术化,但核心思想很直观:

1NF(第一范式):原子值

目标:每一列都应该只包含一个不可再分的值。单个单元格中不应存在重复的数据组

示例:与其使用一个 address 列来存储 123 Main St, City, USA,不如把它拆分成多个单独的列:street_addresscitystatezip_code

-- 未规范化(违反 1NF)
CREATE TABLE Customers_Unnormalized (
    customer_id INTEGER PRIMARY KEY,
    name TEXT,
    address TEXT -- 问题:一列中包含了多条信息
);

-- 规范化到 1NF
CREATE TABLE Customers_1NF (
    customer_id INTEGER PRIMARY KEY,
    name TEXT,
    street_address TEXT,
    city TEXT,
    state TEXT,
    zip_code TEXT
);

2NF(第二范式):消除依赖于主键部分的冗余数据

目标:适用于具有复合主键的表(由两个或多个列组成的主键)。2NF 确保所有非键属性都完全依赖于整个复合主键,而不仅仅是其中一部分。

假设我们有一个名为 order_items 的表。这个表跟踪订单中的商品,我们使用复合主键(order_idproduct_id),因为一笔订单可能包含多个相同的商品(不过在这个简化示例中,为了清晰起见,假设每个订单中每种商品只出现一次,但复合键的逻辑仍然适用)。

展开查看可视化示例

CREATE TABLE OrderItems_Unnormalized (
    order_id INTEGER,
    product_id TEXT,
    product_name TEXT,
    product_price REAL,
    quantity INTEGER,
    order_date TEXT,
    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_nameproduct_price 会重复出现。这些属性只依赖于 product_id,而 product_id 只是复合主键(order_idproduct_id)的一部分,并不是整个主键。这就是部分依赖。

为了达到 2NF,我们需要移除部分依赖的属性(product_nameproduct_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 TEXT PRIMARY KEY,
    product_name TEXT,
    product_price REAL
);

CREATE TABLE OrderItems_2NF (
    order_id INTEGER,
    product_id TEXT,
    quantity INTEGER,
    order_date TEXT,
    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_codecitystatesupplier_id 是主键。

CREATE TABLE suppliers (
    supplier_id TEXT PRIMARY KEY,
    supplier_name TEXT,
    zip_code TEXT,
    city TEXT,
    state TEXT
);

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,我们移除依赖于非键属性的数据(citystate 依赖于 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 TEXT PRIMARY KEY,
    city TEXT,
    state TEXT
);

CREATE TABLE suppliers (
    supplier_id TEXT PRIMARY KEY,
    supplier_name TEXT,
    zip_code TEXT, -- 指向 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');
了解更多

还有其他范式,例如 4NF5NF6NFEKNFETNFDKNF。这里我们不会介绍这些内容,但会在我们的指南和教程部分为它们制作专门的教程系列。

数据库关系

一对一

在一对一关系中,table A 中的每条记录最多只与 table B 中的一条记录相关联,而 table B 中的每条记录也 最多只与 table A 中的一条记录相关联。这是一种非常直接、互斥的配对关系。

用例与示例

  1. 用户资料和用户账户详情:想象一个网站。每个用户账户(在 Users 表中)可能恰好有一个用户资料(在 UserProfiles 表中),其中包含更详细的信息。
  2. 员工和停车位:一个 Employees 表和一个 ParkingSpaces 表。每个员工最多可能被分配一个停车位,而每个停车位最多只分配给一个员工。
  3. 为了组织而拆分表:有时,出于更好的组织或安全原因,你可能会把一个非常宽的表拆分成两个表,并保持它们之间的 1-1 关系。
表 A(单侧)          表 B(单侧)
+---------+             +---------+
| PK (A)  | <---------> | FK (A)  | (引用表 A 的外键)
| ...     |             | ...     |
+---------+             +---------+

一对多

在一对多关系中,table A 中的一条记录可以与 table B 中的多条记录相关联,但 table B 中的每条 记录最多只与 table A 中的一条记录相关联。可以把它看作一种“父子”关系。

用例与示例

  1. 客户和订单:一个客户可以下很多订单,但每个订单只属于一个客户。
  2. 作者和书籍:一个作者可以写很多书,但(为了简化,这里先假设)每本书只由一个主要作者撰写。
  3. 部门和员工:一个部门可以有很多员工,但每个员工只属于一个部门。
表 A(单侧)          表 B(多侧)
+---------+             +---------+
| PK (A)  | ----------> | FK (A)  | (引用表 A 的外键)
| ...     |             | ...     |
+---------+             +---------+
     (一)                  (多)

多对多

在多对多关系中,table A 中的一条记录可以与 table B 中的多条记录相关联,而 table B 中的一条 记录也可以与 table A 中的多条记录相关联。这是一种更复杂、双向的关系。

用例与示例

  1. 学生和课程:一个学生可以选修很多课程,而一门课程也可以有很多学生选修。
  2. 产品和分类:一个产品可以属于多个分类(例如,T 恤可以同时属于“服装”和“夏季服饰”分类),而一个分类可以包含很多产品。
  3. 作者和书籍:一本书可以由多个作者共同撰写,而一个作者也可以写多本书。
表 A(多侧)         连接表            表 B(多侧)
+---------+          +-------------+     +---------+
| PK (A)  | -------->| FK (A)      | <----| FK (B)  |
| ...     |          | FK (B)      |     | ...     |
+---------+          +-------------+     +---------+
     (多)              (连接)            (多)

多对多关系不能直接通过两个主表之间的外键来实现。 相反,你需要一个 junction 表(也称为关联表或桥接表)。 这个表充当中介,用于连接两个表中的记录。

-- 学生表(多侧)
CREATE TABLE students (
    id INTEGER PRIMARY KEY,
    name TEXT
);

-- 课程表(多侧)
CREATE TABLE courses (
    id INTEGER PRIMARY KEY,
    name TEXT,
    credits INTEGER
);

-- 连接表:enrollments(连接学生和课程 - 多对多关系)
CREATE TABLE enrollments (
    id INTEGER PRIMARY KEY AUTOINCREMENT, -- 可选,但对连接表来说是个好做法
    student_id INTEGER,
    course_id INTEGER,
    enrollment_date TEXT,
    -- 复合外键(通常是复合主键或唯一约束的一部分)
    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. 促进数据库设计与理解

  • 外键不仅仅是技术层面的强制执行;它们也是数据库设计文档的重要组成部分。
  • 当你在数据库模式中看到一个外键时,它会立刻告诉你: 表 'X' 以这种方式与表 'Y' 相关联。 这是一种清晰的视觉和结构性关系指示。
  • 这使数据库更容易理解、维护,并随着时间演进。新开发者可以快速 理解数据库不同部分之间是如何连接的。

本质上,外键约束不只是为了检查值;它们是为了:

  1. 定义数据关系的规则
  2. 在数据库层面主动强制执行这些规则
  3. 保证这些关系中的数据完整性和一致性
  4. 让你的数据库更健壮、更可靠、更易于理解

为什么不使用外键?

虽然外键非常有益,但在某些场景下,你可能需要重新考虑,或者谨慎使用外键。
这些通常是边缘情况,并且往往涉及权衡取舍。

1. 高写入环境中的性能开销

  • 场景:超高吞吐量的事务系统(例如,实时日志记录、超高频交易平台、大规模 IoT 数据摄取)。
  • 解释:每次你在带有外键的表中插入或更新数据时,数据库系统都需要执行检查以确保引用完整性。在极高写入场景下,这些检查会引入一定的性能开销,虽然很小,但可能会变得可感知。

2. 分布式数据库系统与跨节点外键:

  • 场景:数据分布在多个数据库节点或集群中的系统(在分片数据库、云环境和微服务中很常见)。
  • 解释:跨节点外键会带来显著的复杂性和性能开销。验证引用完整性需要节点之间进行通信,从而增加延迟。维护一致性所需的分布式事务也更加复杂,而且性能可能比本地事务更差。在这类架构中,可能会考虑使用应用层数据完整性检查或最终一致性模型作为替代方案。

3. 传统系统以及与非关系型数据的集成:

  • 场景:将关系型数据库与较老的传统系统或非关系型数据存储(例如 NoSQL、平面文件、外部 API)进行集成。
  • 解释:传统系统或非关系型数据可能无法始终遵循外键强制执行的引用完整性规则。在这种场景下引入外键,可能会导致数据导入问题、数据不一致,并且可能需要复杂的数据转换,或者改用应用层完整性管理。你可能需要仔细评估外部数据源的数据质量和一致性,并且可能需要依赖应用逻辑或 ETL 流程来确保数据完整性,而不是在数据库层面严格强制外键。

你也可以查看 PlanetScale 团队在他们的文章中提供的一些很棒的解释。

多态关系

多态关系是一个更高级的概念,它允许单个关系指向不同类型的实体或表。当你有不同种类但又具有某些共性的 डेटा 时,它能帮助你创建更灵活、更具适应性的关系。

想象一下你有一个 activities 日志。一次活动可以是 commentlikeshare。 这些 activity 类型各自有不同的细节。与其为每种活动类型及其关联对象分别创建独立的表和关系,不如采用多态方式。

Common Scenarios & Examples

  • 评论/评价:一个 “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 并不直接内置支持像普通外键那样强制执行多态外键约束。