一、问题背景:从"能跑就行"到"每次发布都像拆弹"

我们团队去年做的是一个面向B端的SaaS后台,前端Vue 3 + 后端Spring Boot 3.2。2023年初还只有5个人,Git用法非常原始:所有人往master推,发版前拉个release-20230301分支,谁也不敢动。那时候一周发一次,问题不大。

到了2023年底团队扩到18人,前后端加测试并行开发,问题集中爆发:

  • 每周至少3次因为两个人同时改同一个Service文件产生冲突,解决冲突平均耗时35分钟;
  • 线上出问题想回滚,发现master上混了5个未发布的feature,只能手动revert,最长一次用了40分钟;
  • Code Review靠"喊一声",MR(Merge Request)挂3天没人看是常态,平均合并时间26小时;
  • CI只在master上跑,feature分支的代码合进来才发现编译不过。

我们统计了2023年Q4的数据:37次发布,其中9次因为合并问题延期,2次线上事故回滚超过30分钟。这不是技术问题,是流程问题。

二、环境与版本

先把工具链钉死,避免"你本地能跑我这边不行":

  • GitLab CE 16.9.1(self-managed,Docker部署)
  • GitLab Runner 16.9.1,executor用docker,并发设8
  • Jenkins 2.452.1 LTS + Blue Ocean 1.27.11
  • Git 2.43.0(客户端统一,因为2.43对git rebase --update-refs支持更好)
  • Node 20.11.1 / JDK 21.0.2
  • Maven 3.9.6,Gradle 8.6(后端两个服务分别用)

三、方案设计:为什么最后选了混合模型

我们评估过三种主流方案:

  1. 纯Git Flow:feature → develop → release → main。太重,develop分支和main长期分叉,我们这种一周两发的节奏根本用不上,而且hotfix流程绕。
  2. 纯Trunk-Based:所有人往main合,靠feature flag控制。对测试覆盖率和flag管理要求极高,我们当时单测覆盖率才52%,扛不住。
  3. 混合模型(最终选择):主干是main,所有feature从main切、合回main;发布时从main切release/x.y,release分支只接受cherry-pick的hotfix;feature分支合并必须squash。

分支命名规范:

main                    # 保护分支,只接受MR,禁止直接push
feature/JIRA-1234-xxx   # 功能分支,生命周期 .git-sha'
            }
        }

        stage('Build & Test') {
            steps {
                sh 'mvn -B clean verify -DskipITs=false -Dmaven.test.failure.ignore=false'
            }
            post {
                always {
                    junit 'target/surefire-reports/TEST-*.xml'
                    jacoco execPattern: 'target/jacoco.exec'
                }
            }
        }

        stage('SonarQube Scan') {
            when { changeRequest() }
            steps {
                withSonarQubeEnv('sonar-10.4') {
                    sh """
                        mvn -B sonar:sonar \
                          -Dsonar.projectKey=${env.JOB_NAME} \
                          -Dsonar.pullrequest.key=${env.CHANGE_ID} \
                          -Dsonar.pullrequest.branch=${env.CHANGE_BRANCH} \
                          -Dsonar.pullrequest.base=${env.CHANGE_TARGET}
                    """
                }
            }
        }

        stage('Quality Gate') {
            when { changeRequest() }
            steps {
                timeout(time: 5, unit: 'MINUTES') {
                    waitForQualityGate abortPipeline: true
                }
            }
        }

        stage('Publish') {
            when {
                anyOf {
                    branch 'main'
                    buildingTag()
                }
            }
            steps {
                sh """
                    mvn -B deploy -DskipTests \
                      -DaltDeploymentRepository=nexus::default::${NEXUS_REPO}
                """
            }
        }
    }

    post {
        failure {
            slackSend(
                channel: '#ci-alerts',
                color: 'danger',
                message: "构建失败: ${env.JOB_NAME} #${env.BUILD_NUMBER} ()"
            )
        }
    }
}

Jenkins端Multibranch Pipeline的发现策略我们设的是:扫描间隔5分钟,MR只保留最近10个构建,避免Jenkins磁盘被feature分支塞满。

4.3 日常操作命令

开发者本地最常用的三条命令,写进团队wiki:

# 1. 从最新main切feature
git checkout main && git pull --rebase origin main
git checkout -b feature/JIRA-1234-user-export

# 2. 开发中同步main(用rebase保持线性,别merge)
git fetch origin main
git rebase origin/main
# Git 2.43可以顺手更新所有相关分支的ref
# git rebase --update-refs origin/main

# 3. 推送前压缩历史(其实GitLab squash merge会做,但本地压一下review更清爽)
git rebase -i origin/main

五、踩坑与优化

坑1:squash merge丢了中间commit的关联。 我们一开始所有分支都用squash,结果JIRA的commit关联全断了,issue里看不到代码提交记录。后来改成:feature分支squash,release分支用merge commit,且commit message里强制带JIRA号。GitLab的squash commit message模板我们设成:

%s (#%{merge_request_iid})

%{co_authored_by}

坑2:MR pipeline不触发。 GitLab默认对分支push跑pipeline,MR打开时不重复跑。我们想要MR上的独立pipeline(因为要跑Sonar的pull request分析),必须用rules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"',同时要确保.gitlab-ci.yml里没有only/except混用,否则rules会被忽略。这个坑让我们白等了两周以为Sonar没配好。

坑3:Jenkins多分支扫到release分支也跑部署。 release分支push时触发了Publish stage,差点把快照包发到Nexus正式库。修复是在when里加not { branch 'release/*' },release的构建走单独的Release job。

优化1:cache key。 最初cache用全局key,18个人并行构建时互相覆盖,Maven本地仓库经常损坏。改成$CI_COMMIT_REF_SLUG后,缓存命中率从41%提到78%,平均构建时间从6分12秒降到3分40秒。

优化2:并发控制。 Jenkins disableConcurrentBuilds(abortPrevious: true),同分支新push自动取消旧构建。我们有个feature分支一天推了23次,这个配置省了大概40%的runner时间。

优化3:GitLab push rules。 开了commit message正则校验后,update、wip这种垃圾commit message基本绝迹,main的git log --oneline干净得像样了。

六、效果数据

跑了3个月(2024年1月-3月),对比2023年Q4:

指标 改造前 改造后
MR平均合并时间 26小时 4.5小时
每周合并冲突次数 3.2次 0.6次
线上事故平均回滚时间 40分钟 8分钟
CI平均构建时长 6分12秒 3分40秒
单测覆盖率 52% 74%
发布频率 每周1次 每周2-3次

回滚时间从40分钟降到8分钟,主要靠两点:一是main上每个commit对应一个MR,git revert就能精准回滚;二是镜像tag用$CI_COMMIT_SHORT_SHA,回滚就是改一下K8s的image tag。

七、总结

这套混合模型不是什么银弹,它解决的是"团队从5人到18人"这个阶段的协作问题。核心就三件事:main只接受MR、feature强制squash、release冻结只cherry-pick。工具上GitLab的push rules + CODEOWNERS + MR pipeline,Jenkins的multibranch + when changeRequest(),配置都在上面了,可以直接抄。

如果你们团队还在10人以下、一周一发,其实不用搞这么复杂,trunk-based加个简单保护分支就够了。流程是跟着团队规模长的,别为了流程而流程。

最后一句:分支策略写进文档没用,写进CI配置和分支保护规则里才有用。 人治不如工具治。