一、问题背景:当5人团队变成20人,Git就成了事故现场
2023年初我们团队只有5个人,大家默契地直接在master上开发,push前pull一下,冲突了当场改。那时候每周合并大概30次,冲突不超过2次,没人觉得有问题。
到了年中团队扩到20人,同时并行4条产品线,问题集中爆发:
- 每周至少3次因为直接push master导致构建失败,回滚平均耗时45分钟
- 两个人同时改同一个service文件,冲突解决花了2小时,最后发现逻辑还是错的
- 没人知道某个commit是谁为什么加的,git blame出来全是“fix”
- 测试环境部署靠人肉SSH,一周有2次部署了未review的代码
我们统计了一个月的数据:主干构建失败率18%,MR(Merge Request)平均合并周期26小时,生产事故中60%可追溯到未经Code Review的提交。
这不是工具问题,是工作流问题。下面是我们最终落地的方案,跑了3个月后的数据在后面。
二、环境与版本
- GitLab CE 16.8.2(自托管,Docker部署)
- Git 2.43.0
- Jenkins 2.426.3 LTS + GitLab Plugin 1.8.0
- SonarQube 10.3(代码质量门禁)
- Docker 24.0.7 / Kubernetes 1.28
- 分支命名规范:
feature/*、release/*、hotfix/*、main、develop
三、方案设计:Git Flow做骨架,Trunk-Based做日常
纯Git Flow太重,release分支和develop分支双线维护在20人团队里每周要花3-4小时做合并。纯Trunk-Based又要求极高的测试覆盖率和Feature Flag能力,我们当时覆盖率只有52%,做不到。
最终采用混合策略:
main:生产分支,只接受release和hotfix合并,保护级别最高develop:集成分支,日常feature合入这里,每天至少构建2次feature/xxx:从develop切出,生命周期不超过3天,超过就拆release/x.y.z:从develop切出,只修bug不加feature,存活不超过5天hotfix/xxx:从main切出,修完同时合回main和develop
关键约束:feature分支必须每天rebase develop,不允许merge develop进来,保持线性历史。这条规则把冲突解决从“合并时集中爆发”变成了“每天小剂量处理”。
Code Review流程:每个MR至少1个Approver,且必须是模块Owner。CI必须全绿才能合并。SonarQube新增代码覆盖率低于70%直接block。
四、核心实现
4.1 分支保护配置(GitLab API方式)
我们没用UI点,直接用API配置,方便版本化和重建:
# 配置main分支保护:只有Maintainer能push,且必须MR
curl --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/123/protected_branches" \
--data "name=main&push_access_level=40&merge_access_level=40&allow_force_push=false"
# develop分支:Developer可push但必须MR
curl --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/123/protected_branches" \
--data "name=develop&push_access_level=0&merge_access_level=30&allow_force_push=false"
# 开启merge request的pipeline必须成功
curl --request PUT --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/123" \
--data "only_allow_merge_if_pipeline_succeeds=true&only_allow_merge_if_all_discussions_are_resolved=true&merge_method=ff"
注意 merge_method=ff,强制fast-forward,保证主干历史线性。这一条直接消灭了“merge commit满天飞”的问题。
4.2 GitLab CI配置(.gitlab-ci.yml)
stages:
- validate
- test
- quality
- build
- deploy
variables:
GIT_DEPTH: 50
MAVEN_OPTS: "-Xmx2g -XX:+UseG1GC"
# 分支规则:feature和develop跑全量,release只跑冒烟
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "develop"'
- if: '$CI_COMMIT_BRANCH =~ /^release\/.*/'
- if: '$CI_COMMIT_BRANCH == "main"'
lint:
stage: validate
image: node:20.11.1-alpine
script:
- npm ci --prefer-offline
- npx eslint --max-warnings 0 src/
cache:
key: "$CI_COMMIT_REF_SLUG"
paths: [node_modules/]
unit-test:
stage: test
image: maven:3.9.6-eclipse-temurin-21
script:
- mvn -B -T 4 test
artifacts:
when: always
reports:
junit: target/surefire-reports/TEST-*.xml
coverage: '/Total.*?([0-9]{1,3})%/'
sonar:
stage: quality
image: sonarsource/sonar-scanner-cli:5.0.1
script:
- sonar-scanner -Dsonar.projectKey=myapp -Dsonar.qualitygate.wait=true
allow_failure: false
build-image:
stage: build
image: docker:24.0.7
services:
- docker:24.0.7-dind
script:
- docker build -t registry.example.com/myapp:$CI_COMMIT_SHA .
- docker push registry.example.com/myapp:$CI_COMMIT_SHA
rules:
- if: '$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "main"'
deploy-staging:
stage: deploy
image: bitnami/kubectl:1.28
script:
- kubectl set image deployment/myapp myapp=registry.example.com/myapp:$CI_COMMIT_SHA -n staging
- kubectl rollout status deployment/myapp -n staging --timeout=120s
environment:
name: staging
rules:
- if: '$CI_COMMIT_BRANCH == "develop"'
几个参数说明:GIT_DEPTH: 50 控制浅克隆深度,构建时间从平均4分20秒降到2分50秒。sonar.qualitygate.wait=true 让流水线等质量门结果,不通过直接红。
4.3 Jenkins多分支流水线(Jenkinsfile)
GitLab CI负责MR验证,Jenkins负责release和hotfix的部署编排,因为生产部署要接审批:
pipeline {
agent { label 'linux && docker' }
options {
timeout(time: 30, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '30'))
}
parameters {
choice(name: 'ENV', choices: ['staging', 'prod'], description: '部署环境')
string(name: 'VERSION', defaultValue: '', description: 'release版本号,如1.4.2')
}
stages {
stage('Checkout') {
steps {
checkout scm
sh 'git rev-parse --short HEAD > .git_sha'
}
}
stage('Build') {
steps {
sh 'mvn -B -DskipTests package'
}
}
stage('Deploy') {
when { expression { params.VERSION != '' } }
steps {
script {
if (params.ENV == 'prod') {
timeout(time: 15, unit: 'MINUTES') {
input message: "确认部署 ${params.VERSION} 到生产?", ok: 'Deploy'
}
}
sh """
kubectl set image deployment/myapp \
myapp=registry.example.com/myapp:${params.VERSION} \
-n ${params.ENV}
kubectl rollout status deployment/myapp \
-n ${params.ENV} --timeout=180s
"""
}
}
}
stage('SmokeTest') {
steps {
sh 'bash scripts/smoke-test.sh $ENV'
}
}
}
post {
failure {
slackSend channel: '#ci-alerts', color: 'danger',
message: "部署失败: ${env.JOB_NAME} #${env.BUILD_NUMBER}"
}
}
}
生产部署加了15分钟超时的input审批,避免半夜自动上线。
五、踩坑与优化
坑1:rebase把别人的提交搞丢了。有同事在feature分支上rebase develop时用了git rebase -i误删了commit,push时又用了--force。解决:feature分支只允许--force-with-lease,且GitLab上开了“禁止force push到develop/main”。
坑2:CI缓存导致测试用了旧依赖。node_modules缓存的key一开始只用分支名,结果换lock文件后没失效。改成key: "$CI_COMMIT_REF_SLUG-$(sha256sum package-lock.json | cut -c1-8)"。
坑3:SonarQube误报新代码覆盖率。因为我们用了sonar.newCode.referenceBranch=develop,但feature分支从旧develop切出时基线不对。改成用sonar.scm.revision配合merge base计算。
坑4:Jenkins的input步骤在流水线重启后会卡死。加了timeout和milestone,并且把审批人限制到release-managers组。
优化项:把单元测试从串行改成-T 4并行,20个模块的测试时间从8分12秒降到3分05秒。镜像构建加--cache-from,从3分40秒降到1分20秒。
六、效果数据
跑了3个月(2024年1月-3月),对比之前:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 主干构建失败率 | 18% | 2.3% |
| MR平均合并周期 | 26小时 | 4.7小时 |
| 每周冲突解决耗时 | 6.5小时 | 1.2小时 |
| 生产事故可追溯性 | 40% | 100% |
| 部署频率 | 每周2次 | 每天4-6次 |
| 平均回滚时间 | 45分钟 | 8分钟 |
最直观的变化:以前周五不敢合并,现在周五照常发版,因为CI全绿+1个Approver就能合,出了问题git revert一个MR就行,因为历史是线性的,revert不会引入奇怪的merge冲突。
总结
这套方案的核心不是Git命令多高级,而是三条铁律:feature分支生命周期不超过3天、主干历史必须线性、合并必须过CI+Review。工具上GitLab CI管MR验证、Jenkins管部署审批,各司其职。如果你团队也在15-30人规模、并行多条产品线,可以直接抄这套配置,重点调GIT_DEPTH、-T并行度和SonarQube的新代码基线。别追求一步到位上Trunk-Based,先把分支保护和CI门禁做扎实,收益就已经很大了。