写一个 Go 的 API 服务时,我犯了个低级错误:每个请求都新建一个 sql.DB 实例。上线第三天,凌晨两点,报警炸了------MySQL 连接数飙到 500+,数据库直接拒绝连接。
问题现场:连接池管理翻车
业务场景很简单:一个用户查询接口,每天大概 20 万请求。代码大概是这样的:
go
func GetUser(id int) (*User, error) {
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
defer db.Close()
var user User
err = db.QueryRow("SELECT * FROM users WHERE id = ?", id).Scan(&user)
return &user, err
}
每个请求都 Open 一个新连接,用完 Close。看起来没问题对吧?但 sql.Open 并不会真正建立连接,它只是初始化一个连接池对象。真正建立连接是在第一次查询时。
问题在于:高并发下,每个请求都创建独立的连接池,这些连接池之间不共享连接。MySQL 默认最大连接数是 151,我这边每个连接池可能建立 3-5 个连接,20 个并发请求就能把数据库打满。
解决方案:全局连接池 + 参数调优
重构思路很简单:一个进程只维护一个 sql.DB 实例,所有请求共用这个连接池。
go
var db *sql.DB
func InitDB(dsn string) error {
var err error
db, err = sql.Open("mysql", dsn)
if err != nil {
return err
}
// 关键参数调优
db.SetMaxOpenConns(20) // 最大打开连接数
db.SetMaxIdleConns(10) // 最大空闲连接数
db.SetConnMaxLifetime(30 * time.Minute) // 连接最大存活时间
db.SetConnMaxIdleTime(5 * time.Minute) // 空闲连接超时
return db.Ping()
}
func GetUser(id int) (*User, error) {
var user User
err := db.QueryRow("SELECT * FROM users WHERE id = ?", id).Scan(&user)
return &user, err
}
SetMaxOpenConns 限制最大连接数,防止打满数据库。SetMaxIdleConns 控制空闲连接,避免频繁创建销毁。ConnMaxLifetime 解决长时间占用连接的问题------MySQL 默认 8 小时断开空闲连接,如果连接池不感知,会报 invalid connection。
踩坑细节:参数设置不当照样翻车
第一次重构后,我以为稳了。结果第二天又报警,这次是连接泄漏。排查发现,SetConnMaxLifetime 设成了 1 分钟,导致连接频繁重建,反而增加了数据库压力。
正确做法:根据业务场景调整。我的接口平均响应时间 50ms,30 分钟的超时足够。如果业务有长事务,可以适当延长。
另一个坑:Ping() 必须在初始化时调用,否则 sql.Open 不会验证 DSN 是否正确。我第一次上线时 DSN 写错了,直到第一个请求才报错,导致服务启动无异常但接口全挂。
我们项目在重构后,把连接池配置抽成了配置项,不同环境用不同参数。生产环境 MaxOpenConns 设为 20,测试环境只开 5 个,避免测试把数据库打满。
效果数据:P99 延迟从 800ms 降到 120ms
重构上线后,监控数据对比很明显:
- 数据库连接数:从峰值 500+ 稳定在 15-20
- P99 延迟:从 800ms 降到 120ms
- 错误率:从 12% 降到 0.1%
原因很简单:连接复用减少了 TCP 握手和 MySQL 认证的开销。每个连接建立需要 2-3 次网络往返,复用后这部分时间省掉了。
我们项目自研的监控系统里,还加了个连接池指标看板:当前活跃连接数、等待队列长度、连接创建速率。一旦活跃连接数超过 MaxOpenConns 的 80%,就触发告警。
最后说两句
Go 的 database/sql 包设计得很好,但文档里那些参数说明太官方了。实际调优时,得结合自己的业务场景:读多写少?短查询还是长事务?并发量多大?这些决定了 MaxOpenConns 和 ConnMaxLifetime 怎么配。
如果刚开始写 Go 的数据库操作,记住一句话:全局只有一个 sql.DB,别在函数里创建。