一、问题背景:当仓库长到20人,原来的“随手push”就崩了
我们是一个做SaaS后台的团队,仓库是单体Java服务,早期5个人时用最简单的玩法:所有人往develop推,谁要发版就拉个release分支。前两年相安无事。
2023年团队扩到20人,同时并行需求从3个变成11个,问题全冒出来了:
- 合并冲突爆炸:
git log --merges统计,平均每周37次冲突,最狠的一次OrderService.java冲突了400多行,两个人对着屏幕解了2小时。 - 发布不可控:每次发版前要“代码冻结”3天,所有人停止合并,只修bug。这3天里测试环境基本瘫痪。
- Review形同虚设:很多人直接push到develop,没人看diff。上线后出问题,
git blame一查是三天前某次直接push引入的NPE。 - CI慢且假绿:Jenkins只有一个job,跑全量测试要28分钟,而且经常因为环境问题“假成功”,合并进去才炸。
痛定思痛,我们花了三周重构工作流。下面是我们最终落地的方案,所有配置都可以直接抄。
二、环境与版本:别用太老的Git
先把版本钉死,工具链不统一后面全是玄学问题:
- Git:2.40.1(用到了
git switch、git restore和merge.conflictStyle=zdiff3) - GitLab:16.8.2(自建,用到了MR Approval Rules和Push Rules)
- Jenkins:2.426.1 LTS + GitLab Plugin 1.7.16 + Pipeline 2.6
- JDK 17,Maven 3.9.6
- 代码规模:约48万行Java,单测3200个
特别提一句merge.conflictStyle=zdiff3,Git 2.35引入的,冲突标记会多显示一个共同祖先区块,解冲突时能看清楚“原来是什么、双方各改了什么”,比默认的diff3还好用。这个后面踩坑部分细说。
三、方案设计:主干开发 + 短分支,而不是纯Git Flow
我们对比了三种策略:
- 纯Git Flow:
develop/release/hotfix全套。问题是develop和release长期分叉,20人规模下分叉一周就是几千行差异,合并回去必冲突。 - 纯Trunk-Based:所有人往
main推。对CI要求极高,我们Jenkins还没那么强,且没有feature flag基础设施,不适合。 - 折中:Trunk-Based + 短生命周期Feature分支(我们选的):
main是唯一长期分支,永远可发布。- 功能分支从
main切出,命名feat/xxx、fix/xxx,生命周期不超过2天,超过就必须拆分。 - 不设
develop。 - 发布用tag,
release/*分支只在需要给旧版本打补丁时临时拉。 - 所有合并必须走MR,禁止直接push
main。
分支模型定下来后,规则要落到工具上,不能靠自觉:
- GitLab Push Rules:禁止直接push
main,禁止未签名提交(我们开了GPG签名)。 - MR Approval Rules:至少2个approver,其中1个必须是
@backend-lead。 - Pipeline必须绿才能merge。
四、核心实现:真实配置逐条给
4.1 GitLab 项目级 Push Rules(防止直接推main)
在GitLab项目 → Settings → Repository → Push rules里配,等价的API配置:
curl --request PUT \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"deny_delete_tag": true,
"member_check": true,
"prevent_secrets": true,
"commit_committer_check": true,
"reject_unsigned_commits": true,
"commit_message_regex": "^(feat|fix|docs|refactor|test|chore)(\\(.+\\))?: .{1,72}",
"branch_name_regex": "^(main|release\\/.*|feat\\/.*|fix\\/.*|hotfix\\/.*)$"
}' \
"https://gitlab.example.com/api/v4/projects/123"
commit_message_regex强制Conventional Commits,后面Jenkins要根据它生成changelog。branch_name_regex把乱七八糟的分支名直接挡在push阶段。
4.2 Jenkins 多分支流水线(Jenkinsfile)
这是核心。我们要求:MR创建时跑轻量检查(编译+单测),合并到main后跑完整流水线(含集成测试+镜像构建+部署到staging)。
// Jenkinsfile
pipeline {
agent { label 'jdk17-maven' }
options {
timeout(time: 30, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '30'))
disableConcurrentBuilds()
}
environment {
MAVEN_OPTS = '-Xmx2g -XX:+UseG1GC'
IMAGE_TAG = "${env.BRANCH_NAME}-${env.GIT_COMMIT.take(8)}"
}
stages {
stage('Checkout') {
steps {
checkout scm
sh 'git rev-parse --short HEAD'
}
}
stage('Compile') {
steps {
sh 'mvn -B -q clean compile -DskipTests'
}
}
stage('Unit Test') {
when { not { branch 'main' } }
steps {
sh 'mvn -B test -Dtest=!*IT -DfailIfNoTests=false'
}
post {
always { junit 'target/surefire-reports/*.xml' }
}
}
stage('Integration Test') {
when { branch 'main' }
steps {
sh 'mvn -B verify -Pintegration -DskipUnitTests=true'
}
post {
always { junit 'target/failsafe-reports/*.xml' }
}
}
stage('Build Image') {
when { branch 'main' }
steps {
sh """
docker build -t registry.example.com/app:${IMAGE_TAG} .
docker push registry.example.com/app:${IMAGE_TAG}
"""
}
}
stage('Deploy Staging') {
when { branch 'main' }
steps {
sh """
kubectl set image deploy/app app=registry.example.com/app:${IMAGE_TAG} -n staging
kubectl rollout status deploy/app -n staging --timeout=180s
"""
}
}
}
post {
failure {
slackSend channel: '#ci-alerts',
color: 'danger',
message: "❌ ${env.JOB_NAME} #${env.BUILD_NUMBER} 失败: ${env.BUILD_URL}"
}
}
}
关键点:
- disableConcurrentBuilds()避免main上两个构建互相覆盖镜像tag。
- MR构建只跑单测(约6分钟),main构建跑集成测试(约14分钟),整体比原来28分钟的单job快,而且职责清晰。
- timeout(30)防止kubectl卡死导致agent被占满。
4.3 GitLab MR Approval 配置
在Settings → Merge requests里开“Merge request approvals”,然后加规则。用API配置更可复现:
curl --request POST \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"name": "backend-approval",
"approvals_required": 2,
"rule_type": "regular",
"user_ids": [101, 102, 103],
"groups": []
}' \
"https://gitlab.example.com/api/v4/projects/123/approval_rules"
配合.gitlab/CODEOWNERS:
# 核心领域必须由对应owner review
/src/main/java/com/example/order/ @order-team @backend-lead
/src/main/java/com/example/payment/ @payment-team @backend-lead
*.sql @dba-team
这样即使approver数量够了,CODEOWNERS里指定的人没批也merge不了。
五、踩坑与优化:真实踩过的三个坑
坑1:git rebase把别人的提交搞丢。 早期我们要求功能分支合并前先rebase main,结果有个同学在rebase时用-i压缩提交,误删了同分支上另一个人的commit,强推后丢了。解决:功能分支禁止push --force,改用git merge main同步(会产生merge commit,但我们接受),并在GitLab开“Prevent force push”保护。后来发现merge同步的冲突其实更少,因为Git能利用merge base。
坑2:CI假绿。 Jenkins的mvn test在某些模块返回0但测试没跑(-DfailIfNoTests=false惹的祸)。加了-Dtest=!*IT后,如果匹配不到测试会跳过。解决:在pom里配maven-surefire-plugin的failIfNoSpecifiedTests,并在流水线里检查surefire-reports目录非空。
坑3:解冲突解出隐性bug。 默认冲突标记只有>>>>>>,看不出base。开启zdiff3后:
git config --global merge.conflictStyle zdiff3
冲突块会变成三段,中间多一个||||||| base区块。有一次合并PriceCalculator,双方都改了折扣逻辑,zdiff3让我们一眼看出base里有个Math.max(0, ...)被两边都删了,及时补回,避免了一个负数价格的线上事故。
六、效果数据
落地三个月后的对比(数据来自GitLab和Jenkins的API统计):
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 每周合并冲突次数 | 37 | 4 |
| 从提交到生产平均耗时 | 72小时 | 45分钟 |
| MR平均Review时长 | 无统计(多数无Review) | 3.2小时 |
| CI平均时长 | 28分钟 | MR 6分钟 / main 14分钟 |
| 发布前冻结时间 | 3天 | 0 |
| 线上回滚次数(季度) | 5 | 1 |
45分钟这个数字的含义:开发者push到main → 集成测试14分钟 → 镜像构建6分钟 → staging部署3分钟 → 手动点生产发布,加上人工确认,中位数45分钟。
七、总结
20人规模的团队,别迷信纯Git Flow,也别硬上纯Trunk-Based。主干开发 + 2天内短分支 + 强制MR + 分阶段CI是我们试出来最稳的组合。工具上,GitLab的Push Rules和Approval Rules能把规矩固化下来,Jenkins多分支流水线区分MR和main的检查强度,这两条是效率提升的关键。最后,把merge.conflictStyle设成zdiff3,能省下大量解冲突时的脑力。工作流不是写完就完了,每季度用API拉一次数据回头看,才是真的在维护它。