最近有朋友来问我 Git 分支和发布流程相关的问题,聊着聊着我发现,分支管理这块我自己算是比较熟悉,平时用起来也有一套固定的习惯,但当话题转到 release 发布流程的时候,我反倒得停下来想一想。

因为发布这件事,在不同的团队里真的有截然不同的管理方式。有的团队一天上线十几次,有的团队一个月才发一个大版本;有的靠一条流水线全自动,有的还保留着人工审批的关卡;有的发布的是后端服务,回滚一次只要几分钟,有的发布的是手机 App,版本一旦进了用户手机,想收回来就没那么容易了。

同样是把代码送到用户面前,路径可以差得非常远。这次朋友的提问正好戳中了这个我一直觉得值得梳理却没动笔的话题,于是就有了这篇文章。

分支管理与发布上线流程示意

为什么分支管理这么重要

很多人刚开始用 Git 的时候,会把分支理解成一个简单的代码备份工具。我要做一个新功能,就从 main 拉一条分支;我要试验一个想法,也拉一条分支。这个理解没有错,但只看到了分支的第一层作用。

真正到了团队协作和生产发布里,分支管理解决的不是代码备份问题,而是代码流向问题。也就是说,哪一批代码可以进入测试环境,哪一批代码可以进入生产环境,哪一个 commit 对应当前线上版本,出了问题应该从哪里修,修完之后又应该合回哪里。

如果没有清晰的分支规则,团队很容易进入一种混乱状态:功能还没测完就被带到生产,线上 hotfix 修完忘了合回开发分支,测试环境和生产环境跑的代码说不清楚,发布时大家只能在群里反复确认“现在到底该发哪个 commit”。这种混乱短期看只是沟通成本,长期看就是事故来源。

所以我更愿意把分支管理看成发布系统的一部分,而不是单纯的 Git 使用技巧。分支只是表面,背后真正要设计的是从需求开发、代码合并、测试验证、版本标记、部署上线到回滚修复的完整链路。

主流分支模型的取舍

常见的分支模型大致可以分成 Git Flow、GitHub Flow、GitLab Flow 和主干开发。它们没有绝对好坏,只有适不适合你的产品形态、团队规模和发布频率。

Git Flow

[[Git Flow]] 是 Vincent Driessen 在 2010 年提出的模型,也是很多人接触的第一套正规分支规范。它定义了两条长期分支和三类临时分支。两条长期分支是 main 或者 master,以及 develop。main 永远对应生产环境上正在跑的代码,develop 是所有开发工作汇集的集成分支。三类临时分支分别是 feature、release 和 hotfix。

它的工作方式是这样的:每开发一个新功能,从 develop 拉出一条 feature 分支,做完之后合并回 develop。当 develop 上积累了足够一次发布的功能,就从 develop 拉出一条 release 分支,在这条分支上只做测试和 bug 修复,不再加新功能。release 稳定之后,同时合并进 main 和 develop,在 main 上打一个版本 tag,发布上线。如果线上出了紧急 bug,从 main 拉一条 hotfix 分支,修完后同样合并回 main 和 develop。

c_bR-STyVE

Git Flow 的优点是职责极其清晰,每条分支都有明确的语义,特别适合那种有明确版本概念、需要同时维护多个版本、发布周期比较长的软件,比如安装包类桌面应用、企业客户私有化部署产品、需要给客户提供长期支持版本的产品。

但它的缺点也很突出:太重了。两条长期分支加上频繁的双向合并,会让整个流程变得繁琐。对于现在这种追求持续交付、一天上线好几次的 Web 应用来说,Git Flow 往往是过度设计,反而拖慢节奏。

GitHub Flow

如果说 Git Flow 是重型装甲,[[GitHub Flow]] 就是轻装上阵。它只有一条长期分支 main,规则简单到一句话就能说完:main 永远是可部署的,任何改动都从 main 拉一条描述清晰的短分支,做完后开 PR,CI 通过、代码 review 通过,就合并回 main。

GitHub Flow 的核心假设是 main 分支始终保持健康,任何时刻都能拿去上线。它天然契合持续部署,你合并即上线,不存在攒一批功能再发布的概念。对于小团队、独立开发者、Web 服务、内部工具来说,这套流程非常高效。

nFoCeMqaZ0

但它也有前提:自动化测试要足够可靠,review 要认真,部署流水线要成熟。否则“main 可部署”就只是一句口号。同时它缺少显式的环境概念,没有区分测试环境和生产环境的分支。如果你的发布必须经过多个环境层层验证,纯 GitHub Flow 就会显得有点薄。

GitLab Flow

[[GitLab Flow]] 可以看成是 GitHub Flow 的增强版,它在保持简单的基础上补上了环境和版本的概念。

一种常见做法是引入环境分支,比如除了 main,再加上 pre-production 和 production 分支。代码永远从上游流向下游:先合并到 main,main 部署到测试环境验证通过后,再把 main 合并到 pre-production,最后合并到 production 上线。这样每个环境对应一条分支,你随时能看到每个环境上跑的是哪一批代码。

uUj71QtQrd

另一种做法是用 release 分支来支持版本发布。当你需要维护 2.3、2.4 这样的多个稳定版本时,为每个版本维护一条 release 分支,修复只从 main 往 release 分支 cherry-pick,保证 bug 修复的流向永远是从上游到下游,避免修了新版本忘了修老版本。

GitLab Flow 的价值在于它在简单和严谨之间找到了一个平衡点,既没有 Git Flow 那么繁琐,又比 GitHub Flow 多了对多环境、多版本的支持。

主干开发

[[Trunk-Based Development]] 主干开发是近几年在大厂和高频交付团队里越来越流行的模型,它的理念有点反直觉:尽量不要有长期存在的分支。所有人都直接往主干,也就是 main 上频繁提交。即使要开分支,也应该是那种活不过一两天的极短生命周期分支,做完立刻合并。

这套做法要解决的核心问题是合并地狱。当分支存在的时间越长,它和主干的差异就越大,最后合并的时候冲突就越可怕。主干开发通过强制每个人频繁地把小改动合进主干,让分支永远不会偏离太远,冲突自然就小了。

那没做完的功能怎么办?答案是特性开关。代码合进去了,但入口用 feature flag 关着,直到功能完整、测试通过再打开。Google、Meta 这种超大规模团队用的都是主干开发的思路。

它的门槛是四种模型里最高的。你需要极其完善的自动化测试、成熟的特性开关基础设施、稳定的监控告警,以及团队对小步提交纪律的高度自觉。但一旦这些条件具备,主干开发能带来最快的集成速度和最少的合并痛苦,是持续交付的理想形态。

环境与部署的对应关系

讲完分支模型,必须把它和环境部署对应起来,否则分支就是悬空的。一个典型的部署链路会有这么几个环境:开发环境、测试环境、预发布环境、生产环境。分支模型的作用,就是规定代码怎么从一个环境流到下一个环境,也规定每个环境应该接受什么成熟度的代码。

我一般会把环境分成四层来看。

开发环境(dev)是给开发者快速验证的地方,它对应的通常不是某一条固定分支,而是 feature 分支、个人分支,或者 main 上最新的开发构建。这个环境的目标是快,不是稳。开发环境可以频繁部署,可以被打断,可以连 mock 服务,也可以连一套临时数据库。管理上要注意两点:第一,不要让开发环境承担验收职责;第二,开发环境的数据和配置必须和生产隔离,不能因为图方便就直接连生产依赖。

测试环境(test)是给 QA、产品和团队做集成验证的地方,它通常对应 main、develop,或者专门的 integration 分支。小团队使用 GitHub Flow 时,我更建议 main 合并后自动部署到测试环境;如果团队还保留 develop 分支,那 develop 就应该是测试环境的事实来源。测试环境要比开发环境稳定,不能随便把半成品功能推进去影响别人验证。如果有多个功能并行开发,最好通过 feature flag 控制入口,而不是让测试环境在多个大分支之间来回切。

预发布环境,也就是 staging 或 pre-production,应该尽量模拟生产。它对应的代码通常是 release 分支、待发布 tag,或者某个已经通过测试环境验证的 commit。预发布环境的重点不是发现普通功能 bug,而是验证发布本身:配置是否正确,数据库迁移是否可执行,缓存、队列、定时任务、第三方回调、权限和网络策略是否和生产一致。我的建议是,预发布环境不要跟着每次 main 合并自动更新,而应该由 release candidate 触发部署,这样它代表的是“下一次准备上线的版本”。

生产环境只应该部署已经确认可发布的版本。它对应的可以是 production 分支,也可以是不可变的 Git tag、GitHub Release、镜像 digest 或者某个明确的 commit。我更偏向用 tag 或镜像 digest 标记生产,而不是长期维护一条 production 分支。因为生产环境最重要的是可追溯:你必须能回答现在生产跑的是哪个 commit、哪个构建产物、哪一组配置,以及它是通过哪一次流水线发布出去的。

如果把它们放成一条代码流,大致是这样:feature 分支先进入开发环境,合并到 main 或 develop 后进入测试环境,从 main 切 release 分支或创建 release candidate 后进入预发布环境,最后通过 tag、release 或审批流水线进入生产环境。这个过程里,代码只能向前流动,不能因为生产出了问题就直接在生产分支上手改,也不能把预发布环境里的临时补丁忘在角落里。

环境和分支的对应有两种常见做法。一种是环境即分支,像 GitLab Flow 那样,每个环境对应一条分支,例如 develop 对应测试环境,pre-production 对应预发布环境,production 对应生产环境。合并到哪条分支就部署到哪个环境。这种方式直观,适合流程需要强控制、团队成员对环境概念还不熟的阶段。

另一种是环境即版本,也就是环境不由分支定义,而由 tag、commit、release 或镜像 digest 定义。main 始终是唯一主线,测试环境自动跟随 main,预发布环境部署某个 release candidate,生产环境部署某个不可变 tag。这更符合主干开发和现代 [[GitOps]] 的理念,也更容易做到“同一份构建产物经过测试、预发布、生产逐级推进”。

我更倾向于第二种。因为环境分支看起来直观,但时间一长很容易变成环境分叉:staging 有一些改动,production 有另一些改动,最后谁也说不清哪个才是事实来源。用 commit、tag、镜像 digest 来描述环境状态,通常更干净,也更容易审计。

这里的关键在于自动化。你不应该让人去手动 build、手动上传文件、手动重启服务,这些都应该由 [[CI/CD]] 流水线在代码合并、创建 release candidate 或打 tag 的时候自动完成。手动操作是错误的温床,一次手滑就可能把测试环境配置带到生产。

客户端版本发布的特殊性

如果你的产品有 iOS、Android、桌面客户端、浏览器插件,或者 Electron 这类需要安装到用户设备上的客户端,那么发布流程就不能只按后端服务的思路来设计。

后端发布失败,大多数时候可以马上回滚。客户端发布失败,问题就复杂得多。用户已经下载的 App 不会自动消失,应用商店审核需要时间,不同用户升级节奏不一致,还有一部分用户可能永远停留在旧版本。所以客户端版本管理的核心,不只是“把代码发出去”,而是“在多个客户端版本长期共存的情况下保持系统可用”。

客户端发版通常要同时管理两个版本号。一个是给用户看的 version name,比如 2.4.1;另一个是给商店和系统识别的 build number 或 version code,它必须单调递增。Git tag 通常对应 version name,而每一次提交给 TestFlight、Google Play、企业分发平台或者自动更新服务的构建,都应该有唯一的 build number,并能追溯到具体 commit。

客户端发布还要有更长的冻结窗口。进入 release candidate 阶段后,最好从 main 或 develop 拉出 release 分支,只允许修复阻断发布的问题,不再混入新功能。测试通过后,用同一份产物进入灰度、审核和正式发布。这里我特别强调“同一份产物”,不要测试包是一份,正式包又重新 build 一份。重新 build 就意味着重新引入不确定性。

客户端灰度也和后端不同。后端灰度通常是按流量切分,客户端灰度通常是按用户升级比例切分,比如先给百分之一用户,再扩大到百分之五、百分之二十,最后全量。灰度期间要盯住崩溃率、启动耗时、关键接口错误率、支付或登录等核心漏斗。如果指标异常,第一反应不是继续扩大比例,而是暂停发布、下架新版本或者提高服务端开关的保护力度。

最容易被忽略的是客户端和后端 API 的兼容关系。后端接口不能假设所有客户端都已经升级,客户端也不能假设服务端永远保持旧行为。比较稳妥的做法是后端先兼容新旧协议,客户端再逐步升级,等旧版本占比足够低之后,再清理旧字段和旧接口。对关键功能来说,服务端 feature flag、最低支持版本、强制升级弹窗、降级配置都应该提前设计好,而不是事故发生后再临时补。

所以客户端发布更适合“版本分支加灰度开关”的模式。分支负责冻结代码,tag 负责标记发布版本,build number 负责区分构建产物,远程配置和 feature flag 负责控制功能是否真正对用户开放。

后端服务部署的完整链路

后端服务发布看起来比客户端轻,因为它通常可以随时部署、随时回滚。但也正因为后端离数据、流量和依赖系统更近,它的部署流程不能只停留在“把代码 merge 到 main”。

一个比较健康的后端发布链路应该是这样的:代码合并后触发 CI,跑单元测试、集成测试、静态检查和安全扫描;通过后构建不可变制品,比如 Docker image,并把 commit hash、版本号、构建时间写入镜像标签或元数据;然后部署到测试环境跑 smoke test;确认通过后,再通过人工批准或自动策略进入生产部署。

后端部署方式常见有 rolling update、blue-green deployment 和 canary release。rolling update 是逐批替换实例,简单直接;blue-green 是准备两套完整环境,流量从旧环境切到新环境,回滚时再切回去;canary 是先让少量真实流量进入新版本,观察指标稳定后再扩大比例。小服务用 rolling update 足够,核心链路或高风险改动更适合 canary 或 blue-green。

后端部署里最容易出事故的不是代码本身,而是数据库变更。应用可以回滚,数据库 schema 一旦改坏就很麻烦。所以数据库迁移要遵循向前兼容的思路。比如先新增字段但不删除旧字段,代码同时兼容新旧字段,等新版本稳定并确认旧代码不再访问后,再做清理。这通常被称为 expand and contract 模式。

配置和密钥也要从部署流程里单独拎出来。代码制品应该是不可变的,同一个镜像可以部署到 staging,也可以部署到 production,差异来自环境变量、配置中心和密钥管理,而不是重新编译一份“生产专用包”。如果每个环境都重新 build,一方面难以追溯,另一方面也很难保证测试过的东西就是上线的东西。

上线之后,发布流程还没有结束。后端服务必须有健康检查、日志、指标、链路追踪和告警。发布完成后至少要观察错误率、延迟、CPU、内存、队列积压、数据库连接数和核心业务指标。一个成熟的流水线不只是负责 deploy,也应该负责在关键指标异常时自动停止扩容、暂停灰度,甚至触发回滚。

所以后端服务的分支策略可以很轻,但部署策略不能轻。Git 分支回答的是“哪段代码可以发布”,部署系统回答的是“这段代码如何安全地进入生产流量”。这两个问题必须放在一起看。

版本号与发布的锚点

分支和部署之间还缺一个连接点,就是版本号。[[语义化版本]] 是目前最通用的约定,格式是主版本号、次版本号、修订号,比如 2.4.1。主版本号在有不兼容的 API 变动时加一,次版本号在新增向下兼容的功能时加一,修订号在做向下兼容的 bug 修复时加一。这套约定的好处是,别人光看版本号变化就能大致判断这次升级的风险有多大。

在 Git 里,版本通过 tag 来固化。每次正式发布,你都应该在对应的 commit 上打一个带版本号的 tag,比如 v2.4.1。tag 是不可变的锚点,它让你在任何时候都能精确地回到某个已发布版本的代码状态。当线上出问题需要回滚时,你不是慌乱地去找哪个 commit,而是直接回到上一个 tag。

我强烈建议把打 tag 这个动作和发布流程绑定。很多团队会配置成打 tag 自动触发生产部署,或者创建 GitHub Release 后触发发布流水线。这样 tag 既是版本标记,又是发布触发器,一举两得。

配合 tag,维护一份清晰的 changelog 也很重要。每个版本发布时,记录下这个版本包含哪些新功能、修了哪些 bug、有没有破坏性变更、是否需要数据库迁移、客户端最低兼容版本是多少。这份记录既是给用户看的,也是给未来的自己看的。当半年后你想知道某个行为是从哪个版本开始变的,changelog 就是最快的答案。

热修复的正确姿势

线上救火是每个团队都躲不掉的场景,而热修复流程恰恰最能检验你的分支策略是否健全。

糟糕的做法是,发现线上 bug 后,直接在开发分支上改,改完把开发分支上一堆没测完的东西一起推上线,结果旧 bug 没解决又带出新 bug。正确的做法是,热修复必须基于当前生产环境正在运行的那个版本,也就是从 main 分支、生产 tag 或 release 分支拉出一条独立的 hotfix 分支。

在这条 hotfix 分支上,你只做和这个 bug 相关的最小改动,不夹带任何其他东西。修复完成、测试通过后,合并回 main 并立即部署上线,同时打一个修订号加一的新 tag。

这里有一个容易被忽略的关键步骤:热修复的改动必须同步回开发主线。在 Git Flow 里是合回 develop,在主干开发里就是确保 main 已经包含这次修复。如果客户端还有 release 分支,也要把修复 cherry-pick 回对应的发布分支。否则下次正常发布的时候,这个 bug 会因为开发分支上没有这个修复而重新出现,这种回归 bug 特别隐蔽也特别让人抓狂。

客户端热修复还要多考虑一层:如果新版已经进入应用商店审核或者已经部分灰度,修复版本可能需要重新提审,这中间有时间差。所以客户端关键功能一定要能通过远程配置关闭,后端也要保留对旧客户端的兜底兼容。不要把所有希望都寄托在“赶紧发一个新包”上。

热修复流程的本质,是在紧急情况下依然保持隔离的纪律。越是着急,越容易图省事直接乱改,而这恰恰是把小事故变成大事故的根源。把热修复流程规范化,甚至写成脚本或者文档贴在显眼处,能在关键时刻救你一命。

我的实践建议

讲了这么多模型和流程,落到实处,我想给几条具体的、不同场景下的建议。

如果你是个小团队或者独立开发者,做的是 Web 应用或者服务,别犹豫,直接用 GitHub Flow。一条 main 分支,功能分支开 PR 合并,配好 CI 自动测试和部署。不要一上来就套 Git Flow 那套复杂规范,你会被繁琐的流程拖垮,而且大概率用不上它的多版本维护能力。等团队和复杂度真的涨上来了,再考虑往 GitLab Flow 演进,加上环境分支或发布分支。

如果你做的是有明确版本概念、需要给客户提供长期支持、或者要同时维护多个大版本的软件,那 Git Flow 或者 GitLab Flow 的 release 分支模式是合适的。它的繁琐在这种场景下是必要的代价,因为你需要清楚地知道每个客户、每个环境、每个版本分别停在哪里。

如果你做的是客户端产品,我会建议保留 release 分支。main 可以保持快速迭代,但每次准备发版时从 main 切出 release 分支,进入代码冻结、测试、灰度和正式发布。发布后 tag 固化版本,后续只允许 hotfix 进入这条 release 分支。这样既不影响主线继续开发,也能保证客户端版本稳定。

如果你做的是后端服务,我会建议把精力更多放在流水线和部署策略上。分支可以简单,但要确保构建产物不可变、部署可追踪、数据库迁移可回滚或至少可兼容、灰度可观察、异常可快速止血。后端的安全感不是来自很多分支,而是来自自动化测试、监控告警和可重复的部署流程。

无论用哪种模型,有几条纪律是通用的。保持 main 分支永远可部署,这是底线。功能分支要小而短命,一个分支只做一件事,尽快合并,别让它在角落里存活好几周。所有合并都要走 PR、CI 和 review,不要绕过流程直接 push。每次发布都要有 tag、changelog 和可追溯的构建产物。热修复一定要从生产版本出发,并且修完之后合回主线。

最后

分支管理没有银弹。Git Flow、GitHub Flow、GitLab Flow、主干开发,本质上都是在不同约束下对同一个问题的回答:如何让多人协作的代码,以尽量低风险、可追踪、可回滚的方式进入用户手里。

如果只看代码仓库,很多流程都显得差不多。但一旦把客户端版本发布、后端服务部署、数据库迁移、灰度策略、应用商店审核、线上监控这些因素放进来,你就会发现发布系统其实是一个整体。分支只是入口,真正决定稳定性的,是从 commit 到用户之间的每一道闸门。

我现在看一个团队的工程成熟度,已经不太会只问“你们用什么分支模型”。我更关心的是:main 是否随时可部署,发布是否有明确版本锚点,客户端和后端是否互相兼容,数据库变更是否可控,灰度指标是否有人看,出问题时是否能在几分钟内知道该回滚哪个版本。

能把这些问题回答清楚,分支模型反而只是一个自然结果。流程不是为了显得专业,而是为了在真正出问题的时候,让团队不用靠记忆和运气救火。