症状
错误来自连接池而非数据库本身:sql: connection pool exhausted。两路遥测把它坐实:应用侧 db.Stats() 显示 open 钉在上限、idle 为零、waitCount 数千且等待时长分钟级——连接一去不回;数据库侧会话长期滞留在 idle in transaction,与打开未关的 rows 迭代器一一镜像。goroutine 转储补全画面:大量栈停在 database/sql.(*DB).conn 等待空闲连接。此前正常的查询开始超时,是“服务跑一段时间后”才出现的延迟引爆特征。
诊断思路
先看 db.Stats() 的 WaitCount/WaitDuration 量化排队,再对照 pg_stat_activity 的状态分布找 idle in transaction 会话;然后审计所有 db.Query/tx.Query 调用点,附近没有 defer rows.Close 的就是泄漏者;最后实时观察池指标,OpenConnections 爬到 MaxOpenConnections 后停滞而 WaitCount 持续增长,就是耗尽签名。返回 *sql.Rows 的辅助函数把关闭义务散播给几十个调用点,是结构性放大器。
根因
每个 *sql.Rows 在 Close 之前独占一条连接。未关闭的 rows 永久把连接移出池子;稳定流量下池子到达 MaxOpenConnections,后续每个查询都在排队,直到上下文取消或超时。
修复与验证
Query 之后立即 defer rows.Close(),每条退出路径(含 rows.Next 循环内的错误提前返回)都会释放连接;重构返回 rows 的辅助函数——在函数内消费完毕返回值,调用者拿到的不再是资源而是数据;池上限作为审慎决策设定——MaxOpenConns 匹配数据库容量、ConnMaxLifetime 适配负载均衡与防火墙的空闲切断。验证时驱动固定查询负载,断言结束后 OpenConnections 回落到 MaxIdleConns、WaitCount 保持为零。只调大 MaxOpenConnections 而不修泄漏,等于把耗尽转嫁给与其他服务共享的数据库全局上限。
代码示例
sql: connection pool exhausted
stats := db.Stats()
log.Printf("open=%d idle=%d waitCount=%d waitDuration=%s",
stats.OpenConnections, stats.Idle, stats.WaitCount, stats.WaitDuration)
// open=50 idle=0 waitCount=1421 waitDuration=14m
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return err
}
defer rows.Close() // releases the connection on every exit path
for rows.Next() {
if err := rows.Scan(&id, &name); err != nil {
return err // deferred close still runs
}
// process
}
return rows.Err()
// Consume the rows here; callers receive values, not resources.
func ListUsers(ctx context.Context) ([]User, error) {
rows, err := db.QueryContext(ctx, "SELECT id, name FROM users")
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}