database/sql 的 stats() 不够用,因其仅提供瞬时快照,缺乏历史趋势、告警能力、来源区分、错误统计及连接耗时等诊断维度,无法满足真实场景下的连接池深度观测需求。

为什么 database/sql 的 Stats() 不够用?
Go 标准库的 sql.DB 确实提供了 DB.Stats() 方法,返回 sql.DBStats 结构体,包含 OpenConnections、InUse、Idle 等字段。但它只反映「当前快照」,不记录历史趋势,也没有主动告警能力——你得自己轮询、采样、比对、触发逻辑。
真实场景中,你更关心:连接数是否持续逼近 MaxOpenConns?空闲连接是否长期堆积导致资源浪费?某次慢查询是否让 InUse 卡在高位十几秒?这些都需要带时间维度的观测和轻量聚合。
-
DB.Stats()每次调用是原子读取,但无锁开销仍存在,高频采集(如 100ms 一次)会影响性能 - 它不区分连接来源(如不同业务模块共用一个
*sql.DB),无法定位是哪个服务拖垮了池子 - 没有错误计数(如
driver.ErrBadConn重试次数)、连接建立耗时等诊断字段
用 expvar 暴露指标比 HTTP handler 更轻量
别急着上 Prometheus + Exporter。如果只是内部运维看板或本地调试,expvar 是 Go runtime 原生支持的指标导出机制,零依赖、低侵入、自动注册到 /debug/vars,且支持 JSONP。
关键不是“暴露”,而是「怎么组织字段」:直接把 DB.Stats() 塞进去不够,要拆解+衍生:
- 基础字段直传:
sql_open_connections、sql_in_use、sql_idle - 计算字段必须加前缀避免歧义:
sql_wait_count_total(累计等待次数)、sql_wait_duration_ms_avg(平均等待毫秒,需自行统计) - 错误类指标用布尔或计数器:
sql_conn_errors_total,每次db.Querypanic 或返回driver.ErrBadConn时 +1 - 所有指标用
expvar.NewInt或expvar.NewFloat,避免并发写 panic
示例:监控等待行为
var waitCount = expvar.NewInt("sql_wait_count_total")
var waitDurationSum = expvar.NewFloat("sql_wait_duration_ms_sum")
var waitDurationCount = expvar.NewInt("sql_wait_duration_count")
// 在自定义 QueryContext 封装里:
start := time.Now()
rows, err := db.QueryContext(ctx, query, args...)
if errors.Is(err, sql.ErrConnDone) || strings.Contains(err.Error(), "connection refused") {
waitCount.Add(1)
dur := float64(time.Since(start).Milliseconds())
waitDurationSum.Add(dur)
waitDurationCount.Add(1)
}
如何安全地采集 DB.Stats() 而不干扰业务?
直接在 HTTP handler 里调用 db.Stats() 没问题,但若你打算每秒采样一次做图表,就得起 goroutine + ticker——这时必须注意两点:一是避免 goroutine 泄漏,二是防止 Stats 调用本身成为瓶颈。
- 用
sync.Once启动采集 goroutine,不要每次请求都 new - 采集间隔至少 500ms;低于 200ms 时,
Stats()的原子操作会明显增加runtime.mcall调用频次 - 别在采集 goroutine 里做任何阻塞操作(如写文件、发 HTTP),只更新
expvar变量 - 如果应用启用了
SetMaxIdleConns,记得同时监控Idle和MaxIdleConns比值,长期 >95% 可能意味着连接复用率过高、新连接被扼杀
一个最小可行采集循环:
func startStatsCollector(db *sql.DB, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
defer ticker.Stop()
for range ticker.C {
s := db.Stats()
expvar.Get("sql_open_connections").(*expvar.Int).Set(int64(s.OpenConnections))
expvar.Get("sql_in_use").(*expvar.Int).Set(int64(s.InUse))
expvar.Get("sql_idle").(*expvar.Int).Set(int64(s.Idle))
// 其他字段同理...
}
}()
}
连接泄漏的典型信号和验证方式
监控不是万能的,得知道「看到什么该动手」。最危险的泄漏不是 OpenConnections 持续上涨(那太明显),而是 InUse 偶发卡在高位不回落,伴随 WaitCount 突增。
- 现象:
sql_in_use在 1–2 之间波动,但某次升到 5 后卡住 30s+,同时sql_wait_count_total每秒 +10 - 原因:大概率是某条 SQL 没 close
*sql.Rows,或 context 超时后连接未被 driver 正确标记为可回收 - 验证:用
lsof -p <pid> | grep :5432</pid>(PostgreSQL)或netstat -anp | grep <mysql-port></mysql-port>查看实际 socket 连接数,若远高于sql_open_connections,基本确认泄漏 - 修复:强制在
defer rows.Close()后加日志,或用go-sqlmock在单元测试里断言rows.Close()是否被调用
真正难排查的是跨 goroutine 的泄漏——比如一个 HTTP handler 启动了后台 goroutine 去查库,但 handler 返回后 goroutine 还活着。这种必须结合 pprof 的 goroutine profile 定位。











