凌晨两点被电话叫起来处理线上告警,日志里刷屏的就是这行org.springframework.data.redis.connection.PoolException: Could not get a resource from the pool,我相信很多做 Java 后端的朋友都有过类似体验。这个异常表面上看是"拿不到连接",但它本身只是个结果,真正的根因可能藏在连接池配置、网络超时、服务端限制、客户端版本,甚至一行没写好序列化代码的实体类里。Redis 作为缓存和分布式锁的标配组件,一旦连接层面出问题,往往连带订单、库存、登录态整条链路一起抖,所以这类问题值得我们单独拿出来把它拆透。这篇内容我会按"异常本质—参数计算—根因排查—实战流程—预防手段"的顺序,把踩过的坑和验证过的方法完整讲一遍,适合正在用 Spring Boot + Spring Data Redis 做缓存或分布式锁、并且已经被这个异常折腾过的开发者,也适合还没遇到、想提前把雷排掉的团队。
1. 先搞清楚这个异常到底在说什么
很多人看到Could not get a resource from the pool第一反应是"Redis 挂了",然后跑去重启 Redis,结果发现 Redis 活得好好的,这个问题就又出现了。要避免这种瞎忙,第一步得先读懂异常链路。
1.1 异常链路拆解:PoolException 到底是谁抛的
PoolException这个类属于 Spring Data Redis 的org.springframework.data.redis.connection包,注意关键字:它不是Redis 服务端返回的错误,也不是底层网络组件直接抛的异常,而是 Spring 在"从连接池借用连接"这一步失败时,把底层池化框架的异常做了层包装。
我们可以把调用链简单还原一下。业务代码调用redisTemplate.opsForValue().get("key"),Spring Data Redis 内部会通过RedisConnectionFactory去要一个RedisConnection;如果用的是 Jedis,最终落到JedisConnectionFactory的fetchConnection(),它从 Apache Commons Pool 的GenericObjectPool里borrowObject();如果用的是 Lettuce,走的则是LettuceConnectionFactory,只有在开启了池化(即引入了commons-pool2并配置了 pool 参数)时才会走LettucePoolingClientConnectionProvider。
无论哪种,borrowObject()抛出的NoSuchElementException: Timeout waiting for idle object或Pool exhausted之类,都会被包装成PoolException。所以看到这个异常,你脑子里要立刻形成三个待确认的分支:
- 分支一:池子里的连接确实被借完了,且等待时间超过了
max-wait,也就是连接池耗尽。 - 分支二:池子里其实还有空闲连接,但这些连接是坏的,借出去时校验失败,一直被销毁重建,形成"借不到可用连接"的假象。
- 分支三:对 Lettuce 而言,还有一种高频情况——压根没配池化,走的是共享连接模式,报错信息相似但成因完全不同。
把这三条理清楚,你的排查方向至少不会跑偏。
1.2 Jedis、Lettuce、Redisson 三种客户端的行为差异
Spring Boot 1.x 时代默认是 Jedis,2.x 之后默认切成了 Lettuce,这个切换本身就是很多老项目升级后突然报连接异常的根源。三者的连接模型差别很大,直接决定了你排查时看哪个指标。
| 客户端 | 连接模型 | 是否默认池化 | 典型报错特征 |
|---|---|---|---|
| Jedis | 每个连接对应一个物理连接,同步阻塞 | 引入 commons-pool2 后默认池化 | Could not get a resource from the pool,伴随Timeout waiting for idle object |
| Lettuce | 默认共享单连接,Netty 异步多路复用 | 需显式引入 commons-pool2 才池化 | 未池化时报超时类异常,池化后才有 PoolException |
| Redisson | 自有连接管理,带订阅连接池 | 内置,不依赖 commons-pool2 | 报错偏RedisConnectionException、Unable to acquire connection |
Jedis 的模型最直白:一个连接同时在一条线程上用,池子多大就能扛多少并发命令。它的痛点是同步阻塞,一旦某条命令慢,占用的连接就一直不还,高并发下池子很快见底。Lettuce 的默认共享连接模型则相反,一条连接靠 Netty 多路复用扛大量并发,本意是省资源,但一旦这个共享连接因为网络抖动断掉,所有请求一起失败,报错看起来也像"拿不到资源"。Redisson 因为自带连接管理,出现PoolException的概率反而低,但它的连接池也有自己的上限参数。
所以你在排查前,先确认项目到底用的哪个客户端。一个特别快的判断方法:看pom.xml里有没有spring-boot-starter-data-redis之外的jedis或redisson-spring-boot-starter依赖,再看 Spring Boot 版本。版本 + 依赖这两条信息,基本能锁定模型。
1.3 为什么它总出现在高并发或长时间运行之后
有个规律值得留意:这个异常极少在启动后立刻出现,往往是跑了几小时甚至几天之后,或者流量峰值那一刻才冒出来。这两种时间点对应两类不同的根因。
流量峰值触发,多半是连接池大小和实际并发不匹配,池子被瞬时打满,max-wait一到就抛异常。这类问题特点是来得快去得也快,流量一过就自愈,所以更容易被忽视,等到下次大促再复现。
长时间运行后触发,则要警惕连接泄漏。典型场景是有人手动拿了RedisConnection却没还,或者在pipeline/multi模式下异常路径没走close()。泄漏是慢性病,池子里的连接被一点点抽干,一开始偶尔报错,后来越来越频繁,最后彻底不可用。还有一种隐蔽情况:Redis 服务端配置了timeout,空闲连接被服务端主动断开,而客户端池里还认为这些连接可用,借出去才发现是死的。这类问题的排查思路和突发型的完全不同,后面会专门展开。
2. 连接池参数:算一遍比抄一遍重要
网上搜配置,十篇有八篇给你一段max-active: 8, max-idle: 8, min-idle: 0的模板,但几乎没人告诉你为什么是 8。参数配错是这类异常的常见原因之一,我倾向于先把每个参数搞清楚,再结合业务量算一遍。
2.1 关键参数含义与官方默认值对照
先看 Jedis 配置,参数都在spring.redis.jedis.pool下面;Lettuce 池化后参数在spring.redis.lettuce.pool下,名称基本一致。下面这张表按"含义、默认值、调错的后果"三列来对照。
| 参数 | 含义 | commons-pool2 默认 | 调错的典型后果 |
|---|---|---|---|
| max-active | 池中最大连接数 | 8 | 太小则高并发下耗尽,太大则 Redis 端连接数暴涨 |
| max-idle | 最大空闲连接数 | 8 | 大于 max-active 无意义,Spring Boot 会做纠正 |
| min-idle | 最小空闲连接数 | 0 | 长期为 0 时,突发流量要现建连接,首包延迟抖动 |
| max-wait | 借用连接最大等待毫秒数 | -1(无限等待) | 配成 -1 线程会一直挂着,应用整体卡死;配太小则频繁抛异常 |
| time-between-eviction-runs | 空闲连接检测周期 | 依赖版本,通常 30s 或 -1 | 不开启则死连接一直留在池里 |
| min-evictable-idle-time | 连接最小可被回收的空闲时长 | 1800000ms | 与 Redis 端 timeout 不匹配会导致死连接堆积 |
这里我要特别强调max-wait。很多示例配置里写-1,意思是无限等待空闲连接,理论上是"宁可等也不要失败",但在生产上这是灾难性的:当池子耗尽,所有请求线程都卡在借用连接这一步,Tomcat 线程池迅速被占满,整个应用对外表现为无响应而不是快速失败。正确的做法是设一个明确的等待上限,比如 1000ms 到 3000ms,超时后快速抛异常,配合上层降级逻辑。
2.2 用利特尔法则估算合理的 max-active
连接池大小不是拍脑袋定的,可以用排队论里的利特尔法则做粗略估算:需要的并发资源数 ≈ 每秒请求数 × 每个请求平均占用资源时长。
举个具体例子。假设你的 Redis 缓存 QPS 峰值是 3000,每次操作包含一次读写,平均耗时 1ms(含网络 RTT 0.5ms 左右),那么需要的并发连接数是:
3000 × 0.001 = 3
这是纯理论最小值。但实际网络有抖动,命令耗时会波动,还要考虑慢命令、大 key、pipeline 批处理这些占用时间更长的操作。工程上一般按理论值的 3 到 5 倍预留,也就是max-active取 12 到 20 比较稳妥。如果你的业务里有批量操作或者 Lua 脚本,单次占用时间可能到 10ms 以上,那估算结果要重新算:3000 × 0.01 = 30,再乘冗余就是 90 到 150。
这里有个反直觉的点:pool 大小不是越大越好。Redis 服务端本身是单线程处理命令的,过多连接只会增加服务端的上下文切换和内存开销。经验上单实例 Redis 的连接数控制在 500 以内比较健康,max-active乘以应用实例数不能超过服务端maxclients的 70% 左右,要给运维连接、复制连接留余量。
2.3 一份可以直接参考的配置模板
下面这份配置是我目前项目里在用的,适合 QPS 千级别的中等规模业务,你可以按上面算出来的数值替换。注意spring.redis.timeout和 pool 的max-wait是两个不同的超时,前者是命令读写超时,后者是借用连接的等待超时,别混淆。
spring: redis: host: 10.0.0.10 port: 6379 password: your-password database: 0 # 命令读写超时,注意要大于最长命令耗时 timeout: 2000ms connect-timeout: 1000ms lettuce: shutdown-timeout: 200ms pool: max-active: 16 max-idle: 8 min-idle: 4 max-wait: 2000ms time-between-eviction-runs: 30s关于min-idle,我的建议是别设成 0。保持一定数量的常驻空闲连接,可以避免突发流量时"先建连接再执行命令"带来的额外延迟,尤其是在跨机房访问 Redis 的场景里,建连的 TCP + 认证握手时间可能到几十毫秒。
注意:Spring Boot 2.x 下如果只写了
lettuce.pool但没引入commons-pool2依赖,这些参数会被直接忽略,实际走的还是共享连接。这个坑我见过不止一次,一定要确认依赖里有org.apache.commons:commons-pool2。
3. 五类根因的排查路径
异常信息只有一句,但根因分类不下五种。我习惯按"从客户端到服务端"的顺序逐层排查,这样不会漏。
3.1 连接池耗尽与连接泄漏
先确认是不是池子真的用满了。拿 Jedis 来说,可以开启commons-pool2的 JMX 或直接用 Micrometer 把池指标打出来,重点看numActive(活跃连接数)和numWaiters(等待线程数)。如果numActive长期贴着max-active,numWaiters大于 0 并且持续增长,那基本可以确认是耗尽。
耗尽又分两种。一种是瞬时流量超过池容量,属于容量问题,扩容或调大max-active就能缓解。另一种是连接泄漏,池子里的连接被借走后永远没还。判断方法看趋势:如果应用重启后numActive从 0 慢慢往上爬,几小时后稳定在max-active不掉下来,那就是泄漏无疑。
泄漏最常见的三个来源:
- 手动调用
RedisConnectionFactory.getConnection()拿连接做批量操作,异常路径下没调用connection.close()。这类代码务必用try-finally或者显式RedisConnectionUtils.releaseConnection(conn, factory)。 - 使用
redisTemplate.execute(callback)时在回调里自己又去拿连接,形成嵌套借用。 - 早期版本的某些工具类在
pipeline异常时没有正确释放,这类问题要靠升级依赖解决。
心得:排查泄漏时,先在测试环境把
max-active调到很小(比如 4),然后用压测工具持续打流量,观察多久后开始抛 PoolException。如果能稳定复现,再结合jstack抓线程栈,重点关注那些停在 Redis 操作上的业务线程,看它们持有了哪些连接。
3.2 网络与超时问题
第二类根因在网络层。这里有个很容易误判的现象:网络抖动导致部分连接失效,池子误以为连接可用,借出去时校验或首次写入才发现链路已断,于是销毁重建,反复几次池子实际可用数量就掉下来了,表现和"耗尽"很像。
要区分它,得看几个证据。一是日志里是否伴随SocketTimeoutException、RedisConnectionFailureException、Read timed out之类更底层的异常,PoolException 往往只是它们被包装后的表层。二是用ping、telnet直接测客户端到 Redis 的网络,看是否有丢包、RTT 抖动。三是看 Redis 服务端的INFO clients里的connected_clients,如果客户端侧numActive很高但服务端连接数远低于此,说明有大量连接在客户端看来是活的、实际已经断了。
Lettuce 用户还要特别注意一个参数spring.redis.lettuce.shutdown-timeout,以及 Netty 层面的io.netty日志。我在一个项目里遇到过 Netty 的 DNS 解析器在容器环境里超时,导致连接迟迟建立不起来,最终报 PoolException,排查时开了logging.level.io.lettuce=DEBUG才定位到。
另外,跨可用区或跨机房访问 Redis 是最容易被低估的。RTT 从同机房的 0.2ms 涨到跨机房的 2ms,在 QPS 3000 的场景下,理论并发需求从 1 涨到 6,如果池子还按老配置,立刻就紧张了。所以网络架构变化后,连接池参数一定要重新评估。
3.3 Redis 服务端限制与慢查询阻塞
第三类根因在 Redis 服务端。有两个硬限制值得盯住:maxclients和timeout。
maxclients默认是 10000,看着够大,但在多实例部署加多环境共用一个 Redis 的情况下,几百个应用实例轻松就能顶上去。一旦连接数到上限,新的连接请求会被 Redis 直接拒绝并返回ERR max number of clients reached,客户端侧看到的就是拿不到连接。这时候去看INFO stats里的rejected_connections计数,如果大于 0 而且在增长,就是服务端拒绝而不是客户端池子的问题。同时INFO clients里的connected_clients会顶到maxclients附近。
timeout是服务端规定的空闲连接超时,默认是 0(不主动断开)。如果运维改成了比如 300 秒,那么客户端连接池里那些超过 300 秒没用的连接就会被服务端单方面关掉,而客户端min-evictable-idle-time如果还是默认的 1800 秒,就会出现一边服务端在断、一边客户端还留着的错配,表现为"莫名其妙拿不到连接"。解决办法是让服务端的timeout要么为 0,要么和客户端池的回收时间保持协调,客户端min-idle要保证有连接在定期被使用。
慢查询和阻塞是另一个大坑。一个KEYS *、一个没有游标的HGETALL、一段死循环的 Lua 脚本,都会占住 Redis 单线程,让后面所有命令排队。命令排队意味着客户端的每个连接都被占着不还,池子迅速耗尽,于是表面上报"连接不够",实际上是"命令太慢"。用SLOWLOG GET 10看慢查询日志,用redis-cli --latency看延迟,用slowlog-len确认是否有积压,是最直接的定位方式。
| Redis 端指标 | 命令 | 异常阈值参考 |
|---|---|---|
| 当前连接数 | INFO clients的 connected_clients | 接近 maxclients 的 80% 要警惕 |
| 被拒绝连接数 | INFO stats的 rejected_connections | 大于 0 且增长即为服务端拒绝 |
| 阻塞客户端数 | INFO clients的 blocked_clients | 持续大于 0 要查阻塞命令 |
| 慢查询条数 | SLOWLOG LEN | 持续增长说明有慢命令 |
| 命令延迟 | redis-cli --latency -h host -p port | 均值超过 1ms 需排查 |
3.4 认证、序列化与配置错误
第四类是无池化、密码错、库号错这类配置问题,它们有个共同特征:报错相对稳定、启动阶段或固定时间点必现。比如密码错了,Jedis 会抛NOAUTH Authentication required,Lettuce 会抛WRONGPASS;库号超范围会报ERR DB index is out of range。这类错误只要看到具体信息,基本一查一个准。
序列化问题稍微隐蔽一点。默认的JdkSerializationRedisSerializer会把对象序列化成很大的字节数组,一个稍微复杂的实体就能撑出几十 KB 的 value。大 key 会拖慢网络传输、加大内存压力,间接导致连接被长时间占用,从而诱发连接池耗尽。改成GenericJackson2JsonRedisSerializer或者自定义StringRedisSerializer+ JSON,value 体积能降一大截。
还有一个特别容易被忽略的点:RedisTemplate和StringRedisTemplate的默认序列化不同,混用会导致读出来的 key 带一堆不可见字符,虽然不直接引发 PoolException,但会让人误以为缓存没命中、反复重试,进而增加 Redis 压力。这类问题排查时先用redis-cli的KEYS或可视化工具看真实 key 长啥样,很快就能发现。
3.5 客户端版本兼容与线程模型
最后是版本兼容问题。Spring Boot 2.x 默认 Lettuce,但如果你在引入spring-boot-starter-data-redis的同时又显式引入了老版本 Jedis,会出现依赖冲突,运行时行为不确定。反过来,Lettuce 4.x 到 5.x 的升级也涉及连接管理的重构,老版本在断线重连上是有已知问题的。
我的建议是,遇到这类异常先把版本矩阵整理清楚:Spring Boot 版本、Spring Data Redis 版本、Lettuce 或 Jedis 版本、commons-pool2 版本,四个版本要明确。尤其 commons-pool2,2.4 之前和 2.8 之后在空闲连接回收逻辑上有差异,升级到较新版本能规避一批老 bug。
线程模型方面,Lettuce 在高并发下如果用了阻塞式的RedisTemplate方法,实际上是命令级别的同步等待,底层共享连接能不能扛住要看命令耗时。如果发现延迟随并发上升明显,可以考虑换成响应式模板ReactiveRedisTemplate,或者干脆做池化,把请求分散到多条连接上。当然,池化本身又会引入池参数问题,所以还是要回到第 2 节那套计算。
4. 实操:从症状到定位的完整排查流程
讲了这么多理论,落到真实故障现场,你必须有一套能按顺序执行的流程,而不是东一榔头西一棒槌。下面是我目前用的标准动作。
4.1 秒级止血的三步应急
线上正在报警,先别急着定位,第一步是让业务恢复。我的顺序是:
- 确认范围:是所有接口都报,还是只有某个接口报。看日志里 PoolException 的堆栈,如果来自同一个业务类,那就先怀疑这个业务有问题,而不是整体 Redis 挂了。用
grep统计一分钟内的异常条数,看是暴涨还是零星。 - 快速缓解:如果确认是池子耗尽且业务能接受,可以临时把
max-active翻倍并重启实例(应该用配置中心热更新更好,但要评估并发写风险)。如果连的是集群,可以先把流量切到备用节点。这一步的目标是让用户侧恢复,不是根治。 - 保留现场:重启前一定要先抓证据。
jstack打下线程栈,把 Redis 相关线程的栈留住;导出最近半小时的应用日志;在 Redis 侧执行CLIENT LIST、SLOWLOG GET 20、INFO all存下来。这些信息一旦重启就没了,事后复盘全靠它们。
注意:
CLIENT LIST在连接数上万时会返回巨大文本,直接CLIENT LIST可能把客户端卡住,可以用--bigkeys之外的redis-cli --scan思路不行,稳妥做法是分批查或者看监控图表。真实场景里我更推荐看已有的监控面板,而不是现场敲命令。
4.2 关键监控指标与日志采集清单
止血之后是定位。下面这些指标平时就要采集好,出事时才有得看。
应用侧要有的指标:
- 连接池
numActive、numIdle、numWaiters,用 Micrometer + Prometheus 采集,注意区分不同池(如果你有多个 Redis 数据源)。 - Redis 命令耗时的 P99、P999,按命令类型分桶更佳。
- 单位时间内的连接借用失败次数,也就是 PoolException 的计数。
- JVM 线程数、Tomcat 活跃线程数,用来判断是否已经发生线程堆积。
Redis 服务端要有的指标:
connected_clients、blocked_clients、rejected_connections。instantaneous_ops_per_sec与 CPU 使用率,判断是否超载。used_memory与maxmemory,确认是否触发内存淘汰导致命令变慢。- 慢查询条数与主从复制延迟,后者在从库读场景下容易引发超时。
日志方面,把logging.level.org.springframework.data.redis=DEBUG在排查期打开,可以看到连接的获取与释放动作;logging.level.io.lettuce.core=DEBUG能看到底层的连接建立与断线事件。记得排查完关掉,否则日志量很大。
4.3 一次真实案例复盘:池子没配却报了 PoolException
讲一个我印象很深的案例。某项目用的是 Spring Boot 2.3 + Lettuce,某天下午开始间歇性报 PoolException,平均几分钟一次,重启后好一会儿又出现。按常规思路先查池子,结果发现配置里只有spring.redis.lettuce.pool的几个参数,但pom.xml里根本没有 commons-pool2 依赖,所以这些参数从头到尾没生效,走的其实是共享连接模式。
那为什么共享连接会报父类的 PoolException?因为 Spring Data Redis 在某些版本里,共享连接创建失败时也会包装成这个异常,日志的表层信息把人误导了。真正的根因往下扒是:那台 Redis 所在的宿主机做了一次网络调整,导致 TCP 连接偶尔被中间设备静默丢弃,而 Lettuce 的默认重连策略在某个时间窗口内没能及时恢复,请求就报错了。
解决分两步:一是补上 commons-pool2 依赖并让池化真正生效,同时按前面算的参数配置;二是把 Lettuce 的重连参数和命令超时调整得更合理。改完之后同样压力下再没出现过。这个案例给我的教训是,别默认配置生效了,尤其是从博客抄来的参数,一定要验证。
5. 预防:把坑填在事故发生之前
排查能力再强也不如不出事。连接层面的问题,很大一部分是可以在上线前防住的。
5.1 参数与代码规范
配置上,我给自己定了几条硬规则。max-wait永远不写 -1,必须有明确上限;min-idle不小于 4;max-active按业务 QPS 算出来写进配置注释,注明计算依据,方便以后业务量变了再回头看;spring.redis.timeout至少是 P99 命令耗时的 3 倍以上,避免正常慢命令被误判为超时。
代码层面,禁止手动getConnection(),统一走RedisTemplate的封装方法;确实需要连接级操作(比如 pipeline)的,封装成工具类并强制finally释放;所有 Redis 操作的超时和异常都要有兜底逻辑,缓存查询失败要能降级到数据库,而不是直接抛错。分布式锁场景更要注意,锁获取失败要走重试和降级,不能让 Redis 异常直接穿透到业务主流程。
5.2 监控与告警
监控上,至少配三条告警:连接池numWaiters持续 1 分钟大于 0;PoolException 计数一分钟内超过阈值;Redis 服务端connected_clients超过maxclients的 80%。告警要有明确的处置手册,写清楚先看哪个指标、怎么临时缓解,不要只发一条"Redis 异常"就完事。
另外建议做定期的故障演练。用压测工具把 Redis 连接池打到临界,观察应用的表现,验证降级逻辑是否生效,顺便看看监控能不能及时报警。这种演练能提前暴露绝大多数参数问题,比出事后手忙脚乱强得多。
5.3 常见问题速查表
最后整理一张速查表,遇到 PoolException 时可以按现象快速定位方向。
| 现象 | 高概率根因 | 优先排查动作 |
|---|---|---|
| 高并发时短暂爆发,流量过后自愈 | 池容量不足 | 看 numWaiters 峰值,重算 max-active |
| 运行数小时后逐渐恶化,重启可缓解 | 连接泄漏 | 看 numActive 是否单调上升,检查手动 getConnection |
| 伴随 Read timed out、SocketTimeout | 网络抖动或命令慢 | 抓底层异常,看 SLOWLOG 和网络 RTT |
| 仅某个接口报,其他正常 | 该业务写法问题 | 单独看该接口的连接使用与超时配置 |
| 稳定必现,启动即报 | 配置或认证错误 | 检查密码、库号、host、超时设置 |
| Lettuce 项目,参数配了没效果 | 缺 commons-pool2 | 确认依赖与配置前缀是否匹配 |
| Redis 端 rejected_connections 增长 | 服务端连接数打满 | 查 maxclients 与应用实例数总和 |
这张表我在团队里贴在故障处理文档的第一页,值班同学先对号入座,再去细查,效率比漫无目的地翻日志高得多。
我个人在实际处理这类问题中最大的体会是,PoolException 从来不是一个孤立的技术点,它更像一个探针,把连接池、网络、Redis 服务端、应用代码这几个层面的健康度都串起来了。所以每次解决完,我都会把当时的参数计算、排查步骤、用到的命令记进团队的排障笔记里,下次遇到类似的,翻出来照着做就行。另外提一句,参数别长期不动,业务量涨了、机房搬了、Redis 加了从库,这些变化都该推动你重新算一遍连接池大小,把隐患在发生前就消掉。