今天,PlanetScale宣布了对外键约束的支持。
这项功能的开发历时一年多。那么问题是什么?为什么花费了这么长时间呢?这不是所有数据库都支持的功能吗?是也不是。支持外键约束是一回事,而在同时提供在线DDL、分阶段部署、在线数据导入及最终支持跨分片功能的情况下支持外键约束,则是另一回事。
在PlanetScale上线时,我们选择暂不支持外键约束,主要原因是它无法与分支功能和在线DDL相兼容。这是在构建产品时了解到的事实,我们将其视为无法避免的限制,尝试绕过这些挑战。大约一年前,我们决定深入研究,看看实现它实际需要做哪些工作。
结果发现,我们不得不解决产品每一层中几乎各个环节的问题:有的源于MySQL的限制,有的是将外键支持与PlanetScale通过Vitess实现的在线DDL相结合的设计问题,还有一些涉及分支操作和模式分析逻辑。在本文中,你会发现这些问题是如何紧密关联在一起的。

分支与部署请求

我们首先面临的挑战是处理分支和部署请求。通过PlanetScale的分支功能,用户可以在开发环境中自由进行模式更改。完成这些模式更改后,他们提交一个“部署请求”,用于在将更改部署到生产环境之前审查这些更改。
部署请求页面向用户显示主分支(base)与当前分支(head)之间的语义差异。PlanetScale使用三向合并来确定差异。
支持外键约束的模式定义相对简单,只需解析SQL语法即可。然而,语义分析更加复杂。例如,MySQL(更准确地说是InnoDB)要求所有外键约束定义必须遵循以下准则:

  1. 外键约束是一种父表与子表之间的关系。特殊情况下,表可以引用自身作为父表。
  2. 被引用的(父)表必须存在。
  3. 父表中的外键引用列必须存在。
  4. 父表中被引用的列必须按顺序建立索引。索引必须覆盖约束所引用的列,并保持与引用顺序一致。索引可以包含更多额外的列。
  5. 子表的列数和数据类型必须匹配父表中的被引用列。例如,子表的 INT 列不能引用父表中的 BIGINT 列,而 VARCHAR(32)VARCHAR(64) 的匹配则被允许。

这些规则由MySQL服务器严格执行(假设 FOREIGN_KEY_CHECKS=1,关闭该选项会使一致性变差)。因此,当用户提交部署请求时,我们可以安全地假定分支遵循这些规则。然而,在PlanetScale评估部署请求时,它不仅会计算分支之间的差异,还会评估将一个分支(base)转换为另一个分支(head)的路径。这条路径由一系列有效的操作步骤构成,这些步骤在任何时候都保持模式的有效状态。此外,PlanetScale还评估这些步骤是否可以一次性部署,以及如何部署。
外键约束为这种评估增加了额外的复杂性。在最简单的情况下,可能需要按照特定顺序进行部署。例如,假设我们要创建以下父-子表对(简化版本):

create table parent (id int primary key);
create table child (id int primary key, parent_id int, constraint parent_id_fk foreign key (parent_id) references parent (id));

部署计划必须先创建父表,再创建子表。反向顺序无效,因为子表必须引用一个已存在的父表。
某些更改可以同时运行。假设我们有以下表定义:

create table t1 (id int primary key, ref int, key ref_idx (ref));
create table t2 (id int primary key, ref int, key ref_idx (ref));
create table t3 (id int primary key, ref int, key ref_idx (ref));

然后对 t2t3 添加外键约束,使得差异评估为:

ALTER TABLE `t2` ADD CONSTRAINT `t2_ref_fk` FOREIGN KEY (`ref`) REFERENCES `t1` (`id`) ON DELETE NO ACTION;
ALTER TABLE `t3` ADD CONSTRAINT `t3_ref_fk` FOREIGN KEY (`ref`) REFERENCES `t2` (`id`) ON DELETE NO ACTION;

这两个操作可以并发运行,即使它们都影响表 t2(一个直接,一个间接)。关于为何这种情况可以并行处理,我们将在在线DDL部分进行讨论。
更复杂的情况是,当两个迁移不能并发执行时。例如,假设基础模式如下:

create table t1 (id int primary key, info int not null);
create table t2 (id int primary key, ts timestamp);

分支模式变更为:

create table t1 (id int primary key, info int not null, p int, key p_idx (p));
create table t2 (id int primary key, ts timestamp, t1_p int, foreign key (t1_p) references t1 (p) on delete no action);

差异变更为:

ALTER TABLE `t1` ADD COLUMN `p` int, ADD INDEX `p_idx` (`p`);
ALTER TABLE `t2` ADD COLUMN `t1_p` int, ADD INDEX `t1_p` (`t1_p`), ADD CONSTRAINT `t2_ibfk_1` FOREIGN KEY (`t1_p`) REFERENCES `t1` (`p`) ON DELETE NO ACTION;

然而,在完成 t1 的迁移之前,无法开始 t2 的迁移。这是因为MySQL严格要求父表的被引用列必须具有索引。假设父表已经有数据,那么执行第一个迁移可能需要很长时间。只有等它完全完成后才能开始第二个迁移。
最后,并非所有外键约束相关的更改都可部署。如果一个分支的变化幅度过大,以至于无法将其简化为单个操作步骤,PlanetScale会拒绝部署请求。
通过 schemadiff,部署请求在创建时会在内存中评估所有这些内容。

处理回滚

在分支操作中,我们还需要处理回滚的情况。PlanetScale的部署是可回滚的。如果你部署了一个模式更改,然后发现出了错误,我们提供回滚选项,让你可以回滚该迁移,同时保留部署期间和之后可能发生的数据变化。
然而,某些场景中,回滚是不可行的。例如,如果你将某列从 TINYINT 更改为 INT,部署完成后向某行插入了一个值为 256 的数据,那么该值超过了 TINYINT 的范围,因此无法再传输回原始表。
外键约束的场景中也会引发问题。举个简单例子,假设你有一个父表和子表的外键关系。如果你选择删除子表,意味着不再存在外键约束。接着你从父表中执行 DELETE 操作,然后再回滚删除子表。此时虽然可以恢复子表,但会产生不便:恢复的子表会有孤立行,因为我们已删除了父表中的所有数据。
类似的逻辑适用于任何约束的删除或编辑。不能保证回滚的表符合约束,因为在约束被移除期间,你可能对数据进行了不兼容的修改。
PlanetScale允许进行这样的回滚,但会警告你,这种更改可能无法回滚。尽管模式可以恢复,孤立行的问题仍可能存在。

查询服务

接下来我们处理的是查询服务问题。目前,外键约束仅支持未分片或单分片数据库。我们预计未来支持跨分片环境的分片范围外键约束,甚至跨分片外键约束。但当前只讨论单分片支持。这意味着使用外键关系的表查询仅会在单个后端数据库服务上操作。
Vitess(PlanetScale的底层引擎)通常通过将这些查询委派到后端MySQL服务器来优化查询的执行。然而,MySQL的限制影响了Vitess的一些关键组件,需要采取不同的策略。为了理解这个问题,我们需要讨论在线DDL功能,然后再回到查询服务问题。
(以下内容涉及在线DDL、MySQL外键支持的历史、Vitess如何在明显挑战下实现外键逻辑、如何处理父表和子表的变更,以及详细解答与测试。)

小结

总的来说,支持Vitess和PlanetScale中的外键约束是一项面临诸多挑战的任务,但我们非常满意最终的实现效果。



支持外键约束的挑战插图

关注公众号:程序新视界,一个让你软实力、硬技术同步提升的平台

除非注明,否则均为程序新视界原创文章,转载必须以链接形式标明本文链接

本文链接:http://folen.top/2025/09/13/challenges-of-supporting-foreign-key-constraints/