数据库分支:关于模式更改的三路合并
你可能熟悉 Git 的三路合并(three-way merge)机制,该机制允许开发者在各自独立分支上处理代码合并冲突。PlanetScale 现在将三路合并应用于数据库模式分支,用来简化并增强开发团队在模式变更上的协作。虽然它的概念类似于 Git 的三路合并,但实现方式完全不同。在本文中,我们将阐述这一技术实现的细节,以及对比模式差异和代码差异的微妙之处。
什么是模式更改合并?
PlanetScale 提供了一种模式分支(schema branching)和部署请求(deploy requests)模型。简单来说,开发者可以从主数据库创建一个分支,在开发环境中复制数据库模式,修改分支中的模式而不会影响生产环境。多个开发者可以同时进行类似操作。接下来,开发者可以在 PlanetScale 中发起部署请求(类似于 GitHub 的 Pull 请求),将模式更改应用到生产分支。
在部署请求阶段,开发者及其团队审查代码更改。部署请求页面会呈现语义化的差异(semantic diff),例如:ALTER TABLE foo ...
、CREATE TABLE bar (...)
等等。PlanetScale 使用 Vitess 的 schemadiff
库生成主分支(生产分支)与开发者分支之间的语义化差异。如果团队批准该请求,变更将进入部署队列,并最终以非阻塞方式部署至生产环境。
三路合并(three-way merge)的需求出现于多个开发者同时变更模式的场景中。例如,假设开发者 1(Dev 1)几天前创建了一个分支。在此期间,开发者 2(Dev 2)创建并将自己的分支部署至主分支(生产分支)。此时,开发者 1 的分支可能与当前的主分支不兼容——不仅它没有包含主分支中新增加的模式,还可能直接与主分支的更新发生冲突。
在开发者 1 继续在自己的分支上工作时,这些问题并不显现。但在某个时刻,他们希望将自己的更改部署至生产分支并加入部署队列。那么,这些更改是否有效?这时就需要三路合并机制,它用于确定分支间的冲突程度:是有冲突(conflict),相互影响(overlap),还是完全无关(unrelated)。
数据库分支术语
在 Git 中,我们用术语如 merge-base
、topic-head
等。而在数据库模式的解决方案中,我们建议使用不同的术语。以下是相关术语定义:
- main:指代生产分支(production branch),所有开发者都从该分支创建分支,最终也会将更改部署至此。
- branch1 和 **branch2**:代表分别由开发者 1 和开发者 2 创建的分支名称。
有一点需要说明:开发分支在打开期间并不会主动记录变更。开发者 1 可以在自己的分支上随意添加、修改或删除模式,而 PlanetScale会跟踪这些变化。为了简化讨论,我们假设只有在开发者发起部署请求时,PlanetScale 才会审查其分支的模式并计算差异。这些差异表现为一条或多条 SQL 语句(暂不讨论模式未变更的情况),这些语句可以将主分支的模式变更为分支中的模式。例如,假设主分支和分支 1 有以下模式:
主分支 (main
):
CREATE TABLE `customer` ( `id` int, PRIMARY KEY (`id`) );
分支 1 (branch1
):
CREATE TABLE `customer` ( `id` int, `name` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`) );
在 Git 的差异视图中,差异会显示如下:
CREATE TABLE `customer` ( `id` int, + `name` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`) );
但上述差异对数据库无效。相反,部署请求会生成以下语义化 SQL 差异:
ALTER TABLE `customer` ADD COLUMN `name` varchar(255) NOT NULL DEFAULT ''
任何部署请求都会生成这种语义化差异。
模式三路合并
假设主分支是基础分支,同时分支 1 和分支 2 都在部署队列中。
三路合并会比较两个分支与主分支的关系(因此进行三方比较),具体流程如下:
- 计算
diff1
,即diff(main, branch1)
,类似于 Git 中main..branch1
的差异视图。可以将diff1
视为一个函数,比如diff1(main) => branch1
。 - 同样地,计算
diff2
,即diff(main, branch2)
。 - 执行
diff1(diff2(main))
。如果对diff2(main)
应用diff1
是无效的(后续有例子解释),则发生冲突。 - 同样地,执行
diff2(diff1(main))
。如果无效,则发生冲突。 - 如果两者均有效,但
diff1(diff2(main)) != diff2(diff1(main))
,则发生冲突。 - 如果两者均有效且
diff1(diff2(main)) == diff2(diff1(main))
,则两分支之间没有冲突。
实际上,该算法比上述描述更复杂,但我们可以先通过几个例子来理解这些差异及三路合并的工作方式,以及模式变更所带来的 SQL 细微差异。
示例:无冲突
以下是一个简化的模式示例:
主分支(main
):
CREATE TABLE `customer` ( `id` int, PRIMARY KEY (`id`) );
分支 1(branch1
):
CREATE TABLE `customer` ( `id` int, `name` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`) );
分支 2(branch2
):
CREATE TABLE `customer` ( `id` int, PRIMARY KEY (`id`) ); CREATE TABLE `delivery` ( `id` int, `customer_id` int, PRIMARY KEY (`id`) );
差异如下:
diff1
:
ALTER TABLE `customer` ADD COLUMN `name` varchar(255) NOT NULL DEFAULT ''
diff2
:
CREATE TABLE `delivery` ( `id` int, `customer_id` int, PRIMARY KEY (`id`) )
显然,这两个分支之间没有冲突。本质上,一个分支为 customer
表添加了一列,而另一个分支则创建了 delivery
表。无论以何种顺序应用两个差异,结果一致:
CREATE TABLE `customer` ( `id` int, `name` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`) ); CREATE TABLE `delivery` ( `id` int, `customer_id` int, PRIMARY KEY (`id`) );
示例:显式冲突
在下一个例子中,两个分支都向 customer
表添加了一个名称相同但类型不同的列:
主分支(main
):
CREATE TABLE `customer` ( `id` int, PRIMARY KEY (`id`) );
分支 1(branch1
):
CREATE TABLE `customer` ( `id` int, `subscription_type` enum('free', 'promotional', 'paid'), PRIMARY KEY (`id`) );
分支 2(branch2
):
CREATE TABLE `customer` ( `id` int, `subscription_type` int unsigned NOT NULL DEFAULT 0, PRIMARY KEY (`id`) );
差异如下:
diff1
:
ALTER TABLE `customer` ADD COLUMN `subscription_type` enum('free', 'promotional', 'paid')
diff2
:
ALTER TABLE `customer` ADD COLUMN `subscription_type` int unsigned NOT NULL DEFAULT 0
显然,尝试在同一表中添加两列名称相同但类型不同的列会失败。这两个分支之间存在显式冲突。
示例:微妙冲突
怎么样的变更会涉及添加两个完全不同的列呢?以下是一个示例:
主分支(main
):
CREATE TABLE `customer` ( `id` int, `name` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`) );
分支 1(branch1
):
CREATE TABLE `customer` ( `id` int, `name` varchar(255) NOT NULL DEFAULT '', `subscription_type` enum('free', 'promotional', 'paid'), PRIMARY KEY (`id`) );
分支 2(branch2
):
CREATE TABLE `customer` ( `id` int, `name` varchar(255) NOT NULL DEFAULT '', `joined_at` timestamp NOT NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`) );
差异如下:
diff1
:
ALTER TABLE `customer` ADD COLUMN `subscription_type` enum('free', 'promotional', 'paid')
diff2
:
ALTER TABLE `customer` ADD COLUMN `joined_at` timestamp NOT NULL DEFAULT current_timestamp()
通过语法验证,这两个差异可以按任意顺序应用。但最终表的模式可能因应用顺序不同而不同。例如:
- 如果先应用
diff2
再应用diff1
:
CREATE TABLE `customer` ( `id` int, `name` varchar(255) NOT NULL DEFAULT '', `joined_at` timestamp NOT NULL DEFAULT current_timestamp(), `subscription_type` enum('free', 'promotional', 'paid'), PRIMARY KEY (`id`) );
- 如果先应用
diff1
再应用diff2
:
CREATE TABLE `customer` ( `id` int, `name` varchar(255) NOT NULL DEFAULT '', `subscription_type` enum('free', 'promotional', 'paid'), `joined_at` timestamp NOT NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`) );
显然,最终表结构(尤其是列的排列顺序)取决于应用差异的顺序。对于运行 SELECT * FROM customer
且使用位置参数的查询,会得到不同的列顺序。两分支间实际存在冲突。这与 Git 合并冲突类似,它涉及两个分支在文件尾部附加了不同的行。
我们可以通过在一个分支中将列放置在表中的非末尾位置来避免冲突。例如:
CREATE TABLE `customer` ( `id` int, `subscription_type` enum('free', 'promotional', 'paid'), `name` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`) );
此修改会生成如下无冲突的差异:
diff1
:
ALTER TABLE `customer` ADD COLUMN `subscription_type` enum('free', 'promotional', 'paid') AFTER `id`
细节:无冲突
列顺序可能引发冲突,而索引变更通常不会引发实质性问题。以下是一个示例,其中两个分支分别为表添加列或索引:
主分支(main
):
CREATE TABLE `customer` ( `id` int, `name` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`) );
分支 1(branch1
):
CREATE TABLE `customer` ( `id` int, `name` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`), KEY `name_idx` (`name`(16)) );
分支 2(branch2
):
CREATE TABLE `customer` ( `id` int, `name` varchar(255) NOT NULL DEFAULT '', `joined_at` timestamp NOT NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`), KEY `joined_idx` (`joined_at`) );
差异如下:
diff1
:
ALTER TABLE `customer` ADD KEY `name_idx` (`name`(16))
diff2
:
ALTER TABLE `customer` ADD COLUMN `joined_at` timestamp NOT NULL DEFAULT current_timestamp(), ADD KEY `joined_idx` (`joined_at`)
对于上述差异,表结构存在细微差异,具体取决于应用差异的顺序。例如:
- 如果先应用
diff2
再应用diff1
:
CREATE TABLE `customer` ( `id` int, `name` varchar(255) NOT NULL DEFAULT '', `joined_at` timestamp NOT NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`), KEY `joined_idx` (`joined_at`), KEY `name_idx` (`name`(16)) );
- 如果先应用
diff1
再应用diff2
:
CREATE TABLE `customer` ( `id` int, `name` varchar(255) NOT NULL DEFAULT '', `joined_at` timestamp NOT NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`), KEY `name_idx` (`name`(16)), KEY `joined_idx` (`joined_at`) );
虽然索引顺序不同,但对于表实际性能以及查询的行为,索引排列的顺序无关紧要。因此,PlanetScale 不考虑索引排序问题。
重叠变更(Overlapping Changes)
实际算法比上述描述更复杂。为了尽可能减少开发者的阻力,PlanetScale 还会处理分支间的部分重叠或完全相同的差异。例如:
主分支(main
):
CREATE TABLE `customer` ( `id` int, PRIMARY KEY (`id`) );
分支 1(branch1
):
CREATE TABLE `customer` ( `id` int, `name` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`) ); CREATE TABLE `tbl1` ( `id` int, PRIMARY KEY (`id`) );
分支 2(branch2
):
CREATE TABLE `customer` ( `id` int, `name` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`) ); CREATE TABLE `tbl2` ( `id` int, PRIMARY KEY (`id`) );
两个分支都对 customer
表添加相同的 name
列,然后各自进入与其他表相关的变更。由于 schemadiff
对变更操作(如 ALTER
、CREATE
等)进行了格式化处理,我们可以逐条分析这些更改。
对于 name
列的添加,两个分支完全一致,因此它们被认为是重叠,PlanetScale 的三路合并会接受该变更。假如分支 1 先合并,那么分支 2 的差异会自动适配,只剩下对 tbl2
的创建操作。
进一步减少开发阻力
模式变更可能需要较长时间运行,而在此期间更多开发者可能提交自己的变更。PlanetScale 采用了一个部署队列(先到先得),一次仅允许一个部署请求运行。
当开发者提交部署请求时,其更改会与所有已经入队的变更验证冲突。这避免了开发者长时间排队后发现前一个部署导致冲突的情况。PlanetScale 会尽早发出冲突警告,让开发者能够合理利用队列中的等待时间。
结论
模式更改与代码更改之间具有足够的相似性,使得我们可以提供类似于代码生命周期工作流的模式管理工作流。通过适应模式变更部署所面临的特殊差异和挑战,我们可以利用已知的逻辑机制来管理开发者在模式分支上的协作。这种方式既增强了开发体验,也减少了潜在问题出现的可能性。
关注公众号:程序新视界,一个让你软实力、硬技术同步提升的平台
除非注明,否则均为程序新视界原创文章,转载必须以链接形式标明本文链接
本文链接:https://www.choupangxia.com/2025/09/13/database-branching/