从 Git Flow 切到 Trunk-Based Development,我们的分支策略迁移步骤和 CI 流水线改造实录
把 Git Flow 那套臃肿的分支模型换掉之后,我们的发布周期从平均 11 天压缩到了 2.3 天,hotfix 从「先找 release 分支再手动 cherry-pick」变成了一条命令直接往主干推。这篇文章记录我们从 Git Flow 切到 Trunk-Based Development(TBD)的完整迁移过程,包括分支策略调整、CI 流水线改造,以及踩过的几个坑。
为什么要切
我们团队维护的是一个中型 SaaS 产品,前后端加一起 14 个服务,12 名开发。用了三年 Git Flow,问题越来越明显:
- 合并地狱:每个 release 分支存活 2-3 周,develop 和 release 之间的差异经常超过 200 个 commit,合并冲突一次要花半天。
- 集成延迟:feature 分支平均存活 5.7 天,最长的拖了 19 天。代码写好之后一直不合并,等真正集成的时候发现和别人的改动冲突严重。
- hotfix 流程复杂:生产出问题要先从 master 拉 hotfix 分支,修完合并回 master,再 cherry-pick 回 develop。整个过程至少 40 分钟,紧急情况下根本等不起。
- 发布节奏慢:release 分支要经过完整的回归测试才能上线,每次发布准备时间 2-3 天。
我们不是大厂那种一天部署几十次的团队,但两周一次的发版节奏确实拖累了业务响应速度。2024 年 9 月团队内部讨论了迁移方案,10 月初正式切换。
TBD 的核心规则我们怎么定的
Trunk-Based Development 的核心就一条:所有人都直接往主干(main)上提交,或者用极短生命周期的分支。但「极短」需要具体化,否则团队执行起来会混乱。我们定了三条硬性规则:
规则一:分支存活不超过 24 小时
从创建分支到合并回 main,必须在 24 小时内完成。超过 24 小时的分支,CI 里会有警告;超过 48 小时,合并会被自动阻断。
这条规则逼着大家把大功能拆小。比如原来一个「用户权限重构」的 feature 要写 8 天,现在拆成 6 个可独立合并的 PR,每个 PR 控制在 300 行以内。
规则二:所有提交必须通过 Feature Flag 控制
这是 TBD 能跑起来最关键的一条。代码合进 main 不代表功能上线,功能是否生效由 Feature Flag 决定。我们用的是自建的 flag 服务,配置项放在 config/flags.yaml 里:
flags:
- name: user_permission_v2
enabled: false
rollout_percentage: 0
description: 用户权限模块重构
owner: backend-team
created_at: 2024-10-08
CI 流水线会在每次合并时检查:如果新增了未在 flags.yaml 中声明的功能开关,构建直接失败。这个检查用了一个简单的脚本:
# !/usr/bin/env bash
# check_flags.sh - 验证代码中的 flag 是否已声明
MISSING_FLAGS=$(grep -rhoP 'flags\.get\("([^"]+)"' src/ \
| sed 's/.*"\(.*\)"/\1/' \
| sort -u \
| while read flag; do
grep -q "name: $flag" config/flags.yaml || echo "$flag"
done)
if [ -n "$MISSING_FLAGS" ]; then
echo "❌ 以下 flag 未在 config/flags.yaml 中声明:"
echo "$MISSING_FLAGS"
exit 1
fi
规则三:main 分支必须随时可发布
任何合并到 main 的代码,必须通过完整的自动化测试(单元 + 集成 + E2E),构建出的镜像必须能直接部署到生产。不允许「先合进去,测试后面补」的情况。
迁移步骤:我们分了三周推进
迁移不是周五下班前把分支模型文档一改就完事。我们分三个阶段,每个阶段有明确的完成标准。
第一周:准备和工具链改造
先把 CI 流水线从原来的「按分支类型走不同流程」改成「所有分支统一流程」。
原来 Git Flow 的流水线逻辑是:
develop分支 → 跑单元测试 + 构建 staging 镜像release/*分支 → 跑全量测试 + 构建 release 镜像master分支 → 构建生产镜像 + 等待手动发布
改成 TBD 之后,所有分支(包括 main 和短分支)走同一条流水线:checkout → lint → 单元测试 → 集成测试 → 构建镜像 → 部署到 staging → E2E 测试。
GitLab CI 的配置改成了这样:
# .gitlab-ci.yml (核心部分)
stages:
- lint
- test
- build
- deploy-staging
- e2e
# 所有分支统一执行
lint:
stage: lint
script:
- npm run lint
- npm run type-check
unit-test:
stage: test
script:
- npm run test:unit -- --coverage
coverage: '/All files\s+\|\s+(\d+\.\d+)/'
integration-test:
stage: test
services:
- postgres:16-alpine
- redis:7-alpine
script:
- npm run test:integration
build:
stage: build
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
only:
- main
- merge_requests
deploy-staging:
stage: deploy-staging
script:
- helm upgrade --install app-staging ./helm \
--set image.tag=$CI_COMMIT_SHORT_SHA
only:
- main
e2e:
stage: e2e
script:
- npm run test:e2e -- --base-url=https://staging.example.com
only:
- main
关键改动:删掉了原来 only: develop、only: release/* 这些分支过滤条件。所有 MR 都跑完整流程,main 分支的合并结果直接部署到 staging。
第二周:切换分支策略,冻结旧分支
第二周周一开始,我们做了这几件事:
-
冻结
develop和所有release/*分支:在 GitLab 上把这些分支设为 protected 且不允许推送,防止有人习惯性地继续往上面推代码。 -
把
main设为默认分支:所有新 MR 的目标分支改为main。 -
清理长生命周期的 feature 分支:当时有 7 个存活超过一周的 feature 分支。我们逐个评审:
- 3 个已经完成但没合并的,直接合并到 main(用 flag 关闭功能)
- 2 个还在开发中的,拆成小 PR 分批合并
- 2 个已经废弃的,直接删除
-
删掉
release/*分支:当时有一个release/v3.2正在准备发布,我们把它合并回 main,然后用 flag 控制这次发布。
第三周:发布流程调整和监控
Git Flow 时代发布是「分支驱动的」:创建 release 分支 → 测试 → 合并到 master → 打 tag → 部署。切到 TBD 之后,发布变成了「tag 驱动的」:
- main 分支的最新 commit 随时可发布
- 需要发布时,在 main 上打一个
vX.Y.Z的 tag - CI 检测到 tag 推送,自动触发生产部署流水线
# .gitlab-ci.yml 追加
deploy-production:
stage: deploy-production
script:
- helm upgrade --install app-prod ./helm \
--set image.tag=$CI_COMMIT_TAG
only:
- tags
when: manual # 生产部署保留手动确认
为什么生产部署保留手动确认?因为我们还没有完全自动化回滚的能力。如果 E2E 测试覆盖率达到 95% 以上,可以考虑去掉手动确认,但目前保持一道人工关卡更稳妥。
CI 流水线改造的关键细节
解决「合并前测试通过 ≠ 合并后测试通过」的问题
TBD 下 main 分支的合并频率从每周一次变成每天 10-15 次,这意味着「MR 测试通过了,但合并到 main 之后和其他 MR 的代码冲突导致测试失败」的概率大幅增加。
我们的解决方案是:在合并前自动把目标分支(main)的最新代码合并到 MR 分支上重新跑测试。GitLab 的 Merge Trains 功能正好解决这个问题:
# .gitlab-ci.yml
merge_request:
stage: test
script:
- npm run test
only:
- merge_requests
# 启用 Merge Train
variables:
GIT_STRATEGY: fetch
在 GitLab 项目设置里开启 Merge Trains 之后,排队的 MR 会按顺序自动合并到 main 并跑完整流水线,任何一个失败,后面的 MR 不会继续合并。这个机制让 main 分支的稳定性显著提升——从切换后到现在,main 分支因为合并冲突导致的构建失败一共只发生了 3 次,之前 Git Flow 时代每个月至少 5 次。
测试时间从 38 分钟压到 12 分钟
TBD 要求每次合并都跑全量测试,如果测试太慢,开发体验会很差。我们做了几件事把流水线时间压下来:
- 测试并行化:原来的单元测试是一个 job 串行跑,改成按测试文件拆分成 8 个并行 job。GitLab CI 的
parallel配合--shard参数:
unit-test:
stage: test
script:
- npm run test:unit -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
parallel: 8
-
缓存依赖:
node_modules和 Docker layer 缓存配置好之后,安装依赖的时间从 4 分钟降到 40 秒。 -
E2E 测试只跑关键路径:原来 E2E 有 87 个 case,全跑完要 18 分钟。我们精简到 32 个核心 case(覆盖登录、支付、核心业务流),时间降到 6 分钟。非核心的 E2E case 移到 nightly 流水线。
Hotfix 流程简化
Git Flow 时代 hotfix 的流程是 6 步:从 master 拉分支 → 修复 → 测试 → 合并回 master → 打 tag 发布 → cherry-pick 回 develop。
TBD 下 hotfix 流程变成了 3 步:
# 1. 直接从 main 拉修复分支
git checkout main
git pull origin main
git checkout -b hotfix/order-payment-timeout
# 修复代码...
# 2. 提交 MR 合并回 main
git push origin hotfix/order-payment-timeout
# 在 GitLab 上创建 MR,走标准流水线
# 3. 合并后打 tag 触发生产部署
git tag v3.4.1 && git push origin v3.4.1
整个流程从「发现问题」到「修复上线」最快 25 分钟,平均 1.5 小时。之前 Git Flow 时代平均要 4 小时以上。
迁移中踩的坑
坑一:Feature Flag 没有及时清理
切到 TBD 的前两个月,我们的 flags.yaml 从 12 个 flag 膨胀到了 47 个。很多 flag 在功能稳定上线之后就没人管了,代码里到处都是 if (flags.get("xxx")) 的判断,影响可读性。
后来我们定了一条规则:每个 flag 必须设置 expires_at 字段,到期后 CI 会阻止包含该 flag 的代码合并:
flags:
- name: user_permission_v2
enabled: true
rollout_percentage: 100
expires_at: 2024-12-15 # 上线后一个月内必须移除
配合一个简单的 CI 检查脚本,过期 flag 会直接让构建失败。这逼着团队在功能稳定后主动清理 flag 代码。
坑二:部分同事不适应「直接往 main 推」的节奏
切换初期有两位同事反映:「代码还没写完就往 main 推,感觉很不安全」。这其实是心理上的障碍——他们习惯了「分支 = 隔离 = 安全」的思维模式。
解决方式不是强制,而是通过 flag 机制让他们逐步建立信任。我让他们观察:合并到 main 的代码如果没有开 flag,在生产上完全无感知;即使有问题,回滚 flag 只需要改一个配置项,30 秒生效。两周之后,这两位同事也适应了。
坑三:数据库迁移变得敏感
Git Flow 时代,数据库迁移(migration)可以放在 feature 分支里慢慢改,等 release 的时候一起执行。TBD 下,migration 一旦合并到 main,就会在 staging 环境立即执行。
这意味着 migration 必须是向后兼容的。我们的规则是:
- 禁止在一个 PR 里同时做「删除列」和「修改使用该列的代码」
- 删除列分三步:先改代码不读该列 → 合并 → 再提交 migration 删除列 → 合并
- 所有 migration 必须能在 staging 环境自动执行且不破坏现有数据
违反这条规则的 PR 会被 review 直接打回。我们发生过一次事故:一个同事在 PR 里改了 users 表的 email 字段类型,同时在代码里改了相关的查询逻辑。migration 在 staging 执行时锁表 4 分钟,导致 staging 环境不可用。后来加了 lock_timeout 设置:
# db/migrate/20241103_change_users_email_type.rb
class ChangeUsersEmailType < ActiveRecord::Migration[7.0]
def change
execute "SET lock_timeout = '5s'"
change_column :users, :email, :text
end
end
超过 5 秒拿不到锁就失败,不会长时间阻塞其他操作。
迁移后的实际效果
切换到现在(2024 年 10 月 8 日切换到 2025 年 1 月),数据对比如下:
| 指标 | Git Flow(切换前 3 个月) | TBD(切换后 3 个月) |
|---|---|---|
| 发布频率 | 每 11 天一次 | 每 2.3 天一次 |
| Hotfix 平均耗时 | 4.2 小时 | 1.5 小时 |
| 合并冲突次数 | 每月 5.3 次 | 每月 1.7 次 |
| 分支平均存活时间 | 5.7 天 | 7.5 小时 |
| 流水线全流程时间 | 38 分钟 | 12 分钟 |
| 生产事故率 | 每 10 次发布 1.2 次 | 每 10 次发布 0.8 次 |
发布频率提升是最大的变化。之前两周一次发布,业务需求要排队等;现在需求完成当天就能上线(通过 flag 控制),业务的响应速度快了很多。
常见问题
Q:小团队(3-5 人)适合用 TBD 吗?
适合,而且小团队切 TBD 比大团队更容易。人少意味着合并冲突本来就少,TBD 的收益主要体现在发布节奏上。3-5 人的团队甚至不需要 Merge Trains,简单的「合并前 rebase main + 跑测试」就够了。
Q:如果没有 Feature Flag 系统,能切 TBD 吗?
可以,但会有明显局限。没有 flag 意味着合并到 main 的代码必须直接上线,这就要求每个 PR 都是完整可发布的功能增量。对于小改动没问题,但对于需要多天开发的大功能,没有 flag 会很痛苦。建议至少用一个简单的配置文件实现基础的 flag 能力,成本很低。
Q:TBD 和 Code Review 冲突吗?
不冲突。TBD 说的是分支生命周期短,不是不要 review。我们的每个 MR 依然需要至少一位同事 approve 才能合并。短分支反而让 review 更轻松——一个 300 行的 PR 比一个 3000 行的 PR 好 review 得多。
Q:如果 main 分支坏了怎么办?
我们的处理原则是:main 坏了是最高优先级,所有人停止手头工作,先修 main。因为 main 是唯一的发布源,main 坏了意味着无法发布。实际操作中,由于 Merge Trains 和全量测试的存在,main 坏的频率很低(三个月 3 次),每次修复时间不超过 20 分钟。