线上服务响应变慢,第一反应往往是”连接池不够用,调大最大连接数”。调完可能确实缓解,也可能更慢,甚至把数据库拖垮。连接池的参数不是孤立的,它连接的是应用侧并发模型与数据库侧执行模型两端。理解两端的成本结构,才能知道连接数该定多少。
一条连接的建立代价
应用发起一次新建连接,走完的流程大致是:
客户端 服务端
|--- TCP SYN -------->|
|<-- SYN+ACK ---------|
|--- ACK ------------>| 1 次 RTT 完成握手
|<-- Server Greeting --|
|--- Auth Request ---->| 认证交互
|<-- Auth Result ------|
|--- Init Query ------>| 会话初始化:字符集、时区、
|<-- OK ---------------| autocommit、sql_mode、隔离级别
如果中途还启用了 TLS,握手轮次还会增加。跨机房单次 RTT 可能在 1ms 量级,同城也可能落在零点几毫秒。加起来,一条冷连接的建立开销通常在毫秒到十毫秒量级,而热连接上执行一个走索引的单表查询可能只有零点几毫秒。短连接模型把大部分时间花在了握手与初始化上。
除时间开销外,每条连接在服务端还对应一个线程(或线程池中的执行槽位),带自己的线程栈、会话缓冲、排序缓冲、预处理缓存,占用几 MB 量级内存是常见的。新连接还是”冷”的:线程局部缓存、连接级预处理缓存都没有命中率积累,服务端需反复做解析准备。
连接池在解决什么
连接池把建立连接这个昂贵动作从”每次请求”摊薄到”首次或偶发”。池维护一组已完成握手与初始化的就绪连接,业务代码获取的是暂用句柄,用完归还而非销毁。
Connection conn = dataSource.getConnection(); // 从池里借,不等握手
try {
// 执行业务 SQL
} finally {
conn.close(); // 归还,不是真关
}
这段代码的正确性完全依赖 finally。少一次归还,池里可用连接就少一个——这就是连接泄漏。
核心参数与它们的真实语义
| 参数 | 常见命名 | 作用 | 设错之后的症状 |
|---|---|---|---|
| 最小空闲 | minimumIdle / minIdle | 池维持的常驻连接下限 | 设太小,每次流量上升都要现握手,出现周期性抖动 |
| 最大连接 | maximumPoolSize / maxActive | 池允许的总连接上限 | 设太大,数据库过载;设太小,获取连接排队 |
| 获取超时 | connectionTimeout | 借不到连接最多等多久 | 太大则线程池全堵在借连接,接口超时;太小则偶发抖动就报错 |
| 空闲超时 | idleTimeout | 空闲连接保留多久后回收 | 太小则反复创建销毁,退化成短连接 |
| 生命周期 | maxLifetime | 连接自创建起的最大存活时长 | 不设或太长,连接可能已僵死或长期粘在某代理节点 |
| 泄漏检测 | leakDetectionThreshold | 借出多久未归还则告警 | 不设,泄漏只能靠事后猜 |
长期不更换的连接可能已被中间网络设备(LB、NAT 网关、防火墙)悄悄清理过会话表。它本地看是”就绪”的,一旦使用会在第一个包上失败或卡到 TCP 超时。maxLifetime 定期淘汰重建,避免这种僵死连接驻留池中被反复使用,取值应小于链路上任何一层的 idle timeout。
保活机制是 maxLifetime 的补充。多数池支持在连接空闲时定期发送轻量探测(SELECT 1 或协议层的 ping):
# 示例:HikariCP 风格的参数表达
connectionTestQuery=SELECT 1
keepaliveTime=60000
validationTimeout=3000
探测本身有代价。设置得过于频繁(几秒一次),几十上百个空闲连接会持续发送无意义查询,消耗 CPU 与 QPS 配额。合理量级通常是分钟级。
池大小应该定多少
一个流传很广的公式是”CPU 核数 × 2 + 磁盘数”。它描述的是机械磁盘寻道时代的经验值,前提假设是每条连接都在做均匀的磁盘 IO。现代 SSD 环境与混合负载下套用它会失真。
更有参考价值的是从吞吐量目标反推:
设:每条连接上的单次查询平均耗时 RT(秒)
应用希望支撑的目标吞吐 TPS(次/秒)
那么一个连接在串行使用下每秒能完成 1 / RT 次查询
需要的并发连接数 N ≈ TPS × RT
举例说明这个量级:假设单条 SQL 平均耗时 5ms(0.005 秒),应用目标吞吐是每秒 4000 次查询,那么理论上的并发连接需求大约是 4000 × 0.005 = 20 条。这是一个量级上的参考,而不是精确值,因为 SQL 耗时本身会随并发上升而变长。
这个反推关系揭示了一件重要的事:连接数的最优值与 RT 强相关。SQL 变慢时(比如慢了 10 倍),要么所需并发数按比例膨胀,要么吞吐按比例下降。这也解释了为什么 SQL 变慢后调大连接池往往只是掩盖问题——根因没解决,反而允许更多并发进来进一步拖慢 SQL。连接数增到某个拐点之后,单机吞吐通常不再上升甚至回落。
为什么连接数过多会更慢
- 上下文切换:连接数远超 CPU 核数时,内核要在大量可运行线程间频繁切换,直接消耗 CPU,并因线程换入换出拉低缓存命中率。
- 内存压力:排序缓冲、JOIN 缓冲、临时表按需分配。连接越多,同时触发大内存分配的概率越高,容易引发额外 IO 或 OOM。
- 锁竞争加剧:内部的 buffer pool 页面闩、锁管理器哈希表、日志互斥器等共享结构上的竞争随并发上升而加剧,表现是
SHOW ENGINE INNODB STATUS里等待队列变长而 TPS 下降。 - 排队不等于并行:数据库侧通常还有总连接上限与执行槽位限制。池放大到超过它之后,超出部分只是在更上层排队,拉长了平均响应时间。
因此连接池上限本质上是一道限流阀门:它保护数据库不被自己的流量压垮,而不是”越大越通畅”的旋钮。
连接泄漏怎么发现
泄漏的典型演进轨迹是:初期池偶尔打满又自行恢复;中期高峰期开始出现获取连接超时(Connection is not available, request timed out after 30000ms 这类异常);后期应用完全拿不到连接,所有依赖数据库的接口超时,重启后临时恢复。
定位手段:
-- 观察连接数与它们的活跃状态
SELECT id, user, host, db, command, time, state, LEFT(info, 100) AS q
FROM information_schema.processlist
ORDER BY time DESC LIMIT 20;
如果看到大量连接 command 为 Sleep 且 time 持续增长,同时应用侧报获取连接超时,基本可以判定存在没有归还的连接。拿这些 Sleep 连接对应的 info(最后一条 SQL)可以反推代码位置。
一类隐蔽的泄漏藏在事务里:开了事务后中间调用可能长时间阻塞的操作(远程调用、等消息队列、锁等待),事务不提交不回滚,连接就被一直占着。这类问题需要结合应用层堆栈才能定位。
// 反例:远程调用被包在事务里,连接的占用时长完全受控于外部依赖
@Transactional
public void placeOrder(Long orderId) {
orderMapper.insert(orderId);
remoteInventoryService.deduct(orderId); // 慢则连接被长时间占用
}
更合理的做法是把事务边界收缩到只包住本地写操作,远程调用放在事务之外。
超时链必须逐层递减
一个健壮的系统,超时配置应该满足:
网关 / 接入层超时
> 应用业务接口超时
> 数据库连接池获取超时 + 单条 SQL 超时
> 数据库侧 wait_timeout / statement_timeout
如果顺序反过来,会出现”数据库已经放弃等待,应用还在傻等”的情况。举例来说,服务端因锁等待超过 innodb_lock_wait_timeout(默认 50 秒)报错回滚,但应用侧设的是 60 秒,那么应用会多等 10 秒才感知失败,这 10 秒里服务器资源仍被无人关心的 SQL 占用。
另一个方向同样有坑:应用层超时短于数据库侧时,应用超时后会关闭连接,服务端那条 SQL 却可能继续执行。因此应用层除了设置更严格的超时,在超时路径上还应主动终止数据库会话(KILL QUERY),而不是放任语句消耗资源。
一组可操作的配置建议
- 先优化 SQL 再定池大小;用
TPS × RT算量级起点,压测中逐步调整找到拐点。 maxLifetime小于链路上网络设备的空闲会话超时;泄漏检测阈值略大于最慢的正常事务耗时。- 监控
active/idle/pendingThreads。pendingThreads(等待连接的线程数)长期非零才是需要扩容或优化的信号。 - 多服务共用同一实例时,各服务池上限之和必须显著小于数据库总连接上限,留出 DBA 后台操作余量。
连接池不神秘。它的每一个参数都在回答同一个问题:我们希望在数据库前面保留多少并发通道。答案是吞吐目标和单条 SQL 耗时共同决定的,不是拍脑袋的数字,也不存在”越大越好”的方向。
Comments (0)
No comments yet — be the first.
Please Sign in to join the discussion.