连接泄漏即InUse长期高位不回落且OpenConnections持续上涨;关键看db.Stats()中InUse(应波动回落)和OpenConnections(上涨+Idle不增说明未释放),辅以WaitCount>0、pprof搜conn.begin/Rows.Close/Tx.Commit-Rollback定位卡点。

连接泄露不是“偶尔卡住”,而是 db.Stats().InUse 在低并发下长期不回落——比如 QPS 20,InUse 却稳定在 150+,基本可断定有连接没归还。
怎么看 db.Stats() 是否异常
关键只看两个字段:InUse 和 OpenConnections。其他如 WaitCount、Idle 是辅助线索。
-
InUse应随请求波动:进请求时升,出请求后快速回落。若持续高位(远超实际并发),说明连接被持有未释放 -
OpenConnections持续上涨 +Idle不增反降 → 连接被占用后没 Close,池子“只进不出” -
WaitCount > 0且持续增长 → 连接池已满,新请求排队,根源大概率是旧连接泄漏
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()写错位置(比如写在 if err != nil return 之后) -
(*Tx).Commit或(*Tx).Rollback缺失 → 事务启动后分支太多,某些 error 路径漏掉Rollback,连接永远被tx持有 - 大量堆栈末尾是
select + conn.waitClosed→ 连接已被标记关闭,但仍有 goroutine 在等它,通常是Close被提前调用或重复调用
最常漏关连接的三类 GORM 写法
这些模式静态检查几乎无法发现,只有运行时压测才能暴露:
-
rows, err := db.Raw(...).Rows()后只判err != nil就return,没做if rows != nil { defer rows.Close() }—— 即使Raw失败,rows也可能非 nil 且持有连接 - 在
for循环里反复调用db.Rows(),却把defer rows.Close()写在循环外 → 只有最后一次的rows被关,前 N−1 次全泄漏 - 把
*sql.Tx或封装了tx的函数(如func() (*User, error))当返回值传给上层,又没约定谁负责Rollback/Close;常见于 DAO 层返回闭包,闭包内启了tx却没暴露回滚接口
GORM 事务必须显式提交或回滚
哪怕用了 db.Transaction(),也要确认其内部是否真能覆盖所有 panic 和 error 分支。老版本 GORM 的 Transaction 不保证 panic 时 rollback,必须手动 wrap:
func (s *Service) DoWithTx(ctx context.Context, fn func(*gorm.DB) error) error {
tx := s.db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
panic(r)
}
}()
if err := fn(tx); err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}
注意:tx.Commit() 本身可能失败(如主从延迟导致唯一约束冲突),所以不能只看 fn 的 error;tx.Commit().Error 也得检查并处理。
真正难排查的从来不是“哪行没写 Close”,而是 Close 被包裹在多层 defer、嵌套作用域或异步 goroutine 里,最终没被执行。上线前务必用真实流量压测 5 分钟,盯紧 db.Stats() 的 InUse 曲线是否回落——这是唯一可信的验证方式。


















