先讲个真实经历。有一年线上大促,凌晨两点被告警电话叫醒,系统里刷满了Could not get a resource from the pool。查了一圈,Redis本身CPU不到10%,连接数却打到了几千,最后定位到一个非常蠢的问题:业务代码里每次请求都 new 一个 Jedis 客户端,压根没走连接池。更让我意外的是,连上这位同事自己也说不清 Jedis 连接池的maxTotal、maxIdle和testOnBorrow到底该配多大、有什么作用。
那篇分享里我写的就是这个主题:连接池的三大陷阱,从 Jedis 到 HttpClient 再到 MySQL 的连接池,几乎把常见的坑都踩了一遍。如果你是后端开发、中间件维护者、或者写 Java 服务的同学,这篇内容值得认真看。
1. 先搞清楚连接池到底在干什么
1.1 一次TCP连接的真实成本
很多新人觉得“连接池”就是把几个连接放着复用,听起来很简单。但实际运行里,一次 TCP 连接从建立到销毁,成本比想象中高得多。以 Redis 为例,客户端发起一次connect,底层要完成 TCP 三次握手,如果开了 TLS 还有证书协商,Redis 服务端还要为每个连接维护一个客户端对象、一个输入输出缓冲区、一部分内存配额。
我在本机做个粗略测试,新建连接加执行一条命令,比从池里借一个 Keep-Alive 连接执行同样一条命令,耗时差 2 到 10 倍。到了高并发场景,如果接口每秒 3000 次请求,每次都新建连接,服务端会积累大量TIME_WAIT状态的 socket,文件描述符被拖垮,最后表现就是Too many open files。
连接池解决的核心问题只有三个:
- 复用:把已经建立好的连接保存下来,避免反复握手。
- 限流:通过最大连接数限制,避免客户端把下游服务打爆。
- 治理:对空闲连接、失效连接做检测和回收,保证池子里待命的是“活连接”。
这个逻辑说起来非常朴素,但到了实操层,大家基本都会在“参数配置”“连接归还”“死链接检测”这三件事上出事。
1.2 池化模型:借、用、还
连接池的思想本质上和图书馆借书是一样的。核心流程是:
- 借出:调用方从连接池里通过
borrowObject拿到一个可用连接。 - 使用:执行业务操作,比如 Jedis 的
set、HttpClient 的execute、JDBC 的connection.createStatement()。 - 归还:用完调用
close(),对于池化对象来说,这个 close 不是真正关闭连接,而是把连接标记为空闲,返回到池子里等待下一次借用。
绝大多数连接池故障,都可以归结为这三个环节中的某个动作出了问题。比如配置了“池”却没用池、借了没还、归还后连接已经是死连接但没被剔除。
1.3 Jedis、HttpClient、数据库连接池,其实是一家人
如果你把 Jedis 的JedisPoolConfig、HttpClient 的PoolingHttpClientConnectionManager、HikariCP 或 Druid 的配置放在一起看,会发现它们的内在模型本质相同。都有:
- 最大连接数:
maxTotal/maxTotal/maximumPoolSize - 最大空闲数:
maxIdle/defaultMaxPerRoute/maximumPoolSize(HikariCP 里空闲数接近最大连接数) - 获取连接超时:
maxWaitMillis/connectionRequestTimeout/connectionTimeout - 活性检测:
testOnBorrow/validateAfterInactivity/testWhileIdle
后面所有要讲的坑,几乎都能映射到这张公共表上。搞懂了其中一个组件的原理,其他框架不过是换了个 API 壳子。
2. 陷阱一:参数配置不是拍脑袋填的
2.1 你以为 maxTotal 越大越好?
先看一个生产事故。某服务的 Jedis 配置长这样:
JedisPoolConfig config = new JedisPoolConfig(); config.setMaxTotal(200); config.setMaxIdle(200); config.setMinIdle(16); config.setMaxWaitMillis(-1);看着很“豪华”,线上运行一段时间后,Redis 实例 CPU 突然飙到 100%,慢查询堆了一大堆,而且服务的线程池也出现明显排队。
问题不在 Redis 本身,而在连接数设置得太大。Redis 是单线程处理命令的,连接数增多并不会让它更快,反而会导致上下文切换频繁、读写缓冲区占用内存。更大的隐患在调用侧:200 个连接意味着最多可能有 200 个线程同时在等待命令返回,如果 Redis 平均响应 5ms,理论吞吐量是 40000 qps,但如果下游逻辑慢,连接全被占住,新请求只能阻塞在maxWaitMillis上。
我在实际项目中验证过一个规律:单机 Jedis 连接池给到 20 到 30 个连接,已经能支撑非常高的接口 QPS。除非是连接被长时间占用(比如subscribe订阅、BLPOP 阻塞式命令),否则设置几百个连接除了自我安慰,没有实际意义。
HikariCP 官方文档也给过一个经验公式:maximumPoolSize = ((core_count * 2) + effective_spindle_count)。这里effective_spindle_count是磁盘数量,如果用的是 SSD,通常取 1。按这个公式,一个 8 核 16G 的机器,连接池规模在 17 左右就够用了。它不是绝对真理,但方向是对的:连接池设计的目标是“让数据库别闲着”,不是“让数据库忙着”。
2.2 maxWaitMillis、blockWhenExhausted 要配合看
很多人在配置里漏掉一个致命细节:maxWaitMillis。Jedis 的默认值-1,表示永远等待,直到有连接归还。一旦连接池耗尽,请求全部挂住,线程被占满,整个服务就像死了一样,连 health check 都过不去。
我建议的配置思路是:
maxTotal:单个业务节点 20 到 50,根据平均命令耗时和 QPS 推算。maxIdle:和maxTotal一致即可,太多空闲连接白占服务端资源。minIdle:8 到 16,保证流量低谷时池内有一定余量。maxWaitMillis:2000 到 3000 毫秒,超过就快速失败,让上游感知到问题。blockWhenExhausted:true,配合maxWaitMillis使用,而不是无限阻塞。
HikariCP 中对应的是connectionTimeout,默认 30 秒太长了,建议改到 3 秒以内,移动端和高并发网关场景甚至可以直接压到 1000ms。
2.3 一套能上生产的 JedisPoolConfig 参考
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig(); jedisPoolConfig.setMaxTotal(50); jedisPoolConfig.setMaxIdle(50); jedisPoolConfig.setMinIdle(8); jedisPoolConfig.setMaxWaitMillis(2000); jedisPoolConfig.setBlockWhenExhausted(true); jedisPoolConfig.setTestOnBorrow(true); jedisPoolConfig.setTestWhileIdle(true); jedisPoolConfig.setTimeBetweenEvictionRunsMillis(30000); jedisPoolConfig.setNumTestsPerEvictionRun(-1); jedisPoolConfig.setMinEvictableIdleTimeMillis(60000);这套配置我用了很久,实测在大促流量翻倍时也能稳得住。关键点在于:宁可让请求快速失败,也不要让线程无限阻塞。无脑调大参数解决不了底层慢的问题,只会把故障从 Redis 层扩散到整个 JVM。
3. 陷阱二:借了不还,连接池迟早被掏空
3.1 三种“借了不还”的典型现场
这一节要说的是连接池最经典的坑:资源泄漏。我把见过的案例归成三类。
第一类,也是最低级的:每次调用直接new Jedis(host, port),用完也不close()。这在前面开头的那个事故里已经见过。这种写法完全绕过了连接池,连接对象成了孤儿对象,TCP 层面上连接还开着,服务端也不知道客户端已经不再使用了,只能等 TCP KeepAlive 超时或服务端主动断开。
第二类:从池子里borrow了连接,用完后没有放进finally块里归还。比如这段代码:
try (Jedis jedis = jedisPool.getResource()) { jedis.set("key", "value"); // 中间发生异常,finally 没写,连接没归还 }Java 7 的 try-with-resources 能自动 close,但如果用了try { ... } catch { ... }而漏掉 finally,一旦执行jedis.expire()或jedis.eval()时抛了异常,连接就卡死在“已借出”状态,永远不会回池。
第三类是 HttpClient 的专属问题:响应流没有读干净。
3.2 HttpClient 的连接释放问题,细说
很多同学用 Apache HttpClient 的时候,写完response = httpClient.execute(request),拿完状态码就开始处理 JSON,但响应体 InputSteam 没有关闭。在连接池模式下,连接释放的前提是响应体被完全消费或显式关闭。
为什么?因为 HTTP 1.1 的连接复用依赖Content-Length判断消息边界。如果你只调用了getEntity()却没读完整响应流,连接池不知道当前连接已经处于“可复用”状态,它会视为“连接处于不可判定状态”,然后直接销毁这个连接。连接一销毁,池里的可用连接就少了。
更糟糕还有一种情况:读了一半响应流就不读了,然后调用CloseableHttpClient.close()。这会导致 PoolingHttpClientConnectionManager 里这个连接没有被正确标记,连接被泄漏,pool stats的leased数量不降。
正确姿势:
try (CloseableHttpResponse response = httpClient.execute(request)) { // 业务处理 EntityUtils.consume(response.getEntity()); // 确保读完或丢弃剩余内容 } catch (IOException e) { // 处理异常 }EntityUtils.consume会自动读完剩余字节并关闭流,这样连接池才能安全复用。如果你用response.getEntity().getContent()手动拿流做 JSON 解析,记得在 finally 里把 InputStream 也关掉。
3.3 连接池耗尽时的故障特征
连接池耗尽在不同组件里的表现有细微差别,但总体规律一致:
- 一开始接口响应变慢,偶尔报错。
- 过一会儿,新请求大量报超时或拒绝连接。
- 线程池被占满,CPU 飙升(因为大量线程在等连接)。
- 最后出现连锁故障:Redis 堆积、数据库堆积、网关超时重试,服务雪崩。
排查时别急着重启。先看连接池监控指标,比如 HikariCP 的getActiveConnections(),Jedis 的getNumActive()和getNumWaiters(),HttpClient 的getTotalStats().getLeased()。
如果numActive长期等于maxTotal且持续不掉,基本可以断定是借了没还。再用jstack抓线程栈,看哪些线程卡在borrowObject或pool.getResource上,顺着调用栈找到具体业务代码,一揪一个准。
3.4 顺手补充一下:Jedis 客户端本身要不要单例?
这是连接池使用中最常见的一个衍生问题。Jedis 实例本身不是线程安全的,它内部有outputStream、inputStream等状态字段,多个线程共享一个Jedis实例会导致命令流交叉,数据错乱。所以必须通过连接池的getResource()来获取独立的连接实例,用完马上归还。
唯一需要特别注意的场景是使用JedisCluster的时候,它任何时候都不建议你直接保存单个Jedis实例。JedisCluster内部自己管理连接池,业务侧只应该持有JedisCluster单例,然后每个线程通过它执行命令。
4. 陷阱三:僵尸连接比没连接更可怕
4.1 死链接是怎么产生的?
默认配置下,连接池里的连接很长时间都不会主动验证对端是否还活着。而下游服务(MySQL、Redis、防火墙、云厂商LB)通常有 idle timeout。以 MySQL 为例,wait_timeout默认 8 小时,超过这个时间没有活动,MySQL 服务端会主动断开连接。但连接池客户端不知道,它还以为池子里躺着的是好连接。
下一次请求借出这个死连接,发一条 SQL 或 Redis 命令,对端已经关闭了 socket,客户端收到Connection reset或Broken pipe。表现就是:明明连接池里有空闲连接,接口却报错。如果连接池里大量连接都处于这种半开状态,服务一重启就会触顶所有请求都失败,表现得像下游挂了,其实客户端连的是空气。
除了 idle timeout,还有几种常见诱因:
- 数据库、Redis 发生主从切换,旧连接全部失效。
- 防火墙空闲超时把长连接断掉。
- 网络设备 NAT 超时,连接被无声清理。
4.2 活性检测三兄弟:testOnBorrow、testWhileIdle、testOnReturn
针对僵尸连接,不同连接池组件提供的参数非常类似,但用途有明确分工:
| 参数 | 时机 | 作用 | 代价 |
|---|---|---|---|
| testOnBorrow | 每次借出时验证 | 最安全,保证拿到的连接一定可用 | 每次请求多一次 PING/SELECT 探测,性能损耗明显 |
| testWhileIdle | 后台线程定时扫描 | 提前清理坏连接,不阻塞请求 | 需要配置 eviction 线程运行间隔 |
| testOnReturn | 归还时验证 | 验归还的连接,但对下次借用没有帮助 | 可以关掉,作用不大 |
我的建议:
- Jedis:
testOnBorrow=false,testWhileIdle=true,timeBetweenEvictionRunsMillis=30000。 - HikariCP:
connectionTestQuery不要配(默认用 JDBC4 的 isValid),keepaliveTime=30000、maxLifetime=1800000。 - Druid:
testWhileIdle=true、validationQuery=SELECT 1、timeBetweenEvictionRunsMillis=60000。
需要注意的细节:testWhileIdle不是万能的。如果配置了minEvictableIdleTimeMillis=60000,那么一个连接空闲超过 1 分钟才会进入驱逐检测。也就是说,连接死掉后最长可能有 1 分钟的“存活空窗期”,这期间借出的还是坏连接。想要窗口更小,就把驱逐检查频率调高,比如 10 秒一次。
在 HikariCP 里有个更隐蔽的坑:maxLifetime一定要小于 MySQL/Redis 的wait_timeout或timeout。maxLifetime默认 30 分钟,MySQLwait_timeout默认 8 小时,看起来没问题,但如果你调大了maxLifetime,或者数据库那边把wait_timeout改小了(很多云数据库默认只有几十秒空闲清理),就要特别注意。HikariCP 的官方建议是maxLifetime至少比数据库超时时间短 30 秒以上,否则会出现连接生命周期边界上的偶发失败。
4.3 用 Lua 脚本时也别踩连接池的坑
结合之前热搜里总有人提到的“java jedis evalsha”,这里必须多说一句。很多场景下,大家都会用 Lua 脚本来做原子操作,比如库存扣减、限流。evalsha是先用SCRIPT LOAD把脚本缓存到 Redis,之后通过 SHA1 摘要调用脚本,省去每次传输脚本体的开销。
这个流程放到连接池里有三个容易踩的坑。
第一个是第一次调用evalsha时大概率报NOSCRIPT。因为脚本还没被缓存,或者 Redis 重启后缓存失效了。教科书式做法是捕获JedisNoScriptException或JedisDataException,然后回退到eval重新执行。注意回退的这段脚本执行也要在同一个线程、同一个连接会话里做,不要把借来的连接中途归还,否则可能拿到另一条没缓存脚本的连接。
第二个是不要每次请求都SCRIPT LOAD。脚本缓存本身是全局的,跟连接无关。但是由哪个连接触发的加载无所谓,Redis 是全局共享脚本缓存,所以启动时预加载一次脚本就够了。在实际业务代码里,可以加一个本地布尔标识,启动后只加载一次,省掉反复eval的脚本体传输。
第三个坑是evalsha执行完以后,连接返回连接池,脚本缓存还在 Redis 端,只要你用的 SHA1 没变,下次从连接池里随便借一个连接都可以直接evalsha。很多同学会被“连接池 java jedis evalsha”这几个词绕晕,其实这块跟连接池本身关系不大,只是Jedis和JedisPool混在一起后容易让人理解错。记住一个原则:脚本归 Redis 管,连接归池子管,不要混在一起考虑。
5. 常见故障与避坑清单速查
5.1 四个高频故障案例
我把这些年在业务中排查连接池问题遇到过的高频场景整理成一个速查表,方便在座各位直接拿去对照。
| 现象 | 可能原因 | 快速定位方式 | 解决方案 |
|---|---|---|---|
| Redis 连接数飙到几千,但 QPS 不高 | 业务直接 new Jedis,不走池 | netstat查看客户端端口数量 | 强制走 JedisPool,客户端加全局单例 |
接口偶发connect timeout,重启后消失 | 池中有大量半开连接 | 连接池监控 active 高、idle 也高 | 开启 testWhileIdle,缩短 eviction 间隔 |
| 请求大量堆积,线程池被打满 | maxWaitMillis 太长/无限阻塞 | jstack 查看大量线程阻塞在 borrowObject | 设置 maxWaitMillis=2000,快速失败 |
MySQL 偶尔报Communications link failure | maxLifetime 与 wait_timeout 冲突 | 查看数据库超时参数 | 将 maxLifetime 调小,并配 keepaliveTime |
5.2 我自己的排查清单
题目里说“90%的开发者都踩过这些坑”,我认为这个数据不为过。以我个人的排查习惯来说,每次涉及连接池故障,都会从头到尾走一遍这个清单:
- 确认业务代码到底走的是不是连接池。很多隐藏问题都是绕过了池子直接 new 连接。
- 检查连接是否全部归还。看
numActive和numIdle的曲线,active 长期打满就是泄漏。 - 对比服务端空闲断开时间与客户端保活参数。MySQL 看
wait_timeout,Redis 看timeout,连接池的maxLifetime必须小于它们。 - 确认获取连接超时时间。不要用
-1,那是隐藏事故的温床。 - 看看有没有“不必要的大池子”。连接并不是越多越好,尤其是 Redis。
- 观察健康检查是否能把坏连接剔除出去,而不是把死连接借出来再失败。
这套流程几乎可以适配所有连接池组件,因为底层机制完全一致。
最后分享一个我自己的体会。连接池出问题的时候,表面上都是“连接不够用”,但根因千奇百怪:代码绕过池、资源泄漏、配置参数不匹配、对端主动断开。排查的时候少去怀疑中间件本身,多从连接的生命周期入手,把“产生连接、使用连接、归还连接、销毁连接”四个阶段分别列出来,逐个环节打点分析,基本都能定位到问题。另外,生产环境一定要给连接池加上监控指标和告警,看到numActive一晚上不下降,第二天就该查资源泄漏了,别等到用户投诉才动手。