一、问题背景:Git Flow让我们越用越慢

2022年下半年,我们团队12个人维护一个日活30万的SaaS后端,仓库用的是标准Git Flow:masterdevelopfeature/*release/*hotfix/*。刚开始挺舒服,半年后问题集中爆发:

  • develop分支长期领先master 200+ commit,合并冲突一次要解半天;
  • release分支冻结期间,feature分支还在往develop合,导致release要反复cherry-pick;
  • 一次线上P0故障,hotfix从master拉出来后,忘了回合到develop,两周后回归测试才发现代码丢了;
  • 平均一个PR从提交到合并26小时,最长的一个挂了5天。

根因不是Git本身,而是分支生命周期太长。长生命周期分支 = 长时间分叉 = 持续累积的合并债务。我们决定迁移到Trunk-Based Development(TBD),核心原则只有三条:主干唯一、分支短命(<24h)、合并前必过CI。

二、环境与版本

  • Git:2.39.2(用到了git switchgit restoremerge.conflictStyle=zdiff3
  • GitLab:16.8(CE自建,Runner 16.8.1)
  • 代码规模:单仓库,约18万行Go + 少量TS
  • 分支模型:Trunk-Based + 短生命周期feature分支
  • CI:GitLab CI,Docker executor,Go 1.21,缓存用go build cache + go mod cache

三、方案设计:分支策略

3.1 分支模型

只保留三类分支:

分支 生命周期 用途 保护
main 永久 唯一主干,随时可发布 强制保护
feat/xxx <24h 功能开发
fix/xxx <8h 修复

禁止developreleasehotfix。发布靠tag:v1.8.3打在main上,CI自动构建镜像并推送到registry。

3.2 分支保护规则(GitLab具体参数)

Settings → Repository → Protected branches

  • Branch:main
  • Allowed to merge:Maintainers
  • Allowed to push:No one(关键!任何人不能直推main)
  • Allowed to force push:关闭
  • Code owner approval:开启

Settings → Merge requests

  • Merge method:Fast-forward merge(保持线性历史)
  • Squash commits:Require
  • Delete source branch:默认勾选
  • Pipeline must succeed:开启
  • All threads resolved:开启
  • Minimum approvals:1(核心模块2)

3.3 分支命名与提交规范

用Conventional Commits,配合commitlint。分支名格式:feat/JIRA-123-add-rate-limit。CI里用正则校验,不合规直接fail。

四、核心实现:CI/CD配置

4.1 .gitlab-ci.yml

stages:
  - validate
  - test
  - build
  - deploy

variables:
  GO_VERSION: "1.21.5"
  DOCKER_DRIVER: overlay2
  GOPROXY: "https://goproxy.cn,direct"

default:
  image: golang:1.21.5
  cache:
    key:
      files:
        - go.sum
    paths:
      - .go-cache/

# 分支名与commit message校验
lint:commit:
  stage: validate
  image: node:20.11.0
  before_script:
    - npm i -g @commitlint/cli@18.4.3 @commitlint/config-conventional@18.4.3
  script:
    - |
      if [[ ! "$CI_COMMIT_BRANCH" =~ ^(main|feat/|fix/) ]]; then
        echo "非法分支名: $CI_COMMIT_BRANCH"; exit 1
      fi
    - echo "$CI_COMMIT_MESSAGE" | commitlint
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

test:unit:
  stage: test
  script:
    - export GOCACHE=$CI_PROJECT_DIR/.go-cache/build
    - export GOMODCACHE=$CI_PROJECT_DIR/.go-cache/mod
    - go test ./... -race -coverprofile=coverage.out -covermode=atomic
    - go tool cover -func=coverage.out | tail -1
  coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

build:image:
  stage: build
  image: docker:24.0.7
  services:
    - docker:24.0.7-dind
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy:staging:
  stage: deploy
  image: bitnami/kubectl:1.29
  script:
    - kubectl set image deployment/api api=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -n staging
    - kubectl rollout status deployment/api -n staging --timeout=120s
  environment:
    name: staging
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

关键点:

  1. merge request pipeline:只有MR才跑lint+test,main分支才构建镜像,节省Runner资源(我们Runner并发只有4个,之前经常排队)。
  2. 缓存key用go.sum:依赖不变就不失效,命中率从31%提到89%。
  3. rollout status --timeout=120s:部署失败自动fail,避免"假成功"。
  4. coverage正则:GitLab从日志里抓覆盖率,MR页面直接显示。

4.2 本地hook:commit-msg

.husky/commit-msg

#!/usr/bin/env sh
npx --no-install commitlint --edit "$1"

配合commitlint.config.js

module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'scope-enum': [2, 'always', ['api', 'auth', 'billing', 'ci', 'deps']],
    'subject-max-length': [2, 'always', 72],
  },
};

五、Code Review流程

5.1 强制规则

  • PR不超过400行改动(git diff --stat超了CI警告,超800行直接fail)。这条规则是我们最狠也最有效的一条,review时间直接砍半。
  • 必须1个approve(internal/authinternal/billing目录2个,用CODEOWNERS)。
  • 所有comment必须resolve。
  • Pipeline必须绿。

5.2 CODEOWNERS

/internal/auth/    @team-security
/internal/billing/ @team-pay @tech-lead
*.sql              @dba-team
/.gitlab-ci.yml    @devops

5.3 Review节奏

我们约定:PR提交后2小时内必须有人响应(不是必须approve)。这条写进团队on-call轮值里,超时会在群里@值班人。搭配上面的短分支策略,效果才出得来——分支活不过一天,review自然就得快。

六、踩坑与优化

坑1:rebase vs merge。 一开始我们用merge --no-ff,历史像地铁线路图。后来改Fast-forward + Squash,历史线性了,但squash丢了中间commit,调试git bisect时定位粒度变粗。折中方案:MR内commit可以乱,合并时squash成一条符合Conventional Commits的信息,同时把MR链接写进body。

坑2:rebase把别人的commit带进来。 git pull --rebase时如果不加--autostash,本地未提交改动会丢。我们统一在.gitconfig里配:

[pull]
    rebase = true
[rebase]
    autostash = true
    autosquash = true
[merge]
    conflictStyle = zdiff3

坑3:CI缓存污染。 早期cache key直接用分支名,feature分支的缓存把main的覆盖了,出现"本地能跑CI挂"。改成key.files: go.sum后解决。

坑4:main被直推。 尽管配了保护,早期还有人有Maintainer权限绕过。后来把Allowed to push设为No one,连admin都走MR,才彻底堵死。

优化:并行测试。 go test ./...单job跑4分12秒,拆成3个job按package分片,最长2分05秒。加上缓存命中,MR pipeline从8分钟降到3分40秒。

七、效果数据

迁移前后对比(统计周期:迁移前3个月 vs 迁移后3个月):

指标 迁移前 迁移后 变化
PR平均合并时长 26.3h 3.5h -87%
发布周期 14天 1天 -93%
平均回滚率 8.1% 3.1% -62%
MR pipeline时长 8m12s 3m40s -55%
合并冲突次数/周 11 2 -82%
部署频率 2次/月 22次/月 +1000%

数字好看,但代价也有:一开始团队不适应"随时可发布",测试同学压力大,后来补了staging环境的自动化回归才缓解。

八、总结

Trunk-Based不是银弹,它逼你把工程能力补上:CI要快、测试要稳、review要勤。如果连自动化测试都没有,直接上TBD就是灾难。我们的经验是:先把pipeline压到5分钟内、把测试覆盖率提到70%以上,再迁分支模型,顺序反了会翻车。

几个可以直接抄的点:

  1. 分支名和commit message用CI卡死,不合规直接fail;
  2. MR pipeline和main pipeline分开,省Runner;
  3. 400行改动上限,比任何review规范都管用;
  4. merge.conflictStyle=zdiff3,解冲突时能看到base,少猜一半;
  5. 保护规则里Allowed to push = No one,别给自己留后门。

参考:
- GitLab Docs: Protected branches, Merge methods
- Trunk Based Development: https://trunkbaseddevelopment.com
- Conventional Commits 1.0.0