一、问题背景:Git Flow让我们越用越慢
2022年下半年,我们团队12个人维护一个日活30万的SaaS后端,仓库用的是标准Git Flow:master、develop、feature/*、release/*、hotfix/*。刚开始挺舒服,半年后问题集中爆发:
develop分支长期领先master200+ 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 switch、git restore、merge.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 | 修复 | 无 |
禁止develop、release、hotfix。发布靠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"
关键点:
- merge request pipeline:只有MR才跑lint+test,main分支才构建镜像,节省Runner资源(我们Runner并发只有4个,之前经常排队)。
- 缓存key用
go.sum:依赖不变就不失效,命中率从31%提到89%。 rollout status --timeout=120s:部署失败自动fail,避免"假成功"。- 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/auth、internal/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%以上,再迁分支模型,顺序反了会翻车。
几个可以直接抄的点:
- 分支名和commit message用CI卡死,不合规直接fail;
- MR pipeline和main pipeline分开,省Runner;
- 400行改动上限,比任何review规范都管用;
merge.conflictStyle=zdiff3,解冲突时能看到base,少猜一半;- 保护规则里
Allowed to push = No one,别给自己留后门。
参考:
- GitLab Docs: Protected branches, Merge methods
- Trunk Based Development: https://trunkbaseddevelopment.com
- Conventional Commits 1.0.0