当我们想要持久化地存储数据时,使用关系型数据库往往都是最稳妥的选择,这不仅因为今天的关系型数据库种类非常丰富并且稳定,还因为不同社区对关系型数据库的支持都非常完备。我们在前面的文章中曾经分析过 为什么 MySQL 的自增主键不单调也不连续,这篇文章我们来分析关系型数据库中另一个重要的概念 — 外键(Foreign Key)。
在关系型数据库中,外键也被称为关系键,它是关系型数据库中提供关系表之间连接的多个列,这一组数据列是当前关系表中的外键,也必须是另一个关系表中的候选键(Candidate Key),我们可以通过候选键在当前表中找到唯一的元素。在通常情况下,我们都会使用关系表中的主键作为其他表中的外键,这样才可以满足关系型数据库对外键的约束。
图 1 - 关系型数据库与外键
外键不仅仅是数据库表中的一个整数,它还提供了额外的一致性保证。因为数据库往往是整个系统的真理之源(Source of Truth),所以保证数据的一致性和正确性非常重要,关系型数据库虽然提供了外键、触发器等特性保证一致性,但是在今天的生产环境中却很少被使用。
引用完整性(Referential Integrity)是数据的属性,如果数据拥有该属性,那么数据中所有的引用都是合法的,在关系型数据库的上下文中,这就意味着关系型数据库中引用另一个表中的值必须存在[^3]。
- ALTER TABLE posts
- ADD CONSTRAINT FOREIGN KEY (author_id)
- REFERENCES authors(id);
上述 SQL 语句可以向关系表中增加外键约束,该 SQL 语句的执行前提是 posts 表中存在 author_id 字段。从 SQL 语句中的 CONSTRAINT 关键字我们也能推测出外键不是一种数据类型,它是不同关系表之间的约束。
图 2 - 无状态服务与数据库
不使用外键的原因其实很简单,MySQL、PostgreSQL 等关系型数据库很难水平扩容,但是无状态的服务往往都可以很容易地扩容。由于外键等特性需要数据库执行额外的工作,而这些操作会占用数据库的计算资源,所以我们可以将大部分的需求都迁移到无状态的服务中完成以降低数据库的工作负载。
根据更新和删除时的行为不同,我们可以将外键分成 RESTRICT、CASCADE 和 SET NULL 等几种[^4],当我们为关系表中的字段增加外键约束时,需要指定外键的类型,最常见的也就是 RESTRICT 和 CASCADE 两种,其中 RESTRICT 为外键的默认类型,不同类型的外键会带来不同的额外开销,而这些额外开销就是我们不使用外键的理由:
- 使用 RESTRICT 会在更新或者删除记录时对外键对应的记录是否存在进行一致性检查;
- 使用 CASCADE 会在更新或者删除记录时触发级联更新或者删除操作;
注意:MySQL 中的 NO ACTION 和 RESTRICT 具有相同的语义[^5]。
接下来我们会详细介绍关系型数据库如何处理上述两种不同类型的外键,而我们应该如何在应用中模拟这些功能。
一致性检查
当我们使用默认的外键类型 RESTRICT 时,在创建、修改或者删除记录时都会检查引用的合法性。想要在 MySQL 等数据库中触发外键的一致性检查其实非常容易,假设我们的数据库中包含 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中插入多条新数据列引用该条记录,前者不会检查外键的合法性,而后者会做额外的检查。你可以在 这里 找到作者用来测试外键额外开销的 Go 语言代码[^6],经过多次基准测试,我们可以得到如下所示的结果:
- BenchmarkBaseline-8 3770 309503 ns/op
- BenchmarkForeignKey-8 3331 317162 ns/op
- BenchmarkBaseline-8 3192 315506 ns/op
- BenchmarkForeignKey-8 3381 315577 ns/op
- BenchmarkBaseline-8 3298 312761 ns/op
- BenchmarkForeignKey-8 3829 345342 ns/op
- BenchmarkBaseline-8 3753 291642 ns/op
- BenchmarkForeignKey-8 3948 325239 ns/op
作者执行了 4 次外键的基准测试,虽然 4 次测试的结果不是特别稳定,但是使用外键的用例在每次测试中都明显弱于不使用外键的用例,外键带来的额外开销分别为 ~2.47%、~0.02%、~10.41% 和 ~11.52%。这里的基准测试只是一个比较简单的定量分析,但是我们也可以从结果中看到大概的趋势 — 外键的完整性检查确实会带来额外的性能开销,而这些开销在高并发的服务中需要慎重考虑。
想要在应用程序中模拟数据库外键的功能其实比较容易,我们只需要遵循以下的几个准则:
- 向表中插入数据或者修改表中的数据时,都应该执行额外的 SELECT 语句确保它引用的数据在数据库中存在;
- 在删除数据之前需要执行额外的 SELECT 语句检查是否存在当前记录的引用;
需要注意的是为了保证一致性,我们需要在事务中执行上述的查询和修改语句,这样才能完整模拟外键的功能;当我们向 posts 表中插入或者修改数据时,需要的处理相对比较简单,我们只需要执行有限的 SELECT 语句并按照如下所示的模式执行对应的操作就可以了:
- BEGIN
- SELECT * FROM authors WHERE id = <post.author_id> FOR UPDATE;
- -- INSERT INTO posts ... / UPDATE posts ...
- END
但是如果我们要删除 authors 表中的数据,就需要查询所有引用 authors 数据的表;如果有 10 个表都有指向 authors 表的外键,我们就需要在 10 个表中查询是否存在对应的记录,这个过程相对比较麻烦,不过也是为了实现完整性的必要代价,不过这种模拟外键方法其实远比使用外键更消耗资源,它不仅需要查询关联数据,还要通过网络发送更多的数据包。
级联操作
当我们在关系型数据库中创建外键约束时,如果使用如下所示的 SQL 语句指定更新或者删除记录时使用 CASCADE 行为,那么在客户端更新或者删除数据时就会触发级联操作:
- ALTER TABLE posts
- ADD CONSTRAINT FOREIGN KEY (author_id)
- REFERENCES authors(id)
- ON UPDATE CASCADE
- ON DELETE CASCADE;
- 当客户端更新 authors 表中记录的主键时,数据库会同时更新 posts 表中所有引用该记录的外键;
- 当客户端删除 authors 表中的记录时,数据库会删除所有与 authors 表关联的记录;
不过无论是执行更新还是删除操作,数据库都可以保证各个关系表之间引用的一致性和合法性不会出现引用到不存在记录的情况,与 RESTRICT 行为一样,所有外键的更新和删除行为都可以通过执行额外的检查和操作保证数据的一致。
图 4 - 复杂的级联操作
虽然级联删除的出发点也是保证数据的完整性,但是在设计关系表之间的不同关系时,我们也需要注意级联删除引起的数据大规模删除的问题。如上图所示,当客户端想要在数据库中删除 authos 表中的数据时,如果我们同时在 authors 和 posts中指定了级联删除的行为,那么数据库会同时删除所有关联的 posts 记录以及与 posts 表关联的 comments 数据。
这种涉及多级的级联删除行为在数据量较小的数据库中不会导致问题,但是在数据量较大的数据库中删除关键数据可能会引起雪崩,一条记录的删除可能会被放大到几十倍甚至上百倍,这些对磁盘的随机读写会带来巨大的开销,是我们想要尽可能避免的情况。如果我们能够较好地设计各个表之间的关系并且慎用 CASCADE 行为,这对于保证数据库中数据的合法性有着很重要的意义,使用该特性可以避免数据库中出现过期的、不合法的数据,但是在使用时也要合理预估可能造成的最坏情况。
手动实现数据库的级联删除操作是可行的,如果我们在一个事务中按照顺序删除所有的数据,确实可以保证数据的一致性,但是这与外键的级联删除功能没有太大的区别,反而会有更差的表现。如果我们能够接受在一个时间窗口内的数据不一致,就可以将一个大号的删除任务拆成多个子任务分批执行,降低对数据库影响的峰值。
- DELETE FROM posts WHERE author_id = 1 LIMIT 100;
- DELETE FROM posts WHERE author_id = 1 LIMIT 100;
- ...
- DELETE FROM authors WHERE id = 1;
与数据库外键的 CASCADE 相比,这种方式会带来更大的额外开销,只是我们能降低对数据库性能的瞬时影响。
总结
外键提供的几种在更新和删除时的不同行为都可以帮助我们保证数据库中数据的一致性和引用合法性,但是外键的使用也需要数据库承担额外的开销,在大多数服务都可以水平扩容的今天,高并发场景中使用外键确实会影响服务的吞吐量上限。在数据库之外手动实现外键的功能是可能的,但是却会带来很多维护上的成本或者需要我们在数据一致性上做出一些妥协。我们可以从可用性、一致性几个方面分析使用外键、模拟外键以及不使用外键的差异:
- 不使用外键牺牲了数据库中数据的一致性,但是却能够减少数据库的负载;
- 模拟外键将一部分工作移到了数据库之外,我们可能需要放弃一部分一致性以获得更高的可用性,但是为了这部分可用性,我们会付出更多的研发与维护成本,也增加了与数据库之间的网络通信次数;
- 使用外键保证了数据库中数据的一致性,也将全部的计算任务全部交给了数据库;
- 在大多数不需要高并发或者对一致性有较强要求的系统中,我们可以直接使用数据库提供的外键帮助我们对数据进行校验,但是在对一致性要求不高的、复杂的场景或者大规模的团队中,不使用外键也确实可以为数据库减负,而大团队也有更多的时间和精力去设计其他的方案,例如:分布式的关系型数据库。
当我们考虑应不应该在数据库中使用外键时,需要关注的核心我们的数据库承担这部分计算任务后会不会影响系统的可用性,在使用时也不应该一刀切的决定用或者不用外键,应该根据具体的场景做决策,我们在这里介绍了两个使用外键时可能遇到的问题:
- RESTRICT 外键会在更新和删除关系表中的数据时对外键约束的合法性进行检查,保证外键不会引用到不存在的记录;
- CASCADE 外键会在更新和删除关系表中的数据时触发对关联记录的更新和删除,在数据量较大的数据库中可能会有数量级的放大效果;
我们在很多时候其实并不能选择是否使用外键,大多数公司的 DBA 都会对数据库系统的使用有比较明确的规定,但是我们要清楚做出使用外键和不使用外键这一抉择的原因。到最后,我们还是来看一些比较开放的相关问题,有兴趣的读者可以仔细思考一下下面的问题:
- 数据库中还有哪些特性是我们在生产环境中不会使用的?为什么?
- 分布式的关系型数据库与 MySQL 等传统数据库有哪些区别?