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

五条使用规则:

  1. 基线只在主分支更新。在 PR 里跑 -update,等于把新增问题洗白。
  2. key 不含行号,但含文件名;文件重命名会产生“新增 + 修复”各一条,接受它,重新基线即可。
  3. 路径归一化到仓库相对路径:CI 输出绝对路径、本机输出相对路径时,同一条问题会被当成两条。
  4. 基线文件进仓库,与代码同一次评审;基线变动必须有人看 diff。
  5. 报告要全量生成,不要先过滤再进 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 的三条规矩

  1. 必须写原因 + 跟踪号://nolint:errcheck // 关闭失败不影响主流程,见 ISSUE-123。
  2. 禁止文件级 / 包级 nolint:一行关掉全部规则,等于在这份文件里删除 lint。
  3. 抑制要可审计:每月统计 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。

关联