一、问题背景:我们为什么要重做Git工作流
先说背景。我们是一个30人左右的后端+前端混合团队,维护一个Spring Boot 2.7 + Vue 3的中台系统,代码仓库在GitLab 15.8(自建CE版)。2023年初从SVN迁移过来时,基本是"能用就行"的状态:所有人往develop分支直接push,发版前临时拉个release分支,谁手快谁先合。
结果就是:
- 每周三发版前,develop分支平均有11个未合并的feature分支,合并冲突要花半天解
- 有次一个同事把本地调试用的
application-dev.yml里的数据库密码提交上去了,三天后才发现 - Code Review形同虚设,MR(Merge Request)平均存活时间6小时,大部分是"LGTM"式 rubber stamp
- 线上出问题回滚,发现release分支和master差了20多个commit,回滚脚本直接失败
痛定思痛,我们花了两个月重新设计工作流,核心目标三个:分支职责单一、合并前必过CI、发布可追溯可回滚。下面是我们最终落地的方案。
二、环境与版本
先把环境交代清楚,避免配置对不上:
- GitLab CE 15.8.3(自建,Runner是Docker Executor)
- Git 2.39.2
- JDK 17 + Maven 3.9.4
- Node 18.17.1 + pnpm 8.7.0
- SonarQube 9.9 LTS(社区版)
- 部署环境:K8s 1.26,镜像仓库Harbor 2.8
分支模型参考的是Vincent Driessen的Git Flow,但砍掉了hotfix分支(我们线上问题走release分支补丁),实际保留5类分支:main、develop、feature/*、release/*、bugfix/*。
三、方案设计:分支策略与保护规则
3.1 分支职责定义
| 分支 | 来源 | 合并目标 | 生命周期 | 保护级别 |
|---|---|---|---|---|
| main | - | - | 永久 | 最高,仅Lead可push |
| develop | main | release | 永久 | 高,禁止直接push |
| feature/* | develop | develop | 功能上线即删 | 无 |
| release/* | develop | main + develop | 发版后保留1个版本 | 中 |
| bugfix/* | main | main + develop | 修复上线即删 | 无 |
关键约束:feature分支存活不超过5个工作日。我们后来加了个定时任务,每周一扫超过7天没活动的feature分支,发提醒到群,两周不动的自动打stale标签。这条规则把长期分支数量从平均14个压到了3个以内。
3.2 分支保护规则(GitLab配置)
在GitLab的Settings → Repository → Protected branches里配置,核心几条:
main:Allowed to merge = Maintainers,Allowed to push = No one,开启"Merge commit with semi-linear history"develop:Allowed to merge = Developers+Maintainers,Allowed to push = No one- 两个分支都开启:Pipelines must succeed、All threads must be resolved、Require approval from 1 approver
半线性历史(semi-linear)这个选项很关键——它强制MR合并时rebase到目标分支最新,保证了git log是一条干净的线,出问题用git bisect定位非常快。代价是合并时偶尔要手动解冲突,但比merge commit满天飞强。
3.3 Code Review流程
我们的MR模板(.gitlab/merge_request_templates/default.md)是这样的:
## 变更类型
- [ ] 新功能 - [ ] Bug修复 - [ ] 重构 - [ ] 配置变更
## 变更说明
## 自测情况
- [ ] 本地单测通过 - [ ] 涉及接口已用Postman验证
- [ ] 涉及DB变更已附SQL - [ ] 涉及配置变更已同步到config仓库
## Review Checklist(提交者自查)
- [ ] 没有硬编码密钥/密码/token
- [ ] 新增public方法有Javadoc
- [ ] 异常分支有日志且日志含traceId
- [ ] 单测覆盖率不低于60%(新增代码)
Review规则卡得比较死:至少1个approver,且approver不能是提交者本人;涉及核心模块(订单、支付、权限)必须2个approver。另外我们约定review响应时间:工作日4小时内必须给出第一轮反馈,否则MR作者可以直接@对方Leader。这条是从"MR平均存活6小时"压到"平均1.8小时"的主要原因——不是技术问题,是流程约束。
四、核心实现:GitLab CI/CD配置
我们的.gitlab-ci.yml(放在仓库根目录)跑了10个月,迭代了20多版,下面是精简后的核心结构。分四个stage:build → test → review → deploy。
# .gitlab-ci.yml
stages:
- build
- test
- review
- deploy
variables:
MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
DOCKER_DRIVER: overlay2
IMAGE_TAG: "$CI_COMMIT_SHORT_SHA"
# 缓存Maven和pnpm依赖,实测能省2分半
cache:
key:
files:
- pom.xml
- frontend/pnpm-lock.yaml
paths:
- .m2/repository/
- frontend/node_modules/
build-backend:
stage: build
image: maven:3.9.4-eclipse-temurin-17
script:
- mvn -B clean package -DskipTests
artifacts:
paths:
- target/*.jar
expire_in: 1 day
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH =~ /^(develop|release\/.*|main)$/'
test-backend:
stage: test
image: maven:3.9.4-eclipse-temurin-17
script:
- mvn -B test
- mvn -B jacoco:report
coverage: '/Total.*?([0-9]{1,3})%/'
artifacts:
reports:
junit: target/surefire-reports/TEST-*.xml
paths:
- target/site/jacoco/
expire_in: 7 days
sonar-check:
stage: test
image: sonarsource/sonar-scanner-cli:5.0
script:
- sonar-scanner
-Dsonar.projectKey=our-platform
-Dsonar.host.url=$SONAR_URL
-Dsonar.login=$SONAR_TOKEN
-Dsonar.qualitygate.wait=true
allow_failure: false
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
deploy-review:
stage: review
image: bitnami/kubectl:1.26
script:
- kubectl set image deployment/app app=$HARBOR_URL/app:$IMAGE_TAG -n review-$CI_MERGE_REQUEST_IID
- kubectl rollout status deployment/app -n review-$CI_MERGE_REQUEST_IID --timeout=180s
environment:
name: review/$CI_MERGE_REQUEST_IID
url: https://mr-$CI_MERGE_REQUEST_IID.review.ourdomain.com
on_stop: stop-review
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop"'
stop-review:
stage: review
image: bitnami/kubectl:1.26
script:
- kubectl delete namespace review-$CI_MERGE_REQUEST_IID --ignore-not-found
environment:
name: review/$CI_MERGE_REQUEST_IID
action: stop
when: manual
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
几个关键点说明:
Quality Gate卡合并。sonar.qualitygate.wait=true让CI等Sonar结果,不通过直接fail。我们的gate阈值:新代码覆盖率≥60%、重复率≤3%、Blocker问题=0。刚开始卡得全组哀嚎,两周后大家写代码就习惯性带单测了。
MR环境。每个MR自动在K8s的独立namespace里部署一套环境,URL直接回写到MR页面。测试同学点链接就能验,不用等开发打包。这个功能上线后,UI类bug的发现时间从"发版后"提前到"合并前"。
镜像tag用commit short sha,配合Harbor的tag immutable策略,保证任何一次部署都能精确回溯到代码。
部署到生产的部分单独放在另一个项目里(避免开发误操作),用的是GitLab的manual job + 双人审批:
# deploy-prod(独立项目,仅Maintainer可触发)
deploy-production:
stage: deploy
image: bitnami/kubectl:1.26
script:
- |
if [ "$CI_COMMIT_BRANCH" != "main" ]; then
echo "只有main分支允许部署生产"; exit 1
fi
- kubectl set image deployment/app app=$HARBOR_URL/app:$IMAGE_TAG -n prod
- kubectl rollout status deployment/app -n prod --timeout=300s
- |
if [ $? -ne 0 ]; then
echo "滚动更新失败,执行回滚"
kubectl rollout undo deployment/app -n prod
exit 1
fi
environment:
name: production
when: manual
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
rollout status超时自动rollout undo,这是我们的兜底。上线10个月触发过2次,都是因为健康检查没通过,回滚在40秒内完成,用户基本无感。
五、踩坑与优化
坑1:CI缓存key设计错误导致依赖串味。最早cache key直接用了$CI_COMMIT_REF_SLUG,结果每个feature分支一份缓存,Runner磁盘两周就爆了。改成按pom.xml和pnpm-lock.yaml的hash做key(就是上面配置里的key.files),缓存命中率反而从62%提到89%,Runner存储占用降了70%。
坑2:MR pipeline重复跑。GitLab默认对push和MR各跑一次pipeline,我们的配置里用rules只让MR事件和特定分支触发,但一开始漏了workflow规则,导致develop的push和MR双跑。加上这段后省了约35%的CI时长:
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "develop" && $CI_PIPELINE_SOURCE == "push"'
- if: '$CI_COMMIT_BRANCH =~ /^release\/.*/ && $CI_PIPELINE_SOURCE == "push"'
- if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"'
坑3:rebase合并后CI结果失效。开启semi-linear后,MR合并前会rebase,rebase产生的commit没跑过CI。我们的做法是要求合并前必须"Pipeline passed on latest commit",GitLab的"Pipelines must succeed"配合"Merge when pipeline succeeds"能覆盖,但要注意别让作者在CI跑完前手动点merge——这个靠分支保护里的"Only allow merge if pipeline succeeds"卡死。
优化:CI时长从14分钟压到4分20秒。手段就三个:Maven依赖缓存、-T 1C并行构建(我们模块多)、单测分片(surefire的forkCount=2配合Runner并发)。前端那边pnpm的store缓存也起了大作用,install从90秒降到12秒。
六、效果数据
跑了10个月,几个关键指标对比(迁移前后各取3个月均值):
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 主干合并冲突率 | 37% | 6% |
| MR平均存活时间 | 6.1小时 | 1.8小时 |
| CI平均反馈时间 | 14分10秒 | 4分20秒 |
| 月均发布事故 | 2.3次 | 0.2次 |
| 发布回滚次数 | 月均5次 | 0次(10个月) |
| 新代码单测覆盖率 | 21% | 67% |
日均MR合并量从改造前的约25次涨到80次左右,但事故反而降了——说明大部分"慢"其实是流程混乱造成的,约束清楚之后吞吐自然上来。
七、总结
回头看,这套工作流能跑起来,技术配置只占三成,七成是规则被真的执行:分支存活期有人盯、review超时有人催、quality gate不通过就是不让你合。配置本身没什么黑魔法,.gitlab-ci.yml加起来不到200行,但每一条都是踩过坑之后加的。
给想落地的团队三个建议:一是别一上来就全套Git Flow,先从"main + develop + feature"三条线跑顺;二是CI的quality gate一定要设成阻塞式,允许失败等于没设;三是MR模板和review checklist比任何工具都重要,把"该检查什么"写清楚,比指望大家自觉靠谱得多。
配置我脱敏后放在内部wiki了,有需要的同行可以按上面的片段自己拼,基本能直接跑。