Go Modules 从 1.11 版本开始成为官方依赖管理方案后,很多项目仍然会在某个命令执行后遇到依赖版本悄悄变化的问题。需要先澄清的是,go.sum 并不是一个类似 package-lock.json 的锁文件,它只保存模块内容的哈希值,用于完整性校验,并不会决定应该选择哪个版本。真正参与版本选择的是 go.mod 中的 require、replace、exclude 等指令,以及 Go 的最小版本选择算法。因此,想禁止某些依赖自动升级,不能只依赖 go.sum,而要同时管住命令、版本声明和构建环境。

下面分别从版本为什么会漂移、如何锁定直接与间接依赖、vendor 固化以及 CI 防回归几个层面展开。只有理解 Go Modules 的版本选择逻辑,才能避免在错误的方向上做无用功。
版本漂移从哪里来:触发升级的命令与MVS机制
在 Go Modules 中,执行 go get 是最常见的修改依赖方式。例如 go get -u 会把所有依赖升级到最新版本,go get ippipp.com/foo@latest 会单独升级 foo 并调整依赖树。go mod tidy 虽然主要用于清理未使用的依赖,但它也会在扫描导入路径后重新计算间接依赖版本,某些情况下会提升间接依赖的版本。即使只运行 go mod download,它只下载 go.mod 和 go.sum 中声明的模块,不会修改版本选择。
Go 的最小版本选择(Minimal Version Selection,MVS)算法会在构建时收集所有 require 声明,并选择满足所有约束的最高版本。这意味着即便你的 go.mod 中写死了 foo v1.2.0,如果另一个依赖 bar v1.3.0 的 go.mod 中声明需要 foo v1.3.0,最终构建仍会使用 foo v1.3.0。这是 Go 与 npm 等工具的重要差异,也是间接依赖可能悄悄升级的原因。很多人误以为只要直接依赖不改,整棵依赖树就不会动,实际上间接依赖的变化同样会改变最终构建产物。
// 查看当前构建会选择哪些模块版本 go list -m all // 查看某个模块的依赖路径 go mod graph | grep "ippipp.com/foo"
锁定直接依赖:写死版本、replace和exclude
要让某个依赖不自动升级,第一原则是在 go.mod 中使用精确的语义版本号。例如 require ippipp.com/foo v1.2.3,而不是 latest、master 或本地路径。语义版本格式为 vMAJOR.MINOR.PATCH,主版本号大于 1 时模块路径通常需要带 /v2 后缀。对于还没有正式发布 v1 标签的模块,可以使用伪版本 v0.0.0-20230101120000-abcdef123456,但要把时间戳和提交哈希写完整,防止被替换为更新的伪版本。
如果某个版本存在缺陷,但依赖树中的其他模块会把它带进来,可以使用 exclude 指令将该版本排除。例如 exclude ippipp.com/foo v1.2.4,这样即便某个模块声明需要 v1.2.4,MVS 也不会选择它,而是回退到更低的可用版本或报错提示无可用版本。另一个更直接的工具是 replace,它可以把依赖重定向到另一个版本或本地路径,用来临时修复问题或绕过有问题的版本。
下面是一个同时使用 require、exclude 和 replace 的 go.mod 片段。假设 foo 的 v1.2.4 存在严重 bug,团队决定全部项目禁用该版本,并临时使用本地修复版。
module ippipp.com/myapp
go 1.20
require (
ippipp.com/foo v1.2.3
ippipp.com/bar v1.4.0
)
exclude ippipp.com/foo v1.2.4
replace ippipp.com/foo => ../local/foo
使用 replace 到本地路径时,go.sum 不会记录本地模块的哈希,但会保留原模块信息。要注意 replace 指令只在主模块的 go.mod 中有效,不会传递到依赖方。因此如果作为库发布,replace 不会影响下游项目,此时应通过 exclude 或修复版本解决。日常开发中更推荐用 go mod edit -require=ippipp.com/foo@v1.2.3 命令来修改版本,它可以避免手工编辑格式错误。
间接依赖与go.sum:锁定整棵依赖树
直接依赖容易看到,间接依赖的升级往往更隐蔽。Go 1.17 之后,go.mod 会为间接依赖添加 // indirect 注释。例如 require ippipp.com/baz v0.9.1 // indirect。即使你的代码没有直接导入 baz,它也可能被 bar 引入。go mod tidy 会根据当前模块图重新整理这些间接依赖,如果某个间接依赖在缓存中已经是新版本,它可能提升 require 中的版本号。因此禁止间接依赖升级,需要额外关注 indirect 标记的模块。
要查看某个间接依赖为什么会出现在构建列表里,可以使用 go mod why -m ippipp.com/baz 或 go mod graph | grep ippipp.com/baz。排查清楚后,如果确定需要固定版本,可以直接在 go.mod 的 require 块中为间接依赖也写上精确版本。MVS 会选择所有 require 中最高的版本,因此只要间接依赖的版本不超过你写的版本,你写什么就固定什么;但如果其他模块声明了更高版本,仍然会升级。这时可使用 replace 或 exclude 进一步限制。
go.sum 文件虽然不是锁文件,但它是构建可重复性的第二道防线。go.sum 记录每个模块特定版本的哈希值,包括 go.mod 的哈希。即使 go.mod 中版本相同,如果模块内容被篡改或重新打包,构建会因为哈希不匹配而失败。可以定期运行 go mod verify 检查本地模块缓存与 go.sum 是否一致。CI 中也应提交 go.sum,不要加入 .gitignore。
// 校验本地模块缓存 go mod verify // 查看为什么需要某个模块 go mod why -m ippipp.com/baz // 查看模块依赖图的指定分支 go mod graph | grep "ippipp.com/baz"
使用vendor目录彻底冻结依赖
如果项目对稳定性要求极高,或者构建环境无法访问外部模块代理,可以使用 vendor 模式。执行 go mod vendor 会把构建所需的所有依赖源码复制到项目根目录的 vendor 目录中,并生成 vendor/modules.txt 记录模块列表和校验信息。Go 1.14 及以上版本默认在存在 vendor 目录且 go.mod 中 go 版本大于等于 1.14 时,自动使用 vendor 模式构建。
vendor 模式的最大好处是:即使 go.mod 中声明了某个版本,实际编译仍以 vendor 目录中的代码为准,构建过程不会访问网络,也不会受远程仓库删除、版本强制更新或代理缓存失效的影响。因此,只要团队把 vendor 目录提交到版本控制,就能实现最彻底的依赖冻结。代价是仓库体积会变大,更新依赖时需要额外的 vendor 同步步骤。
推荐的更新流程是:先修改 go.mod 中的版本,执行 go mod tidy,确认 go.mod 和 go.sum 变化符合预期,然后运行 go mod vendor 重新生成 vendor 目录,最后一起提交。如果只修改 go.mod 但不重新 vendor,而存在 vendor 目录时构建仍会使用旧代码,可能造成版本声明与实际编译不一致。可以在 CI 中使用 go mod vendor 后 git diff 判断是否有人漏提交 vendor 变化。
// 生成 vendor 目录 go mod vendor // 使用 vendor 模式构建 go build -mod=vendor ./...
防回归:设置只读模式与CI检查
即使开发者都了解锁版本的方法,团队协作中仍然可能出现误操作。比如有人为了调试临时执行 go get package@latest,然后忘记回滚就提交了 go.mod。为了减少这种情况,可以将本地和 CI 的默认行为设为只读。Go 提供 -mod=readonly 模式,在该模式下构建,如果发现需要修改 go.mod,会直接报错而不是自动写入。可以在构建命令中统一使用 go build -mod=readonly ./...,这样任何依赖缺失或版本不一致都会暴露。
在 CI 流水线中,建议增加一个 go.mod 与 go.sum 一致性检查步骤。使用 git diff --exit-code -- go.mod go.sum vendor/modules.txt 就可以判断本次提交是否包含未预期的依赖变更。如果团队使用 Go 1.18 及以上版本,还可以运行 go mod tidy -diff,它只输出差异而不写入文件,很适合作为 CI 检查命令。下面是一个常见的检查脚本。
go mod tidy -diff
if [ $? -ne 0 ]; then
echo "go.mod or go.sum is not tidy"
exit 1
fi
git diff --exit-code -- go.mod go.sum vendor/modules.txt
更严格的做法是把依赖升级纳入代码评审流程:任何修改 go.mod 或 go.sum 的合并请求,必须由负责人确认版本变化原因。对于核心基础库,可以设置 CI 白名单,当检测到 require 中某个模块版本超出批准范围时直接失败。这样结合写死版本、vendor 固化、只读构建和自动检查,就能有效禁止 Go 自动升级某些依赖,保证构建结果可预期、可重复。