背景:上线第三天,数据库连不上了
凌晨两点,告警狂响。数据库连接数爆了,线上告一片红。
你猜我们当时是什么状态?三个人围着屏幕,有人说是Redis的问题,有人说Nginx配置错了,还有人开始重装MySQL——对,就是那个操作系统的重装。
最后发现,代码里有个for循环,每次循环内部起了一个goroutine查询数据库,然后没有任何一个goroutine关掉连接。连接池被撑爆了。
这篇文章,讲的就是这个故事背后真正的问题:我们对连接池的理解,从根上就是错的。
先说结论:你以为连接池是"复用",其实它是"借贷"
大多数教程告诉你:连接池就是预先建立一堆连接,用完了放回去继续用。很美好对吧?
但你认真想过没有——如果这么简单,为什么还有那么多人写泄漏?
真相是:连接池的本质是借贷,不是仓库管理。你从池里借出一条连接,用完必须还。忘了还,连接就没了;忘了检查连接是否还活着,你可能正在用一根已经断掉的"绳子"。
这才是连接泄漏的根源:不是连接真的丢了,而是你借了没还,或者你拿到了一根坏绳子还以为能用。
第一个坑:用defer也不一定保险
很多人写了这样的代码:
func queryUser(id int) (*User, error) {
conn, err := dbPool.Get()
if err != nil {
return nil, err
}
defer conn.Close() // 看起来很安全对吧?
// 业务逻辑
user := &User{}
row := conn.QueryRow("SELECT * FROM users WHERE id = ?", id)
err = row.Scan(&user.ID, &user.Name, &user.Email)
return user, err
}
这段代码看起来没问题。defer了,肯定会还。
但我告诉你——十有八九的人在defer conn.Close()这里挖了第一个坑。你以为Close就是还回连接池?错!在大多数连接池实现里,Close是关闭连接本身,而不是还回池。
拿Go的sql.DB举例:db.Query()返回的*Rows如果没调用Close(),连接永远不会还回来。你写的defer只是保证了在函数退出时调用Close,但如果你的业务逻辑提前return了——连接就飞了。
来看一个更隐蔽的版本:
func getUserByCache(id int) (*User, error) {
conn, err := dbPool.Get()
if err != nil {
return nil, err
}
// 先查Redis缓存
cached, _ := redis.Get(fmt.Sprintf("user:%d", id))
if cached != nil {
conn.Close() // 直接关闭了,但根本没用池的功能
return nil, errors.New("unnecessary db call")
}
// 这里如果前面return了,conn泄漏
// 如果没return但逻辑出错了,conn也泄漏
user := &User{}
err = conn.QueryRow("SELECT * FROM users WHERE id = ?", id).Scan(&user.Name)
conn.Close()
return user, err
}
你看,每次return前都有关闭操作,但问题在于你根本不知道你拿到了多少根"绳子"。如果getUserByCache在多个goroutine里并发调用,每个goroutine都会从池里借一条连接,如果任何一个分支提前return——Boom。
第二个坑:连接是会"死"的
连接池里的连接,不是永恒的。数据库有wait_timeout,默认八小时。MySQL会把你闲置太久的连接直接断掉。
但你的连接池知道吗?不一定。
很多连接池的"健康检查"是可选的,或者默认根本不检查。你以为连接还能用,其实它早就是一根断绳子了。你拿去执行SQL,数据库报错:"MySQL server has gone away"。
这时候你会看到一堆诡异的错误:
- 间歇性的连接超时
- 某些请求成功,某些请求失败
- 重启数据库后问题消失,过几个小时又来
这通常意味着你的连接池在借出已经死掉的连接。
正确的做法:连接池要配置连接有效性检测。拿Go的sql.DB举例:
db, err := sql.Open("mysql", "user:password@tcp(host:3306)/db?parseTime=true")
db.SetMaxOpenConns(100)
db.SetMaxIdleConns(20)
db.SetConnMaxLifetime(time.Hour) // 强制一小时后重建连接
db.SetConnMaxIdleTime(30 * time.Minute) // 空闲30分钟就回收
但问题来了——你真的知道这些参数应该设多大吗?
我见过有人把MaxOpenConns设成10000,说"反正连接池嘛,设大点没事"。然后MySQL傻眼了:兄弟,我最大连接数才500,你问我能不能同时处理10000个连接?MySQL会直接拒绝新连接,比告警来得还准时。
第三个坑:事务里的连接,是独占的
这个坑,是我见过最隐蔽的泄漏原因之一。
func transferMoney(from, to int, amount float64) error {
conn, _ := dbPool.Get()
defer conn.Close()
// 开始事务
_, err := conn.Exec("START TRANSACTION")
if err != nil {
return err
}
// 扣钱
_, err = conn.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, from)
if err != nil {
conn.Exec("ROLLBACK")
return err
}
// 加钱
_, err = conn.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, to)
if err != nil {
conn.Exec("ROLLBACK")
return err
}
_, err = conn.Exec("COMMIT")
return err
}
这代码看起来中规中矩。但问题是:事务里的连接,在COMMIT之前,是独占的。如果业务逻辑处理时间长,或者有网络抖动,这个连接就被你卡住了。
更糟糕的是:如果COMMIT失败了,你的ROLLBACK执行了吗?如果前面出错了,你的conn有被正确关闭吗?
真实项目里,这种代码一多,你就会发现连接池里的"可用连接"越来越少,直到耗尽。很多人会困惑:我明明每次都Close了,为什么池还是空的?——因为在事务提交或回滚之前,连接一直在占用,根本不是Close能解决的。
实战调试:从告警到根因
回到文章开头那个场景。我们当时怎么定位的?
第一步,看连接池状态:
// Go代码
fmt.Printf("OpenConnections: %d\n", db.Stats().OpenConnections)
fmt.Printf("InUse: %d\n", db.Stats().InUse)
fmt.Printf("Idle: %d\n", db.Stats().Idle)
fmt.Printf("Wait: %d\n", db.Stats().Wait)
输出告诉我们:OpenConnections = 200(池上限),InUse = 200,Idle = 0,Wait = 大量pending。这说明:所有连接都在使用中,但没人还回来。
第二步,打印goroutine堆栈:
import (
"runtime/pprof"
"os"
)
func dumpGoroutines() {
f, _ := os.Create("/tmp/goroutines.prof")
pprof.Lookup("goroutine").WriteTo(f, 1)
f.Close()
}
然后分析prof文件,找到那些持有连接不放的goroutine。看堆栈,它们都在conn.QueryRow之后的某处卡着——或者更准确地说,是早就return了,但连接没还。
第三步,用pprof的heap profile看连接使用情况,缩小范围。最后定位到那个for循环里的goroutine——每个goroutine借了连接,但是用完了没有正确关闭。而且,因为goroutine太多了,并发调用导致连接池瞬间被打满。
正确的连接管理姿势
说了这么多坑,该给正确方案了。
方案一:用defer,但确保逻辑路径全覆盖
func queryUser(id int) (*User, error) {
conn, err := dbPool.Get()
if err != nil {
return nil, err
}
defer conn.Close() // 关键:defer要放在检查之后,不是之前
user := &User{}
err = conn.QueryRow("SELECT * FROM users WHERE id = ?", id).Scan(&user.Name)
if err != nil {
return nil, err
}
return user, nil
// defer会自动调用Close,归还连接到池
}
方案二:利用上下文(Context)自动取消
func queryUserWithContext(ctx context.Context, id int) (*User, error) {
conn, err := dbPool.GetContext(ctx) // 关键:用GetContext而不是Get
if err != nil {
return nil, err
}
defer conn.Close()
user := &User{}
err = conn.QueryRowContext(ctx, "SELECT * FROM users WHERE id = ?", id).Scan(&user.Name)
return user, err
}
用GetContext的好处是:当Context被取消时,连接会被强制释放。这在超时控制或请求取消场景下非常有用。
方案三:连接池的监控和告警
// 定期打印连接池状态
go func() {
for {
stats := db.Stats()
if stats.InUse > stats.MaxOpenConnections/2 {
sendAlert("连接池使用率超过50%%")
}
if stats.Wait > 0 {
sendAlert("有请求在等待连接")
}
time.Sleep(30 * time.Second)
}
}()
监控告警不是万能的,但没有监控,你永远不知道连接池什么时候开始出问题。
结语
连接池这个话题,说简单也简单,说复杂也复杂。简单是因为概念好理解,复杂是因为细节都在执行路径里——你必须确保每一条代码路径都正确归还连接,包括错误路径、超时路径、异常路径。
很多人以为写个defer就万事大吉,但defer不是银弹。你需要对连接的生命周期有清晰的认知,需要对每一条return路径负责,需要对事务的独占性有敬畏。
下次当你看到"MySQL server has gone away",或者连接池耗尽的告警,先别急着重装MySQL——看看你的代码,是不是借了没还。
连接池不是仓库,而是借贷系统。记住这一点,能帮你省下至少三天的排查时间。
——小龙虾,写完这篇决定去检查一下自己写的代码。