52.2 静态检查
一句话
静态检查不是“多跑几个 linter”,而是一条按成本排序的漏斗:格式 0.1 秒、vet 1 秒、lint 10 秒、深度数据流分钟级;便宜的先跑挡住机械问题,贵的放到合并后,中间用 ratchet 只挡新增——只加规则不分层,最后一定被人绕过。
先破除四个误区
| 误区 | 现实 |
|---|---|
| “lint 只管风格,不影响正确性” | staticcheck 的 SA 类直接抓运行时 panic:nil map 写入、非法正则、无效的同步用法。同一份代码 go vet 返回 0,staticcheck 报 SA5000 + SA1000(见下) |
| “规则开得越多越安全” | 每条规则都有误报和 triage 成本。checks: ["all"] 会把 ST1000(包注释)也打开,300 个包就多几百条噪声,真正的问题被埋掉 |
| “先把存量修完再开 lint” | 存量 400 条、每周新增 20 条,修完要几个月,这期间零保护。正确做法是基线冻结 + 只挡新增 |
| “linter 版本随便升” | linter 与编译器版本强耦合。本机 Go 1.27.1 + golangci-lint 1.52.2 直接报 package unsafe is not in std,一条规则都没执行——CI 显示“失败了”,很容易误判成代码问题 |
四层漏斗:成本决定位置
| 层 | 工具 | 单次耗时 | 能抓什么 | 放在哪 |
|---|---|---|---|---|
| L0 格式 | gofmt / goimports / gofumpt | 0.02–0.1 s | 缩进、导入顺序、换行 | pre-commit + CI 第一秒 |
| L1 编译 + vet | go build / go vet | 0.3–3 s | 类型错误、printf 参数、锁值拷贝 | 每次 push |
| L2 lint / SA | staticcheck / golangci-lint | 1–30 s | 死代码、nil map、非法正则、错误未检查 | 每次 push |
| L3 深度分析 | gosec / Semgrep / CodeQL | 1–10 min+ | 污点传播、注入、越权、硬编码密钥 | 合并后 / 夜间 |
🔑 顺序是经济学,不是偏好:0.1 秒能挡的问题留到 10 分钟才发现,等于用最贵的资源做最便宜的判断。
静态检查管“代码写得对不对”,依赖漏洞管“引用的东西安不安全”,两条门禁在 52.3 依赖与漏洞扫描 分家,不要混在一个 job 里。
按 100 个 PR/天算:把一道 1 分钟的检查从 L3 挪到 L2,省 100 分钟/天 ≈ 50 人时/月;如果它每次占 5 分钟机时,机时成本是 100 × 5 × 30 × $0.008 ≈ $120/月(单价沿用 52.1 构建与缓存 的示例)。人等的时间远贵于机器时间,所以先挪检查位置,再谈并行。
数字:vet 与 staticcheck 不是一回事
最小复现(example.com/checkout,Go 1.27.1):
package checkout
import "regexp"
var couponRE = regexp.MustCompile("^[A-Z0-9-{2,16}$") // 量词写错位置
func MarkUsed(used map[string]bool, code string) bool {
var local map[string]bool
local[code] = true // nil map 写入:第一次调用就 panic
used[code] = true
return couponRE.MatchString(code)
}
$ go vet ./...
(无输出,exit 0)
$ staticcheck -f stylish ./...
coupon.go
(5, 35) SA1000 error parsing regexp: missing closing ]: `[A-Z0-9-{2,16}$`
(9, 2) SA5000 assignment to nil map
✖ 2 problems (2 errors, 0 warnings, 0 ignored)
vet 保守,staticcheck 做数据流:vet 只做编译器不做的“高置信度”检查,宁可漏报不误报;staticcheck 会跨语句推断常量、追踪 map 的来源,所以能提前发现“上线第一次调用就 panic”的代码。
两者都不执行代码,因此有四类问题它们永远抓不到:
| 抓不到的东西 | 该用什么 |
|---|---|
| 并发时序、数据竞争 | go test -race(见 15.7 竞态检测与数据竞争) |
| 函数级行为错误(边界、分支) | 单元测试(见 53.2 单元测试) |
| 数据库约束、外部服务契约 | 集成测试(见 53.3 集成测试) |
| 业务规则“不该这么写” | 代码评审(见 51.2 代码评审) |
⚠️ 把 lint 当测试用,是静态检查最常见的越界:它能证明“没有某类确定性错误”,不能证明“功能正确”。
三种门禁模式
| 模式 | 做法 | 适用 | 代价 |
|---|---|---|---|
| 全量阻断 | 存量问题也必须清零才能合并 | 新项目、存量 < 50 条 | 存量仓库第一天就红 |
| 只报告 | 跑但不阻断 | 最多两周的过渡期 | 没人看,存量继续涨,三个月后规则名存实亡 |
| ratchet 基线 | 存量冻结,新增阻断 | 绝大多数存量项目 | 要维护基线文件 |
| diff 门禁 | 只查本次 diff 新增行 | 大仓、多语言单仓 | 依赖 git 历史;浅克隆或路径不一致会静默漏报 |
用数字看三种策略(diff 门禁只影响增量,不改变存量,见下节)。假设存量 400 条,每周新增 20 条、修复 10 条:
| 策略 | 第 1 周 | 第 40 周 | 新增问题的命运 |
|---|---|---|---|
| 全量阻断 | 流水线永久红 | 仍红 | 团队加 \|\| true,等于关闭 |
| 只报告 | 400 | 400 + 30×20 = 1000 | 无人处理 |
| ratchet | 400(冻结) | 400 − 40×10 = 0 | 新增 0,存量按预算下降 |
ratchet 的关键不是工具,而是预算:每周固定还 10 条技术债,40 周清零;只挡新增不还旧债,技术债就永久化。还债排期属于项目管理,见 58.2 技术债管理。
ratchet 的最小实现
golangci-lint --new-from-rev 能挡 diff,但它依赖 git 历史与路径,出问题时是静默的(下文有实例)。更可控的做法是自己维护基线:把“当前所有问题”冻结成文件,只对“不在基线里的新问题”返回失败。
package main
import (
"bufio"
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// staticcheck 默认输出:/path/file.go:12:5: message (SA5000)
var lineRE = regexp.MustCompile(`^(.+):(\d+):(\d+): (.+) \(([A-Z]+[0-9]+)\)$`)
type finding struct {
file, code, msg string
line int
}
// key 不含行号:插入几行代码不应该让整份基线失效。
func (f finding) key() string { return f.file + "\t" + f.code + "\t" + f.msg }
func parse(path string) ([]finding, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
root, _ := filepath.Abs(".")
var out []finding
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
m := lineRE.FindStringSubmatch(line)
if m == nil {
// 基线文件是 file\tcode\tmsg,也接受,便于 -update 直接生成。
if parts := strings.Split(line, "\t"); len(parts) == 3 {
out = append(out, finding{file: parts[0], code: parts[1], msg: parts[2]})
}
continue
}
var ln int
fmt.Sscanf(m[2], "%d", &ln)
// 归一化到仓库相对路径:CI 与本机的绝对路径不同,基线才能通用。
file := m[1]
if rel, err := filepath.Rel(root, file); err == nil && !strings.HasPrefix(rel, "..") {
file = rel
}
out = append(out, finding{file: file, line: ln, code: m[5], msg: m[4]})
}
return out, sc.Err()
}
func main() {
baselinePath := flag.String("baseline", "lint-baseline.txt", "基线文件")
reportPath := flag.String("report", "lint-report.txt", "本次扫描输出")
update := flag.Bool("update", false, "用本次结果覆盖基线")
flag.Parse()
report, err := parse(*reportPath)
if err != nil {
fmt.Fprintln(os.Stderr, "读取报告失败:", err)
os.Exit(2)
}
base, err := parse(*baselinePath)
if err != nil && !os.IsNotExist(err) {
fmt.Fprintln(os.Stderr, "读取基线失败:", err)
os.Exit(2)
}
known := make(map[string]bool, len(base))
for _, f := range base {
known[f.key()] = true
}
seen := make(map[string]bool, len(report))
var added []finding
for _, f := range report {
seen[f.key()] = true
if !known[f.key()] {
added = append(added, f)
}
}
var fixed int
for _, f := range base {
if !seen[f.key()] {
fixed++
}
}
if *update {
keys := make([]string, 0, len(seen))
for k := range seen {
keys = append(keys, k)
}
sort.Strings(keys)
out, _ := os.Create(*baselinePath)
defer out.Close()
for _, k := range keys {
fmt.Fprintln(out, k)
}
fmt.Printf("基线已更新:%d 条\n", len(keys))
return
}
fmt.Printf("基线 %d 条,本次 %d 条,新增 %d 条,已修复 %d 条\n", len(base), len(report), len(added), fixed)
for i, f := range added {
if i == 10 {
fmt.Printf("……另有 %d 条新增未显示\n", len(added)-10)
break
}
fmt.Printf(" NEW %s:%d %s %s\n", f.file, f.line, f.code, f.msg)
}
if len(added) > 0 {
os.Exit(1)
}
}
实测:
$ ./lintgate -baseline baseline.txt -report report.txt
基线 2 条,本次 2 条,新增 0 条,已修复 0 条
$ ./lintgate -baseline baseline.txt -report report.txt # 报告里多了一条 S1002
基线 2 条,本次 3 条,新增 1 条,已修复 0 条
NEW pkg/c.go:3 S1002 should omit comparison to bool constant, can be simplified to x
$ echo $?
1
五条使用规则:
- 基线只在主分支更新。在 PR 里跑
-update,等于把新增问题洗白。 - key 不含行号,但含文件名;文件重命名会产生“新增 + 修复”各一条,接受它,重新基线即可。
- 路径归一化到仓库相对路径:CI 输出绝对路径、本机输出相对路径时,同一条问题会被当成两条。
- 基线文件进仓库,与代码同一次评审;基线变动必须有人看 diff。
- 报告要全量生成,不要先过滤再进 gate——过滤规则本身就是漏报源。
diff 门禁:一个会静默失效的坑
diff 门禁看起来更省事:golangci-lint run --new-from-rev=origin/main。但它依赖“linter 看到的路径”和“git 看到的路径”是同一条字符串,一旦不一致,过滤结果会是 0 issues——不是报错,是静默通过。
本机复现(golangci-lint v2.15.0,macOS,/tmp 是 /private/tmp 的符号链接):
$ cd /tmp/sc-git && golangci-lint run --new-from-rev=HEAD ./...
0 issues.
$ cd /private/tmp/sc-git && golangci-lint run --new-from-rev=HEAD ./...
c.go:6:2: SA5000: assignment to nil map (staticcheck)
1 issues:
* staticcheck: 1
同一个仓库、同一份代码,只因为工作目录经过符号链接,门禁从“抓住 1 条”变成“0 条”。
🔑 所有 diff 门禁都必须做 canary 自检:在 CI 里保留一个已知问题(或临时注入),确认门禁会红。只有“会红”的门禁,“绿”才有意义。
同类坑还有两个:
| 坑 | 后果 | 修法 |
|---|---|---|
浅克隆(fetch-depth: 1) | origin/main 不存在或不是 merge-base,比较对象错 | 显式 fetch base 分支;大仓用 git fetch --depth=1 origin main |
| 未跟踪/生成文件 | --new 只看已有改动,新文件可能被跳过 | CI 里先 git add -A,或改用全量 + 基线 |
配置:最小可用集(v2 格式)
version: "2"
linters:
default: standard
enable:
- staticcheck
- ineffassign
- unused
exclusions:
generated: lax
formatters:
enable:
- gofmt
- goimports
golangci-lint config verify 返回 0(无输出)。作为对照,把 linters.settings.staticcheck.checks 设成 ["all"] 后,同一份代码变成 3 条,多出来的那条是 ST1000“包注释”:
coupon.go:1:1: ST1000: at least one file in a package should have a package comment (staticcheck)
coupon.go:5:35: SA1000: error parsing regexp: missing closing ]: `[A-Z0-9-{2,16}$` (staticcheck)
coupon.go:9:2: SA5000: assignment to nil map (staticcheck)
3 issues:
* staticcheck: 3
三条经验:
- 默认集优先:
standard已含 errcheck / govet / ineffassign / staticcheck / unused,先跑默认,再按误报记录逐条加。 - 格式也走 CI:
formatters让 gofmt/goimports 变成门禁,而不是评审意见。 - 生成代码排除:
exclusions.generated: lax,否则每次go generate都新增几百条。
版本必须锁死。本机实测:Go 1.27.1 + golangci-lint 1.52.2(旧版)直接启动失败:
level=error msg="Running error: context loading failed: failed to load packages: ...
stderr: package unsafe is not in std (/opt/homebrew/Cellar/go/1.27.1/libexec/src/unsafe)"
升级 Go 必须同批升级 linter,并在镜像里固定版本(@v2.15.0 或 digest),不要用 latest。
抑制://nolint 的三条规矩
- 必须写原因 + 跟踪号:
//nolint:errcheck // 关闭失败不影响主流程,见 ISSUE-123。 - 禁止文件级 / 包级 nolint:一行关掉全部规则,等于在这份文件里删除 lint。
- 抑制要可审计:每月统计 nolint 数量与原因,持续增长就是规则设计或代码质量的信号。
⚠️ 无原因的 nolint 等价于删除规则;半年后没人敢删,lint 名存实亡。
生产落地:指名 → 可抄 → 可验 → 可退
指名:golangci-lint v2.15.0(聚合器,本机验证)+ staticcheck 2026.2.2(单独跑,输出更干净)+ go vet(零配置)。多语言或跨仓统一规则用 Semgrep / CodeQL,它们做代码级污点分析,属于 L3,规则与流程见 38.9 安全开发。替代品:只要低误报可只用 staticcheck;要可配置风格规则可用 revive。
可抄(Makefile,版本与本机验证一致):
GOLANGCI := v2.15.0
STATICCHECK := 2026.2.2
tools:
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI)
go install honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK)
lint:
gofmt -l . | tee /tmp/gofmt.out
test ! -s /tmp/gofmt.out
go vet ./...
golangci-lint run --timeout=5m ./...
lint-ratchet:
staticcheck ./... > lint-report.txt || true
go run ./tools/lintgate -baseline lint-baseline.txt -report lint-report.txt
可验(本机真实输出):
$ golangci-lint config verify
(无输出,exit 0)
$ golangci-lint run ./...
coupon.go:5:35: SA1000: error parsing regexp: missing closing ]: `[A-Z0-9-{2,16}$` (staticcheck)
coupon.go:9:2: SA5000: assignment to nil map (staticcheck)
2 issues:
* staticcheck: 2
exit 1
$ go vet ./...
(无输出,exit 0)
这组对比就是价值证明:vet 放过的 nil map 写入,lint 抓住了。
可退:
- 紧急发布:
LINT_BLOCKING=false时仍扫描但放行,必须同时开 ticket 并写进发布检查单。 - 规则大面积误报:从
enable列表移除该规则(配置回滚),不要用 nolint 满地补。 - 工具链升级失败:镜像 digest 回滚到上一版,linter 与 Go 版本成对回滚。
- 门禁本身误伤发布:按 55.1 发布策略 的回滚流程处理,不要临时删检查步骤。
golangci-lint run ./... || { [ "${LINT_BLOCKING:-true}" = "false" ] && exit 0; exit 1; }
参数怎么给
| 你的情况 | 优先做 |
|---|---|
| 新项目 / 存量 < 50 条 | 全量阻断,只开默认集 + SA 类 |
| 存量 50–500 条 | ratchet 基线 + diff 门禁双保险 |
| 存量 > 500 条 | 先只开 3–5 条零误报规则,每月加一条,同时排还债预算 |
| 单仓 > 1000 包 | 只跑受影响包 + 合并后全量(见 51.4 单体仓库 vs 多仓库) |
| CI 已 > 20 分钟 | lint 拆成独立并行 job,不要串在构建后(见 52.4 流水线设计与耗时优化) |
| 多语言仓库 | 每种语言原生 linter + Semgrep 做跨语言规则 |
| 有合规 / 审计要求 | 报告转 SARIF 上传,保留 90 天 |
| 团队 < 5 人 | 不要维护自定义规则,默认集足够 |
反模式
| 反模式 | 后果 |
|---|---|
| 存量仓库直接全量阻断 | 第一天就红,三天后加 \|\| true,永久失效 |
checks: ["all"] | 文档/风格噪声淹没真问题,开发者批量 nolint |
文件级 //nolint | 一行关掉所有规则,等于没开 |
| 只跑 lint 不跑 vet / build | 类型错误在更晚阶段才暴露,反馈变慢 |
linter 用 latest | 昨天绿今天红,无法复现,也无法回滚 |
| 只查 PR 不查主分支 | 合并后引入的问题没人负责 |
| 把 lint 当测试 | 并发、集成、业务规则问题照样上线 |
| 报告只进日志、不存基线 | 无法 ratchet,也无法证明“本周没新增” |
| 生成代码不排除 | 每次生成新增几百条,基线反复失效 |
| 用 lint 数量做个人绩效 | 刷分、藏问题、拆小文件,指标立刻失真 |
自检
Q1 同一份代码,go vet 返回 0,staticcheck 报 2 条。既然 staticcheck 更强,为什么还要跑 vet?
▸ 答案:两者不是替代关系,是漏斗的两层。vet 是编译器团队维护的高置信度检查,几乎零误报、随 Go 版本免费获得;staticcheck 做数据流分析,能抓 vet 不管的 nil map、常量正则、无效赋值,但规则更多、误报和版本耦合也更多。正确顺序是 gofmt → vet → staticcheck/golangci-lint:vet 用 1 秒挡住类型层问题,再让更贵的分析只处理剩下的代码。少了 vet,staticcheck 要独自承担编译级检查,反馈更慢;少了 staticcheck,就会漏掉 SA5000 这类一拍就中的运行时错误。
Q2 存量 400 条问题,怎么在一天内把 lint 开起来,又不阻断发布?
▸ 答案:用 ratchet,而不是“先修完”。① 全量跑一次,把结果冻结成 lint-baseline.txt 并提交;② CI 里跑全量、与基线比对,只有新增返回 1;③ 基线只允许在主分支更新,PR 里禁止 -update;④ 同时排还债预算(例如每周 10 条),否则存量永远在。这样第一天就能挡住新增,发布不被历史问题卡住,还债进度也可度量。反过来,“先修完再开”在 400 条的规模上通常意味着永远不开。
Q3 golangci-lint run --new-from-rev=origin/main 返回 0 issues,能证明这个 PR 没有新增问题吗?
▸ 答案:不能。diff 门禁依赖路径、git 历史、文件跟踪状态三者一致,任何一项不一致都会静默过滤掉全部问题,而不是报错。本机就复现过:同一仓库在 /tmp(符号链接)下返回 0,在 /private/tmp 下返回 1。所以必须给门禁加 canary:保留一个已知问题或临时注入一个,确认 CI 会红;同时确认浅克隆已 fetch base 分支、新文件已 git add。没有 canary 的“0 issues”,只是“没扫到”的另一种写法。
Q4 为什么 checks: ["all"] 不适合做默认?
▸ 答案:因为规则的价值是 真问题 / (真问题 + 误报 × triage 成本),不是“规则数量”。全开会把 ST1000(包注释)、ST1003(命名)这类文档/风格规则也变成阻断项;300 个包就是几百条噪声,开发者只能批量 nolint,结果连 SA5000 这种真 bug 一起被忽略。默认集应该只含高信噪比规则,新增规则按“本季度误报记录”逐条加,并且每条都要能说出它挡住过什么。
Q5 //nolint 和 LINT_BLOCKING=false 都是“放行”,区别在哪?
▸ 答案:作用域、生命周期、可审计性都不同。//nolint 是行级、永久、静默的,适合“确认是误报”的个别位置,必须带原因和跟踪号;LINT_BLOCKING=false 是全局、临时、显式的,适合事故止血,必须带 ticket、责任人和恢复时间。前者滥用会让规则逐行消失,后者滥用会让整条门禁消失。判断标准:如果这个放行需要三个月后还有人记得,就必须写进 ticket;如果只是这一行的误报,就写 nolint 并链接 issue。