一、引言
当我们需要持久化地存储数据时,关系型数据库通常是首选。它不仅种类丰富、稳定,而且得到了广泛社区的支持。本文将探讨关系型数据库中的一个重要概念——外键(Foreign Key)。
二、外键的作用
在关系型数据库中,外键也被称为关系键。它是一组数据列,用于在不同关系表之间建立连接。这组数据列在当前表中是外键,而在另一个表中必须是候选键(Candidate Key)。通过候选键,我们可以在当前表中找到唯一的元素。通常,我们会使用关系表中的主键作为其他表中的外键,以满足关系型数据库对外键的约束。
外键不仅是一个数据库表中的列,它还提供了额外的一致性保证。因为数据库通常是整个系统的“真理之源”(Source of Truth),所以保证数据的一致性和正确性非常重要。关系型数据库提供了外键、触发器等特性来保证一致性,但在实际生产环境中,这些特性却很少被使用。
引用完整性(Referential Integrity)是数据的属性,如果数据拥有该属性,那么数据中所有的引用都是合法的。在关系型数据库的上下文中,这意味着关系型数据库中引用另一个表中的值必须存在。
上述 SQL 语句可以向关系表中增加外键约束,该语句的执行前提是 posts
表中存在 author_id
字段。从 SQL 语句中的 CONSTRAINT
关键字可以推测出,外键不是一种数据类型,而是不同关系表之间的约束。
图 2 - 无状态服务与数据库
不使用外键的原因其实很简单。MySQL、PostgreSQL 等关系型数据库很难水平扩容,但无状态的服务往往可以很容易地扩容。由于外键等特性需要数据库执行额外的工作,这些操作会占用数据库的计算资源。因此,我们可以将大部分需求迁移到无状态的服务中完成,以降低数据库的工作负载。
根据更新和删除时的行为不同,外键可以分为 RESTRICT
、CASCADE
和 SET NULL
等几种。当我们为关系表中的字段增加外键约束时,需要指定外键的类型。最常见的类型是 RESTRICT
和 CASCADE
,其中 RESTRICT
是外键的默认类型。不同类型的外键会带来不同的额外开销,而这些额外开销正是我们不使用外键的理由:
-
使用
RESTRICT
会在更新或删除记录时对外键对应的记录是否存在进行一致性检查。 -
使用
CASCADE
会在更新或删除记录时触发级联更新或删除操作。
需要注意的是,MySQL 中的 NO ACTION
和 RESTRICT
具有相同的语义。
接下来,我们将详细介绍关系型数据库如何处理上述两种不同类型的外键,以及我们如何在应用中模拟这些功能。
三、一致性检查
当我们使用默认的外键类型 RESTRICT
时,在创建、修改或删除记录时都会检查引用的合法性。假设我们的数据库中包含 posts(id, author_id, content)
和 authors(id, name)
两张表,在执行以下操作时都会触发数据库对外键的检查:
-
向
posts
表中插入数据时,检查author_id
是否在authors
表中存在。 -
修改
posts
表中的数据时,检查author_id
是否在authors
表中存在。 -
删除
authors
表中的数据时,检查posts
中是否存在引用当前记录的外键。
作为专门用于管理数据的系统,数据库与应用服务相比能够更好地保证完整性。上述操作是引入外键带来的额外工作,但也是数据库保证数据完整性的必要代价。这些分析是理论上的定性分析,我们可以通过简单的定量分析来研究引入外键对性能的影响。
在这里,我们在数据库中同时创建 authors
、posts
和 foreign_key_posts
三种表。posts
和 foreign_key_posts
两个表中的列完全相同,只是 foreign_key_posts
表为 author_id
字段增加了 RESTRICT
类型的外键约束。
图 3 - 外键性能测试关系图
我们先在 authors
表中插入一条记录,随后分别在 posts
和 foreign_key_posts
中插入多条新数据列引用该条记录。posts
表不会检查外键的合法性,而 foreign_key_posts
表会进行额外的检查。通过多次基准测试,我们可以得到如下结果:
执行了 4 次外键的基准测试,虽然结果不是特别稳定,但使用外键的用例在每次测试中都明显弱于不使用外键的用例。外键带来的额外开销分别为约 2.47%、0.02%、10.41% 和 11.52%。基准测试只是一个简单的定量分析,但从结果中可以看到,外键的完整性检查确实会带来额外的性能开销。在高并发的服务中,这些开销需要慎重考虑。
想要在应用程序中模拟数据库外键的功能其实比较容易,我们只需要遵循以下准则:
-
向表中插入数据或修改表中的数据时,执行额外的
SELECT
语句确保引用的数据在数据库中存在。 -
删除数据之前,执行额外的
SELECT
语句检查是否存在当前记录的引用。
需要注意的是,为了保证一致性,我们需要在事务中执行上述查询和修改语句,这样才能完整模拟外键的功能。当我们向 posts
表中插入或修改数据时,处理相对简单。我们只需要执行有限的 SELECT
语句并按照以下模式执行对应的操作即可:
但是,如果我们删除 authors
表中的数据,就需要查询所有引用 authors
数据的表。如果有 10 个表都有指向 authors
表的外键,我们就需要在 10 个表中查询是否存在对应的记录。这个过程相对麻烦,但也是实现完整性的必要代价。然而,这种模拟外键的方法其实远比使用外键更消耗资源。它不仅需要查询关联数据,还要通过网络发送更多的数据包。
四、级联操作
当我们在关系型数据库中创建外键约束时,如果使用如下 SQL 语句指定更新或删除记录时使用 CASCADE
行为,那么在客户端更新或删除数据时就会触发级联操作:
-
当客户端删除
authors
表中的记录时,数据库会删除所有与authors
表关联的记录。
数据库可以保证各个关系表之间引用的一致性和合法性,不会出现引用到不存在记录的情况。与 RESTRICT
行为一样,所有外键的更新和删除行为都可以通过执行额外的检查和操作保证数据的一致性。
图 4 - 复杂的级联操作
虽然级联删除的出发点是保证数据的完整性,但在设计关系表之间的关系时,我们需要注意级联删除引起的数据大规模删除问题。如上图所示,当客户端想要在数据库中删除 authors
表中的数据时,如果我们同时在 authors
和 posts
中指定了级联删除的行为,那么数据库会同时删除所有关联的 posts
记录以及与 posts
表关联的 comments
数据。
这种涉及多级的级联删除行为在数据量较小的数据库中不会导致问题,但在数据量较大的数据库中,删除关键数据可能会引起雪崩效应。一条记录的删除可能会被放大到几十倍甚至上百倍,这些对磁盘的随机读写会带来巨大的开销,是我们想要尽可能避免的情况。如果我们能够较好地设计各个表之间的关系并且慎用 CASCADE
行为,这对于保证数据库中数据的合法性有着重要意义。使用该特性可以避免数据库中出现过期的、不合法的数据。
五、外键对数据库性能的影响
1. 插入和更新操作延迟
外键约束会增加数据库在插入和更新操作时的负担。每次向包含外键的表中插入或更新数据时,数据库都需要检查外键约束,确保引用的记录在父表中存在。这会增加额外的查询开销,尤其是在外键引用的表较大时,这种检查可能会显著延迟操作。
2. 删除操作复杂性
删除被外键引用的记录时,数据库需要检查是否存在依赖记录,可能导致级联删除或拒绝删除,增加了操作的复杂性。
3. 锁竞争
外键约束可能导致更多的锁竞争,尤其是在高并发环境下,多个事务同时修改相关表时。这可能会导致性能瓶颈。
4. 索引开销
为了提高外键约束的检查效率,通常需要对被引用的字段创建索引,这会增加存储开销和维护成本。
5. 查询性能
虽然外键本身不会直接影响查询性能,但它们可能会通过触发器和复杂的查询逻辑间接影响。在某些情况下,外键可以优化连接操作(JOIN),因为数据库可以根据外键约束来选择最佳的连接方法,从而提高连接操作的性能。
6. 性能影响案例分析
以电商平台订单系统为例,订单表中的用户ID(user_id
)作为外键指向用户表的主键。在高并发场景下,大量订单的插入和更新操作会导致频繁的外键检查,从而影响性能。
7. 优化策略
为了最大限度地减少外键对性能的影响,可以采取以下策略:
-
索引优化:为外键列和引用列创建适当的索引,减少回表操作。
-
查询优化:使用索引字段和外键字段进行查询,避免全表扫描。
-
事务和锁机制优化:尽量使用行锁而非表锁,减少锁竞争。
-
数据库配置调整:适当增加缓冲池大小,提高数据缓存效率。
六、总结
外键在数据库设计中是一个双刃剑。它可以在数据库层面确保数据的完整性和一致性,但也会增加数据库的负担,影响性能和开发灵活性。在实际应用中,需要根据具体的业务需求和性能要求来决定是否使用外键。对于数据完整性要求高的核心业务表,使用外键可以确保数据的正确性;而对于性能要求高的非核心业务表,可以在应用层通过逻辑控制来确保数据一致性,从而提高系统的整体性能。