起因
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
实战调参流程
- 启动配
MaxOpenConns = 25,pprof 看实际使用 WaitCount持续上涨 → 增大池或加 PG- PG 端
pg_stat_activity看实际并发:
sql SELECT count(*) FROM pg_stat_activity WHERE state = 'active'; - 加 PgBouncer 减少 PG 物理连接数
踩过的坑
-
每个请求
sql.Open新连接 → 没池化,几秒内打满。
sql.Open一次性 + db 全局共享 + DI 注入。 -
死锁 + 长事务:事务 A 持有 row 锁 + 等连接(池满),
事务 B 持有连接 + 等 A 的 row 锁。
解决:短事务 + ctx timeout。 -
MaxIdleConns > MaxOpenConns:参数互相矛盾,实际生效的是
MaxOpenConns。Idle 上限被 cap。 -
NAT 后面 DB:长连接经过几小时 NAT 表项过期 → 应用以为还活,
实际 DB 已断 → 报 "broken pipe"。设 ConnMaxLifetime < NAT timeout
(通常 30 min 安全)。 -
使用
Ping()测连接:高频 Ping 浪费连接 + 是 round-trip 开销。
只在启动时验证一次;运行时用 ConnMaxLifetime + 实际 query 检测。
登录后参与评论。