数据库连接泄漏表现为sql.DB.Stats().InUse长期高于实际并发峰值,如QPS 20时InUse稳定在150+;关键指标是InUse(应波动回落)和OpenConnections(持续上涨且Idle不增说明未释放)。

数据库连接泄漏不是“可能没关”,而是sql.DB.Stats().InUse在低并发下长期卡在高位、不回落——比如 QPS 20,InUse却稳定在 150+,基本可断定是连接没归还。
怎么看 Stats 数据是否异常
sql.DB.Stats() 返回的四个字段里,真正关键的是 InUse 和 OpenConnections:
-
InUse:当前被*sql.Tx或*sql.Rows持有的连接数。它应该随请求来去而波动,**绝不能长期高于实际并发峰值** -
OpenConnections:已建立但未关闭的连接总数(含 idle)。若持续上涨且Idle不增反降,说明连接被占用后没释放 -
WaitCount> 0 且持续增长 → 连接池已满,新请求在排队,根源大概率是旧连接没 Close -
MaxOpenConns设得太小(如 5)会放大问题,但不是根本原因;调大只是掩盖,不是修复
pprof 堆栈里哪些线索直接指向泄漏点
访问 curl "http://localhost:6060/debug/pprof/goroutine?debug=2" 后,搜索以下关键词:
-
database/sql.(*DB).conn或conn.begin→ goroutine 卡在获取连接,说明上一个连接没归还,池已耗尽 -
(*Rows).Next或(*Rows).Close→ 正在遍历结果集但没走到rows.Close(),或defer rows.Close()写错位置 -
(*Tx).Commit或(*Tx).Rollback缺失 → 事务启动后分支太多,某些 error 路径漏掉 rollback,连接永远被 tx 持有 - 大量堆栈末尾是
select+conn.waitClosed→ 连接已被标记关闭,但仍有 goroutine 在等它,通常是 close 被提前调用或重复调用
最常漏关连接的三类代码模式
这些写法在静态检查里几乎无法发现,只有运行时暴露:
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
立即学习“go语言免费学习笔记(深入)”;
-
rows, err := db.Query(...)后只判err != nil就 return,没做if rows != nil { defer rows.Close() }—— 即使 Query 失败,rows也可能非 nil 且持有连接 - 在 for 循环里反复调用
db.QueryRow(),却把defer rows.Close()写在循环外 → 只有最后一次的rows被关,前 N−1 次全泄漏 - 把
*sql.Tx或*sql.Rows当返回值传给上层函数,又没约定清楚谁负责 Close;常见于封装 DAO 层时返回func() (*User, error)类型闭包,闭包内启了 tx 却没暴露 rollback 接口
修复后必须验证的两个动作
改完代码别急着上线,先本地压测确认效果:
- 启动服务后记下
runtime.NumGoroutine()和db.Stats().InUse初始值;发 100 次请求,再等 5 秒,看这两个值是否回落到初始水平附近 - 在 handler 入口和出口加日志:
log.Printf("db.InUse@start: %d", db.Stats().InUse)和@done—— 如果 @done 值比 @start 高,说明这次请求没清理干净,立刻查该 handler 内所有Query/Begin调用
真正难排查的不是“哪行没写 Close”,而是“Close 被写了,但执行不到”——比如 panic 发生在 defer 之前,或 tx.Rollback() 被包裹在深层 if 分支里,而 error case 没覆盖全。盯住 pprof 堆栈里阻塞时间最长的那几条,它们往往就是泄漏源头。

















