一、问题背景:当仓库长到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 switchgit restoremerge.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

我们对比了三种策略:

  1. 纯Git Flowdevelop/release/hotfix全套。问题是developrelease长期分叉,20人规模下分叉一周就是几千行差异,合并回去必冲突。
  2. 纯Trunk-Based:所有人往main推。对CI要求极高,我们Jenkins还没那么强,且没有feature flag基础设施,不适合。
  3. 折中:Trunk-Based + 短生命周期Feature分支(我们选的):
  4. main是唯一长期分支,永远可发布。
  5. 功能分支从main切出,命名feat/xxxfix/xxx生命周期不超过2天,超过就必须拆分。
  6. 不设develop
  7. 发布用tag,release/*分支只在需要给旧版本打补丁时临时拉。
  8. 所有合并必须走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-pluginfailIfNoSpecifiedTests,并在流水线里检查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拉一次数据回头看,才是真的在维护它。