数据库连接池配错,服务半夜挂了我背锅
数据库连接池配错,服务半夜挂了我背锅
这是《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/sql 的 WaitCount 指标飙升——大量请求在排队等连接。把 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/sql 的 conn 方法。抓 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 件事:
- 打开你的项目,全局搜索
sql.Open - 检查后面跟了几个
Set方法 - 对照上面的"黄金组合"补全
你们 MaxOpenConns 设的多少?有没有被默认坑过?评论区见。
下期预告:《日志规范——日志级别乱打,线上排查像大海捞针》
本文完。如果对你有用,点个「在看」等于告诉微信"这货值得推给别人"。