数据库连接池配错,服务半夜挂了我背锅


数据库连接池配错,服务半夜挂了我背锅

这是《Go工程踩坑实录》第1期。这个系列会记录我在真实项目里摔过的跤——不是教科书知识,是带着伤疤的经验。


凌晨2点,钉钉炸了。

服务无响应,告警群里刷屏。我眯着眼爬起来,登录服务器,敲下 netstat | grep 3306 | wc -l——输出 500+。MySQL 吐着 “Too many connections”。

我盯着那段代码看了十分钟:

db, _ := sql.Open("mysql", dsn)

除了数据库地址,我从没改过别的参数。


01 凌晨2点,连接池炸了

背景是一台电商服务,QPS 不高,也就 200 左右。但接口响应越来越慢,像温水煮青蛙,直到整锅沸腾。

症状很典型:

  • 服务不报 panic,但接口延迟从 50ms 爬到 2s+
  • 重启后好 10 分钟,然后继续恶化
  • show processlist 里几百个 Sleep 状态的连接,躺着不动

排查链路是这样的:

先看代码——连接没显式 Close?不对,用的是 sql.DB 连接池,按理说会自己管。

再看 MySQL——连接数打到上限,新请求进不来。

最后看 sql.DB 参数——全默认。MaxOpenConns 是 0(无限),MaxIdleConns 是 2。

根因很蠢:高峰期并发上来,连接池疯狂开新连接。峰值过了,连接变成 Sleep,但池子只留 2 个,多余的关了。下次高峰再来,重新建——反复创建、销毁、再创建,TCP 挥手+MySQL Auth 认证(身份校验、权限检查)反复执行,这部分 CPU 开销往往比纯 TCP 握手更显著。

【配图建议:连接数增长趋势图】横轴时间,纵轴连接数,呈阶梯状上升,峰值后小幅下降又继续攀升


02 sql.DB 的 5 个参数,我逐个说

Go 的 sql.DB 有 5 个生死参数,80% 的人只调过 1 个,有的甚至 1 个都没调过。

我把它们分成两类:

  • 数量控制:MaxOpenConns、MaxIdleConns
  • 生命周期:ConnMaxLifetime、ConnMaxIdleTime
  • 第 5 个是它们的化学反应

MaxOpenConns —— 最大连接数

控制什么:同时能打开多少个数据库连接。

默认值:0(无限)

这是最大的坑。0 意味着不设上限,业务峰值瞬间能打爆 MySQL 的连接上限(默认 151,即使调了也有限)。你的服务不是第一个崩的——MySQL 先崩,然后所有连它的服务一起雪崩。

怎么算?

经典公式来自《Java 并发编程实战》,针对单机磁盘 I/O 的理论参考:

连接数 = (CPU 核心数 × 2) + 有效磁盘数

但这只是单机时代的经验。现代微服务环境下,I/O 等待极高,磁盘往往是分布式存储,这个公式仅供参考。更重要的是:MySQL 连接数是全局资源,公式必须考虑全量实例总和。

MaxOpenConns = MySQL 总连接数 × 0.8 / 同库服务实例数

举例:MySQL 设了 1000 连接,4 个服务实例共享 → 每个实例 200。

db, _ := sql.Open("mysql", dsn)
// 不设这个,等于没有保护
db.SetMaxOpenConns(200)

MaxIdleConns —— 最大空闲连接

控制什么:连接池里"躺着不动"的连接,最多留几个。

默认值:2

2 个是什么概念?高峰期建了 100 个连接,用完只剩 2 个,剩下的全关了。下次高峰再来,重新建 100 个——反复创建/销毁,TCP 握手+MySQL 认证,性能全耗在这上面。

Go 官方为什么要设为 2?为了保守,不占用过多资源。但问题是:官方的保守是为了通用性,而你的生产环境需要的是吞吐量。

通常等于 MaxOpenConns,或者至少 MaxOpenConns 的 50%。高频服务建议直接相等,让连接保持"热"状态,拿来就用。

// 错误:只设了最大连接,空闲只留2个
db.SetMaxOpenConns(200)

// 正确:让连接复用起来
db.SetMaxOpenConns(200)
db.SetMaxIdleConns(200)

ConnMaxLifetime —— 连接最大生命周期

控制什么:一个连接最多活多久,到期强制关闭,不管用不用。

默认值:0(无限)

MySQL 端有个 wait_timeout,默认 8 小时。连接超过这个时间,MySQL 会单方面关掉。Go 这边的 sql.DB 不知道,拿着一个死连接去执行 SQL,报错 bad connection

更坑的是:报错后才重建,期间那个请求已经失败了。

怎么设?

必须小于 MySQL 的 wait_timeout

生产建议 1 小时以内,让连接自然轮换。用云数据库(RDS、Cloud SQL)的建议更短,15-30 分钟——因为云厂商在中间加了代理层,代理的超时往往比 MySQL 更短。

注意:别设得太短(比如 1 分钟)。如果 ConnMaxLifetime 到期时并发极高,会导致池内连接频繁大规模刷新,产生明显的连接抖动(Connection Churn),引起 CPU 波动。1 小时是个相对安全的平衡点。

// 1 小时换一批连接,避免被 MySQL 单方面断开
db.SetConnMaxLifetime(1 * time.Hour)

ConnMaxIdleTime —— 空闲连接最大存活(Go 1.15+)

控制什么:连接空闲多久没使用,就关掉。

默认值:0(无限)

很多人分不清它和 ConnMaxLifetime 的区别:

  • Lifetime:不管用不用,到点就死(强制刷新,防连接老化)
  • IdleTime:不用才死,用着的不管(节约资源)

为什么重要?

微服务弹性缩容时,实例减少了,连接该释放就释放。别让 10 个实例变 3 个实例后,那 7 个实例的连接还占着 MySQL 资源。

db.SetConnMaxIdleTime(10 * time.Minute)

参数之间的化学反应

单独看每个参数都明白,合在一起就容易出错。

常见错误组合:

组合 后果 体检报告表现
MaxOpenConns=100, MaxIdleConns=2 连接反复创建/销毁,CPU 浪费在握手上 WaitCount 持续增加,OpenConnections 频繁跳变
MaxOpenConns=0, ConnMaxLifetime=0 连接只增不减,直到 MySQL 爆掉 OpenConnections 单调上升,Idle 占比极低
ConnMaxLifetime > MySQL wait_timeout 拿到死连接,随机报错 bad connection 偶发错误,无规律,OpenConnections 波动小

金句:连接池的配置,本质上是在**响应速度(热连接)资源保护(MySQL 上限)**之间拉大锯。

黄金组合(生产建议):

db.SetMaxOpenConns(200)                 // 根据 MySQL 总连接数÷实例数算
db.SetMaxIdleConns(200)                 // 高频服务 = MaxOpenConns
db.SetConnMaxLifetime(1 * time.Hour)    // 小于 MySQL wait_timeout
db.SetConnMaxIdleTime(10 * time.Minute) // 不用就释放,节约资源

【配图建议:参数关系状态机图】展示连接从创建 → 使用 → 空闲 → 关闭的流转,标注每个参数的触发点


03 从默认到生产级,我踩了 3 个坑

坑1:“默认就挺好的"陷阱

初期代码就是这么写的:

db, _ := sql.Open("mysql", dsn)

什么都没设。甚至很多人以为执行完 sql.Open 报错就代表连不上数据库——错了,sql.Open 只是初始化结构体,并不真正建立连接。必须执行一次 db.Ping() 才能确认配置和网络是否通畅。这也是为什么很多人配错了参数,服务启动时却不报错的原因。

业务增长后,连接数缓慢爬升,直到打满 MySQL。我那时候还以为 sql.DB 的零值是"安全默认值”——错了,零值只是"让你跑起来"。

坑2:MaxIdleConns 设太小

后来学了乖,设了 MaxOpenConns=200,但 MaxIdleConns 没动(默认 2)。

结果 pprof 一看,database/sqlWaitCount 指标飙升——大量请求在排队等连接。把 MaxIdleConns 提到 200,WaitCount 归零,平均响应时间降了 40%。

坑3:ConnMaxLifetime 被忽略

线上偶发 driver: bad connection,概率 1%,很难复现。查 MySQL 日志才发现,大量连接被 wait_timeout 关闭。加上 SetConnMaxLifetime(30 * time.Minute),问题消失。

这 3 个坑的排查时间加起来,够我写 10 篇文章。

调优前后对比:

配置 平均响应 P99 MySQL 连接数波动
全默认 120ms 800ms 剧烈(2→300→2)
只设 MaxOpenConns 80ms 500ms 中等
四参数全调 25ms 80ms 平稳(180-200)

【配图建议:调优前后对比柱状图】三组柱状图对比平均响应和 P99


04 下次事故前,这 3 个命令能救你

1. db.Stats() —— 连接池的体检报告

sql.DB 内置了 Stats 方法,关键指标:

  • OpenConnections:当前打开的连接数
  • InUse:正在执行 SQL 的连接数
  • Idle:空闲连接数
  • WaitCount:等连接池的阻塞次数(>0 说明池子小了

实战做法:暴露到 Prometheus,画成 Dashboard。

// 每 10 秒打印一次,上线时观察
stats := db.Stats()
log.Printf("open=%d in_use=%d idle=%d wait=%d",
    stats.OpenConnections, stats.InUse, stats.Idle, stats.WaitCount)

连接泄露警示:如果 InUse 一直涨、Idle 始终为 0,即使配置了很大的 MaxOpenConns 也没用——这说明代码里有连接没释放。常见原因:rows.Next() 没走完就 return,或者忘了 rows.Close()。池子配得再好,也扛不住代码逻辑层面的泄露。

2. MySQL 侧验证

-- 当前连接数和状态
show status like 'Threads_%';

-- 谁在占连接
show processlist;

-- 连接历史峰值(看看有没有打过上限)
show status like 'Max_used_connections';

3. pprof + 火焰图

连接池等待时,goroutine 会卡在 database/sqlconn 方法。抓 goroutine profile:

curl http://localhost:6060/debug/pprof/goroutine > goroutine.out
go tool pprof goroutine.out

火焰图里 database/sql.(*DB).conn 占比高 → 连接池瓶颈实锤。

【配图建议:Prometheus Dashboard 截图位置】展示 OpenConnections / WaitCount / InUse 三条曲线


05 面试官爱问的 2 道题

Q1:连接池和线程池有什么区别?

线程池管理的是执行任务的线程,线程是 CPU 调度单位,数量有限(几百就很多了)。

连接池管理的是数据库 TCP 连接,连接是网络资源,瓶颈在 MySQL 端(通常几千上限)。

关键区别:线程执行完任务回到线程池复用;连接执行完 SQL 回到连接池复用。

Go 的特殊点:goroutine 很轻量,一个 goroutine 可以拿一个连接去做 IO。所以 Go 里"线程池"概念很弱,但"连接池"是刚需。

Q2:如果连接池满了,新的请求怎么办?

sql.DB 内部有等待队列,请求会阻塞等连接释放,不是直接报错。

但如果等太久(超过 context 超时),会返回 context deadline exceeded

所以连接池设置要和超时策略联动:超时时间 > 正常 SQL 执行时间,但 < 用户可容忍的最大等待。

更好的做法:连接池大小根据压测定,预留 20% 余量。


记住这 4 行代码

db.SetMaxOpenConns(200)
db.SetMaxIdleConns(200)
db.SetConnMaxLifetime(1 * time.Hour)
db.SetConnMaxIdleTime(10 * time.Minute)

用一句话串起来,方便背诵:

开够用的(Open),留住热的(Idle),在它老死前换掉它(Lifetime),在它闲死时辞退它(IdleTime)。

别再用默认了。默认是让你跑起来的,不是让你跑好的。

下次 review 代码,看到 sql.Open 后面光秃秃的,顺手把这几行加上。也许某个凌晨 2 点,你会感谢现在的自己。

现在可以做的 3 件事:

  1. 打开你的项目,全局搜索 sql.Open
  2. 检查后面跟了几个 Set 方法
  3. 对照上面的"黄金组合"补全

你们 MaxOpenConns 设的多少?有没有被默认坑过?评论区见。

下期预告:《日志规范——日志级别乱打,线上排查像大海捞针》


本文完。如果对你有用,点个「在看」等于告诉微信"这货值得推给别人"。

wx

关注公众号

©2017-2023 鲁ICP备17023316号-1 Powered by Hugo