一、问题背景:从SVM单干到Git百人混战
我们团队在2023年初从SVN整体迁移到Git,当时拍板的是“全都要”策略——所有人直接在一个master分支上开发。结果第一个月就崩了:20个并行需求,Merge冲突此起彼伏,经常出现“我明明改了A模块,却要和改B模块的人解冲突”的诡异情况。更气人的是,每次发布都要临时冻结代码,因为master上永远有半成品。
那时候我们就明白:Git本身不解决协作问题,解决的是版本追踪问题,协作需要的是工作流约束。
二、环境与版本:我们踩过的版本坑
先说一下基础环境,这些都是我们用血泪换来的版本组合:
| 组件 | 版本 | 说明 |
|---|---|---|
| Git | 2.39.1 | 必须>=2.33,否则--rebase-merges有bug |
| Gerrit | 3.7.2 | 代码审查必须用,GitLab MR不够严格 |
| Jenkins | 2.414.1 | 发布流水线用,配合Pipeline插件2.4.6 |
| GitLab | 15.11 | 只是代码托管,不开MR功能(避免双轨混乱) |
重要警告:如果你还在用Git 2.30以下的老版本,请立刻升级。我们曾经在2.28版本上遇到过rebase --onto合并策略导致提交历史丢失的问题,那个事故直接让我们回滚了3个服务。
三、方案设计:双轨制分支策略
我们最终落地的是双轨制,这个“双轨”不是指两条永久分支,而是按场景分流:
3.1 核心分支模型
main(受保护,只能由release分支合并)
├── release/1.x(版本分支,冻结后从main拉出)
├── feature/JIRA-1234-描述(需求分支,从main拉出)
├── bugfix/HOTFIX-5678(紧急修复,从release分支拉出)
└── dev(内部集成分支,允许直接push,但必须通过CI)
3.2 关键决策点
- 开发新功能 → 从
main拉feature分支,代码必须过Gerrit Review - 修线上Bug → 从
release/1.x拉bugfix分支,修复后同时合入release和main - 预发布集成 → 每周五下班前,所有feature分支合并到
dev做集成测试 - 正式发版 → 一个迭代结束后,从
main拉出release/2.0,之后只允许修Bug
这个模型的核心思想是:main永远是可发布状态,feature分支生命周期不超过2周。
四、核心实现:Gerrit Code Review + Git命令配置
4.1 Gerrit Review流程配置
我们使用Gerrit的LABEL: Verified机制,强制要求至少1个+1验证和1个+2审核才能合入main。.gitreview配置如下:
[gerrit]
host=gerrit.example.com
port=29418
project=finance-core
defaultbranch=main
defaultrebase=1
track=1
scheme=ssh
# 强制使用change-id,缺了会拒收
[hook]
push=gerrit
配合客户端侧的一个实用alias(写入~/.gitconfig):
[alias]
review = "!f() { \
git push origin HEAD:refs/for/main%topic=$(git rev-parse --abbrev-ref HEAD) \
-o l=Verified+1 -o l=Code-Review+1; \
}; f"
reviewdry = "!f() { \
git log --oneline origin/main..HEAD; \
echo '--- 以上提交将推送到Gerrit ---'; \
}; f"
4.2 合并冲突的终极武器:--rebase-merges
这是我最想推荐给所有人的。传统git rebase会把你的commits压扁,丢失合并节点。但--rebase-merges可以保留你的merge commit拓扑结构。我们的feature分支内部合并策略是:
# 在feature分支上定期同步main
git fetch origin main
git rebase --rebase-merges --onto origin/main $(git merge-base origin/main HEAD) HEAD
# 这个命令的含义:把当前分支上相对于main的合并结构,完整搬到最新main上
这个命令踩坑记录:--rebase-merges在Git 2.39之前默认不启用--fork-point,所以必须在后面手动指定$(git merge-base...)。我们团队最初用git rebase origin/main,结果把别人合进来的commit全丢了,后来写成脚本固化下来。
4.3 文件级合并策略
对于数据库迁移脚本和配置文件,我们使用自定义合并驱动:
# .gitattributes 配置
migrations/*.sql merge=ours
config/*.yml merge=union
在~/.gitconfig中定义合并驱动:
[merge "ours"]
driver = true
[merge "union"]
driver = git merge-file -p %A %O %B > %A
这样migrations目录下的SQL冲突永远以本地版本为准(因为迁移脚本是改一次永远不改的),而YAML配置则自动合并行级差异。
五、CI/CD集成:Jenkins Pipeline与GitLab CI的联动
5.1 Jenkins Pipeline(发布构建)
我们的发布构建必须从release分支触发,且每次只构建一个版本。核心Pipeline脚本片段:
pipeline {
agent any
parameters {
string(name: 'RELEASE_TAG', defaultValue: '2.1.0', description: '发布版本号')
choice(name: 'ENV', choices: ['staging', 'production'], description: '目标环境')
}
triggers {
// 监听release分支push事件
gitlab(triggerOnPush: true, branchFilter: 'release/*')
}
stages {
stage('校验分支') {
when {
expression {
// 强制要求当前分支是release/*或main
return env.GIT_BRANCH =~ /^(release\/|main$)/
}
}
steps {
echo "构建分支: ${env.GIT_BRANCH}"
}
}
stage('版本号生成') {
steps {
script {
// 从git describe自动生成构建号
def version = sh(script: "git describe --tags --always --dirty", returnStdout: true).trim()
// 设置环境变量给后续stage使用
env.BUILD_VERSION = "${params.RELEASE_TAG}-${version}-${BUILD_NUMBER}"
}
}
}
stage('构建与测试') {
steps {
sh '''
./gradlew clean build -x test
./gradlew test --tests "com.example.**.*Test" \
--junit-xml build/test-results/test
'''
}
}
stage('发布产物') {
when {
expression { params.ENV == 'production' }
}
steps {
sh 'docker build -t registry.example.com/finance:${BUILD_VERSION} .'
sh 'docker push registry.example.com/finance:${BUILD_VERSION}'
}
}
}
post {
success {
// 更新GitLab tag
sh "git tag release-${params.RELEASE_TAG}-${BUILD_NUMBER}"
sh "git push origin --tags"
}
failure {
// 通知群机器人
sh "curl -X POST http://monitor.example.com/api/notify -d 'status=failed&job=${JOB_NAME}'"
}
}
}
5.2 GitLab CI的轻量门禁
对于dev分支,我们只跑静态检查和快速测试,不构建镜像:
# .gitlab-ci.yml
stages:
- lint
- test
variables:
GRADLE_OPTS: "-Xmx1024m"
lint-job:
stage: lint
script:
- ./gradlew spotbugsMain
- ./gradlew checkstyleMain
only:
- dev
- main
- /^feature\/.*$/
allow_failure: false
timeout: 5m
test-job:
stage: test
script:
- ./gradlew test --tests "com.example.core.*"
artifacts:
paths:
- build/reports/tests/
only:
- dev
- main
timeout: 10m
这里有个比较独特的做法:GitLab CI只跑测试,不跑构建,因为所有的构建都统一在Jenkins里做。这样可以避免双套构建系统产生版本不一致。
六、踩坑与优化:我们流过的血与泪
6.1 最大的坑:Gerrit的Change-Id和merge commit
Gerrit默认不允许merge commit,它要求每个commit都有Change-Id。但我们的feature分支内部就是有merge commit(为了保持主干的历史整洁)。解决方案:
- 在
.gitreview中配置defaultrebase=1 - 要求开发者在push前执行
git review --rebase(自动把merge commit线性化) - 如果必须保留merge,需要修改Gerrit的
receive.maxBatchCommits和receive.allowGroup
6.2 性能优化:减少fetch次数
我们团队约120人,每天fetch总量有15GB。优化的措施:
# 使用浅克隆+动态拉取
git clone --depth=50 --branch=main https://git.example.com/finance.git
# 每5分钟自动fetch一次最新main,只拉增量
git fetch origin main --shallow-since="2 days ago"
这个改动让CI的代码检出时间从42秒降到了11秒。
6.3 Code Review的节奏控制
我们尝试过强制所有feature分支必须过Review,但结果是小需求被Review阻塞了3天。后来改成分级审查:
- 涉及金额计算、支付通道的 migration/ 路径 → 必须2个Senior +1 Gerrit
- 普通业务代码 → 1个Reviewer +1即可
- 纯配置变更(如logback.xml)→ 直接push dev,CI自动检查
这个策略让平均review等待时间从8小时降到了1.5小时,同时生产事故率没有上升。
七、效果数据与总结
双轨制工作流运行了6个月后,我们拿到了这些数字:
| 指标 | 改造前(SVN时代) | 改造后(当前) |
|---|---|---|
| 代码冲突率(每周) | 23次/周 | 8.5次/周(↓63%) |
| 发布构建成功率 | 85.2% | 98.5% |
| 平均发布准备时间 | 4小时 | 40分钟(↓83%) |
| 紧急修复上线时间 | 2小时 | 35分钟 |
| 并行开发需求数 | 8个 | 22个 |
总结三条核心经验:
-
分支策略不是越多越好。我们最终只保留了
main、release/*、feature/*、bugfix/*四种前缀,但通过Gerrit的权限控制让不同的分支有不同的写入权限——这是Git作为工具无法做到的事情。 -
CI/CD必须绑定分支模型。Jenkins只触发
release/*和main,GitLab CI只跑dev和feature,职责分离让构建流程不会互相干扰。 -
自动化是手段不是目的。我们最终没有追求100%的自动化,因为代码审查中的人为判断仍然是不可替代的。但把能自动化的(版本号生成、合并冲突检测、基础测试)全部自动化后,人的精力就集中在真正需要判断的地方。
目前这套体系已经稳定运行了6个月,支撑了2个大版本(2.0和2.1)的发布。如果你也在规划Git工作流,我强烈建议先用小团队试点一个迭代周期,把--rebase-merges和--shallow-since这些参数跑通,然后再推广到全团队。Git本身足够强大,但工作流的约束才是让团队高效协作的关键。