连接池设置的艺术:从一次“Threads_connected 超 10000”的线上告警说起
350 台应用服务器,2 个数据库集群,每个集群 6 个分库,每个分库
minPoolSize=5……当这些数字叠加在一起时,每个数据库集群的Threads_connected轻松突破 10000,数据库瞬间进入高压模式。这次告警我作为稳定性负责人临时接手排查——服务并非我设计,日常也非我维护,但正是这种“旁观者”视角,让我看清了连接池配置与分库数量之间被长期忽视的乘积炸弹。
一、事故现场还原
某天下午,监控系统突然爆出两条告警:
- 数据库连接使用率超过 70%(两个 MySQL 集群各自的
Threads_connected均突破 10000) - 具体数值:集群 A 约 10,500,集群 B 约 10,200,双双超过预设的 10,000 告警阈值
当时我作为稳定性负责人被拉进紧急会议,开始排查这套我从未参与设计、日常也不负责维护的服务。初步了解架构后发现:应用使用了分库分表,数据访问层采用美团开源的 Zebra 数据中间件。Zebra 会为配置的每个物理数据库(每个分片)独立创建并维护一个连接池,应用服务器直连物理 DB 的数量等于分片数。
架构细节:2 个数据库集群(cluster_0 / cluster_1),每个集群下各有6 个物理数据库(db_0 ~ db_5)。Zebra 配置了这两组数据源,每个应用服务器启动后会建立2 × 6 = 12 个独立的 C3P0 连接池。
每个连接池(C3P0)的配置为:
initialPoolSize=5 minPoolSize=5 # 关键!每个连接池启动后会至少保持 5 个空闲连接 maxPoolSize=30数据库服务器配置:每个集群的数据库实例运行在独享容器中,规格为24核 / 140GB内存 / PCIE-SSD 2900G / 千兆网卡。这样的配置在业界已属中高端,但在 10,000+ 连接面前仍然捉襟见肘。
为什么告警值是 10,000,而不是350 × 6 × 30 = 63,000?因为连接池的minPoolSize=5意味着每个应用服务器对每个物理数据库会维持至少 5 个常驻连接。扩容后,连接池会立即按minPoolSize创建连接,于是实际已经建立的连接数为:
单个集群总连接数 = 应用实例数 × 该集群内物理 DB 数量 × minPoolSize
= 350 × 6 × 5 =10,500
maxPoolSize=30是峰值上限,但由于业务并发尚未打满,连接池实际保持在minPoolSize=5的水平。即便如此,10,500 已经远超 MySQL 集群常见的max_connections(通常设为 4000~8000),告警立即触发。而两个集群各自独立,因此同时收到两条“超过 10000”的告警。
如果把maxPoolSize=30用满,单个集群连接数将高达 63,000,那将是灾难性的——但正是minPoolSize这个“温和”的参数,已经让系统在扩容瞬间就突破了警戒线。
二、根本原因:分库分表下的“保底连接”爆炸 + 过度分片
很多团队在配置连接池时,只关注maxPoolSize,却忽略了minPoolSize或initialPoolSize在多实例多分片场景下的乘积效应。通用公式有两个:
- 保底连接数(稳定态)= 应用实例数 × 分片数 ×
minPoolSize - 峰值连接数(极限)= 应用实例数 × 分片数 ×
maxPoolSize
在这个案例中,minPoolSize=5导致每一个物理数据库都被每个应用服务器长期占用至少 5 条连接,无论有没有请求。当应用实例数膨胀到 350 时,单个集群的保底连接数就破万了。
这解释了为什么告警不是发生在业务高峰,而是在扩容后立刻出现——因为连接池初始化时就会按照initialPoolSize/minPoolSize创建连接,与业务流量无关。
🔍 深度反思:6 个分库的设计是否合理?
随着排查深入,我发现一个更根本的问题:为什么每个集群要分 6 个库?了解业务数据后得知:每个集群总数据量约 1.25T,写入 QPS 峰值不超过 8000。在这样的数据量下:
- 单库 1.25T 虽然偏大,但在 PCIE-SSD + 140GB 内存的配置下仍可承受(主要挑战是备份和 DDL 时间)。
- 写入 QPS 8000,一个主库完全能轻松应对(MySQL 单库写入能力可达 1.5 万+)。
也就是说,6 个分库并非出于容量或性能硬性需求,而是早期设计者“为了分片而分片”,或者为了未来的增长预留。但代价是:连接数被乘以 6,在 350 台实例下直接引爆。
正确的分片数选择逻辑:
- 写入 QPS 8000 → 单库足够 → 分片数 1~2 即可。
- 数据量 1.25T → 可接受 2 个分库(每库 625GB)或 3 个分库(每库 416GB)。
- 6 个分库严重过度,直接导致了连接数爆炸。如果当初设计为 2 个分库,保底连接数仅为 350 × 2 × 5 = 3,500,远低于告警阈值,甚至可能永远不会出问题。
这次经历让我深刻体会到:分片数量不是越多越好,每增加一个分片,连接数、运维成本、故障半径都会成倍增加。分片决策必须基于真实的数据量和 QPS,而不是凭空猜测。
三、不是连接泄露,而是“过度连接”
很多同学第一反应是“连接泄露”。但从我们抓取的information_schema.processlist看:
SELECTSUBSTRING_INDEX(HOST,':',1)ASip,COUNT(*)FROMinformation_schema.processlistGROUPBYipORDERBYCOUNT(*)DESCLIMIT10;结果中每个 IP 在该集群内的连接数稳定在6 × 5 = 30左右(因为实际活跃池大小接近minPoolSize),且状态多为Sleep,TIME普遍小于 10 秒。这不是连接泄露,而是连接池主动维持的空闲连接。换句话说,该集群内的每一个物理 DB 都被每个应用服务器占用了至少 5 条“备勤”连接,无论是否有业务请求。
四、解决方案:四个层次的优化策略
作为临时接手排查的负责人,我需要给出快速止血和长期治理的方案。最终我们从架构层、配置层、路由层、代码层逐级优化。
1. 架构层(长期):减少分片数或引入代理
最根本的解决方式是减少分片数。经评估,业务写入 QPS ≤ 8000,总数据量 1.25T/集群,完全可以将 6 个分库合并为 2~3 个。如果合并,保底连接数将降至350 × 2 × 2 = 1,400(假设 min=2),问题彻底消失。
如果无法快速合并(需要数据迁移),则引入数据库代理(如 ShardingSphere-Proxy),让应用只连代理,代理连接后端 6 个分片。连接数变为350 × 1 × 每池连接数,同样大幅下降。
2. 配置层(紧急止血):调整initialPoolSize与minPoolSize
在等待架构调整的同时,我紧急修改了 C3P0 配置(经过压测验证):
# 原配置(每个物理 DB 的连接池) initialPoolSize=5 minPoolSize=5 maxPoolSize=30 # 优化后配置 initialPoolSize=2 # 启动时创建 2 个连接,加快启动速度但不过度 minPoolSize=2 # 最小空闲连接降为 2,保底连接数下降 60% maxPoolSize=20 # 峰值上限根据压测结果从 30 降至 20 maxIdleTime=600 # 空闲 10 分钟回收 maxIdleTimeExcessConnections=300 checkoutTimeout=3000为什么选择 minPoolSize=2 而不是 1?压测表明,在单机并发 100 TPS 时,连接池的活跃连接数中位数在 2~4 之间,若minPoolSize=1,会出现频繁的连接创建与销毁,反而增加数据库负担。设置minPoolSize=2恰好覆盖大部分静默流量,且保底连接数从 5 降到 2,单集群总连接数从 10,500 降至350 × 6 × 2 = 4,200,降幅 60%,低于 MySQLmax_connections(8000)的 60%,安全可控。同时maxPoolSize从 30 降到 20,进一步削峰。
3. 路由层(中期):Zebra 分组路由
Zebra 支持通过配置namespace或hint实现“应用分组直连部分分片”。我将 350 台服务器分为 6 组,每组约 58 台,通过 Zebra 的dbGroup特性让 Group0 只连接db_0,Group1 只连接db_1,依此类推。这样每个数据库的连接数降至58 × 2 = 116,集群总连接数 = 6 × 116 = 696,极大幅度降低。
4. 代码层:拒绝长事务 & 异步任务滥用
我们在排查中发现业务代码中存在多处类似模式:
@TransactionalpublicvoidbatchSave(List<SessionAssign>assigns){// 分批 + 循环 + 单条兜底for(List<SessionAssign>batch:batches){mapper.batchInsert(batch);}}并且在某些异步场景中被CompletableFuture.runAsync()并发调用。这导致单个事务持有连接时间长达数秒,连接池利用率低下,间接推高了所需的maxPoolSize。
优化建议:
- 拆分事务边界,每批数据一个独立事务(使用
TransactionTemplate)。 - 异步任务必须配置有界线程池,控制并发度。
- 添加
@Transactional(timeout = 3)强制短事务。
五、每新增一个数据库连接,究竟耗费哪些资源?
很多开发者对“连接数”的代价没有感性认识。下面从多个维度量化每增加一个 MySQL 连接带来的系统开销(以 MySQL 5.7+ / 8.0 为例,结合我们 24核/140GB 的数据库容器配置):
| 资源类型 | 每连接开销 | 说明 |
|---|---|---|
| 内存(线程私有) | ~256KB – 3MB | 每个连接对应一个线程,线程栈(thread_stack)默认 256KB;加上网络缓冲区(net_buffer_length默认 16KB)、临时表等,实际常驻内存约 2-3MB。10000 个连接 ≈ 20-30GB 内存,占 140GB 的 15-20%,虽未触顶但已显著影响 buffer pool 可用空间。 |
| CPU 上下文切换 | 随连接数线性增长 | 大量空闲连接会导致 MySQL 的pthread调度开销增加。在 24 核机器上,10000 连接时上下文切换次数可达到每秒数十万次,CPU 的sy(系统态)占用超过 30%,即使没有业务查询。 |
| InnoDB 内部结构 | 每连接约 4KB – 10KB | 事务对象、锁结构、事务隔离信息等,在trx_sys中维护。10000 连接额外占用约 40-100MB。 |
| 文件描述符 | 每个连接占用一个 socket | 操作系统每个进程能打开的文件描述符有限,需要调大ulimit -n(我们设为 65535)。 |
| 网络资源 | TCP 缓冲区 + 端口范围 | 每个连接占用一个本地端口(客户端侧);服务端每个连接占用一个 socket,双向缓冲区(net_buffer_length等)。 |
| 性能衰减 | 连接数 > 5000 时吞吐量明显下降 | 实测 MySQL 在 10000 连接时的 TPS 比 1000 连接时下降 40%~60%,因为锁竞争(LOCK_thread_count)和上下文切换成为瓶颈。在我们的 24 核机器上,10000 连接时 TPS 从峰值 8,000 降至 3,500。 |
结论:每个连接都不是免费的。当Threads_connected超过 10000,数据库已经处于重压状态,即使这些连接全是Sleep空闲连接,也会消耗大量内存和 CPU,导致正常查询响应变慢甚至超时。
六、配置数据库连接数应该考虑哪些内容?——必须经过压测验证
配置连接池大小(包括minPoolSize和maxPoolSize)绝不是凭经验或简单公式拍板,而应该遵循“压测驱动 + 指标闭环”的原则。以下是完整的配置决策流程,并重点分析Threads_running与 CPU 核数的关系。
1. 收集基础约束
- 数据库侧:
max_connections上限(如 8000),预留 20-30% 给管理、备份、监控。 - 应用侧:最大实例数(考虑扩容到极限),每个实例需要连接的分片数。
- 网络/OS:文件描述符限制、内存上限。
2.Threads_running与 CPU 核数的关系(核心指导原则)
Threads_running是 MySQL 中当前正在执行查询的线程数,不同于Threads_connected(包含空闲)。真正消耗 CPU 的是正在运行的线程,而非空闲连接。
经验公式:在典型 OLTP 场景下,最佳Threads_running约为 CPU 核数的 1.5 ~ 3 倍。
- 当
Threads_running≈ CPU 核数时,CPU 利用率可达到 100%(无等待)。 - 当
Threads_running超过 CPU 核数的 3~5 倍,操作系统会频繁进行上下文切换,吞吐量开始下降,平均响应时间急剧增加。 - 当
Threads_running超过 CPU 核数的 10 倍,系统进入“活锁”状态,TPS 几乎不再增长,RT 飙升。
如何利用这个关系配置连接池?
首先,通过压测确定:在目标数据库服务器(24核)上,业务 SQL 的平均执行时间(例如 5ms)。那么单核每秒约能处理 200 个查询(1000ms/5ms)。
其次,设定目标 CPU 使用率(如 70%),则允许的全局并发查询数(
Threads_running)≈ 24 × 0.7 / (查询耗时占比) ≈ 约 30~50。然后,
maxPoolSize的总和(所有应用服务器连接池上限之和)应该控制在使数据库的Threads_running不超过这个范围。因为每个maxPoolSize连接可能同时发出查询,所以:所有应用实例的
maxPoolSize之和 × 平均活跃率 ≤ 目标 Threads_running在我们的场景中,350 台实例,每台
maxPoolSize=20,理论并发查询能力为 7000,远超 CPU 处理能力。因此必须降低maxPoolSize或引入排队机制。实际做法:压测时在数据库端监控
Threads_running和 CPU 利用率。调整应用并发数,直到Threads_running达到 CPU 核数的 2 倍左右,此时整体吞吐量最高。将此并发数除以应用实例数,得到单实例合理的maxPoolSize。
3. 单实例压测确定单池maxPoolSize
在预发环境对单个应用实例 + 单个数据库进行梯度压力测试:
- 从
maxPoolSize=5,10,15,20,30逐步增加。 - 监控指标:应用的 TPS、平均 RT、99 线 RT、数据库 CPU 使用率、
Threads_running、连接池等待次数。 - 选择拐点:当继续增大
maxPoolSize时,TPS 不再提升,甚至下降(因上下文切换开销),该点即为最佳maxPoolSize。例如实测发现maxPoolSize=12时性能最好,再增大反而 RT 上升,则确定 12。
4. 多实例压测确定minPoolSize与保底策略
- 模拟 350 台实例同时启动(可用容器批量拉起),观察数据库连接数增长速度。
- 测试不同
minPoolSize(1/2/5/10)下,数据库内存和 CPU 占用,以及应用启动耗时。 - 选择原则:
minPoolSize应尽可能低,但要避免因空闲连接被回收而频繁重建导致性能波动。通常minPoolSize=2~3能覆盖绝大多数低峰期流量。我们通过压测发现minPoolSize=2时,空闲连接回收频率与创建频率平衡,且数据库Threads_connected稳定在 4000 左右,CPU 无异常。
5. 全链路压测验证全局连接数与Threads_running
- 使用生产规模实例数(350 台)和真实流量模型压测。
- 观察数据库的
Threads_connected、Threads_running、max_connections_used、CPU 利用率等指标。 - 核心目标:保证压测过程中
Threads_running始终不超过 CPU 核数的 3~4 倍(即 24核 × 3 = 72),否则说明连接池的maxPoolSize总和过大,需要进一步降低。 - 在我们的压测中,当
maxPoolSize=20时,峰值Threads_running达到约 120(5倍 CPU 核数),RT 明显上升。最终我们将maxPoolSize降到 12,此时峰值Threads_running≈ 70(3倍),TPS 反而提升了 15%。
6. 设置动态告警与自动降级
- 当
Threads_connected超过max_connections的 70% 时,发出预警。 - 当
Threads_running持续超过 CPU 核数的 4 倍时,触发紧急告警并可能限流。 - 当超过 85% 时,禁止应用继续扩容(通过服务注册中心暂缓注册)。
我们的实践:通过上述压测流程,最终确定每个 Zebra 连接池的minPoolSize=2, maxPoolSize=12。在 350 台实例下,单集群保底连接数为 4,200,峰值连接数为 8,400,峰值Threads_running控制在 70 以内,数据库 CPU 利用率稳定在 65%,整体 TPS 相比最初配置提升了 15%,同时 RT 降低了 30%。
七、最终效果 & 经验总结
我们按照上述四层方案逐步实施后,单个数据库集群的连接数变化如下:
| 阶段 | 每台服务器对集群内单库的连接数(保底/峰值) | 集群总连接数(保底) | 峰值 Threads_running | 数据库 CPU 利用率 |
|---|---|---|---|---|
| 扩容后原始状态(min=5, max=30) | 5 / 30 | 10,500 | >120 | 85%(过载) |
| 仅调整 C3P0(min=2, max=20) | 2 / 20 | 4,200 | ~90 | 70% |
| + 再调整至 max=12(压测优化) | 2 / 12 | 4,200 | ~70 | 65% ✅ |
| + Zebra 分组路由(min=2, max=12) | 2 / 12(只连1个分片) | ≈700 | ~35 | 35% ✅ |
最终我们选择了Zebra 分组路由 + 连接池优化(minPoolSize=2, maxPoolSize=12),数据库连接使用率稳定在 50% 以下,Threads_running健康,系统吞吐量相比原始配置提升了 15%。
核心经验(供所有稳定性负责人参考)
- 作为故障排查者,要保持对“历史设计”的批判性思考:不要因为服务不是自己设计的就接受所有现状。本次如果我不质疑“为什么一定要 6 个分库”,就无法找到根本的架构缺陷。
- 分片数量不是越多越好,必须基于真实的数据量和 QPS:写入 QPS 8000 完全不需要 6 个分库,2~3 个足矣。过度分片是连接数爆炸的元凶之一。
- 中间件并不是银弹:Zebra 这类轻量级框架虽然方便,但“每分片独立连接池”的设计在超大规模实例下会放大连接数。务必评估架构上限。
- 计算连接数必须考虑分片倍数与保底参数:
保底连接数 = 实例数 × 分片数 × minPoolSize峰值连接数 = 实例数 × 分片数 × maxPoolSize
这是架构级约束,不能仅靠调参解决。 Threads_running比Threads_connected更能反映数据库真实压力:配置连接池时,应以Threads_running不超过 CPU 核数的 3~4 倍为核心目标。minPoolSize和initialPoolSize必须按压测结果设置:过高浪费数据库连接,过低会导致频繁连接重建。我们的案例中从 5 降到 2,既节省 60% 连接又不影响性能。- 每个连接都有成本:内存、CPU、文件描述符、锁竞争。超过 10000 连接时,数据库性能会急剧下降,即使在 140GB 内存的机器上也是如此。
- 配置必须由压测验证:任何连接池参数(包括
maxPoolSize)都应该通过全链路压测确定拐点,而不是凭经验或默认值。
八、附:不同规模下的连接池参考配置(MySQL + Zebra / C3P0)
| 应用规模 | 集群内分片数 | 单池 minPoolSize | 单池 maxPoolSize | 集群总连接数(保底,按200台算) | 建议 |
|---|---|---|---|---|---|
| 小型(<10台) | 1~2 | 5 | 20~30 | <2000 | 标准配置即可 |
| 中型(10~50台) | 2~6 | 3 | 15~20 | <6000 | 开始需要关注乘积 |
| 大型(50~200台) | 6~12 | 2 | 10~15 | 2400~7200 | 必须使用分组路由或代理 |
| 超大型(>200台) | 12+ | 1~2 | 8~12 | 2400~9600 | 分组路由 + 代理 + 动态扩缩容控制 |
最后提醒:Threads_connected超过 10000 对 MySQL 来说极其危险。除了引发 CPU 上下文切换暴增外,还会导致 InnoDB 内部锁竞争加剧、内存占用过高。请务必将峰值Threads_running控制在 CPU 核数的 3 倍以内,所有配置必须经过压测验证。
希望这次复盘能让更多团队意识到:连接池配置不是点石成金的银弹,它只是系统容量规划中一个需要被量化的变量。真正的解法往往在架构层 + 严谨的压测体系。而作为稳定性负责人,即使是临时接手,也要敢于质疑历史设计,才能彻底解决问题。
作者:某大厂技术专家,本次告警排查中临时担任稳定性负责人,原服务非本人设计与维护。
原文首发 CSDN,转载请注明出处。
