一、问题背景:当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门禁做扎实,收益就已经很大了。