Go 依赖管理不依赖独立的 lock 文件:go.mod 的 require 列表本身就是构建清单,版本选择由 MVS(Minimal Version Selection,最小版本选择)算法决定。给定同样的 go.mod,构建列表恒定一致,因此不需要 package-lock.json 这类锁文件。把 go.sum 误解为"应当被 ignore 的锁文件",是围绕这套机制最常见的错误。

常见的依赖管理错误包括:使用 go get -u ./... 一次性升级整棵依赖树,go.mod 变更范围失控;在库的 go.mod 中保留指向本地目录的 replace 并发布,导致下游编译失败;从未运行 govulncheck,直到 CVE 披露才确认依赖树中早已存在漏洞;将 go.work 提交进仓库,使 CI 的构建行为发生隐性变化。这类问题在引入时通常没有直接后果,往往到发布、审计或下游 issue 阶段才集中暴露。

MVS:为什么 Go 不需要 lock 文件

MVS 的规则是:取满足所有模块需求的最低版本。例如模块 A 要求 pkg@v1.2.0,模块 B 要求 pkg@v1.3.0,MVS 选中 v1.3.0,即所有需求中的最高版本,而不是仓库中已发布的 v1.9.0。

这一设计有三个直接后果。其一,相同的 go.mod 输入必然产生相同的构建列表,独立锁文件因此没有必要存在。其二,构建使用贴近各模块作者实际测试过的版本组合,保真度高。其三,算法是一次不到 50 行的图遍历,不是 NP 难的约束求解问题。

升级与降级都是"修改需求图后重新计算":go get pkg@v1.5.0 在需求图中加入一条指向 v1.5.0 的边;go get pkg@v1.2.0 删除图中所有高于 v1.2.0 的版本,并回溯受影响的依赖。

go.mod 与 go.sum 都必须提交进版本库。go.mod 是事实上的锁文件;go.sum 记录每个依赖版本的加密校验和,go mod verify 依据它识别供应链篡改——缺少它,被污染的 module proxy 可以注入恶意代码而不被发现。

语义化版本和 v2 后缀

版本号遵循 vMAJOR.MINOR.PATCH:主版本变更破坏兼容,次版本向后兼容地新增功能,补丁版本修复缺陷。v0.x.x 不承诺任何兼容性,预发布版本(v1.5.0-beta.1)同样按不稳定对待。对仍停留在 v0 的库做深度适配之前,应评估其距离 v1 的成熟度。

v2 及以上,module 路径必须带 /vN 后缀,import 路径随之变化。这是 Go 的导入兼容性规则:不同主版本被当作完全不同的模块,可以在同一次构建中共存:

// go.mod
module github.com/example/pkg/v2

// 代码里
import "github.com/example/pkg/v2/subpkg"

三种特殊形态需要能够识别:未打 tag 的 commit 会解析成伪版本(pseudo-version),形如 v0.0.0-20210101120000-abcdef123456;+incompatible 表示某个 v2+ 模块未迁移到 /vN 路径;gopkg.in/yaml.v3 这类域名则用点号替代斜杠后缀。

加依赖、删依赖、升级

引入依赖之前,应依次确认三件事:标准库能否满足需求、license 是否兼容、维护状态是否健康。每个依赖都会增加攻击面、维护负担和二进制体积;net/http、slices、maps 已覆盖的能力,不应再引入第三方实现。Go 谚语 "A little copying is better than a little dependency" 表达的正是这一取舍:少量复制好过多一个依赖。确需引入时,优先从社区验证过的清单中选取,见热门库选型。

常用操作如下:

go get github.com/google/uuid@v1.6.0    # 指定版本
go get github.com/google/uuid@latest    # 显式最新
go get github.com/google/uuid@none      # 标记移除
go mod tidy                             # 补缺失、删未用

常规升级应使用 go get -u=patch ./...,避免动辄使用 -u ./...:-u 会把直接和间接依赖全部推到最新次版本,影响范围大到难以 review;patch 更新风险最低;需要连带测试依赖时加 -t。升级后应执行完整的验证流程,每一步都不可省略:

go get -u=patch ./...
go mod tidy
go test ./...
go vet ./...
govulncheck ./...

major 升级应单独评估,并先阅读 changelog。持久化、序列化、网络、认证、加密这几类库的破坏性变更,往往只体现在发布说明中,编译器无法发现。

凡改动过依赖的提交,应先 go mod tidy 再 commit。tidy 补上缺失、删除未用的模块,使 go.mod 与代码保持一致,diff 也更干净。go.mod 中长期残留无人使用的依赖,多数源于遗漏这一步。

Go 1.24 起,固定 CLI 工具版本应使用 tool 指令:

go get -tool golang.org/x/vuln/cmd/govulncheck@latest
go tool govulncheck ./...    # 以固定版本运行,保证团队与 CI 版本一致

仅当模块仍需兼容 Go <1.24 时,才保留 tools.go blank import 的旧方案。同理,代码不应使用比 go 指令更新的 API;升级语言版本应通过 go mod edit -go=1.xx 加 go mod tidy 显式完成,避免在无关改动中无意变更。

replace、exclude、retract

replace 只在主模块的 go.mod 中生效,发布库之前必须删除。当前模块被其他模块依赖时,replace 会被完全忽略——带着 => ../my-local-fork 发布,下游会直接编译失败。正当场景只有两类:本地调试 fork 与强制对齐版本:

// go.mod(主模块)
replace example.com/pkg => ../my-local-fork               // 本地调试
replace example.com/pkg v1.2.0 => example.com/pkg v1.3.1  // 强制版本

若需拉高某个间接依赖,优先使用显式 go get:go get example.com/transitive@v1.5.0 会在 go.mod 中写入一条显式 require,覆盖 MVS 的默认选择。exclude 用于必须跳过的问题版本:go mod edit -exclude=example.com/pkg@v1.3.0 会把对该版本的需求重定向到更高的可用版本。模块作者一侧的对应操作是 retract:被撤回的版本仍可下载,但 go get 默认不再选中:

// go.mod(被发布模块)
retract v1.0.0            // auth 存在严重 bug
retract [v1.1.0, v1.2.0]  // 区间撤回

go.work 和多模块仓库

跨 module 联调应使用 go.work,不应在 go.mod 中堆叠本地 replace。workspace 会让本地目录自动参与模块解析,各 go.mod 保持干净的 require:

go work init                 # 仓库根初始化
go work use ./services/auth  # 加入 module
go work sync                 # 同步 workspace

go.work 与 go.work.sum 均不应提交,workspace 只用于本地开发。它不影响模块发布后下游的解析结果——外部用户与 CI 看到的,仍然只有各模块自己的 go.mod。单 module 项目无需使用它;多模块仓库的组织方式见项目布局规范。

漏洞、过期依赖、自动更新

每次发布前应运行 govulncheck ./...。它通过可达性分析把报告收敛到"代码真正调用到的漏洞函数":依赖中存在漏洞但调用链不可达时,不会误报。输出包含漏洞编号、受影响模块、修复版本,以及从调用方代码到漏洞函数的调用轨迹:

govulncheck ./...                      # 源码扫描(最常用)
govulncheck -mode=binary ./bin/myapp   # 扫描编译产物
govulncheck -format json ./...         # JSON 输出,供 CI 消费

过期依赖与二进制体积同样应纳入监控。go list -u -m -json all | go-mod-outdated -update -direct 列出可升级的直接依赖;goweight 或 go-size-analyzer(gsa)按体积对二进制中的包排序归因。只被 *_test.go 引用的包不会链接进二进制,不构成体积问题。

自动更新可交给 Dependabot 或 Renovate,原则是小步自动、大版本人工:patch/minor 更新在 CI(test + lint + govulncheck)全绿后可自动合并;major 更新一律通过 PR 人工审查;安全更新不受版本段限制,应直接放行。Renovate 平台覆盖更广、配置更成熟,纯 GitHub 项目用 Dependabot 起步更快。工作流配置细节见CI/CD 规范。

依赖来源与冲突排查

排查依赖来源,应先使用 go mod why,必要时再使用 go mod graph:

go mod why -m github.com/some/module   # 到该模块的最短 import 链
go list -m all                         # 构建实际选中的版本
go mod graph                           # 完整需求图,每行一条边

需要图形化视图时,应将 modgraphviz 固定为 tool,再配合 graphviz 出图:绿色节点是 MVS 最终选中的版本,灰色是图中存在但未被选中的版本。

go get -tool golang.org/x/exp/cmd/modgraphviz@latest
go mod graph | go tool modgraphviz | dot -Tpng -o deps.png

冲突排查应遵循固定流程:查看需求图、定位引入方、优先升级直接依赖、必要时以 replace/exclude 作为最终防线、执行 tidy、以构建与测试验证。大多数冲突在前三步即可解决,因为上游通常已发布修复版本。replace/exclude 属于应急处理手段,不是长期方案,落地时应在 issue 中记录去向。

vendor 目录

默认不提交 vendor/。值得开启的场景只有两种:封闭构建(hermetic,无网络、无 proxy 访问),或要求超出校验和级别的可复现性——某些 CI 与 Docker 构建会从中受益。开启后必须遵守纪律:每次依赖变更都应重跑 go mod vendor 并提交 vendor/ 目录,否则构建产物与 go.mod 会逐渐脱节,此类不一致的排查成本很高。

速查清单

  • 应提交 go.mod 与 go.sum:MVS 之下 go.mod 就是锁文件,go.sum 负责完整性校验。
  • 应理解 MVS 取"满足所有需求的最低版本";升级与降级是修改需求图后重新计算,不得期望自动采用最新版本。
  • 应识别版本形态:v0.x.x 不承诺兼容,v2+ 路径带 /vN,以及 pseudo-version、+incompatible、gopkg.in/yaml.v3 三种特殊形态。
  • 引入依赖前应确认:标准库能否满足、license 是否兼容、维护状态是否健康。
  • 推荐以 pkg@v1.6.0 加依赖、pkg@none 加 tidy 删依赖;升级优先 -u=patch,major 升级必读 changelog。
  • 改动过依赖的提交应先执行 go mod tidy;升级后应依次运行 get → tidy → test → vet → govulncheck。
  • Go 1.24+ 固定 CLI 工具应使用 tool 指令;代码不得使用比 go 指令更新的 API。
  • replace/exclude 只在主模块生效,发布库前应删除;问题版本应用 retract 撤回;拉高间接依赖应用显式 go get。
  • go.work 与 go.work.sum 不得提交。
  • 发布前应运行 govulncheck ./...;CI 推荐以 -format json 消费结果。
  • 自动更新:patch/minor 在 CI 通过后可自动合并;major 应人工审查;安全更新应直接放行。
  • 依赖来源排查应使用 go mod why 与 go mod graph;体积归因推荐 goweight/gsa。
  • vendor 仅用于封闭构建;开启后每次依赖变更应重跑 go mod vendor 并提交目录。