ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot Redis 连接池 PoolException 排查

Spring Boot Redis 连接池 PoolException 排查 线上跑着一个 Spring Boot 服务和一套 Redis某天开始日志里每隔几秒就刷一条org.springframework.data.redis.connection.PoolException: Could not get a resource from the pool接口 RT 从 20ms 飙到 3 秒最后干脆大面积 500。这个异常我前前后后处理过不下十次它出现的场景五花八门有的是连接池参数拍脑袋写的有的是 Redis 服务端被一条keys *顶住了还有一次是运维改了安全组白名单客户端死活连不上。这条报错的本质其实一句话就能说清——连接池借不到连接但为什么借不到有至少七八种可能而每一种的修法完全不一样改错方向只会把问题拖得更久。这篇内容我打算按真实排查顺序走一遍先把异常尾巴读明白再把 Jedis 和 Lettuce 两套池子的参数算清楚然后从客户端一路查到服务端最后给出几个高频场景的完整复盘和一份速查表。不管你是刚接手的 Spring Boot 项目、正在做缓存治理还是面试前想把 Redis 这块彻底捋一遍都能直接从里面抄配置、抄排查命令。1. 先把异常读透别急着改参数1.1 异常尾巴才是真正的线索Could not get a resource from the pool这句话本身信息量几乎为零它只是池子在喊我这儿没货了。真正有价值的是它下面Caused by的那一行我习惯把它分成三档来看。第一档是网络层报错尾巴一般是java.net.ConnectException: Connection refused或者connect timed out、No route to host。这说明池子里的连接根本建立不起来池子里的空闲连接被耗尽之后新建连接又失败于是对外统一表现为借不到。这类问题的排查重点在端口、地址、防火墙、DNS跟池子参数一点关系都没有。第二档是服务端拒绝尾巴常见redis.clients.jedis.exceptions.JedisDataException: ERR max number of clients reached或者客户端侧直接看到rejected_connections在涨。这说明能连但服务端已经到maxclients上限新连接一律被拒。同一时间所有客户端都会复现而且往往是本来好好的突然全炸。第三档才是真正的池内问题尾巴是JedisExhaustedPoolException: Could not get a resource since the pool is exhausted或者压根没有Caused by直接就是PoolExceptionTimeout waiting for idle object。这种情况说明池子里的连接都被借走没还或者借的时候排队超时。只有这一档才需要去调池子参数前两档调参数纯属浪费时间甚至会把问题掩盖得更深。有个小技巧把PoolException打印的完整堆栈单独抓出来搜Could not get a resource之后的第一行Caused by90% 的情况下这一行就能直接定位方向比看任何监控都快。1.2 三分钟判断是池内还是池外很多人一上来就maxTotal从 8 调到 200结果 Redis 那边连接数直接冲上几千服务端开始抖问题更严重。给你一个三分钟的判断路径按顺序做不用猜。判断动作结果结论方向redis-cli -h host -p port ping从应用机器执行返回 PONG网络和服务端基本正常重点看池内同上超时或 refused网络/安全组/服务端挂了跟池子无关redis-cli info clients | grep connected_clients接近或等于 maxclients服务端连接数打满池子只是受害者redis-cli info stats | grep rejected_connections持续增长连接被拒先加 maxclients 或查客户端泄漏redis-cli slowlog get 10有明显几百毫秒的慢命令单线程被阻塞所有连接一起排队redis-cli info clients | grep blocked_clients大于 0 且持续有人用了 BLPOP 之类的阻塞命令长期占着连接以上全正常应用侧 active 连接数顶到 maxTotal—真池内问题开始调参和查泄漏这个表我贴在自己团队的排查手册第一页。经验是先排除池外再动池内顺序反了就容易误伤。2. 连接池参数到底该怎么配2.1 先确认你的配置真的生效了这一步能救很多人。Spring Boot 2.4 之前Redis 配置前缀是spring.redis.*2.4 之后改成了spring.data.redis.*。如果你从老项目复制配置过来前缀写错了那一整段配置会被静默忽略全部走默认值——而默认值通常是maxTotal8、maxWait-1无限等待。maxWait-1意味着借不到连接时会一直挂着线程池被慢慢占满最后整个应用卡死日志里刷的正是这条PoolException。Jedis 场景的完整配置大概长这样spring: data: redis: host: 10.0.0.21 port: 6379 password: ${REDIS_PASSWORD} database: 0 timeout: 2000ms # 命令读写超时 connect-timeout: 1000ms # 建连超时 jedis: pool: max-active: 64 # 对应 maxTotal max-idle: 32 min-idle: 16 max-wait: 1000ms # 借连接最长等待千万别写 -1 time-between-eviction-runs: 30s min-evictable-idle-time: 5mLettuce 场景如果你显式启用了池化配置前缀是spring.data.redis.lettuce.pool字段名和 Jedis 那套基本一致max-active、max-idle、min-idle、max-wait。要特别注意Lettuce 默认是单连接共享模式不加commons-pool2依赖、不写 pool 配置的话它压根没有池子多个线程复用一个 Netty 连接。这时候报PoolException就很奇怪了说明你确实引入了池化。验证配置生效最简单的办法写一个PostConstruct把JedisConnectionFactory或者LettuceConnectionFactory的配置对象打出来或者直接在启动日志里打开logging.level.org.springframework.data.redisDEBUG看有没有打印池子的 maxTotal。别嫌土这招帮我抓到过三次配置前缀写错的问题。2.2 maxTotal 该设多少得算不能拍先说结论maxTotal是并发连接数上限不是 QPS 上限。用 Littles Law 算并发数 QPS × 平均耗时。假设你的服务峰值 QPS 是 3000每次 Redis 操作平均耗时 0.5ms那理论并发 3000 × 0.0005 1.5也就是平均只有 1 到 2 个连接在同时工作。那是不是 maxTotal 设 4 就够了不行因为平均值会骗人。真实流量有突发命令耗时也有长尾。我的做法是取 P99 耗时来算如果 P99 是 5ms突发峰值 QPS 按 2 倍算那就是 6000 × 0.005 30 个并发连接。再留一倍冗余应对网络抖动和扩容maxTotal取 64 比较稳。如果单实例算出来超过 128我反而建议先看看是不是有慢命令而不是继续加连接——连接数堆上去Redis 服务端的 CPU 花在上下文切换上的比例会明显上升。maxIdle建议设成maxTotal的一半到全量。设太小会导致连接频繁创建销毁你会在日志里看到连接建立耗时的抖动设成等于maxTotal则意味着空闲时也不会回收内存占用略高但延迟最稳。minIdle我一般设maxTotal的四分之一目的是避免流量刚起来时冷启动批量建连那段时间正好是借连接最容易超时的窗口。maxWait给 500ms 到 1s必须设有限值。超过了直接抛异常快速失败比线程无限堆积好得多——请求进来至少能立刻返回错误线程池不会连带崩掉。2.3 Jedis 和 Lettuce池化行为差别很大这两套客户端的连接模型完全不同排查思路也不一样很多人混着用导致判断失准。Jedis 是一个连接一个线程的同步模型每次命令都要从池子里借连接用完归还。所以 Jedis 天生就是池化的maxTotal就是它的命门配置写得不对PoolException会来得非常频繁。好处是行为直观连接泄漏了 friendlyclient list里能直接看到来源 IP 和命令数。Lettuce 基于 Netty是异步、多路复用的模型默认所有线程共享一个连接压根不需要池子。共享连接模式下不存在借不到的问题但会出现另一种麻烦一条耗时长的命令会把整个连接堵住后续所有命令排队。如果你在 Lettuce 上启用了池化maxTotal的语义就变成了最多允许多少个独立连接每个连接内部还是多路复用。到底选哪个我的经验是普通读写为主、没有阻塞类命令的服务Lettuce 默认单连接就很够用配置简单、资源占用低。如果业务里有BLPOP、BRPOP、SUBSCRIBE这类长期占着连接的用法或者有大批量 pipeline那就用池化模式把阻塞类操作隔离到独立连接上。另外老项目里jedis用得非常多看到PoolException时先确认自己用的是哪套再去看对应的配置段。3. 按顺序排查从客户端一路查到服务端3.1 网络这一层必须最先确认排查的第一步永远是连通性别看它简单我遇到的PoolException里有将近三成根源在网络。先在最靠近应用的机器上执行# 基础连通建议直接用目标版本一致的 redis-cli redis-cli -h 10.0.0.21 -p 6379 -a $REDIS_PASSWORD --no-auth-warning ping # 没有 redis-cli 时用 nc 看端口通不通 nc -zv 10.0.0.21 6379如果 ping 不通先看四个地方云安全组/网络 ACL 的出方向规则、Redis 侧的白名单、DNS 解析结果dig或nslookup确认解析到的是不是内网地址、以及容器环境里的网络模式。我在 K8s 里踩过一次坑Pod 的dnsPolicy配错域名解析到了公网地址端口自然不通报错也是PoolException。还有一种特别隐蔽的情况——连接建立慢但不失败。比如跨可用区访问建连 RTT 有 30ms池子冷启动阶段minIdle个连接要串行建立maxWait只给了 200ms那前几秒的请求必然大批借连接超时几秒后又自己好了。这种周期性抖动很难查建议看一下maxWait和建连耗时是否在同一数量级。注意排查连通性时用的redis-cli版本最好和服务端大版本一致。用 3.x 的客户端去连 7.x 的集群某些命令会返回奇怪的错误容易把你带偏。3.2 服务端的连接数上限和内存水位确认网络通了下一步看服务端到底还能不能收新连接redis-cli -h 10.0.0.21 -p 6379 info clients # connected_clients:9821 # blocked_clients:3 # maxclients:10000 redis-cli -h 10.0.0.21 -p 6379 info stats | grep -E rejected|expired # rejected_connections:18342 redis-cli -h 10.0.0.21 -p 6379 config get maxclients redis-cli -h 10.0.0.21 -p 6379 client list | head -50connected_clients逼近maxclients的时候新连接会被直接拒绝并计入rejected_connections客户端侧看到的就是连接池借不到资源。此时有两个动作一是临时调大maxclients止血config set maxclients 20000注意这个值还受操作系统文件描述符限制ulimit -n要同步调二是回头查是谁把连接耗光了。client list的输出里重点看三类addr相同的连接特别多说明某个客户端实例在泄漏、cmd是subscribe或blpop且age很大的长期占用、latest字段是cmdkeys、hgetall之类的高危命令。我见过一次是某个定时任务每 10 秒起 50 个线程跑批量操作跑完不归还一天下来攒了几千个连接。内存水位同样会导致这个报错虽然路径绕一点maxmemory打满且淘汰策略是noeviction时所有写命令返回OOM command not allowed应用侧如果没做好异常处理可能一直重试、一直占着连接不放。看info memory里的used_memory_human和maxmemory_human比值长期在 0.9 以上就该扩容或者调淘汰策略了。3.3 慢命令才是隐藏最深的元凶Redis 处理命令是单线程的命令执行部分一条耗时 300ms 的命令会让这 300ms 内所有连接上的请求一起排队。客户端侧看到的现象就是连接都借出去了一个个卡在那里不动池子迅速被占满然后PoolException爆炸。而这时候connected_clients可能远没到上限内存也很健康非常容易误判成池子太小。定位慢命令靠两个东西redis-cli config set slowlog-log-slower-than 10000 # 记录超过 10ms 的命令 redis-cli slowlog get 20 redis-cli slowlog len # 按累计耗时排序看命令画像 redis-cli info commandstats | sort -t -k2 -rn | head -20slowlog看单次commandstats看累计。我喜欢用commandstats里的usec_per_call找出平均耗时高的命令类型再和业务代码对照。常见的慢命令就那么几个keys *全量扫描绝对禁止、hgetall拉超大 hash、smembers拉几万元素的 set、zrange ... withscores拉大 zset、mget里塞几百个 key、还有flushdb/flushall这种核弹。另一个隐藏杀手是大 key 删除删一个几百万元素的集合Redis 4.0 之前是同步删的直接阻塞好几秒4.0 之后可以用unlink异步删。我的处理顺序是先把slowlog里最耗时的命令对应的业务调用点找出来用scan替换keys用hscan/sscan分批拉大 key 拆成多个小 key删除改用unlink。这一步做完往往不用动任何池子参数PoolException自己就消失了。3.4 客户端侧最常见的是连接没归还如果服务端一切正常、命令也不慢那基本就是客户端在泄漏连接。Jedis 的手动用法必须严格配对// 错误写法异常路径下连接永远不归还 Jedis jedis jedisPool.getResource(); jedis.set(k, v); jedis.close(); // 上面抛异常这行不执行 // 正确写法 Jedis jedis null; try { jedis jedisPool.getResource(); jedis.set(k, v); } catch (Exception e) { log.error(redis op failed, e); } finally { if (jedis ! null) { jedis.close(); // 注意是 close不是 shutdown } }用RedisTemplate的话Spring Data Redis 内部通过RedisConnectionUtils自动获取和释放连接正常用redisTemplate.opsForValue().get()不需要操心归还。但如果你绕过RedisTemplate直接拿RedisConnectionRedisConnection conn connectionFactory.getConnection(); // 忘记 conn.close() —— 每个请求泄漏一个连接那就等着池子被抽干。另外用RedisTemplate.execute(RedisCallback)时回调里不要自己再去getConnection()会嵌套借连接容易死锁。还有一个隐蔽点RedisTemplate的executePipelined、executeWithStickyConnection这类方法会长时间持有连接如果在里面做了耗时操作比如循环发几万条命令连接会被占用很久池子很容易被打满。4. 几个高频场景的完整复盘4.1 场景一慢命令把池子拖死现象是流量高峰期必现PoolException低峰期完全没有connected_clients只有几百内存正常。slowlog一拉发现有个hgetall平均 120ms对应的 key 是个攒了两年的用户行为 hash元素超过 20 万。修法分三步。第一业务侧改成hscan分批读每次 500 个 field循环拉完第二把这个大 hash 按时间维度拆成user:behavior:202401、user:behavior:202402这样的多个小 key读的时候只取需要的月份第三加个兜底在代码里对单次 Redis 调用加超时熔断超过 50ms 直接降级走本地缓存。改完之后slowlog里最慢的命令从 120ms 降到 2ms池子maxTotal32就够用了压根不用加到 200。这个案例我的体会是池子不够用八成是有人在池子里泡澡。4.2 场景二maxWait 设成 -1 把问题藏了三个月有个服务maxWait写的是-1也就是无限等待。问题不显性只是高峰期接口 RT 慢慢涨。等到某天 Redis 网络抖了一下所有线程都卡在借连接上Tomcat 线程池被占满整个服务不可用日志里才刷出PoolException。把maxWait改成 1000ms 之后问题立刻显性化高峰期能看到少量借连接超时的告警。顺着这条线索查发现是某个接口在循环里单个set了 2000 次每次借一次连接。改成 pipeline 批量提交后连接占用时间从 40ms 降到 1ms告警消失。maxWait-1最大的问题是把池子不够变成了线程堆积前者你能收到明确告警后者只会表现为 RT 缓慢劣化查起来非常痛苦。所以我坚持一句话maxWait必须有限值宁可快速失败不要安静排队。4.3 场景三主从切换后池子里全是死连接用哨兵或者集群模式的项目运维做了一次主从切换之后应用端持续报PoolExceptionPING又是通的。这种情况通常是池子里残存着指向旧主节点的连接Sentinel 通知到位了但连接池里的连接还没被清理。Jedis 哨兵模式要确保JedisConnectionFactory配置了正确的哨兵节点并且池子的testOnBorrow或者testWhileIdle打开让探活机制把死连接剔掉。Lettuce 侧有专门的拓扑刷新配置spring: data: redis: lettuce: cluster: refresh: adaptive: true period: 30s集群模式下遇到MOVED重定向如果客户端没跟上也会表现为操作超时进而耗尽池子adaptive刷新可以在拓扑变化后主动更新连接。另外切换瞬间的重试策略要对RedisTemplate默认重试次数是 0可以配合 Resilience4j 或 Spring Retry 做一次短重试但重试次数别超过 2 次否则会把 Redis 压得更狠。4.4 场景四四个超时配置互相打架这是最容易搞混的地方。客户端侧同时存在四个超时各管各的配错了现象很奇怪配置项作用范围典型值配错的后果connect-timeoutTCP 建连阶段500ms~1s太小则在网络抖动时批量建连失败太大则借连接线程卡住timeout命令超时单条命令读写1s~2s太小则大 key 读取频繁超时太大则连接长期被占用max-wait从池中借连接的等待时间500ms~1s设为 -1 导致线程无限堆积设为 0 则一借不到就抛异常socket keepalive / 服务端 timeout空闲连接是否被服务端断开服务端timeout 300服务端主动断连池中连接变死连接需探活机制清理一个常见错误组合connect-timeout设 5smax-wait设 200ms。结果就是建连慢的时候池子里的线程还没等到连接建立完成就超时抛异常白白浪费了建连开销还会不断重试把连接数推高。合理的搭配是max-wait ≥ connect-timeout或者干脆在应用启动时预热minIdle个连接避免运行时批量建连。服务端的timeout配置也要看一眼。如果 Redis 配了timeout 300空闲 300 秒断开而客户端池子的空闲检测间隔是 10 分钟那中间这 7 分钟池子里就是一堆死连接借出来必报错。解决办法是把客户端time-between-eviction-runs调到小于服务端 timeout并开启test-while-idle。5. 改完之后怎么验证怎么长期盯住5.1 三个层面的关键指标改完配置不算完得确认真的好了。我一般从三个层面看指标。JVM 侧Spring Boot 通过 Micrometer 会暴露连接池指标开启management.metrics.enable.jedistrue或lettuce重点看active正在被借用的连接数、idle、waiters等待借连接的线程数。waiters长期大于 0 说明池子偏小或者有慢命令active长时间贴顶maxTotal说明容量真的不够。这三个指标比看异常日志提前得多异常是结果waiters是前兆。Redis 服务端侧重点盯connected_clients、rejected_connections、blocked_clients、instantaneous_ops_per_sec以及info commandstats里 top 命令的usec_per_call。我习惯把usec_per_call排前 5 的命令做成一张周报一旦有命令的平均耗时涨了 50%立刻去看对应业务代码有没有改动。应用侧统计PoolException的发生次数和时间分布。这个最好做成按分钟聚合的曲线如果只在每天某个固定时间点出现基本能锁定是定时任务如果跟着流量曲线走那多半是容量或者慢命令问题。5.2 用压测把问题复现出来线上问题难复现就造一个。我常用的组合是本地起一个和线上同版本的 RedisDocker 一条命令就行用redis-benchmark打满基础压力同时用 JMeter 或 wrk 打应用接口。要验证连接池行为关键是制造网络抖动# 给 Redis 端口加 100ms 延迟和 5% 丢包模拟跨机房抖动 tc qdisc add dev eth0 root netem delay 100ms loss 5% # 验证完记得删掉规则否则后面的测试全废 tc qdisc del dev eth0 root在这个前提下观察waiters曲线和PoolException的数量就能判断当前maxTotal、maxWait的组合在异常网络下是否扛得住。我的经验是在 100ms 延迟下还能不抛PoolException的配置线上基本就稳了。反过来如果本地网络完美的情况下压测就报错那说明池子容量严重不足先加容量再谈别的。5.3 告警阈值怎么定别只配一条出现 PoolException 就告警那样你会在小抖动里疲于奔命。分两级预警级waiters连续 1 分钟大于maxTotal的 10%或者active连续 5 分钟超过maxTotal的 80%。这时候还没报错但趋势不对可以提前看。告警级PoolException每分钟超过 5 次或者rejected_connections出现增长或者 P99 接口耗时翻倍。再补一条服务端的connected_clients / maxclients 0.8持续 3 分钟告警。这条能提前发现连接泄漏比等到池子报错早得多。6. 常见问题速查表与我的避坑清单6.1 一张表覆盖八成报错现象最可能原因快速验证方式处理动作服务启动就报一直复现地址/端口/密码错或安全组不通redis-cli ping手动连修配置或开白名单高峰期必现低峰正常连接池容量不足或慢命令看waiters、slowlog提maxTotal同时查慢命令突然全量报错之前很稳服务端 maxclients 打满info clients临时提maxclients查泄漏源只有部分实例报错网络分区或某台机器配置漏改对比实例配置和网络统一配置报错伴随 RT 缓慢上涨maxWait-1静默排队检查max-wait配置改成有限值主从切换后开始报错池中死连接未清理test-while-idle是否开启开探活配 Lettuce 拓扑刷新偶发单次无规律网络抖动 建连超时太短抓包看建连耗时应用启动预热 minIdle 连接报错同时内存告警maxmemory 打满写拒绝info memory扩容或调淘汰策略这张表我贴在工位上遇到报错先扫一遍通常两分钟内就能定方向。6.2 几条用血换来的注意事项第一永远不要在业务代码里jedisPool.getResource()之后不 close。哪怕你觉得自己写对了也建议在 code review 里专门加一条检查项或者干脆封一个工具类统一处理。我抓过最夸张的一次是一个 try 里借了连接catch 里 log 完就 return 了一个月泄漏了几万个连接。第二maxTotal不是越大越好。加到几百之后Redis 服务端处理连接本身的 CPU 开销会明显上升尤其是短连接频繁创建销毁的场景。而且连接数堆上去之后一旦某条慢命令出现被阻塞的连接数也更多雪崩规模更大。我见过把maxTotal从 32 加到 500 之后报错频率反而上升的案例。第三把 Redis 调用包一层超时和降级。不管连接池配得多好网络总有抽风的时候。给关键业务加一个本地缓存兜底或者用 Resilience4j 做熔断能在 Redis 抖动时保证主流程可用。这比调参数管用得多。第四别在生产环境用keys *排查问题。我见过同事为了找一个 key在线上执行keys user:*直接触发了一次 30 秒的阻塞PoolException瞬间刷屏。正确做法是用scan游标分批遍历或者用可视化管理工具按前缀过滤。顺带说一句可视化工具有不少选择但连生产库的时候一定要用只读账号别手滑点了个删除。第五配置变更要能回滚。连接池参数调大容易调小之后如果发现撑不住回滚窗口是很短的。建议每次调参只改一个变量观察 24 小时再动下一个同时把变更记录写清楚。第六版本差异要留意。Spring Boot 2.4 前后配置前缀不同commons-pool2的版本会影响空闲检测的行为Jedis 3.x 和 4.x 的默认超时值也不一样。升级依赖的时候顺手把这些参数在启动日志里打一遍对比能避免很多升级完就出问题的莫名现象。6.3 一个可以复制的排查脚本最后贴一个我常用的小脚本把前面那些命令串起来出问题的时候一把梭#!/bin/bash # redis-triage.sh 用法: ./redis-triage.sh 10.0.0.21 6379 HOST${1:-127.0.0.1} PORT${2:-6379} CLIredis-cli -h $HOST -p $PORT echo 1. 连通性 $CLI ping || { echo PING 失败先查网络; exit 1; } echo 2. 连接数 $CLI info clients | grep -E connected_clients|blocked_clients|maxclients echo 3. 连接拒绝统计 $CLI info stats | grep -E rejected_connections|total_connections_received echo 4. 内存水位 $CLI info memory | grep -E used_memory_human|maxmemory_human|maxmemory_policy echo 5. 慢查询最近 10 条 $CLI slowlog get 10 echo 6. 命令耗时画像 $CLI info commandstats | tr -d \r | awk -F[:,] /cmdstat/ {print $4, $0} | sort -rn | head -10 echo 7. 连接来源 TOP $CLI client list | awk {print $2} | cut -d -f2 | cut -d: -f1 | sort | uniq -c | sort -rn | head -10跑一遍基本能把方向定下来。第 7 项特别有用如果某个 IP 的连接数远超其他那泄漏源就找到了。这个脚本我在好几家公司都用过稍微改改就能接进运维平台的巡检任务里。我个人在这类问题上的体会是PoolException从来不是孤立的配置问题它更像一个症状真正的原因往往藏在慢命令、连接泄漏、网络抖动或者拓扑变更里。上来就调maxTotal是最省事也最容易做错的选择正确的顺序永远是先看异常尾巴再排除池外因素最后才动池子参数。还有一个习惯值得养成——任何一次 Redis 相关的故障处理完之后都把slowlog和client list的当时快照存下来过两周再对比一次往往能发现一些慢性的、正在恶化但还没爆发的隐患这比事后救火有价值得多。
返回列表