Go 标准库已经提供了 database/sql,为什么生产环境还要专门配置连接池?因为 sql.DB 本身就是连接池,而它的默认配置只是“能工作”,并不意味着适合所有 Web 服务。
我踩过一个典型事故:数据库前面挂了一层代理,代理会定期清理空闲连接。Go 这边 ConnMaxLifetime 默认是 0,表示不限制连接的最大生命周期,连接只要仍被连接池保留,就可能长期复用。某次请求拿到一条已经被代理关闭的连接,日志开始大量出现 connection reset by peer。最后处理问题时,我发现连接池里几个参数的职责必须分开理解。
database/sql 最重要的四个参数分别是:
| 参数 | 默认值 | 作用 |
|---|---|---|
MaxOpenConns |
0 | 最大打开连接数,0 表示不限制 |
MaxIdleConns |
2 | 最大空闲连接数 |
ConnMaxLifetime |
0 | 单条连接的最大生命周期 |
ConnMaxIdleTime |
0 | 单条连接允许保持空闲的最长时间 |
MaxIdleConns
其中最容易被忽略的是 MaxIdleConns=2。假设服务突然出现 50 个并发请求,连接池可能建立大量连接;请求结束后,因为最多只能保留 2 条空闲连接,其余连接会被关闭。下一波流量到来时又重新建立连接。这样虽然请求本身没问题,却会产生明显的 connection churn:连接不断创建、关闭、再创建。
因此,对于流量比较稳定、希望尽可能复用连接的 Web 服务,我通常会让 MaxIdleConns 接近 MaxOpenConns。例如:
db.SetMaxOpenConns(40)
db.SetMaxIdleConns(40)
但这不是绝对规则。如果业务存在明显的峰谷,也可以主动降低 MaxIdleConns,用高峰后的重新建连成本换取更少的长期空闲连接。
MaxOpenConns
而 MaxOpenConns 解决的是另外一个问题:并发连接到底允许有多少条。 设置太小,请求会因为没有可用连接而排队,db.Stats() 中的 WaitCount 和 WaitDuration 会持续增长;设置太大,则可能直接把数据库连接数打满。
因此它不能简单按照“服务器有多少 CPU”来决定,而应该从数据库的连接预算倒推。例如数据库允许 500 条连接,预留 100 条给其他服务、管理和监控,剩余 400 条给应用;如果有 10 个 Pod,那么每个 Pod 可以从 40 左右开始,再根据实际监控调整。
ConnMaxLifetime
另外一个容易混淆的参数是 ConnMaxLifetime。它和 MaxIdleConns 解决的不是同一个问题。
如果数据库、代理、负载均衡器存在连接生命周期限制,那么客户端最好主动轮换连接。例如中间层允许连接最多存在 5 分钟,可以设置:
db.SetConnMaxLifetime(4 * time.Minute)
它的意义是避免客户端长期复用一条已经接近服务端生命周期上限的连接,从而降低 stale connection 导致 connection reset by peer、server has gone away 等问题的概率。
ConnMaxIdleTime
如果真正想控制“连接空闲多久就回收”,则应该使用:
db.SetConnMaxIdleTime(2 * time.Minute)
可以把两者简单理解为:MaxIdleConns 控制“最多留多少条”,ConnMaxIdleTime 控制“闲多久”,ConnMaxLifetime 控制“活多久”。
连接正确释放
最后还有一个比参数配置更常见的问题:连接没有正确归还。
rows, err := db.QueryContext(ctx, query)
if err != nil {
return err
}
defer rows.Close()
Rows、事务以及通过 db.Conn() 获取的专用连接,都应该在使用完成后及时释放。否则连接无法及时回到池里,即使连接池参数配置得再漂亮,也可能最终因为连接被长期占用而出现排队。