「Go 工程化与架构实战」系列第 3 篇。上一篇聊的是「mock 到底该断什么」,这一篇往前一步:把 lint 变成 CI 门禁,让机器替你守那些靠人自觉守不住的规矩。
先说这件事
一个比较典型的生产场景是这样的:接手一个几万行的 Go 服务,想上一道 lint 门禁。第一次跑 golangci-lint run,屏幕上刷刷刷出来三千多条。
修,三个月起步。不修,门禁从第一天起就是摆设。于是大部分团队走到第三条路上:把 golangci-lint run 从 CI 里摘掉,改成「本地有空跑跑」。
我对这条路没什么意见——存量债务清不动是现实。但它有个前提:你得先知道自己到底面对多少债务。 而我这轮的实测结果是,golangci-lint 报给你的数字,不一定是你真实面对的数量。
我搭了一个 9 个文件的小服务,往里面埋了几类真实缺陷,然后用 golangci-lint 反复跑。同样的代码、同一次运行:
- 我配好的一份「推荐配置」告诉我:21 个问题。
- 我把三个默认开关关掉之后:38 个问题。
差的 17 个不是被我自己写的排除规则过滤掉的,是它默认就没往外说。
环境:go1.23.3 / darwin-arm64 / Apple M4 / GOMAXPROCS=10,golangci-lint v2.13.2(2026-08-28 的稳定版,它本身是用 go1.27 编的)。下面所有数字、所有输出都是本机实跑出来的。
一、先把 Demo 摆出来
Demo 是一个看着很正常的订单服务,模块名叫 example.com/lintdemo,9 个 .go 文件、321 行。我在里面埋的问题,都是写业务代码时手滑就能写出来的那种,不是故意作恶。
文件构成也顺便说一下,后面几节都要用到:业务层 2 个(service.go + 测试)、HTTP 客户端 1 个、数据访问 1 个、批量解析 1 个,剩下 4 个是我故意造的「生成文件」变体——第六节专门讲它们。
下游 HTTP 调用:
// internal/httpapi/client.go
package httpapi
import (
"encoding/json"
"io"
"net/http"
)
type Client struct {
BaseURL string
}
// FetchUser 从一个下游服务拉用户资料
func (c *Client) FetchUser(userURL string) (map[string]any, error) {
resp, err := http.Get(userURL)
if err != nil {
return nil, err
}
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return out, nil
}
// Drain 把响应体读完就丢掉,用来复用连接
func (f *Failover) Drain(backupURL string) (int64, error) {
resp, err := http.Get(backupURL)
if err != nil {
return 0, err
}
io.Copy(io.Discard, resp.Body)
return 0, nil
}
FetchUser 里 resp.Body 从头到尾没关,Drain 里 io.Copy 的错误没人看。两处 http.Get 也都没带 context。
数据访问层:
// internal/store/mysql.go
const mysqlDSN = "root:SuperSecret123@tcp(127.0.0.1:3306)/orders"
func (r *Repo) ListByUser(userID int64) ([]Order, error) {
rows, err := r.db.Query("SELECT id, amount FROM orders WHERE user_id = ?", userID)
if err != nil {
return nil, fmt.Errorf("list orders: %w", err)
}
out := make([]Order, 0, 8)
for rows.Next() {
var o Order
if err := rows.Scan(&o.ID, &o.Amount); err != nil {
return nil, fmt.Errorf("scan order: %w", err)
}
out = append(out, o)
}
return out, nil
}
func (r *Repo) CountAll() int {
var n int
r.db.QueryRow("SELECT COUNT(*) FROM orders").Scan(&n)
return n
}
db.Query 用的是不带 context 的版本(应该是 QueryContext),rows 没有 Close,循环结束也没检查 rows.Err(),Scan 的返回值直接扔了。连接串里还躺着明文密码。
业务层和批量解析:json.Unmarshal 的返回值没人接、log.Printf 的格式串和参数对不上、fmt.Errorf 用了 %v 而不是 %w、一个 quota := int64(1000) 紧接着就被覆盖、几个函数写完就再没被调用过。
9 个文件,问题类型 5 类。跑一遍看看。
二、第一次跑:默认配置给我 12 条
不写任何配置文件,--no-config 直接跑:
golangci-lint run --no-config --color=never
输出(节选):
internal/batch/batch.go:15:16: Error return value of `json.Unmarshal` is not checked (errcheck)
internal/batch/batch.go:21:16: Error return value of `json.Unmarshal` is not checked (errcheck)
internal/batch/batch.go:27:16: Error return value of `json.Unmarshal` is not checked (errcheck)
internal/httpapi/client.go:36:9: Error return value of `io.Copy` is not checked (errcheck)
internal/store/mysql.go:44:51: Error return value of `(*database/sql.Row).Scan` is not checked (errcheck)
internal/user/service.go:40:35: printf: log.Printf format %d has arg u.Name of wrong type string (govet)
internal/user/service.go:60:2: ineffectual assignment to quota (ineffassign)
internal/user/service.go:55:9: S1025: the argument is already a string, there's no need to use fmt.Sprintf (staticcheck)
internal/store/mysql.go:8:7: const mysqlDSN is unused (unused)
internal/store/mysql.go:22:6: func connString is unused (unused)
internal/user/service.go:44:6: func normalizeEmail is unused (unused)
internal/user/service.go:68:6: func legacyParse is unused (unused)
12 issues:
* errcheck: 5
* govet: 1
* ineffassign: 1
* staticcheck: 1
* unused: 4
退出码 1,门禁会红。govet 抓到了格式串类型不匹配、staticcheck 抓到了 fmt.Sprintf("%s", x) 这种废话、ineffassign 抓到了被覆盖的赋值。这几条都是默认开的,质量不错。
12 issues 这个数字看着很清楚。问题是它不对。
先确认一下「默认开了哪几个」这件事——v2 的默认集合叫 standard:
golangci-lint help linters --json | jq 'length'
结果是 115。一个 115 个 linter 的工具,默认给你开几个?
| 组 |
linter |
默认开(standard) |
errcheck、govet、ineffassign、staticcheck、unused |
| 默认关 |
其余 110 个,包括 bodyclose、noctx、gosec、sqlclosecheck、rowserrcheck、errorlint、perfsprint、nolintlint… |
5 个。
这 5 个的名字直译过来是「没检查的错误值、vet 的那套、无效赋值、静态检查、没用到的代码」——很标准的语言卫生检查。它们的共同点是:都只跟你自己写的代码有关,不跟外部世界打交道的语义有关。
三、它漏掉了什么
把我埋的问题和实际抓到的对照一下,就清楚了:
| 我埋的问题 |
默认 standard |
开了之后谁抓到的 |
json.Unmarshal 返回值没接 |
errcheck 抓到 |
— |
log.Printf 格式串类型不匹配 |
govet 抓到 |
— |
quota 被立即覆盖的无效赋值 |
ineffassign 抓到 |
— |
fmt.Sprintf("%s", x) |
staticcheck 抓到 |
— |
| 4 个写完全没调用的函数 |
unused 抓到 |
— |
http.Get 的响应体没 Close |
漏 |
bodyclose |
http.Get 没带 context |
漏 |
noctx + gosec G107 |
db.Query 没用 QueryContext |
漏 |
noctx |
rows 没 Close |
漏 |
sqlclosecheck |
rows.Err() 没检查 |
漏 |
rowserrcheck |
fmt.Errorf 用了 %v 而不是 %w |
漏 |
errorlint |
| DSN 里的明文密码 |
漏 |
也漏 |
前 5 条是「我写错了」,后 7 条是「我跟外部世界打交道的方式有隐患」。后 7 条里的每一条,上线后都可能变成事故:响应体不关会耗光连接池、SQL 不带 context 会导致半夜的查询杀不掉、%v 替代 %w 会让 errors.Is 静默失效。
而那 5 个默认 linter 里没有一个会告诉你这些。
最后一行我特意去确认过:const mysqlDSN = "root:SuperSecret123@tcp(...)/orders" 这条,gosec 的 G101(硬编码凭据)也没抓到。G101 匹配的是 password、secret、token 这类变量名模式,密码藏在 DSN 字符串里它就看不见了。这件事值得记一笔——linter 只能抓它有规则的那部分,不是「开了 gosec 就安全了」。
把该开的都开上,跑一遍:
golangci-lint run --color=never # 用后面第五节那份配置
21 issues:
* errcheck: 10
* errorlint: 1
* gosec: 2
* govet: 1
* ineffassign: 1
* noctx: 1
* perfsprint: 1
* unused: 4
21 条,比 12 条多。bodyclose、sqlclosecheck、rowserrcheck 还是没出现。
我一开始以为是配置写错了,把这三个 linter 单独拎出来跑:
golangci-lint run --no-config --enable-only bodyclose
internal/httpapi/client.go:19:23: response body must be closed (bodyclose)
internal/httpapi/client.go:32:23: response body must be closed (bodyclose)
2 issues:
* bodyclose: 2
它们工作得好好的。那为什么合在一起跑就不报了?

四、数字是怎么被吃掉的
这是这篇最想讲的一件事:golangci-lint 默认有三层静默截断。它们不报错、不给警告,只是不往外说。
4.1 max-same-issues=3:同一个文案最多只留 3 条
--help 里这个参数的原始说明是:
--max-same-issues int Maximum count of issues with the same text. Set to 0 to disable (default 3)
注意是「same text」,不是 same file、不是 same linter。
我在 internal/batch/batch.go 里写了 6 个解析函数,每个都犯同一个错:json.Unmarshal 返回值没接。这 6 条的文案完全一样,全是 Error return value of json.Unmarshal is not checked。全仓库里同文案的一共 9 条——batch 那 6 条,加上业务层、测试和一个生成文件变体各 1 条。
默认配置下,你能看到 3 条。剩下 6 条,不存在。
对比一下:
golangci-lint run --no-config --color=never
# 12 issues
golangci-lint run --no-config --color=never --max-same-issues=0 --max-issues-per-linter=0
# 18 issues
errcheck 从 5 条变成 11 条,多了 6 条——正好是被抽掉的那 6 条同文案问题。而那个 12 issues 的统计行,也是按截断后的结果算的——你连「少报了」这件事都看不出来。
4.2 uniq-by-line=true:一行只留一条
这个更隐蔽:
--uniq-by-line Make issues output unique by line (default true)
一行只报一条。听起来挺合理——同一行被两个 linter 骂,报一条不就够了吗?
问题在于「同一行被多个 linter 命中」恰恰是最常见的情况,因为一个根因往往同时违反几家的规则。看真实输出:
golangci-lint run --color=never --uniq-by-line=false
internal/store/mysql.go:27:25: (*database/sql.DB).Query must not be called. use (*database/sql.DB).QueryContext (noctx)
internal/store/mysql.go:27:25: rows.Err must be checked (rowserrcheck)
internal/store/mysql.go:27:25: Rows/Stmt/NamedStmt was not closed (sqlclosecheck)
mysql.go:27 这一行(就是那句 r.db.Query(...))被三个 linter 同时命中。默认配置下你只会看到其中一条——按排序,留下的是 noctx 那一句。修掉它,rows 没关和 rows.Err() 没检查这两件事仍然躺在那儿,但你的屏幕上不会再有提示,直到下次全量跑。
client.go:19 那句 http.Get 一样,三家里只留一条:
internal/httpapi/client.go:19:23: response body must be closed (bodyclose)
internal/httpapi/client.go:19:15: G107: Potential HTTP request made with variable url (gosec)
internal/httpapi/client.go:19:23: net/http.Get must not be called. use net/http.NewRequestWithContext ... (noctx)
同一份配置,只加一个 --uniq-by-line=false:
| 运行方式 |
报出来的问题数 |
推荐配置(默认 uniq-by-line=true) |
21 |
推荐配置 + --uniq-by-line=false |
38 |
这 17 条差在哪里,用 -v 能看到完整链路。golangci-lint 内部是一串处理器,每一环都可能吞掉问题:
level=info msg="[runner] Processors filtering stat (in/out):
cgo: 49/49, generated_file_filter: 49/39, exclusion_rules: 39/38,
uniq_by_line: 38/21, max_per_file_from_linter: 21/21"
49 条原始问题 → 生成文件过滤后 39 → 我自己写的排除规则过滤后 38 → uniq_by_line 之后 21。
前面两环都是我配的,我知道自己在干什么。最后一环是默认值,我当时完全没意识到它的存在。
4.3 还有 max-issues-per-linter=50
--max-issues-per-linter int Maximum issues count per one linter. Set to 0 to disable (default 50)
单个 linter 最多报 50 条。我造过一个 300 个包的合成仓库,errcheck 一口气报了 1800 条——默认限制下你会看到 50 条,然后以为「就 50 个问题」。
4.4 它们会让门禁漏判吗?
这是我在写这篇时反复问自己的问题。答案是:不会漏判,但会让你彻底误判工作量。
只要还剩哪怕一条问题,退出码就是 1,CI 照样红。所以从「红/绿」的角度看,这三层截断不影响门禁的判定。
但它们影响另一件更重要的事:你对自己欠了多少债的判断。
看到 21 issues 和看到 38 issues,是两个完全不同的决策。前者你会说「行吧,今天顺手清了」,后者你会说「这个得排期」。
所以我的结论很直接:做门禁的第一件事,是把这三层截断关掉。 让报表等于事实,再谈要不要拦。
issues:
max-issues-per-linter: 0
max-same-issues: 0
命令行上补一个 --uniq-by-line=false。
补一句版本差异:max-issues-per-linter 和 max-same-issues 这两个默认值从 v1 到 v2 没变;--uniq-by-line 也是 v1 就有的参数,只是在 v1 时代很少有人拿它当门禁用。v2 真正变的是别的——后面第六节会说。
五、配置该怎么写
v2 的配置结构和 v1 有实质变化,如果你手上还有一份 v1 的 .golangci.yml,它会直接被拒绝(schema 校验不通过)。官方给了一条迁移命令:
golangci-lint migrate
它会先把原文件备份成 .bck.<时间戳>,再按 v2 结构重写。有几个地方得知道:
version: "2" 是必填的,没有它 v2 会按 v1 解析。
gci / gofmt / gofumpt / goimports 从 linters 挪到了新的 formatters 段。它们不是 linter,是 formatter。
gosimple / stylecheck 被合并进 staticcheck,写这三个名字会报未知 linter。
linters.disable-all: true → linters.default: none;enable-all → default: all。
linters-settings 改名成 linters.settings(少了那个横杠)。
linters.presets 这个字段被删了,migrate 会把里面的东西展开成一份显式的 enable 列表。
- v1 里
run.timeout 默认是 1 分钟;v2 默认不限时(Timeout for total work. Disabled by default)。
- 迁移不会搬注释,也不管已经废弃的字段,这两块得手工补。
下面是我这次用的配置。它不复杂,但每一行都有理由:
version: "2"
run:
timeout: 3m
tests: true
relative-path-mode: gomod
linters:
default: standard
enable:
- bodyclose # 响应体必须关
- noctx # 出网/出库必须带 context
- errorlint # %w / errors.Is / errors.As 的用法
- gosec # 安全规则
- sqlclosecheck # rows/stmt 必须关
- rowserrcheck # rows.Err() 必须查
- nolintlint # //nolint 必须写清楚
- misspell # 注释里的拼写
- durationcheck # time.Duration 相乘不会变成秒
- perfsprint # fmt.Sprintf 的廉价替代
- unconvert # 无意义的类型转换
- prealloc # 切片能预分配
settings:
errcheck:
check-type-assertions: true
check-blank: true
nolintlint:
require-explanation: true
require-specific: true
exclusions:
generated: lax
presets:
- comments
- std-error-handling
rules:
- path: _test\.go
linters:
- gosec
- rowserrcheck
- sqlclosecheck
formatters:
enable:
- gofmt
- goimports
issues:
max-issues-per-linter: 0
max-same-issues: 0
output:
sort-order:
- linter
- file
几处值得解释:
default: standard 而不是 all。 115 个全开,结果就是几百条风格告警(lll 嫌行太长、varnamelen 嫌变量名太短),团队的第一反应是把门禁删掉。standard 打底 + 手里有数的十几个按需加,是能长期活下去的形态。
check-blank: true。 默认情况下 errcheck 放过 _ = doSomething() 这种写法。开了之后,_ = 也是「没检查错误」。这是防止「用 _ 把红灯涂绿」的唯一手段。
presets: [comments] 和 std-error-handling。 前者放行包注释、导出符号注释这类风格告警,后者放行 fmt.Fprintf、os.Remove 这类标准库惯用忽略。这两个 preset 建议留着,它们挡掉的大部分是噪音。
output.sort-order。 默认按文件排,同一个文件里十几个 linter 混在一起。改成先按 linter 排,看输出的时候「这一类问题在哪些地方出现」一目了然,批处理的效率差很多。
顺带说一句 formatters 的检查方式:golangci-lint run 会把 formatters 里启用的 gofmt/goimports 一起检查并报出来(实测报 File is not properly formatted (gofmt)),而 golangci-lint fmt --diff 是专门用来看「格式差在哪」的入口。
如果你手上是 v1 的配置,还有一件事要先确认:golangci-lint config verify 能校验配置文件是否合法。上 CI 之前本地跑一次,比在 CI 里看着「配置解析失败」的报错猜要快得多。
六、generated 文件的坑:v1 到 v2 悄悄改严了
这一节和上一篇的 gomock 直接接上。
golangci-lint 会自动跳过「生成的文件」(// Code generated ... DO NOT EDIT. 那种),你不需要为 mockgen 生成的几百行替身写排除规则。但 v2 里这个判断变严了:
issues.exclude-generated has a new default value (v1 lax, v2 strict).
我按四种写法各造了一个文件,注释都写 // Code generated by MockGen. DO NOT EDIT.,唯一区别是它在文件里的位置:
| 变体 |
注释位置 |
v2 strict(默认) |
v1 lax |
| A |
第 1 行 |
排除 |
排除 |
| B |
版权注释 + 空行之后 |
排除 |
排除 |
| C |
package 之后 |
不排除(报出来) |
排除 |
| D |
首行空行之后 |
排除 |
排除 |
配置切换一跑就能复现:
# generated 模式写 strict(v2 默认)
golangci-lint run --enable-only errcheck ./internal/genc/
# -> internal/genc/gen_c.go:15:16: Error return value of `json.Unmarshal` is not checked (errcheck)
# generated 模式写 lax(v1 默认)
# -> 0 issues.
规律很干净:strict 只认 package 子句之前的那行注释,lax 扫全文件。
这为什么会咬到人?因为「把 generated 注释放在 package 之后」恰恰是一些工具的历史行为,也有人手工往生成文件顶部加版权声明、改行位置时挪动过它。v1 时代这些都不影响,v2 之后它们会突然开始出现在你的 lint 报告里。
两个处理方式,选一个:
- 在配置里显式写回
linters.exclusions.generated: lax——我在上面的配置里就是这么写的,向后兼容成本最低。
- 保持
strict,去把生成文件的注释位置修对。如果你用的是 Go 1.24+ 的 tool 指令跑 mockgen/stringer,生成物大概率已经是标准位置,就不用管。
我不建议的做法是把整个 internal/mocks/ 目录加进 exclusions.paths。生成代码确实不该被 lint,但你要清楚代价:那天它生成的替身里有一个方法签名写错了,也没人会告诉你。
七、只拦新引入的问题
门禁要能落地,绕不开这件事:存量债务不拦,只拦这次改动引入的。
golangci-lint 自带了几个参数做这件事:
-n, --new 只报未提交改动(或 HEAD~)
--new-from-rev REV 只报 REV 之后的新问题
--new-from-merge-base BRANCH 只报相对公共祖先的新问题
--new-from-patch PATH 只报某个 patch 里的新问题
--whole-files 把「被改动文件」整体纳入范围
先说结论:功能本身是对的,但我不建议把门禁押在它上面。 两个原因,都是我实测碰到的。
7.1 它第一次让我以为坏了
我按官方推荐用了 --new-from-rev:
golangci-lint run --new-from-rev=HEAD~1
# -> 0 issues.
一个干干净净的 0。我的仓库里明明刚新增了两个函数、带两处未检查的错误。
换 --new-from-patch,先自己 git diff 导出 patch 文件再喂进去,还是 0。加 --whole-files,还是 0。我把 verbose 打开:
Processors filtering stat (in/out): ..., diff: 6/0, ...
6 条问题进去,0 条出来。diff 这一环把它们全扔了。
排查到后面发现是路径对不上:macOS 上 /tmp 是 /private/tmp 的软链接,我的实验仓库建在 /tmp 下面,issue 报告的路径和 git diff 里的路径对不上,匹配就全部失效。把整个仓库换成真实路径(~/lint-new-test)再跑:
old/old.go:12:16: Error return value of `json.Unmarshal` is not checked (errcheck)
old/old.go:17:16: Error return value of `json.Unmarshal` is not checked (errcheck)
2 issues:
* errcheck: 2
这次对了。仓库里本来还有一条第 7 行的老问题(在改动之前就存在),它被正确地忽略了。--new-from-patch 也一样正常。
我顺手把 v2.11.4 也拉下来对比过,行为一致,所以不是 2.13 的回归。这个坑的教训是:*`--new-from-` 靠「路径比对」工作,任何让路径对不上的东西(软链、monorepo 里的 working-directory、CI 里的挂载点)都会让它静默返回 0。** 而一个永远返回 0 的门禁,比没有门禁更危险——它会给你虚假的安全感。
7.2 官方 FAQ 自己就写明了它的边界
我后来在官方 FAQ 里翻到这一段,算是把话说透了:
The options --new-from-merge-base, --new-from-rev, and --new-from-patch work by comparing git diff output and issues. If an issue is not reported as the same line as the changes then the issue will be skipped. ... To fix that, you have to use the option --whole-files. The side effect is the issues inside the file that contain changes but not directly related to the changes themselves will be reported.
翻译一下:它按「行号是否落在变更行集合里」判断。一个 issue 只要不在你改动的那几行上,即使它在同一个文件、甚至同一个函数里,也会被丢掉。 想放宽就得加 --whole-files,代价是把整个文件的存量问题一起报出来。
AI 时代新增的 Go 代码里,很大一部分是工具生成的(internal/ 下的新包、测试文件),改动往往「文件是新的、但只有一部分行算新增」。这个边界会很频繁地踩到。
7.3 所以我自己写了一个门禁脚本
思路很朴素:我不猜它的匹配逻辑,我自己算变更行,做一次交集。
tools/lint-gate.sh:
#!/usr/bin/env bash
# lint-gate.sh —— 只拦「本次改动新引入」的 lint 问题
#
# 用法:
# tools/lint-gate.sh # 与 HEAD~1 比
# tools/lint-gate.sh origin/main # 与主干比(CI 里用这个)
#
# 退出码:0 = 干净;1 = 新代码引入了问题;2 = 环境/参数问题
set -uo pipefail
BASE="${1:-HEAD~1}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
command -v golangci-lint >/dev/null || {
echo "golangci-lint 不在 PATH 里"; exit 2;
}
# 1) 算出「本次新增/修改」的行号集合:file:line
# - git diff 覆盖已跟踪文件的改动
# - 未跟踪的新文件不在 diff 里,单独算(整个文件都算新增)
{
git diff --unified=0 --no-color "$BASE" -- '*.go' \
| awk '
/^\+\+\+ /{ f=$2; sub(/^b\//,"",f); next }
/^@@ /{
split($3, a, ",")
start = substr(a[1], 2) + 0
len = (a[2] == "") ? 1 : a[2] + 0
for (i = 0; i < len; i++) print f ":" (start + i)
}
'
git ls-files --others --exclude-standard -- '*.go' \
| while IFS= read -r f; do
n=$(wc -l < "$f")
[ "$n" -gt 0 ] && awk -v f="$f" 'END{for(i=1;i<=NR;i++) print f ":" i}' "$f"
done
} | sort -u > "$WORK/touched.txt"
if [ ! -s "$WORK/touched.txt" ]; then
echo "gate: 本次没有 .go 改动,跳过。"
exit 0
fi
# 2) 全量跑一遍,拿到每一行的原始问题(关掉三个静默截断开关)
golangci-lint run \
--color=never \
--show-stats=false \
--issues-exit-code=0 \
--max-issues-per-linter=0 \
--max-same-issues=0 \
--uniq-by-line=false \
--output.text.print-issued-lines=false \
--output.text.print-linter-name=true \
> "$WORK/report.txt" 2>&1 || true
# 3) 只留命中「被改动行」的问题
awk '
NR==FNR { touched[$0] = 1; next }
match($0, /^[^:]+:[0-9]+:[0-9]+: /) {
split($0, p, ":")
key = p[1] ":" p[2]
if (touched[key]) print
}
' "$WORK/touched.txt" "$WORK/report.txt" > "$WORK/blocking.txt"
total=$(grep -c . "$WORK/report.txt" || true)
hit=$(grep -c . "$WORK/blocking.txt" || true)
if [ "$hit" -gt 0 ]; then
echo "gate: 本次改动引入了 $hit 个问题(全仓共 $total 个,存量不拦)"
echo "---"
cat "$WORK/blocking.txt"
exit 1
fi
echo "gate: 本次改动干净(全仓仍有 $total 个存量问题,按计划慢慢清)"
exit 0
几个设计上的取舍:
- 自己算变更行,不依赖
--new-from-* 的匹配。路径怎么变都不会翻车。
- 未跟踪的新文件单独处理。
git diff 看不到没 git add 的文件,这个分支不写就会漏掉「新加的整个包」——而这恰恰是最该拦的部分。
- 关掉三个截断开关。
--uniq-by-line=false 是这里的关键:同一行被多家 linter 命中的问题会全部进报告,交集判断才完整。
--max-issues-per-linter=0。否则某个 linter 在前 50 条就把配额用光,新代码里同类的问题直接进不了报告。
实测四个场景,全部符合预期:
| 场景 |
输出 |
退出码 |
只改了非 .go 文件 |
gate: 本次没有 .go 改动,跳过。 |
0 |
全仓有 38 个存量问题,本次没碰 .go |
gate: 本次没有 .go 改动,跳过。 |
0 |
改动 .go 但内容是干净的 |
gate: 本次改动干净(全仓仍有 38 个存量问题,按计划慢慢清) |
0 |
第三行最值得看:全仓躺着 38 个问题,门禁照样绿。这正是「只拦新增」的意义——存量债务不阻塞任何人,新增问题一个都跑不掉。
然后新加一个文件,里面写一行未检查的 error:
| 场景 |
输出 |
退出码 |
| 新文件里有 1 行未检查的 error |
拦下 2 条 |
1 |
| 把那行改成检查 err |
gate: 本次改动干净(全仓仍有 38 个存量问题…) |
0 |
输出的细节值得看一眼:
internal/pricing/price.go:13:16: Error return value of `json.Unmarshal` is not checked (errcheck)
internal/pricing/price.go:13:2: G104: Errors unhandled (gosec)
gate: 本次改动引入了 2 个问题(全仓共 40 个,存量不拦)
一处改动、两个 linter、两条都报出来。 默认的 uniq-by-line=true 在这里只会给你一条。另外注意它连「全仓共 40 个」也一起告诉你——存量在涨还是在降,每次 CI 都顺手记录下来了。
八、nolint 是最大的那道后门
上面所有配好的规则,都能被一个注释绕过去:
// Legacy 老接口,先不管
//nolint
func Legacy(sku string) string {
return fmt.Sprintf("%s", sku)
}
一个不带参数的 //nolint,把整个函数里所有 linter 的问题全部静默放行。我实测这一下吞掉了 1 条 perfsprint(本来会报 3 条,加上它之后只剩 2 条)。
不带参数的 //nolint 太容易写,也太容易变成团队里的「消音器」——它不需要任何理由,而且删掉它的成本比写上它的成本高得多。
所以 nolintlint 我建议默认就开,两个开关都拧上:
linters:
settings:
nolintlint:
require-explanation: true
require-specific: true
效果是逼你把 //nolint 写成这样:
//nolint:gosec // 这里的 SQL 用?占位符,参数已绑定,不是拼接注入
再跑一次,那个裸 //nolint 会被自己抓出来:
internal/pricing/price.go:29:1: directive `//nolint` should mention specific linter such as `//nolint:my-linter` (nolintlint)
internal/pricing/price.go:29:1: directive `//nolint` should provide explanation such as `//nolint // this is why` (nolintlint)
注意它是并列报两条:不指名 linter 一条,没写理由一条。这两件事是独立的,require-specific 防的是「一个豁免放行一整片问题」,require-explanation 防的是「三个月后没人知道当初为什么豁免」。
我对这件事的看法是://nolint 不是坏事,写不下 200 个字的理由才是。一个 //nolint:errcheck // 这段是幂等的清理逻辑,失败可忽略,见 #4127 的评价,比一条无声放行高出好几个量级。
九、放在哪里跑
门禁放在哪一环,取决于它有多快。我造了一个 300 个包、15,900 行、1800 个问题的合成仓库来量一下(本机 go1.23.3 / Apple M4):

| 运行方式 |
墙钟耗时 |
| 冷启动,全量 |
1453 ms |
| 缓存命中,全量 |
448 ms |
缓存命中 + --fast-only |
271 ms |
| 缓存命中 + 只跑 2 个包 |
151 ms |
先说清楚这个数据不能外推。 这是合成代码,每个包只有几十行、依赖极简、没有 cgo、没有外部 module。真实项目光是加载依赖图就要花掉这里的全部时间,几万行的服务冷启动几秒到几十秒都很正常。上面这张表能说明的,是相对关系:缓存值钱(3 倍)、--fast-only 省时间、缩小范围最有效。
按这个相对关系,我给的分工是:
pre-commit 钩子:只跑 --fast-only,或者干脆把范围缩到「本次改动的包」。目标是在 1 秒内返回。慢的钩子只有一个下场——被 --no-verify 绕过,然后团队成员开始互相教怎么绕过它。
CI 的 PR 环节:跑全量 + -race 无关的那份配置,用上面那个 lint-gate.sh 只拦新增。这是主力战场,慢一点没关系。
CI 的定时任务(每天或每周):跑一次全量、不加门禁判定、只把结果发出来。它的作用是让存量债务的规模变化可见——债务在涨还是在降,得有人看。
至于集成方式,GitHub 上直接用官方 action:
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.13.2 # 别写 latest,见下
only-new-issues: true # PR 上走 GitHub API 的 diff,等价于 --new-from-patch
三个坑记一下:
version 不要写 latest。 团队里 A 的本地是 v2.13.2、CI 拉到 v2.14,新增的规则会让昨天还绿的门禁今天变红,而且没人知道为什么。版本号钉死,升级当成一次有意识的动作。
- action 的版本和 golangci-lint 的版本要配对。
@v8 起要求 golangci-lint ≥ v2.1.0;如果你 pin 的是更早的 action 且 version: latest,有可能装到 v1 的二进制,它会在 config verify 阶段就拒绝你的 v2 配置——报错信息跟 lint 本身毫无关系,很容易排查错方向。
- action 的
only-new-issues 在 PR 上走 GitHub API 的 diff,和本地命令行 --new-from-patch 是两条路径。我在第七节碰到的路径匹配问题,不代表 action 上也会碰到;但结论是一样的——上线第一周,拿一个「确定有新问题」的 PR 去验一次它到底会不会红。绿得毫无理由的门禁,一定要查。
十、我的结论
四句话:
-
默认的 standard 是「不扰民」,不是「够用」。 5 个 linter 管的是语言卫生,管不了「响应体没关」「SQL 不带 context」这类线上隐患。bodyclose、noctx、sqlclosecheck、rowserrcheck、errorlint 这几个,是把它从「代码好看」升级到「服务可靠」的关键。
-
关掉三层静默截断,再谈别的。 max-same-issues=3、uniq-by-line=true、max-issues-per-linter=50 不影响红绿,但让你误判工作量——同一份代码 21 条还是 38 条,是两个完全不同的排期决策。
-
门禁要能落地,必须只拦新代码。 而 --new-from-* 靠路径比对工作,路径对不上会静默返回 0;我最后选择自己算变更行、做交集。多写 40 行 shell,换来的是「它红了我知道为什么红」。
-
//nolint 是最大的一道后门。 require-specific + require-explanation 两个开关拧上,它就从「消音器」变成了「有记录的豁免」。
还有一个没写进上面的观察:这一轮实测里,gosec 没抓到我埋在 DSN 里的明文密码。这不是 gosec 的锅,是「linter 只抓它有规则的那部分」这条规律的又一个例子。lint 是网眼固定的筛子,不是安全审计。
下一篇回到「让机器替你写代码」这条线上:go generate。上面第六节聊到生成文件的注释位置,其实只碰到了它的一半——真正麻烦的是「生成物该不该提交」「接口改了忘跑 generate,CI 该怎么发现」,还有 Go 1.24 那个 tool 指令到底解决了什么问题。
参考资料
如果你有更多 Go 工程化一线的踩坑经历,也欢迎到云栈社区分享你的配置和脚本。