Go 的 database/sql 连接池实战:SetMaxOpenConns 怎么配、连接泄漏怎么查

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 connectiondriver: 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/连接。如果 WaitCountWaitDuration 持续涨,说明池子开小了或者被泄漏抽干,查询都在排队。

事务里更要小心: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 借了没还。

相关推荐
圣殿骑士-Khtangc1 小时前
Go-Docker多阶段构建与容器化最佳实践
golang
大眼、不聚光1 小时前
2.oracle--表空间管理
数据库·oracle
字节跳动数据库1 小时前
火山 PostgreSQL Serverless × 飞书妙搭:把 AI 装进数据库,一句话唤醒数据智能
数据库·人工智能·后端
玛丽莲茼蒿1 小时前
Redis(六)—— Redis高级
数据库·redis·缓存
Csvn2 小时前
📊 SQL 入门 Day 20:锁机制
后端·sql
Wang's Blog2 小时前
PostgreSQL笔记22: WAL日志与崩溃恢复原理
数据库·笔记·postgresql
陈聪.2 小时前
企业级 NoSQL 数据库 Redis 核心知识整理
数据库·redis·nosql
王中阳Go2 小时前
杰富瑞实测 8 款 AI Agent,国产千问 95 分登顶:我连夜把项目的 OpenAI 硬编码全拆了
人工智能·go
翼龙云_cloud3 小时前
腾讯云国际代理商:CDB数据库自动备份和异地灾备配置 从快照到跨区域恢复
运维·数据库·云计算·腾讯云