2358 字
约 7 分钟
16
S06-A17 · 构建产物的版本号:1.2.3 是谁定的

一个内部库的版本从 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. 自测

  1. 三段号各自承诺什么?"不破坏"的三项定义是什么?
  2. 五类易误判为 patch 的变更里,哪一项最隐蔽?为什么"改默认值"会影响下游?
  3. ^1.2 与 1.2.7 分别控制什么?为什么"缺一不可"?
  4. 1.2.4-SNAPSHOT 在依赖解析里可能造成什么?为什么"本地正常 CI 失败"?
  5. 为什么"线上是 1.2.3"这句话信息量低?该补什么?
  6. 判断"是否需要严格 SemVer"的那个问题是什么?
S06-A17 · 构建产物的版本号:1.2.3 是谁定的
https://www.clxhxhhr.top/posts/6522/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。