为什么数据库不应该使用外键

作者:Draveness
来源:真没什么逻辑
为什么这么设计(Why’sTHEDesign)是一系列关于计算机领域中程序设计决策的文章 , 我们在这个系列的每一篇文章中都会提出一个具体的问题并从不同的角度讨论这种设计的优缺点、对具体实现造成的影响 。 如果你有想要了解的问题 , 可以在文章下面留言 。
当我们想要持久化地存储数据时 , 使用关系型数据库往往都是最稳妥的选择 , 这不仅因为今天的关系型数据库种类非常丰富并且稳定 , 还因为不同社区对关系型数据库的支持都非常完备 。 我们在前面的文章中曾经分析过为什么MySQL的自增主键不单调也不连续 , 这篇文章我们来分析关系型数据库中另一个重要的概念—外键(ForeignKey) 。
在关系型数据库中 , 外键也被称为关系键 , 它是关系型数据库中提供关系表之间连接的多个列1 , 这一组数据列是当前关系表中的外键 , 也必须是另一个关系表中的候选键(CandidateKey) , 我们可以通过候选键在当前表中找到唯一的元素2 。 在通常情况下 , 我们都会使用关系表中的主键作为其他表中的外键 , 这样才可以满足关系型数据库对外键的约束 。
为什么数据库不应该使用外键
文章图片

【为什么数据库不应该使用外键】图1-关系型数据库与外键
外键不仅仅是数据库表中的一个整数 , 它还提供了额外的一致性保证 。 因为数据库往往是整个系统的真理之源(SourceofTruth) , 所以保证数据的一致性和正确性非常重要 , 关系型数据库虽然提供了外键、触发器等特性保证一致性 , 但是在今天的生产环境中却很少被使用 。
引用完整性(ReferentialIntegrity)是数据的属性 , 如果数据拥有该属性 , 那么数据中所有的引用都是合法的 , 在关系型数据库的上下文中 , 这就意味着关系型数据库中引用另一个表中的值必须存在3 。
ALTERTABLEpostsADDCONSTRAINTFOREIGNKEY(author_id)REFERENCESauthors(id);上述SQL语句可以向关系表中增加外键约束 , 该SQL语句的执行前提是posts表中存在author_id字段 。 从SQL语句中的CONSTRAINT关键字我们也能推测出外键不是一种数据类型 , 它是不同关系表之间的约束 。
为什么数据库不应该使用外键
文章图片

图2-无状态服务与数据库
不使用外键的原因其实很简单 , MySQL、PostgreSQL等关系型数据库很难水平扩容 , 但是无状态的服务往往都可以很容易地扩容 。 由于外键等特性需要数据库执行额外的工作 , 而这些操作会占用数据库的计算资源 , 所以我们可以将大部分的需求都迁移到无状态的服务中完成以降低数据库的工作负载 。
根据更新和删除时的行为不同 , 我们可以将外键分成RESTRICT、CASCADE和SETNULL等几种4 , 当我们为关系表中的字段增加外键约束时 , 需要指定外键的类型 , 最常见的也就是RESTRICT和CASCADE两种 , 其中RESTRICT为外键的默认类型 , 不同类型的外键会带来不同的额外开销 , 而这些额外开销就是我们不使用外键的理由:
使用RESTRICT会在更新或者删除记录时对外键对应的记录是否存在进行一致性检查;使用CASCADE会在更新或者删除记录时触发级联更新或者删除操作;注意:MySQL中的NOACTION和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-83770309503ns/opBenchmarkForeignKey-83331317162ns/opBenchmarkBaseline-83192315506ns/opBenchmarkForeignKey-83381315577ns/opBenchmarkBaseline-83298312761ns/opBenchmarkForeignKey-83829345342ns/opBenchmarkBaseline-83753291642ns/opBenchmarkForeignKey-83948325239ns/op作者执行了4次外键的基准测试 , 虽然4次测试的结果不是特别稳定 , 但是使用外键的用例在每次测试中都明显弱于不使用外键的用例 , 外键带来的额外开销分别为~2.47%、~0.02%、~10.41%和~11.52% 。 这里的基准测试只是一个比较简单的定量分析 , 但是我们也可以从结果中看到大概的趋势—外键的完整性检查确实会带来额外的性能开销 , 而这些开销在高并发的服务中需要慎重考虑 。
想要在应用程序中模拟数据库外键的功能其实比较容易 , 我们只需要遵循以下的几个准则:
向表中插入数据或者修改表中的数据时 , 都应该执行额外的SELECT语句确保它引用的数据在数据库中存在;在删除数据之前需要执行额外的SELECT语句检查是否存在当前记录的引用;需要注意的是为了保证一致性 , 我们需要在事务中执行上述的查询和修改语句 , 这样才能完整模拟外键的功能;当我们向posts表中插入或者修改数据时 , 需要的处理相对比较简单 , 我们只需要执行有限的SELECT语句并按照如下所示的模式执行对应的操作就可以了:
BEGINSELECT*FROMauthorsWHEREid=FORUPDATE;--INSERTINTOposts.../UPDATEposts...END但是如果我们要删除authors表中的数据 , 就需要查询所有引用authors数据的表;如果有10个表都有指向authors表的外键 , 我们就需要在10个表中查询是否存在对应的记录 , 这个过程相对比较麻烦 , 不过也是为了实现完整性的必要代价 , 不过这种模拟外键方法其实远比使用外键更消耗资源 , 它不仅需要查询关联数据 , 还要通过网络发送更多的数据包 。
级联操作当我们在关系型数据库中创建外键约束时 , 如果使用如下所示的SQL语句指定更新或者删除记录时使用CASCADE行为 , 那么在客户端更新或者删除数据时就会触发级联操作:
ALTERTABLEpostsADDCONSTRAINTFOREIGNKEY(author_id)REFERENCESauthors(id)ONUPDATECASCADEONDELETECASCADE;当客户端更新authors表中记录的主键时 , 数据库会同时更新posts表中所有引用该记录的外键;当客户端删除authors表中的记录时 , 数据库会删除所有与authors表关联的记录;不过无论是执行更新还是删除操作 , 数据库都可以保证各个关系表之间引用的一致性和合法性不会出现引用到不存在记录的情况 , 与RESTRICT行为一样 , 所有外键的更新和删除行为都可以通过执行额外的检查和操作保证数据的一致 。
为什么数据库不应该使用外键
文章图片

图4-复杂的级联操作
虽然级联删除的出发点也是保证数据的完整性 , 但是在设计关系表之间的不同关系时 , 我们也需要注意级联删除引起的数据大规模删除的问题 。 如上图所示 , 当客户端想要在数据库中删除authos表中的数据时 , 如果我们同时在authors和posts中指定了级联删除的行为 , 那么数据库会同时删除所有关联的posts记录以及与posts表关联的comments数据 。
这种涉及多级的级联删除行为在数据量较小的数据库中不会导致问题 , 但是在数据量较大的数据库中删除关键数据可能会引起雪崩 , 一条记录的删除可能会被放大到几十倍甚至上百倍 , 这些对磁盘的随机读写会带来巨大的开销 , 是我们想要尽可能避免的情况 。 如果我们能够较好地设计各个表之间的关系并且慎用CASCADE行为 , 这对于保证数据库中数据的合法性有着很重要的意义 , 使用该特性可以避免数据库中出现过期的、不合法的数据 , 但是在使用时也要合理预估可能造成的最坏情况 。
手动实现数据库的级联删除操作是可行的 , 如果我们在一个事务中按照顺序删除所有的数据 , 确实可以保证数据的一致性 , 但是这与外键的级联删除功能没有太大的区别 , 反而会有更差的表现 。 如果我们能够接受在一个时间窗口内的数据不一致 , 就可以将一个大号的删除任务拆成多个子任务分批执行 , 降低对数据库影响的峰值 。
DELETEFROMpostsWHEREauthor_id=1LIMIT100;DELETEFROMpostsWHEREauthor_id=1LIMIT100;...DELETEFROMauthorsWHEREid=1;与数据库外键的CASCADE相比 , 这种方式会带来更大的额外开销 , 只是我们能降低对数据库性能的瞬时影响 。
总结外键提供的几种在更新和删除时的不同行为都可以帮助我们保证数据库中数据的一致性和引用合法性 , 但是外键的使用也需要数据库承担额外的开销 , 在大多数服务都可以水平扩容的今天 , 高并发场景中使用外键确实会影响服务的吞吐量上限 。 在数据库之外手动实现外键的功能是可能的 , 但是却会带来很多维护上的成本或者需要我们在数据一致性上做出一些妥协 。 我们可以从可用性、一致性几个方面分析使用外键、模拟外键以及不使用外键的差异:
不使用外键牺牲了数据库中数据的一致性 , 但是却能够减少数据库的负载;模拟外键将一部分工作移到了数据库之外 , 我们可能需要放弃一部分一致性以获得更高的可用性 , 但是为了这部分可用性 , 我们会付出更多的研发与维护成本 , 也增加了与数据库之间的网络通信次数;使用外键保证了数据库中数据的一致性 , 也将全部的计算任务全部交给了数据库;在大多数不需要高并发或者对一致性有较强要求的系统中 , 我们可以直接使用数据库提供的外键帮助我们对数据进行校验 , 但是在对一致性要求不高的、复杂的场景或者大规模的团队中 , 不使用外键也确实可以为数据库减负 , 而大团队也有更多的时间和精力去设计其他的方案 , 例如:分布式的关系型数据库 。
当我们考虑应不应该在数据库中使用外键时 , 需要关注的核心我们的数据库承担这部分计算任务后会不会影响系统的可用性 , 在使用时也不应该一刀切的决定用或者不用外键 , 应该根据具体的场景做决策 , 我们在这里介绍了两个使用外键时可能遇到的问题:
RESTRICT外键会在更新和删除关系表中的数据时对外键约束的合法性进行检查 , 保证外键不会引用到不存在的记录;CASCADE外键会在更新和删除关系表中的数据时触发对关联记录的更新和删除 , 在数据量较大的数据库中可能会有数量级的放大效果;我们在很多时候其实并不能选择是否使用外键 , 大多数公司的DBA都会对数据库系统的使用有比较明确的规定 , 但是我们要清楚做出使用外键和不使用外键这一抉择的原因 。 到最后 , 我们还是来看一些比较开放的相关问题 , 有兴趣的读者可以仔细思考一下下面的问题:
数据库中还有哪些特性是我们在生产环境中不会使用的?为什么?分布式的关系型数据库与MySQL等传统数据库有哪些区别?


    推荐阅读