Go database/sql 连接池调优:避免"too many connections"和死锁

起因

Go 服务上线后偶发"connection pool exhausted" 和 PG "too many
connections" 错误。debug 后发现 Go database/sql 默认连接池配置
极宽松(无 max),高并发下能瞬间打开几百个 DB 连接。

下面讲怎么正确配 + 排查问题。

默认行为陷阱

db, _ := sql.Open("postgres", dsn)
// db 默认 MaxOpenConns = 0(无限制)
// MaxIdleConns = 2
// ConnMaxLifetime = 0(永不过期)

并发 1000 请求 + 每个 query 50ms → 瞬间 1000 个 DB 连接 → PG max_connections 100 撞墙 →
报错。

正确配置

db, err := sql.Open("postgres", dsn)
if err != nil { ... }

db.SetMaxOpenConns(25)         // 同时最多 25 个连接
db.SetMaxIdleConns(10)          // 池里 idle 保留 10 个
db.SetConnMaxIdleTime(5 * time.Minute)   // idle 超 5 分钟关闭
db.SetConnMaxLifetime(30 * time.Minute)  // 连接最长存活 30 分钟(轮换)

四个参数:

作用 推荐
MaxOpenConns 并发上限 DB max_connections / 进程数
MaxIdleConns 闲置池上限 MaxOpenConns 的 1/2
ConnMaxIdleTime 闲置多久关 5-10 min
ConnMaxLifetime 总寿命 30-60 min(防 DB 重启 / NAT timeout)

计算 MaxOpenConns

设 PG max_connections = 100,集群跑 4 个 Go 服务进程:

每进程 MaxOpenConns = (100 - 10 reserved) / 4 = 22

留 10 个 superuser / 监控 / DBA 用。

如果你的应用还会跑 background worker 进程也吃连接,进一步分配。

测试当前配置

import "fmt"
import "time"

func printPoolStats(db *sql.DB) {
    for range time.Tick(5 * time.Second) {
        s := db.Stats()
        fmt.Printf(
            "[db] open=%d in_use=%d idle=%d wait_count=%d wait_dur=%s\n",
            s.OpenConnections, s.InUse, s.Idle,
            s.WaitCount, s.WaitDuration,
        )
    }
}

go printPoolStats(db)

WaitCount > 0 持续增长 = MaxOpenConns 太小(请求在等池)。
InUse 接近 MaxOpenConns 时常 = 业务高峰;偶尔正常,持续要扩。

Prometheus exporter:

import "github.com/prometheus/client_golang/prometheus"

func collectDBMetrics(db *sql.DB) {
    s := db.Stats()
    openGauge.Set(float64(s.OpenConnections))
    inUseGauge.Set(float64(s.InUse))
    waitCountCounter.Add(float64(s.WaitCount))
}

ctx 超时控制

ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()

rows, err := db.QueryContext(ctx, "SELECT ...")

QueryContext 而不是 Query —— query 卡死时 ctx cancel 让 query
立刻断 + 连接归还池。
否则连接被卡住一直占。

长事务陷阱

// ❌
tx, _ := db.BeginTx(ctx, nil)
sendEmail(...)              // 慢 IO(几秒)
tx.Commit()

事务期间一直占一个连接。1000 请求 × 3 秒邮件 = 池被压爆。

// ✅
sendEmail(...)              // 先发邮件
tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback()
// 短的 DB 操作
tx.Commit()

事务内只做 DB;非 DB 操作放外面。

rows 没 Close → 连接泄露

// ❌ panic 时 rows.Close() 没跑
rows, _ := db.Query("...")
for rows.Next() {
    ...
}
// ✅ defer
rows, err := db.Query("...")
if err != nil { return err }
defer rows.Close()

for rows.Next() {
    ...
}
return rows.Err()    // 别忘 Err 检查

或者用更高级的 ORM(sqlx / GORM)封装这些细节。

prepared statement cache

stmt, err := db.PrepareContext(ctx, "SELECT * FROM users WHERE id = $1")
// stmt 可以跨多次 query 复用
for _, id := range ids {
    rows, _ := stmt.QueryContext(ctx, id)
    // ...
}
stmt.Close()

但 PG 的 prepared statement 是 per-connection 的。database/sql
自动处理(每次 PrepareContext 实际 ad-hoc prepared per connection)。

Postgres 推荐用 prepared statement 提升性能。或者用 pgx 替代
(更原生)。

pgx vs database/sql

go get github.com/jackc/pgx/v5
// pgx 直接连接池
import "github.com/jackc/pgx/v5/pgxpool"

pool, err := pgxpool.New(ctx, dsn)
// 配置:
config, _ := pgxpool.ParseConfig(dsn)
config.MaxConns = 25
config.MinConns = 5
config.MaxConnLifetime = 30 * time.Minute
pool, _ = pgxpool.NewWithConfig(ctx, config)

// query
rows, _ := pool.Query(ctx, "SELECT id FROM users WHERE x = $1", x)

pgx 优势:

  • 直接 PostgreSQL,性能比 database/sql 包 lib/pq 快 30-50%
  • 原生 prepared statement cache
  • 支持 PG 特有类型(jsonb / array / range)

如果你只用 PG,直接 pgx 替代。

PgBouncer:连接池中间层

如果有 N 个进程,每个开 25 连接 = N × 25 连接到 PG。
PG 单连接 5-10 MB → 100 连接 = 1 GB+ RAM。

PgBouncer 在 PG 前面挡:

app1 ─┐
app2 ─┤
... ─┼→ PgBouncer (在 app 端,pool transaction-level) → PG (少量真连接)
appN ─┘
sudo apt install -y pgbouncer

/etc/pgbouncer/pgbouncer.ini

[databases]
mydb = host=pg-server dbname=mydb

[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

pool_mode = transaction       # 关键:transaction-level pooling
max_client_conn = 1000
default_pool_size = 25
reserve_pool_size = 5
server_idle_timeout = 60

应用端连 localhost:6432 替代 PG 5432。

pool_mode=transaction 让连接事务级共享,1000 client → ~25 PG 连接。
极适合 short-lived query 场景。

注意:transaction mode 不能用 prepared statement / SET LOCAL(跨事务
状态丢)。pgx 5.0+ 有 PgBouncer 兼容模式。

DB 端配置

PostgreSQL postgresql.conf

max_connections = 200              # 集群总上限
shared_buffers = 8GB                # 通常 RAM 25%
effective_cache_size = 24GB         # RAM 75%
work_mem = 64MB                     # per query
maintenance_work_mem = 1GB

max_connections = 200 × work_mem = 64MB = 最差 12.8 GB(每 query
峰值)。可能 OOM。

工业上:

  • max_connections 100-200
  • 应用端 PgBouncer pool 共享
  • 单连接 work_mem 16-32MB

实战调参流程

  1. 启动配 MaxOpenConns = 25,pprof 看实际使用
  2. WaitCount 持续上涨 → 增大池或加 PG
  3. PG 端 pg_stat_activity 看实际并发:
    sql SELECT count(*) FROM pg_stat_activity WHERE state = 'active';
  4. 加 PgBouncer 减少 PG 物理连接数

踩过的坑

  1. 每个请求 sql.Open 新连接 → 没池化,几秒内打满。
    sql.Open 一次性 + db 全局共享 + DI 注入。

  2. 死锁 + 长事务:事务 A 持有 row 锁 + 等连接(池满),
    事务 B 持有连接 + 等 A 的 row 锁。
    解决:短事务 + ctx timeout。

  3. MaxIdleConns > MaxOpenConns:参数互相矛盾,实际生效的是
    MaxOpenConns。Idle 上限被 cap。

  4. NAT 后面 DB:长连接经过几小时 NAT 表项过期 → 应用以为还活,
    实际 DB 已断 → 报 "broken pipe"。设 ConnMaxLifetime < NAT timeout
    (通常 30 min 安全)。

  5. 使用 Ping() 测连接:高频 Ping 浪费连接 + 是 round-trip 开销。
    只在启动时验证一次;运行时用 ConnMaxLifetime + 实际 query 检测。

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。