一个内部库的版本从 1.2.3 发到 1.2.4,而其中有一个不兼容的方法签名变更。下游三个服务升级后编译失败,因为"补丁版本应该兼容"是所有人的默认预期——而这个默认预期,正是版本号唯一的作用。
版本号的本质是一份兼容性承诺,不是"发布的次数"。更麻烦的是"承诺"这件事是单向的:下游按你的承诺设置策略,而你看不到他们的策略。所以一次"以为是 patch"的发布,可能在别人那里触发了自动升级。
而多数团队把它当"计数器"用,于是每次发版随便加一,直到某天一次"小版本升级"引发了全量故障。
1. 三段号各自承诺什么
| 段 | 承诺 |
|---|---|
| major(1) | 有破坏性变更 |
| minor(2) | 只新增,不破坏 |
| patch(3) | 只修 bug |
这三条是语义化版本(SemVer)的核心。违反它的代价不在发布时,而在下游的"自动升级"上:因为下游是按"承诺"设置升级策略的,承诺一破,策略就变成了风险。
而"不破坏"的定义要说清:编译能过、行为不变、默认值不变。三项里任何一项变了,都是 major。还有一个"第四项"值得加上:性能。把某方法从 O(1) 改成 O(n),编译能过、行为不变、默认值不变——但它足以让下游的服务在上量后崩掉。严格 SemVer 不要求声明性能,这是它的一个已知缺口。
判断"算不算破坏"有一个可操作的检查表,发布前逐项过一遍:
// 破坏性变更的六项检查(命中任一 → major)
1 方法/字段被删除或改名
2 参数类型/返回类型变化(含 int → long)
3 默认值变化(超时、重试次数、开关)
4 异常类型或语义变化(原来抛 A,现在抛 B)
5 序列化字段名/格式变化
6 性能量级变化(O(1) → O(n))
// 只增不改(新增方法、新增可选参数)→ minor
2. 五类容易被误判为 patch 的变更
| 变更 | 实际级别 |
|---|---|
| 改默认参数值 | minor(行为变) |
| 收紧参数校验 | minor 甚至 major |
| 依赖大版本升级 | 可能 major(传递) |
| 改日志格式 | patch(不算破坏) |
| 改序列化字段名 | major |
第一项最隐蔽:"默认超时从 30s 调到 10s"只是一个数字,但它会改变所有未显式设置的下游行为。
第三项的处理有个实用规则:依赖升 major 时,你的版本至少要升 minor,并在变更说明里写"传递依赖行为可能变化"。否则下游会以为"你只是修了个 bug"。
第三项常被忽略:你自己没改 API,但依赖升了 major,而它的行为变化会通过你传递出去。
3. 四种常见版本策略
| 策略 | 适用 |
|---|---|
| SemVer(三段) | 库、对外 SDK |
| CalVer(日期) | 应用、内部服务 |
| 单调整数 | 内部工具、无兼容承诺 |
| 不版本化(latest) | 严格同仓同步 |
第一项的前提是"有外部使用者,且他们要按承诺自动升级"。没有这个前提,SemVer 的严格性就是负担。
第二项(CalVer)在应用上很实用:**"2026.11.10"比"1.2.3"更能说明"这是哪天的构建"**第二项的另一个好处是"减少争论":应用不需要承诺兼容,所以也就不用每次讨论"这算 minor 还是 major"。而那个讨论的成本往往高于版本号本身的价值。
,而应用通常不需要"兼容承诺"——大家用的都是同一个版本。
版本号怎么"自动产生",也有三种做法,各有代价:
// 三种自动版本策略
1 手工打 tag:准确,但容易忘、容易打错
2 按 git 提交数自增(1.2.{count}):不会忘,但"数字含义"消失
3 按提交信息推导(feat/fix/BREAKING):准确且自动,要求遵守规范
// 常见折中:manual major + auto patch(major 由人决定,patch 随提交走)
4. 版本号与依赖解析
| 机制 | 影响 |
|---|---|
范围依赖(^1.2) |
自动吃 minor/patch |
精确依赖(1.2.3) |
手动升级 |
| 锁文件 | 锁定实际解析结果 |
第三项(锁文件)还有一层"审计"价值:出了事故时,"当时解析到的是哪个版本"是可回答的。而没有锁文件时,这个问题只能靠猜。
第一项是 SemVer 存在的理由:允许"自动吃 patch"是建立在"patch 不破坏"的承诺上。承诺一破,这个机制就从"省事"变成"随机故障"。
第三项是"可复现"的保障:锁文件锁定的是"实际解析结果",而声明的范围只是"允许什么"。
// 声明 vs 锁定(两件事)
声明:mylib = "^1.2" → 允许 1.2.x 到 1.x(不含 2.0)
锁定:mylib = 1.2.7 → 本次构建一定用 1.2.7
// 结论:范围用于"允许升级",锁文件用于"复现构建";缺一不可
产物里"嵌什么信息"决定了排障能力,这一项常被省掉:
// 构建时嵌入(三项,成本极低)
version=1.2.3
git_sha=9f2c1a7
build_time=2026-11-10T19:00:00Z
// 启动日志打印三者 → "线上跑的是哪个 commit"成为可回答问题
5. 预发布与构建号
| 后缀 | 含义 |
|---|---|
-SNAPSHOT |
开发中,不保证稳定 |
-rc.1 |
候选发布 |
-beta |
功能可用,接口可能变 |
+build.42 |
构建元数据(不参与排序) |
第一项的坑很具体:**1.2.4-SNAPSHOT 在依赖解析里可能"大于" 1.2.3**第一项还有个"共享仓库"版本:多人往同一个 SNAPSHOT 版本上发布,于是"同一个版本号、不同内容"——而依赖方无法区分,只能"清缓存重来"。
,于是某人本地跑的是快照版,而 CI 跑的是正式版——两者行为不同。
第四项不参与排序,所以**"用构建号区分版本"在依赖解析里无效**,它只是元数据。
6. 排错:五种"版本号引起的问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 补丁升级后编译失败 | 破坏性变更打成 patch | 改 major,加弃用期 |
| 本地正常 CI 失败 | SNAPSHOT 混入 | 禁 SNAPSHOT 依赖 |
| 升级后行为微变 | 默认值改了 | 视为 minor,写变更说明 |
| 两次构建产物不同 | 无锁文件 | 加锁 + 固定版本 |
| 无法判断"哪个版本在线" | 只记版本号不记 commit | 打 git sha |
第二项(本地正常 CI 失败)的排查很费时间,因为错误信息不会说"你用了快照版"。所以最有效的做法是"在构建时禁止 SNAPSHOT 依赖",让它直接失败。
第五项是运维必需:"线上是 1.2.3"这句话信息量很低,因为同一个版本号可能对应多次构建。所以产物里要嵌 git sha,并在启动日志里打印。
"弃用"是版本号体系里的另一半,它决定了 major 升级能否平滑:
// 两段式弃用:先标 deprecated,下个 major 再删
@Deprecated(since = "1.3.0", forRemoval = false)
public void oldMethod() { newMethod(defaultArgs); } // 1.3:保留并委托
// 2.0:真正删除;此时下游已有至少一个 minor 周期的迁移时间
7. 边界:什么情况不必严格 SemVer
| 场景 | 为什么 | 做法 |
|---|---|---|
| 单仓应用(无外部消费者) | 无兼容承诺 | CalVer 或整数 |
| 内部工具(同仓同步升级) | 一起改一起发 | 不版本化 |
| 一次性脚本 | 无重用 | 不版本化 |
| 已有强约束(平台要求) | 遵循平台 | 跟随 |
判断口径一句话:有没有"别人按你的版本号决定是否升级"。最后一条与"沟通"有关的边界:版本号只是承诺的一半,另一半是变更说明。没有说明的 major 升级,下游只能"全量回归";有说明,才能"只测那几处"。所以"写变更"不是形式,它决定了升级成本。
有,SemVer 就是契约,必须严格;没有(同一批人一起改),版本号只是标签。
8. 自测
- 三段号各自承诺什么?"不破坏"的三项定义是什么?
- 五类易误判为 patch 的变更里,哪一项最隐蔽?为什么"改默认值"会影响下游?
^1.2与1.2.7分别控制什么?为什么"缺一不可"?1.2.4-SNAPSHOT在依赖解析里可能造成什么?为什么"本地正常 CI 失败"?- 为什么"线上是 1.2.3"这句话信息量低?该补什么?
- 判断"是否需要严格 SemVer"的那个问题是什么?