先讲个真实经历。有一年线上大促凌晨两点被告警电话叫醒系统里刷满了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/maximumPoolSizeHikariCP 里空闲数接近最大连接数获取连接超时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一致即可太多空闲连接白占服务端资源。minIdle8 到 16保证流量低谷时池内有一定余量。maxWaitMillis2000 到 3000 毫秒超过就快速失败让上游感知到问题。blockWhenExhaustedtrue配合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归还时验证验归还的连接但对下次借用没有帮助可以关掉作用不大我的建议JedistestOnBorrowfalsetestWhileIdletruetimeBetweenEvictionRunsMillis30000。HikariCPconnectionTestQuery不要配默认用 JDBC4 的 isValidkeepaliveTime30000、maxLifetime1800000。DruidtestWhileIdletrue、validationQuerySELECT 1、timeBetweenEvictionRunsMillis60000。需要注意的细节testWhileIdle不是万能的。如果配置了minEvictableIdleTimeMillis60000那么一个连接空闲超过 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设置 maxWaitMillis2000快速失败MySQL 偶尔报Communications link failuremaxLifetime 与 wait_timeout 冲突查看数据库超时参数将 maxLifetime 调小并配 keepaliveTime5.2 我自己的排查清单题目里说“90%的开发者都踩过这些坑”我认为这个数据不为过。以我个人的排查习惯来说每次涉及连接池故障都会从头到尾走一遍这个清单确认业务代码到底走的是不是连接池。很多隐藏问题都是绕过了池子直接 new 连接。检查连接是否全部归还。看numActive和numIdle的曲线active 长期打满就是泄漏。对比服务端空闲断开时间与客户端保活参数。MySQL 看wait_timeoutRedis 看timeout连接池的maxLifetime必须小于它们。确认获取连接超时时间。不要用-1那是隐藏事故的温床。看看有没有“不必要的大池子”。连接并不是越多越好尤其是 Redis。观察健康检查是否能把坏连接剔除出去而不是把死连接借出来再失败。这套流程几乎可以适配所有连接池组件因为底层机制完全一致。最后分享一个我自己的体会。连接池出问题的时候表面上都是“连接不够用”但根因千奇百怪代码绕过池、资源泄漏、配置参数不匹配、对端主动断开。排查的时候少去怀疑中间件本身多从连接的生命周期入手把“产生连接、使用连接、归还连接、销毁连接”四个阶段分别列出来逐个环节打点分析基本都能定位到问题。另外生产环境一定要给连接池加上监控指标和告警看到numActive一晚上不下降第二天就该查资源泄漏了别等到用户投诉才动手。