
前言
Go Module 里最容易让人"感觉反直觉"的部分,往往不是下载、不是缓存,而是版本选择:你明明知道某个依赖已经发布了新版本,但 Go 就是不选;你只升级了一个依赖,却发现一串间接依赖跟着变了;你删掉一个依赖,版本却没有自动降回去。
这些现象不是 Go "不够聪明",而是它在严格执行一套非常工程化、可复现、可推导的规则:Minimal Version Selection(MVS)。
1. 常见问题:你到底在和什么现象打架?
先把大家最常遇到的"怪事"列出来。读完后面的 MVS,你会发现它们其实都是计算后的结果。
1.1 为什么不用最新版?
go.mod 里写的是 v1.2.0,但你明明知道 v1.5.0 已经发布,Go 却偏偏不选最新版。
1.2 为什么我升级了一个依赖,另一个依赖却没动?
你把某个直接依赖升级了,但另一个间接依赖版本完全没变化,甚至还停留在你觉得"有点老"的版本上。
1.3 为什么我只加了一个依赖,间接依赖却变了一串?
你只是新增(或升级)了一个依赖,结果 go.mod / go.sum 里一堆间接依赖版本跟着变化,看起来像"Go 顺便帮你全家桶更新了"。
1.4 为什么我删掉依赖,版本不会自动降回去?
你移除了某个依赖后,直觉上以为某些间接依赖可以"降回去",但 Go 并不会主动做这种回退。
1.5 // indirect 到底是什么意思?它和版本选择有什么关系?
你会看到 require 行后面标了 // indirect,它是否意味着"可选""不重要"?它会不会影响版本选择?
1.6 replace / exclude 会不会改变版本选择规则?
你写了 replace 或 exclude 之后,build list 变了;那这到底是 MVS 变了,还是输入被你改写了?
2. 什么是 MVS:Go 的版本选择到底在做什么?
简单理解:在满足所有依赖约束的前提下,为每个 module 选择"最低可用版本"。
2.1 版本选择的对象:module 版本,不是 package 版本
在 Go Module 语境里,版本号绑定的是 module,而不是某个 package。一个 module 里可以有很多包,但版本选择发生在 module 层面。
一个直观例子:假设 github.com/acme/kit 这个 module 内部同时提供 github.com/acme/kit/log 和 github.com/acme/kit/net/httpx 两个包;你的项目里 A 依赖用到了前者,B 依赖用到了后者。Go 最终只会为 github.com/acme/kit 选择一个版本(比如 v1.2.0 或 v1.5.0),然后这两个包都来自同一个 kit@vX.Y.Z 目录;你不能做到"kit/log 用 v1.2.0,而 kit/net/httpx 用 v1.5.0"这种包级别的版本选择。
2.2 版本选择的产物:build list
Go 最终要得到的是一个 构建列表(build list):
- 对每个
module path,最终只会有一个被选中的版本 - 构建过程中用到的依赖集合,以"路径 → 版本"的映射形式稳定下来
这也是为什么同一个 module path 不会同时出现两个版本进入同一次构建。
注意 :build list 不是一个具体文件 ,而是 Go 在执行 go build / go test / go list 这类命令时,根据主模块和各依赖的 go.mod 在内部计算出来的结果集合;它描述的是"这一次构建里,每个 module path 最终用哪个版本"。之后 Go 可能会把校验信息写入 go.sum,或在必要时调整主模块的 go.mod,但 build list 本身不以独立文件形式存在。
2.3 MVS 算 build list 时,"依据的数据"从哪里来:go.mod 的 require
显而易见:来自每个相关 module 的 go.mod,尤其是其中的 require。require 表达的不是"必须等于某版本",而是"我需要它至少到某个版本":
- 你可以把每条
require理解成:我依赖 X,并且 X 的版本不能低于 vA。 - 把所有 module 的这些
require串起来,就得到一张"最低版本要求"的依赖关系图。 - MVS 就是把这张图里的所有"至少版本"要求汇总起来,最终算出 build list。
2.4 MVS 核心规则
MVS 可以用一句话概括:
从主模块出发,把可达的依赖都纳入 build list;对同一个
module path,如果出现多个"最低版本要求",最终选其中最高的那个版本。
这也是最容易误读的地方:它叫 Minimal Version Selection,但又说"选最高"。
不矛盾,因为 "Minimal" 指的是:
- 在不违反任何已知最低版本要求的前提下,最终选择的每个 module 版本都尽可能低
- 它不追新;只在"被迫"的时候升级
2.5 三个关键名词:require、root requirements、build list
- require(最低要求) :某个 module@版本 在
go.mod里提出的"最低版本要求" - root requirements(根要求) :主模块
go.mod里直接写出来的那些require - build list:MVS 最终算出来的"路径 → 版本"的稳定结果
这里也顺便回答一个常见误会:// indirect 不会改变 MVS 的规则,它更像是一种"记录状态"的标注,而不是"版本约束类型"。
2.6 不回溯、不求全局最优
你可以把 MVS 想成一次"迭代扩展 + 必要时抬高"的过程:
- 从主模块的 root requirements 出发,先得到一份初始 build list。
- 读取 build list 里每个 module@版本 的
go.mod,把它们的 require 扩展进来。 - 如果某个 require 对已有
module path提出了更高的最低版本,就把 build list 里该路径的版本抬高到更高者。 - 只要发生了抬高,就继续读取"更高版本"的
go.mod(因为更高版本可能引入新依赖,或提高其他依赖的最低版本)。 - 直到不再发生任何抬高,build list 稳定。
结果只由依赖图决定,与解析顺序、机器环境无关,因此可复现。
2.7 为什么 v2+ 能共存:/vN 把"主版本冲突"变成"不同路径"
v2 及以上版本要求 module path 带 /vN,这使得不同主版本在 MVS 里天然是不同的 key:
- v1:
example.com/lib - v2:
example.com/lib/v2
于是它们可以在同一个 build list 中并存,各自沿着自己的依赖图扩展。
2.8 replace / exclude:你改的是输入,不是 MVS 规则
- replace:把依赖图里某个 module 的来源/版本强行改写成另一个 module 或本地目录;MVS 规则不变,但输入图变了。
- exclude:把某个具体版本从候选集合里剔除;同样不改变 MVS 规则,只是限制可选版本。
3. MVS 的特性:为什么它在工程上"好用但不讨好"
回到开头那些现象,其实都来自 MVS 的几个核心特性。
3.1 不追新:只满足最低要求("为什么不用最新版")
如果依赖图里没有任何地方提出"最低版本至少要到 v1.5.0",那 v1.2.0 依然是一个合法解,MVS 就不会主动把它抬高。
所以"为什么不用最新版"的答案通常是:没有人要求它更高。
3.2 被迫升级:升级有明确来源("一串间接依赖怎么变了")
当你新增/升级了某个依赖,build list 发生连锁变化,通常不是"Go 帮你顺便更新",而是:
- 新引入的 module@版本 的
go.mod提出了更高的最低版本要求 - MVS 必须把对应
module path抬高到满足所有最低要求的高度
因此,间接依赖的升级往往是"被迫"的,而且可以追溯到某个具体 require 的来源。
3.3 不主动回退("删依赖怎么不降级")
MVS 只负责在给定输入下"选版本",它不负责替你做"让版本变小"的策略决策。
只要主模块的 root requirements 里还显式要求了某个更高版本,或者依赖图中仍然存在更高的最低版本要求,build list 就不会自动降回去。
3.4 确定性:同输入同输出(可复现)
同一份依赖图、同一组 root requirements,MVS 的 build list 是唯一的。这让构建结果具备很强的可复现性,也减少了"换台机器就不一样"的随机性问题。
3.5 单调性:只要你在根上做升级,结果不会出现降级
在 MVS 下,如果你把 root requirements 里的某个版本抬高(只升级,不降级),那么最终 build list 只可能保持不变或整体向上抬升,不会出现"升级一个依赖导致另一个依赖被选到更低版本"的情况。
这让依赖升级更可控:你推动某个版本向上,其余变化要么没有,要么都有明确"最低版本要求"的原因。
3.6 // indirect
主模块没直接引用它,但你引用的第三方依赖(或依赖的依赖)里引用到了它,所以它被带进来了。它不等于"主模块完全没用到"。有时因为测试、构建标签、工具依赖等原因,主模块在某些场景也会用到,但在"主模块普通源码的直接 import 链"里看不到,于是被标成 indirect。
它不会让某个依赖变成"可有可无",更不会改变 MVS 的版本选择规则;真正决定版本的是依赖图里各处 require 提出的最低版本要求。
总结
MVS:最低版本要求 && 同路径取更高者。
下次再遇到"为什么选了这个版本",你只需要追问一句:是谁提出了这个最低版本要求? 然后沿着依赖图往回找,就能找到答案了。