ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

自建合并队列:从Bors到Merge Train的完整实践

自建合并队列:从Bors到Merge Train的完整实践

合并队列这个词,很多没经历过大仓库协作的开发者可能都没听过。我最初对它有切肤之痛,是因为我们团队在维护着Gas Town Refinery这个代号下的一个核心代码库——每天有二十多个开发者同时提交合并请求,CI排队排到两小时开外。最让人头疼的从来不是合并时的那点冲突,而是:大家习惯性地把PR合并进master,然后第二天早晨master满屏红叉,所有人的功能分支乱成一团,谁都不敢再往上推代码。

后来我参考了开源社区里Bors那套"合并前测试"的设计思路,在团队里从零到一实现了一条完整的合并队列。这篇文章就是那次落地的全过程记录,包括架构选型、核心代码、踩坑实录和运维经验。如果你正在经历:PR的检查结果明明都显示通过,一合入master就把别人的代码挂掉了;或者团队PR堆积如山,每次合并都要人工协调谁先谁后;又或者你只是好奇代码托管平台Merge Queue按钮背后的运转逻辑——这篇文章应该能给你一份可以直接抄作业的答案。

1. 为什么团队需要自建一套合并队列

1.1 Bors到底解决了什么核心痛点

先说清楚Bors思路的关键,分两层。第一层,保证master永远处于可部署状态。Bors的做法不是直接把PR合进master,而是把PR拿下来,合到一个单独的staging分支上,在这个staging分支上跑完整CI。如果绿了,才把staging快进到master;如果红了,那就不合,并且把PR"踢出队列",除非提交者把代码修好重新加进来。

这看起来比直接合并多了一步,但恰恰是这一步救了那些"检查全绿但合并必炸"的仓库。原因在于,代码托管平台的默认检查只对PR自己的分支生效,并不能反映它和最新master合并后的真实状态。A分支合并了最新的master并通了CI,B分支也合并了最新的master并通了CI,但A和B都是基于同一个旧的master提交,彼此之间从来没跑过组合测试。等到A、B都进了master,冲突和破坏才会集中爆发。这就是典型的"单测全过、合并就挂"。

提示:代码托管平台后来提供的Merge Queue功能、Merge Train功能,本质上都是这个思路的托管版本。自建合并队列的意义在于:不受平台配额限制,可以接入更多自定义检查项,还能把排队策略做成团队真正想要的形态,而不是平台给什么就用什么。

第二层,解决"合并顺序"的协调问题。在人工模式下,谁先合谁后合全靠猜,而猜错的代价往往要第二天早上才显现。Bors思路把合并顺序排成队列,严格按FIFO处理,每个PR在入队时看到的master状态基础上进行测试,后一个PR永远基于前一个成功的合并结果继续前进,不再有人会因为"晚了几分钟"导致自己的PR测试突然失效。这套机制把"市场混沌"变成了"流水线纪律"。

1.2 什么时候该自建而不是直接用平台功能

我自己的判断标准很简单。先看量:一个月PR合并量超过几百次,并发测试资源经常打满,这时候人工协调的成本已经超过维护工具的成本。再看定制度:团队需要对PR做额外检查,比如性能基线、构建产物比对、接口变更审计,平台自带的合并队列通常只能跑标准CI任务,拓展性不如自己控制。最后是可控性:我们吃过平台合并队列接口限流的亏,某个版本发布的前一天晚上,PR瞬时涌进来几十个,平台的队列机制开始排队响应,后面的PR全都悬空,团队只能干瞪眼。

如果上述任意一条命中,自建一条合并队列就是一个值得投入的项目。接下来说整体架构。

2. 整体设计与架构拆解

2.1 队列模型与状态机设计

合并队列本质上就是"带状态机的FIFO任务队列"。我把每个PR定义成四种状态:

状态含义流转条件
waiting等待被组合队列空闲且有基础分支未过时的PR
staged已被纳入合并列车,测试中CI状态返回success
merged已合入master推送成功,任务结束
failed测试失败或存在冲突提交者重新push修复后再入队

这里有一个微妙的决策:状态不该只是内存里的变量,因为进程重启、服务崩溃都会导致状态丢失。我的做法是"不单独持久化状态,直接根据仓库和CI的可观测事实反向推导"。具体来说,每次启动时扫描master的最新提交、每个PR当前是否已经合入、远程分支的head SHA,就能重建出队列应该长什么样。这种无状态恢复的思路,让工具在重启后几乎无感地继续工作,省掉一大套状态同步的麻烦。

2.2 为什么坚持用单消费线程

并发是这类工具最容易翻车的地方。两个线程同时去操作同一个Git仓库目录,一个在fetch,一个在push,轻则互锁等待,重则把本地仓库弄成detached HEAD,后面所有git命令全部失灵。我在实现里做了一个看起来"很简陋"但极其有效的决定:整个队列只有一个消费循环,串行处理所有任务。哪怕接收PR的HTTP服务是多线程的,真正执行Git操作的只会有一个worker。

单消费线程的好处是彻底消灭了对待共享Git仓库的竞态问题,处理顺序天然等于入队顺序,逻辑上不需要任何锁。坏处是当一个PR测试跑很久,排在后面的PR也只能等着。但Bors思路本身也是这样:队列的本质就是在等待,等待是它工作的一部分,串行不是缺点,反而是特性。

2.3 批量合并与合并列车策略

标题里的"批量合并",落到底层实现就是merge train。它不是把多个PR同时并行合并,而是像火车一样,前一个车头带着后面的车厢走。具体做法是:当队列前两个连续的PR(记为P1、P2)都可合并时,先在staging分支临时合入P1,CI通过了,再合入P2。注意P2不是从master重新拉一份代码再合,而是在P1合并后的staging基础上继续合。这样P2的测试结果就是"包含P1在内的合并列车最终状态",也就是真实合并到master之后的样子。

这里有个需要权衡的参数:批量窗口的大小(batch size)。Bors的实现里常用batch=2或3,但绝不是越大越好。批量越大,单次CI能覆盖的组合场景越多,系统吞吐越高;但一旦其中某个PR失败,整个batch都会标记失败,单个问题的影响面会变大。我最终把batch size做成了可配置项,默认2,同时允许维护者临时调成1来处理某个诡异的PR。这样既保证吞吐,又保留排障的灵活性。

3. 核心实现:从零手写一个Bors风格合并队列

3.1 Git操作层:命令的安全封装

所有Git操作统一通过child_process调系统git,没有用任何第三方Git库。原因很简单:对已有仓库做fetch、merge、push,系统命令最稳,不容易被git库版本差异坑到。我封装了一个GitRepo类,核心方法包括:ensureCleanWorktree(保证工作区干净)、fetchAll(拉取所有远端引用)、resetToMaster(强制重置到master)、mergePullRequest(合并某个PR到当前分支)、pushStagingToMaster(把staging分支快进推送到master)。

最容易踩的坑是fetch不彻底。必须显式声明fetch PR引用,最简单的方式是加refspec,把远端PR的head全部拉下来:

git fetch origin "+refs/pull/*/head:refs/remotes/origin/pr/*"

只fetch常规分支是不够的,很多平台默认不把PR分支的head拉下来。我第一版忘了加refspec,结果本地根本找不到PR分支,排查了半天才发现是fetch范围太窄。

3.2 队列消费循环的骨架

下面是核心消费循环的简化版代码,我用TypeScript写,因为团队效率工具基本都跑在Node上,生态也够成熟:

type PullRequest = { number: number; sourceBranch: string; targetBranch: string; headSha: string; }; class MergeQueue { private running = false; async start(): Promise<void> { await this.repo.ensureClean(); await this.repo.fetchAll(); // 从仓库和CI状态恢复队列,而不是依赖本地内存 await this.restoreFromRepoState(); while (true) { const pr = await this.peekEligiblePR(); if (!pr) { await sleep(5_000); continue; } await this.processTrain(pr); } } private async processTrain(first: PullRequest): Promise<void> { const train = await this.buildTrain(first); const stagedRef = `refs/heads/staging/train-${Date.now()}`; await this.repo.resetToMaster(); for (const p of train) { const ok = await this.repo.mergePullRequest(p); if (!ok) { await this.repo.abortMerge(); break; } } // 对staging分支统一触发并等待CI const result = await this.ci.waitForChecks(stagedRef, 45 * 60_000); if (result.success) { await this.repo.pushStagingToMaster(stagedRef); for (const p of train) { await this.markMerged(p); } } else { // 只踢出真正失败的PR,其余进入下一轮 await this.repo.dropStagingBranch(stagedRef); await this.markFailed(result.failedPRNumber); } } }

这段代码的核心思路,就是每次处理一辆train,train内所有PR按顺序绑定到同一个staging分支上,CI只对这个staging分支负责。通过就快进master;不通过就解散train,只标记具体的失败PR,其余PR下一轮继续排队。我刻意没有引入消息队列,也没有做多线程并行,因为Git仓库和CI状态的一致性,优先级永远高于并发吞吐。

3.3 构建合并列车时的冲突预判

有一个细节值得单独讲:不是所有PR都能放进同一辆列车。如果一个PR与staging分支上已有的合并结果冲突,它会打断整趟列车。所以buildTrain里每合并一个新PR后,要立刻检测是否冲突。git merge是增量式的,如果合并失败,工作区会留下冲突标记。

我这里的做法是:每趟列车合并前先resetToMaster,然后逐个merge PR。merge失败就abort,并把那个PR标记为"冲突",从本次train中移除,其余PR继续跑。冲突PR必须由提交者手动解决冲突后再重新入队,这是一个非常明确的业务规则。

还要强调一个几乎必踩的坑:merge失败后如果忘记git merge --abort,本地仓库会一直卡在"有冲突"状态,后面所有git命令都无法正常执行。我在merge封装里统一catch错误,失败时强制执行abort并reset --hard master,保证队列进程永远不带伤运行。

3.4 CI状态轮询与超时兜底

CI系统一般提供REST接口查询某个分支的pipeline状态。我封装了一个轮询客户端,默认每15秒查一次staging分支状态,直到出现success、failure或timeout。超时时间我设置为45分钟,看着很长,但这是有原因的:如果列车里包含两个大型PR,两个pipeline叠加起来很可能会超过默认的1小时限制。宁可等久一点,也不要在超时控制上误杀一个正常的列车。

超时之后我的处理是解散当前train,不直接标记任何一个PR失败,而是把train里的所有PR重新放回队尾,让它们各自单独再跑一遍。这样即使CI系统本身出问题,队列内容也不会丢失。

轮询还要考虑API限流。代码托管平台的REST API通常有每分钟几十次到两百次的限制,如果只有一个队列线程,频率根本打不满。但一旦有人把队列工具跑成两个实例,限流马上爆发。所以我额外加了一个实例锁:通过文件锁保证全局只有一个进程在消费队列。这是运维阶段踩过最大的坑,后面专门说。

3.5 Webhook接收与入队接口

合并队列除了消费循环,还需要一个让开发者把PR"提交"进来的入口。我实现了一个轻量HTTP服务,接收两种事件:一是平台Webhook通知(PR被开启、更新、合并);二是开发者手动调用的REST接口,把指定PR加入队列。最省事的做法是监听Webhook:当PR变为opened或synchronize时自动入队,当closed或merged时自动出队。

Webhook是异步且可能重复送达的,所以在HTTP handler里必须做幂等处理:入队操作如果发现PR已经在队列里,就更新它的head SHA而不是重复插入。这里记录head SHA非常重要,后面判断PR是否被force push会用到。

4. 实操中的关键细节与踩坑记录

4.1 并发安全:两个队列实例抢同一个仓库

第一次部署完,第二天早上发现队列里一堆PR显示staged,但master没有任何动静。查了半天,是运维的定时任务把队列脚本配了双份调度,两个进程同时在跑。因为它们都从同一个队列数据源读状态,互相以为对方在处理PR,最后全部卡死。

解决办法是在启动时抢一个文件锁,抢不到就退出:

exec 9>/tmp/gas_town_refinery.queue.lock flock -n 9 || { echo "另一个队列实例正在运行"; exit 1; }

这个方案比分布式锁轻量得多,部署时零额外依赖,特别适合内网自建工具。经历过这次事故之后,我养成了一个习惯:任何定时任务或常驻脚本,都必须带实例锁,不加锁就是给自己埋雷。

4.2 force push与PR head SHA变化处理

如果开发者修改了PR并force push覆盖分支,队列怎么识别"这个PR的代码变过了"?我的做法是在入队时记录PR的head SHA,每次轮询前对比当前远端head SHA。如果发现不一致,就把该PR从队尾重新排队,并丢弃已有的staged结果。这一步必须做,否则会出现"队列认为PR已经合并,实际PR还挂在列表上"的严重幻觉。

再补一个细节:在train运行期间,如果队列里某个PR的head SHA变了,必须立刻解散当前train。因为train的结果已经不基于最新的PR状态,继续跑下去只会浪费CI资源,极端情况下还可能把已经过时的代码合进master。

4.3 超时取消与staging分支复用

有一个很隐蔽的问题:CI的pipeline取消接口经常是异步的,返回200不代表清理任务已经执行完。如果超时后立即重用同一个staging分支名,前一个pipeline的清理任务可能还在执行,新pipeline就被迫排队,甚至互相覆盖状态。我碰到的现象是:队列报告train成功,但CI后来又把同一批commit跑了第二遍,产生了重复pipeline。

解决方法是:每个staging分支名都带唯一时间戳(比如staging/train-1691234567890),绝对不重复使用分支名。Git分支很便宜,多创建几个没有坏处,用完丢弃即可。这一个小小的命名习惯,避免了大量CI重复触发和状态错乱。

4.4 Dashboard运维可视化

当队列开始服务二十多人的团队时,光靠日志已经不够了。我额外写了一个简单的Dashboard页面,实时显示队列里所有PR、每个PR的状态、最近五趟列车的结果。技术上只是一个定期读取队列状态的接口加一个前端页面,但团队体验完全是质的提升。每天看Dashboard就能知道"我的PR大概什么时候能合",而不是反复去问维护者。

这个Dashboard还帮我发现了不少问题:比如某个PR反复失败、某个开发者频繁force push、某个batch特别容易冲突。原来这些模式埋在日志里没人看,可视化之后一眼就能发现。

5. 常见问题与排查技巧速查

5.1 队列卡住不动了

先确认进程还活着。如果进程在但队列不动,90%是卡在了一个pending的CI上。我写的轮询逻辑会打印当前正在等待哪个pipeline,先看那个pipeline在CI平台上的真实状态。如果CI平台显示已经完成,但队列还在等,多半是轮询接口的token过期了,刷新token即可。这个排查顺序能解决大部分"队列假死"问题。

5.2 PR显示staged但master没更新

查staging分支的commit时间。如果staging分支存在但master没有快进,说明push步骤失败了。常见原因是机器人账号的推送权限不够,或者分支保护规则要求"必须从PR合并",直接push被拒。我的做法是给队列专用的机器人账号单独授权,不要复用普通成员账号,否则权限边界很难划清。

5.3 合并列车失败率偏高

如果最近连续多趟train都以失败结束,先不要急着调batch size,先看失败的具体PR是不是同一两个。很多时候不是批量策略的问题,而是某个PR的基础分支太旧,处于"随时会冲突"的边缘状态。把它踢出队列,让提交者先把master合进它的分支,再重新入队,失败率会立刻降下来。批量策略只是放大器,真正的问题通常在具体某个PR身上。

5.4 自建队列与平台内置队列冲突

如果部署自建队列的仓库同时开启了平台自带的Merge Queue、分支保护规则,两边会互相打架。平台也会尝试合并PR、推送master,保护规则同样会拦住机器人的操作,最后大家一起懵。我的建议很直接:一个仓库上二选一,要么用平台功能,要么用自建队列。混合使用只会把问题复杂化,谁也救不了谁。

6. 最后再分享两个体会

第一个体会是,合并队列工具本身不难写,真正难的是让它"默默工作、不惹事"。我写的这个队列比很多业务代码都要少,但它解决团队痛点的效果立竿见影。从那以后master几乎没再红过,开发者每天到公司第一件事不再是看CI灯,而是看Dashboard上自己的PR排到哪了。这种稳定感,比任何口号都更有说服力。

第二个体会是,不要等仓库乱到不行才想到合并队列。如果你开始频繁听到"我明明测试过了啊,怎么又挂了"这句话,就说明已经到了该引入这套机制的时候。整个实现工程量不大,一个熟悉Node的开发者一周就能跑通最小可用版本,但换来的master稳定性和团队安全感,是再多的口头协调都换不来的。

返回列表