Go 的 database/sql 连接池实战:SetMaxOpenConns 怎么配、连接泄漏怎么查
线上服务跑着跑着突然大面积报 Error 1040: Too many connections,或者数据库 CPU 不高但接口全在等,十有八九是 database/sql 的连接池没配好。很多人以为 sql.Open 拿到的是一条连接,其实它返回的是一个连接池,池子怎么开、怎么回收、怎么防泄漏,全靠你自己设。这篇把连接池的几个关键参数和最常见的泄漏场景一次讲透。
先纠正一个误解:sql.Open 不建立连接
go
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
sql.Open 只做参数校验,不会真正连数据库。第一次执行查询时才会惰性建立连接。所以想在启动时就确认数据库通不通,得手动 Ping:
go
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
// 启动即验证,连不上直接 fail-fast,别等第一个请求进来才发现
if err := db.PingContext(context.Background()); err != nil {
log.Fatalf("db 不可达: %v", err)
}
还有一个高频错误:在每次请求里 sql.Open 。*sql.DB 是并发安全的,整个进程共用一个就够了。每请求 Open 一次会不断新建池子,连接数瞬间打爆。
go
// 错误:每个 handler 里都 Open,连接池根本没复用
func handler(w http.ResponseWriter, r *http.Request) {
db, _ := sql.Open("mysql", dsn) // 每次新池子!
defer db.Close()
// ...
}
正确做法是进程启动时 Open 一次,存成全局或依赖注入进去。
三个核心参数:池子多大、闲多久、活多久
go
db.SetMaxOpenConns(50) // 池子最多同时开 50 条连接
db.SetMaxIdleConns(10) // 空闲时最多保留 10 条待命
db.SetConnMaxLifetime(30 * time.Minute) // 一条连接最多活 30 分钟就丢弃重建
db.SetConnMaxIdleTime(5 * time.Minute) // 空闲超过 5 分钟的连接主动关掉
逐个说清楚它们的作用和踩坑点:
SetMaxOpenConns ------ 最大打开连接数。 默认是 0(无限制),这是最危险的默认值。无限制意味着高并发下 Go 会疯狂新建连接,直到把数据库的 max_connections 顶穿。设一个上限后,超出的查询会排队等待 空闲连接,而不是压垮数据库。数值怎么定?看数据库端 max_connections 减去其他服务占用,再除以你的实例数,留点余量。
SetMaxIdleConns ------ 最大空闲连接数。 请求高峰过后,池子里会留几条连接待命,避免下次请求又要重新握手。这个值如果比 MaxOpenConns 小很多,会出现一个隐蔽的性能问题:高峰期开了 50 条,峰值一过只留 2 条空闲,其余 48 条被关掉;下一波流量来了又得重新建 48 条连接。建议 MaxIdleConns 设成和 MaxOpenConns 相近(或至少不要差太多),让连接能被稳定复用。
SetConnMaxLifetime ------ 连接最大存活时间。 这个参数专门治一类诡异的报错:invalid connection 或 driver: bad connection。原因是数据库端(或中间的 LVS/云数据库代理)会主动断开长时间的连接,但 Go 的池子不知道,下次拿这条已经死掉的连接去查就报错。设一个比数据库 wait_timeout 略小的 Lifetime,让 Go 主动淘汰老连接,永远不会用到被服务端悄悄掐断的连接。
go
// 假设 MySQL wait_timeout=3600s,那 Lifetime 设小于它,比如 30 分钟
db.SetConnMaxLifetime(30 * time.Minute)
连接泄漏:Rows 忘了 Close,池子被慢慢抽干
这是 database/sql 最经典、最难查的坑。看这段代码:
go
// 有泄漏!
func listUsers(db *sql.DB) ([]string, error) {
rows, err := db.Query("SELECT name FROM users")
if err != nil {
return nil, err
}
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err // 提前 return,rows 没关!
}
names = append(names, name)
}
return names, nil // 正常路径也没关 rows
}
db.Query 会从池子里借走一条连接,这条连接要等 rows.Close() 才归还 。上面代码里 Scan 出错提前 return、以及正常返回时都没有关 rows,每次调用都漏掉一条连接。请求量一大,MaxOpenConns 很快被占满,后续查询全部卡在等连接,表现就是「接口超时但数据库很闲」。
正确写法:defer rows.Close() 紧跟在拿到 rows 之后,并且循环结束后检查 rows.Err():
go
func listUsers(db *sql.DB) ([]string, error) {
rows, err := db.Query("SELECT name FROM users")
if err != nil {
return nil, err
}
defer rows.Close() // 拿到 rows 立刻 defer,任何路径都归还连接
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err // 现在提前 return 也安全
}
names = append(names, name)
}
// rows.Next() 返回 false 可能是遍历完,也可能是中途出错,必须查
if err := rows.Err(); err != nil {
return nil, err
}
return names, nil
}
补一个细节:QueryRow 单行查询不用手动 Close,Scan 完会自动释放连接;但如果你 QueryRow 之后没有调用 Scan ,连接同样会泄漏。所以 QueryRow(...).Scan(...) 要连着写。
用池子状态监控揪出泄漏
光靠肉眼 review 不可靠,db.Stats() 能实时告诉你池子的健康度,把它接到监控里:
go
func monitorPool(db *sql.DB) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
s := db.Stats()
log.Printf("open=%d inUse=%d idle=%d waitCount=%d waitDuration=%s",
s.OpenConnections, // 当前打开的连接总数
s.InUse, // 正在被使用(没归还)的连接数
s.Idle, // 空闲待命的连接数
s.WaitCount, // 累计有多少次查询在等空闲连接
s.WaitDuration, // 累计等待时长
)
}
}
判断依据很直接:如果 InUse 长期贴着 MaxOpenConns 下不来,而流量并不高,基本就是有地方没关 rows/连接。如果 WaitCount 和 WaitDuration 持续涨,说明池子开小了或者被泄漏抽干,查询都在排队。
事务里更要小心:Tx 不 Rollback/Commit 会一直占着连接
事务会独占一条连接直到 Commit 或 Rollback。中途 return 忘了收尾,这条连接就永久泄漏了:
go
func transfer(db *sql.DB, from, to int, amount int) (err error) {
tx, err := db.Begin()
if err != nil {
return err
}
// defer 里根据是否出错决定提交还是回滚,保证连接一定归还
defer func() {
if err != nil {
tx.Rollback() // 出错回滚
} else {
err = tx.Commit() // 没错则提交,把 Commit 的错也带出去
}
}()
if _, err = tx.Exec("UPDATE account SET balance=balance-? WHERE id=?", amount, from); err != nil {
return err // 直接 return,defer 会 Rollback
}
if _, err = tx.Exec("UPDATE account SET balance=balance+? WHERE id=?", amount, to); err != nil {
return err
}
return nil
}
这里用命名返回值 err 配合 defer 是关键:无论从哪个分支 return,defer 都能根据 err 是否为 nil 决定 Commit 还是 Rollback,连接一定归还。
小结
sql.Open返回的是连接池 且惰性连接,进程内只 Open 一次并共享*sql.DB,启动时用PingContext验证连通。- 四个参数:
MaxOpenConns设上限防打爆数据库;MaxIdleConns设大点让连接稳定复用;ConnMaxLifetime设成小于数据库wait_timeout,躲开被服务端掐断的死连接;ConnMaxIdleTime回收长期空闲连接。 - 泄漏根源永远是「借了不还」:
Query后立刻defer rows.Close()并检查rows.Err();QueryRow一定接Scan;事务用命名返回值 + defer 保证 Commit/Rollback。 - 用
db.Stats()把InUse/WaitCount接进监控,InUse长期打满就是有泄漏。
一句话记忆点:database/sql 管的是池子不是连接,所有诡异的超时和 too many connections,先去查有没有 rows 或 tx 借了没还。