📖 本篇简介:连接是应用访问数据库的入口,连接池配置不当——要么打满数据库,要么偶发断连难复现。本篇基于 MySQL 8.0,梳理连接池大小、连接泄漏、空闲超时、长事务占连接等 11 个高频坑。前置知识是 MySQL 连接管理与 OS 线程模型。学完后你应能合理配置连接池大小、识别连接泄漏、并处理偶发的连接断开问题。
目录
- 坑 1:连接池 max 过大打爆数据库
- 坑 2:忘记 close 导致连接泄漏
- 坑 3:空闲连接被 wait_timeout 静默杀掉
- 坑 4:连接池大小按 CPU 核数错误配置
- 坑 5:长事务占着连接不释放
- 坑 6:连接池没配连接有效性检测
- 坑 7:跨业务共用一个连接池
- 坑 8:短连接频繁建连开销大
- 坑 9:连接获取超时设置过长
- 坑 10:未监控连接池等待队列
- 坑 11:SSL 连接握手开销被忽视
- 连接问题排查速查
- 连接池配置方法论
- 速查表:连接池关键参数
- 自测练习
坑 1:连接池 max 过大打爆数据库
场景 / 错误示范:
开发觉得"多配点连接更保险",把连接池 max_size 设成 500。应用有 20 个实例,数据库瞬间面临 20×500=10000 个连接,远超 max_connections,新连接全被拒。
# 错误:单实例连接池过大,乘以实例数打爆数据库
pool = create_pool(max_size=500) # 20 个实例 = 1 万连接
为什么是坑:数据库能同时处理的连接数是有限的,不是越大越好。连接池设太大,多个实例叠加,数据库 max_connections 被打满,新连接被拒。更糟的是,即使连上了,过多并发连接会让 CPU 大量花在线程上下文切换上,吞吐反而下降。连接池大小要按"数据库能扛的总并发"倒推,而不是单个实例拍脑袋。
正确做法:
按数据库总承载倒推单实例连接池大小。
# 正确:数据库 max_connections=1000,留 20% 给管理,
# 20 个应用实例共享 800 个业务连接 -> 每池 max_size=40
pool = create_pool(max_size=40)
# 关键:总连接数 = 实例数 × 单池 max,不能超过数据库承载
-- 数据库侧总连接上限(本示例基于 MySQL 8.0)
SHOW VARIABLES LIKE 'max_connections'; -- 据此反推应用侧连接池总和
加分点:数据库实际有效并发远小于连接数(大多数连接在等待 IO),所以连接池不用开很大,靠队列排队即可。
坑 2:忘记 close 导致连接泄漏
场景 / 错误示范:
代码里从连接池借了连接,异常分支忘了 close。时间一长,池子里可用连接被借空,新请求拿不到连接,超时报错。
# 错误:异常路径没归还连接,连接泄漏
conn = pool.get()
try:
do_query(conn)
except Exception:
log.error("出错了")
# 这里忘了 conn.close(),连接再也回不到池里
为什么是坑:连接池里的连接是有限资源,借了不还,池子会被慢慢掏空。连接泄漏的典型表现是"系统启动时好好的,跑一段时间越来越慢,最后拿不到连接"。它不像语法错误立刻暴露,而是随时间累积,最难排查。
正确做法:
用 try-with-resources / with 语句保证连接一定归还。
# 正确:用上下文管理器,无论正常还是异常都归还连接
with pool.get() as conn:
do_query(conn) # 离开 with 块自动 close,即使抛异常也归还
加分点:连接池通常配 max_lifetime 和泄漏检测(如 HikariCP 的 leakDetectionThreshold),超过时间未归还就打日志告警。
坑 3:空闲连接被 wait_timeout 静默杀掉
场景 / 错误示范:
连接池里一条连接空闲了 9 小时,超过 MySQL 的 wait_timeout=8 小时,MySQL 把它关了。但连接池不知道,下次复用这条"已死"的连接,报 Lost connection during query。
# 错误:连接池长期保留空闲连接,被数据库静默关闭后复用
conn = pool.get() # 这条连接其实已被 MySQL 关闭
conn.execute("SELECT 1") # Lost connection
为什么是坑:MySQL 对空闲连接有 wait_timeout 清理,超时就关。连接池如果把这条连接一直留着复用,就会用到一条"数据库侧已关、池里以为还活着"的死连接。这种错误偶发、低频、难复现,是生产上最烦的连接问题之一。
正确做法:
连接池回收时间 < wait_timeout,并取连接前探活。
-- 数据库侧空闲超时(本示例基于 MySQL 8.0)
SET GLOBAL wait_timeout = 28800; -- 8 小时
SET GLOBAL interactive_timeout = 28800;
# 连接池:连接最长存活 < wait_timeout,取连接前 ping
pool = create_pool(
max_lifetime=7*3600, # 7 小时回收,小于 8 小时
test_on_borrow=True, # 取连接前探活,死连接自动剔除
)
加分点:排查偶发 Lost connection 先查 wait_timeout 与连接池回收配置,命中率极高。
坑 4:连接池大小按 CPU 核数错误配置
场景 / 错误示范:
有人引用"数据库连接数 = ((核数 × 2) + 有效磁盘数)"这个公式,在 32 核机器上把连接池设成 70。但这库是 IO 密集型(大量慢查询等待),70 个连接远远不够用。
# 错误:机械套用 CPU 核数公式,不看业务类型
pool_size = 32 * 2 + 1 = 65 # IO 密集库这个值太小
为什么是坑:连接池大小没有放之四海皆准的公式。CPU 密集型(计算、索引命中好)的库,并发确实接近核数×2;但 IO 密集型(大量查询在等磁盘),线程大部分时间在等待,可以开更多并发。机械套公式,要么开太大打爆库,要么开太小浪费吞吐。
正确做法:
按业务类型压测确定,而非套公式。
| 业务类型 | 并发特征 | 连接池参考 |
|---|---|---|
| CPU 密集、索引命中好 | 线程忙计算 | 接近核数×2 |
| IO 密集、有慢查询 | 线程多在等磁盘 | 可适当加大 |
| 混合 | 两者都有 | 压测找拐点 |
# 正确:压测找到吞吐拐点——连接数加到吞吐不再涨就停
# 不是越大越好,过了拐点吞吐反而下降
pool = create_pool(max_size=压测得到的拐点值)
加分点:压测时同时看数据库侧 CPU 和 RT,找到"RT 不恶化、吞吐最大"的连接数。
坑 5:长事务占着连接不释放
场景 / 错误示范:
一个事务里先做了个慢查询,中间还夹了个 RPC 调用外部接口。整个事务期间连接一直被占着,并发一来连接池被这类长事务占满。
# 错误:事务里夹了外部调用,连接被长时间占用
conn.begin()
do_db_query(conn)
call_remote_api() # 慢,几秒到几十秒,连接一直被占
do_db_query(conn)
conn.commit()
为什么是坑:事务期间连接不能被复用。事务里夹外部 RPC、慢查询,会把连接占用时间拉得很长。并发量一大,这些"长事务连接"占满连接池,新请求拿不到连接。连接池不够用,根因往往不是池子小,而是连接被长事务占着不放。
正确做法:
缩短事务,事务里不要夹外部调用。
# 正确:先做完外部调用,再开短事务
result = call_remote_api() # 事务外做慢的外部调用
with conn:
conn.begin()
do_db_query(conn, result)
conn.commit() # 事务尽量短,快速释放连接
加分点:监控"事务持续时间"和"连接占用时长",长事务是连接池耗尽的常见根因。
坑 6:连接池没配连接有效性检测
场景 / 错误示范:
连接池配了 max=100,但没开连接检测。后端某个节点重启后,池子里缓存的连接全部失效,业务持续报错,直到池子慢慢把死连接淘汰完。
# 错误:连接池不检测,后端重启后缓存的连接全废
pool = create_pool(max_size=100, test_on_borrow=False)
# 后端 DB 重启后,池里旧连接全失效,业务报错
为什么是坑:后端数据库重启、网络闪断、超时关闭都会让池里的连接失效。连接池如果不检测,就会把死连接发给应用用。高可用切换场景尤其明显——主库重启/切换后,连接池缓存的旧连接全废。
正确做法:
开启取连接前的有效性检测。
# 正确:取连接前先探活,死连接自动剔除重连
pool = create_pool(
max_size=100,
test_on_borrow=True, # 借连接时先 SELECT 1 探活
validation_query="SELECT 1",
)
加分点:高可用切换后主动通知连接池刷新拓扑,比等它逐个探活淘汰快得多。
坑 7:跨业务共用一个连接池
场景 / 错误示范:
订单服务和报表服务共用一个数据库连接池。报表服务跑了个大查询占着连接不放,订单服务拿不到连接,核心业务被非核心业务拖垮。
# 错误:核心业务和离线报表共用连接池
shared_pool = create_pool(max_size=50)
# 报表大查询占满连接,订单业务拿不到连接
为什么是坑:不同业务的连接需求不同——核心业务要低延迟、不能被排队;离线报表可以慢、可以等。共用一个池子,慢业务会把连接占满,连累核心业务。连接池隔离是资源隔离的一部分,核心和非核心必须分开。
正确做法:
核心与非核心业务分开连接池,数据库侧也可区分账号/资源组。
# 正确:核心业务和报表业务用独立连接池
order_pool = create_pool(max_size=30) # 订单核心
report_pool = create_pool(max_size=20) # 报表离线,单独限流
加分点:更进一步可用 MySQL 资源组(8.0 Resource Group)把报表查询绑到低优先级 CPU,不抢核心业务资源。
坑 8:短连接频繁建连开销大
场景 / 错误示范:
脚本每次执行都新建连接、用完就断。高并发下,MySQL 把时间都花在 TCP 握手、认证、建连上,真正查数据的时间反而不多。
# 错误:每次操作新建短连接,不复用
def query():
conn = pymysql.connect(host=..., user=...) # 每次新建
cur = conn.execute("SELECT ...")
conn.close() # 用完就断,下次再建
为什么是坑:建连成本不低——TCP 握手、账号认证、权限加载,每次都要几百 ms。短连接模式把大量时间花在"建连-断连"上,数据库吞吐被建连开销吃掉。尤其高并发短连接,会出现"连接数打满但都是新建连接"的现象。
正确做法:
用连接池复用长连接。
# 正确:全局一个连接池,长连接复用
pool = create_pool(max_size=20)
def query():
with pool.get() as conn: # 从池里拿已有连接,不新建
return conn.execute("SELECT ...")
加分点:脚本类短任务也建议用轻量连接池,而不是每次新建。
坑 9:连接获取超时设置过长
场景 / 错误示范:
连接池满了,请求等待连接,超时设成 30 秒。结果一个慢操作把 30 秒全耗在等连接上,上游已经超时返回,这个请求还在傻等,线程白白挂着。
# 错误:获取连接超时设太长,线程长时间挂起
pool = create_pool(max_wait=30) # 等 30 秒才超时,太长
为什么是坑:连接池满时,新请求要排队等连接。等待时间设太长,会让应用线程长时间挂着占资源,上游早就超时了,这边还在等。超时应该比上游超时短,快速失败而不是傻等,把线程释放出来处理别的。
正确做法:
获取连接超时设短,快速失败。
# 正确:获取连接超时短于上游,快速失败释放线程
pool = create_pool(max_wait=3) # 3 秒拿不到连接就报错,快速失败
# 上游超时一般 5 秒,这里 3 秒先失败,避免线程挂死
加分点:拿不到连接快速失败,比挂着傻等更能保护应用,配合限流降级。
坑 10:未监控连接池等待队列
场景 / 错误示范:
连接池慢慢被打满,但没人监控"等待连接的请求数"。业务已经在排队了,监控大盘却还正常,等到请求超时告警才知道连接池早满了。
# 错误:连接池内部状态不监控
# active_count / pending_count 没人看,满了不知道
为什么是坑:连接池的健康状态——活跃连接数、等待队列长度——是比数据库指标更靠近业务的信号。等待队列变长说明连接不够用了,是连接池容量不足或连接被占用太久的早期信号。不监控它,就只能等业务超时才发现。
正确做法:
采集连接池关键指标并告警。
# 监控连接池指标(本示例为通用连接池,按具体框架调整)
# active_count: 当前在用连接
# idle_count: 空闲连接
# pending_count: 等待获取连接的请求数
# pending_count 持续增长 -> 连接池不够用,告警
metrics.gauge("db.pool.active", pool.active_count)
metrics.gauge("db.pool.pending", pool.pending_count)
加分点:pending_count 突增是连接池告急的最早信号,比请求超时早几分钟到几分钟。
坑 11:SSL 连接握手开销被忽视
场景 / 错误示范:
为了安全开启了客户端到数据库的 SSL 加密。每次新建连接都要做 TLS 握手,建连时间从几百微秒涨到几毫秒,高并发下建连成了瓶颈。
# 错误:频繁建连 + SSL,握手开销被放大
conn = pymysql.connect(..., ssl={"ssl_mode": "REQUIRED"})
# 每次新建连接都做完整 TLS 握手,开销大
为什么是坑:SSL/TLS 握手需要多次往返和非对称加密,建连成本远高于明文。如果用短连接或连接池很小、连接频繁重建,SSL 开销会被放大。安全要求和性能之间要权衡——连接池复用长连接能把 SSL 握手摊到很少次数,抵消大部分开销。
正确做法:
用长连接池复用摊薄 SSL 握手,内网高安全再考虑。
# 正确:连接池复用长连接,SSL 握手只在建连时做一次
pool = create_pool(max_size=20, ssl={"ssl_mode": "REQUIRED"})
# 长连接复用,握手开销摊到整个连接生命周期,可接受
加分点:内网已隔离的环境可评估是否真需要端到端 SSL,别为了合规无差别全开。
连接问题排查速查
连接类报错种类多,但排查路径高度固定。遇到连接问题时按下面的顺序自问自答,能快速定位。
| 报错/现象 | 第一怀疑点 | 验证命令/手段 |
|---|---|---|
| Too many connections | 连接数打满 | SHOW STATUS LIKE 'Threads_connected' |
| Lost connection during query | 空闲连接被静默杀 | 查 wait_timeout vs 池回收时间 |
| 拿不到连接、超时 | 连接池耗尽/泄漏 | 看池 pending_count、活跃连接 |
| 偶发连接拒绝 | 连接数突增 | PROCESSLIST 按用户分组 |
| 建连很慢 | 短连接/SSL 握手 | 抓建连耗时、是否频繁新建 |
| 后端重启后批量报错 | 池里死连接未探活 | 查 test_on_borrow 是否开启 |
排查顺序口诀:先看数据库侧连接数是否打满,再看是不是死连接复用,再看是不是池子里连接被长事务占着,最后才怀疑网络和 SSL。绝大多数连接问题都在前两步就能定位,别一上来就查网络。
连接池配置方法论
连接池没有"默认最优值",但有一套可复用的配置思路。以下从"为什么这么配"角度整理。
| 参数 | 配多少 | 依据 |
|---|---|---|
| max_size | 按数据库总承载 ÷ 实例数 | 总连接不能超 max_connections |
| min_idle | 预热几个常驻连接 | 避免冷启动建连抖动 |
| max_lifetime | < wait_timeout | 防空闲连接被数据库静默杀 |
| max_wait | 短于上游超时 | 拿不到连接快速失败 |
| test_on_borrow | 开启 | 防用到死连接 |
| leak detection | 开启 | 抓忘记 close 的连接泄漏 |
核心认知:连接池的目标不是"越大越好",而是"在不打爆数据库的前提下,让请求尽量不排队"。监控 pending_count(等待队列)是判断连接池够不够的最直接信号——它增长就说明要扩容或优化慢连接了。
常见误读:把"数据库连接数"等同于"数据库并发能力"。其实大多数连接在等磁盘 IO,真正在 CPU 上跑的并发小得多,所以连接池不需要开很大,靠排队 + 快速失败保护系统即可。
速查表:连接池关键参数
| 参数 | 错误配置 | 正确配置 | 后果 |
|---|---|---|---|
| max_size | 单实例 500 | 总承载÷实例数 | 打爆 max_connections |
| max_lifetime | 不设 | < wait_timeout | 复用死连接报错 |
| test_on_borrow | 关闭 | 开启 | 后端重启后批量报错 |
| max_wait | 30 秒 | 3 秒快速失败 | 线程挂死 |
| 连接隔离 | 核心报表共用 | 分池 | 慢业务拖垮核心 |
| 泄漏检测 | 不开 | 开启 | 连接耗尽难排查 |
自测练习
1. 连接池 max_size 应该怎么定?
<details>
<summary>参考答案</summary>
按数据库 max_connections 总承载倒推:总连接数 = 实例数 × 单池 max,不能超数据库承载。机械按 CPU 核数套公式容易错,应压测找吞吐拐点,结合业务是 CPU 密集还是 IO 密集。
</details>
2. 偶发 `Lost connection` 怎么排查?
<details>
<summary>参考答案</summary>
多半是连接空闲超过 wait_timeout 被 MySQL 静默关闭,连接池复用了死连接。应让连接池 max_lifetime 小于 wait_timeout,并开启 test_on_borrow 探活。
</details>
3. 连接泄漏的典型表现和根因是什么?
<details>
<summary>参考答案</summary>
表现是"启动正常,跑一段时间拿不到连接"。根因是借了连接在异常分支忘了 close。应用 try-with-resources/with 语句保证归还,并开 leakDetectionThreshold 告警。
</details>
4. 为什么核心业务和报表业务不能共用一个连接池?
<details>
<summary>参考答案</summary>
报表大查询会长时间占用连接,把池子占满,连累核心业务拿不到连接。应分池隔离,必要时用 MySQL Resource Group 给报表低优先级。
</details>
5. 怎么判断连接池够不够用?
<details>
<summary>参考答案</summary>
监控 pending_count(等待获取连接的请求数),它持续增长说明连接池不够用或连接被长事务占用太久。这比请求超时更早暴露问题。
</details>
下一篇预告:第 68 章 定时任务与脚本踩坑
文章回复
0 条公开回复