从 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: developonly: release/* 这些分支过滤条件。所有 MR 都跑完整流程,main 分支的合并结果直接部署到 staging。

第二周:切换分支策略,冻结旧分支

第二周周一开始,我们做了这几件事:

  1. 冻结 develop 和所有 release/* 分支:在 GitLab 上把这些分支设为 protected 且不允许推送,防止有人习惯性地继续往上面推代码。

  2. main 设为默认分支:所有新 MR 的目标分支改为 main

  3. 清理长生命周期的 feature 分支:当时有 7 个存活超过一周的 feature 分支。我们逐个评审:

    • 3 个已经完成但没合并的,直接合并到 main(用 flag 关闭功能)
    • 2 个还在开发中的,拆成小 PR 分批合并
    • 2 个已经废弃的,直接删除
  4. 删掉 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 要求每次合并都跑全量测试,如果测试太慢,开发体验会很差。我们做了几件事把流水线时间压下来:

  1. 测试并行化:原来的单元测试是一个 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
  1. 缓存依赖node_modules 和 Docker layer 缓存配置好之后,安装依赖的时间从 4 分钟降到 40 秒。

  2. 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 分钟。