原文标题:实战总结|一次访问Redis延时高问题排查与总结(续)
原文作者:阿里云开发者
冷月清谈:
怜星夜思:
2、文章提到的Jedis乐观锁方案,听起来很美好,但为何Jedis官方没有采用这种方案呢?除了维护Command队列的复杂性,还有其他原因吗?
3、除了Jedis,还有哪些Java Redis客户端可以选择?它们在连接池管理方面有何特点?
原文内容
阿里妹导读
本文是一次访问Redis延时高问题排查与总结的续篇,主要讲述了当时没有发现的一些问题和解决方案。
背景
在今年4月份,笔者写的 中,通过配置参数实现了一个稳态的连接池,解决了问题(至少在当时这么认为的)。
但近期压测分析发现,仍然有部分请求卡在了从JedisPool中获取连接上。从而导致整体请求处理超时。
-
如下,每天22:00会固定压测, 从而导致一波超时错误:(理论上按照服务器容量预估, 不应该超时)
-
JedisPool详细信息如下,获取连接最大等待耗时仍然在900ms以上:
问题1: 为啥还是会获取Jedis连接超时?
根据 文中介绍, 可以通过设置如下参数,来保持一个稳定的Pool(即只在failover场景下会重建Pool内对象):
spring.redis.jedis.pool.max-active=500 // 线上稳定保有4台, 4*500=2000, 仍然远小于Redis规格支持的3w spring.redis.jedis.pool.max-idle=500 spring.redis.jedis.pool.min-idle=500 // 连接池的稳定数量
spring.redis.jedis.pool.test-while-idle=true //定时心跳保活与检测
spring.redis.jedis.pool.time-between-eviction-runs-millis=30000 // 定时心跳保活与检测
spring.redis.jedis.pool.num-tests-per-eviction-run=-1 // 每次保活检测, 都需要把500个连接都检测一遍. 如果设置为-2, 则每次检测1/2比例的的连接.
spring.redis.jedis.pool.min-evictable-idle-time-millis=-1 // 不要因为idleTime大于某个阈值从而把连接给删除掉. 这样可以防止无意义的大规模连接重建.
为啥 createdCount, destroyedCount 的量仍然较大? 大概率超时还是由于连接反复重建导致。
这里勘误 一次访问Redis延时高问题排查与总结 文中最终查看池内对象新建&销毁量, 需要看 createdCount 而不是 createCount 值. 当时有点儿得意忘形导致看错。
再去拉取线上其他机器上看了下,如下 create/borrow 比例将近70%。说明该问题并非偶然。按照之前文章参数,并不能实现 稳态的连接池!里边的对象(连接)仍然是被反复销毁&重建的!
// 小流量地域 borrowedCount: 670756 createdCount: 468204 destroyedCount: 467704 destroyedByEvictorCount: 467704
// 大流量地域
borrowedCount: 10308566
createdCount: 3973975
destroyedCount: 3973475
destroyedByEvictorCount: 3973475
问题2: 为啥Jedis连接还是会反复销毁&重建连接?
仔细看如下参数值, destroyedCount与destroyedByEvictorCount的值相同, 也就是说这次全都是因为 Evictor 导致. 与上篇文章中解决的因为 池子里Idle连接的数量 导致销毁的原因是完全不同的. 那我们接下来分析下Evictor的逻辑:
destroyedCount: 467704
destroyedByEvictorCount: 467704
Evictor逻辑分析
核心代码: org.apache.commons.pool2.impl.GenericObjectPool#evict
流程解析如下:
如上图所示, 可能有2点导致连接被回收:
-
Idle时间超过阈值
-
Redis心跳探活失败
我们分别去分析:
可能性1: 是否是Idle超时导致?
我们来看下如何判定Idle是否超过阈值, 根据配置文件参数分析, evict()判定必然返回false, 因此必然不会因为连接Idle超时导致. 而这个参数也是Jedis官方背书的。
可能性2: 是否Redis心跳探活失败导致?
尝试在本机Ping了下RedisServer, 发现延时等都正常. 理论上只有RedisServer挂了或者主备切换或者网络故障等才会导致探活失败。
此时排查进入了死胡同。
实际分析
由于destroy量很大,因此推测线上evict一直在发生。此时只能通过终极武器Arthas来分析。果然抓到了一些线索:
-
发现idleEvictTime并非我们预期的**Long.MAX_VALUE**, 而是**60000ms**如下:
-
导致连接只要超过60秒空闲, 就会把这个连接给释放掉。
-
另外由于JedisPool默认是LIFO模式, 更是加剧了该问题. 在空闲时, 处于队尾的一堆Connection处于反复的 Create---> Idle>minIdle(60s) --> Destroy --> Create 循环中。
-
Borrow时, 始终从 队首 获取连接;
-
Return时, 根据是否是LIFO, 来判定是放在队首还是队尾:

-
如下 LIFO默认为true
-
但为啥如下关键参数未生效呢? 而是使用了JedisClient的默认值(即60s)呢?
spring.redis.jedis.pool.min-evictable-idle-time-millis=-1 // 不要因为idleTime大于某个阈值从而把连接给删除掉. 这样可以防止无意义的大规模连接重建.
问题3: 为啥JedisPool参数注入失败?
入口:
org.springframework.boot.autoconfigure.data.redis.JedisConnectionConfiguration#jedisPoolConfig
-
核心逻辑如下: 其中 RedisProperties.Pool 即为配置文件中配置反序列化出来的对象. 如下, 发现根本没有从 RedisProperties.Pool 对象中获取MinEvictableIdleTimeMillis参数, 因此也就没有赋值给真正的配置 JedisPoolConfig 中:
private JedisPoolConfig jedisPoolConfig(RedisProperties.Pool pool) {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(pool.getMaxActive());
config.setMaxIdle(pool.getMaxIdle());
config.setMinIdle(pool.getMinIdle());
if (pool.getTimeBetweenEvictionRuns() != null) {
config.setTimeBetweenEvictionRunsMillis(pool.getTimeBetweenEvictionRuns().toMillis());
}
if (pool.getMaxWait() != null) {
config.setMaxWaitMillis(pool.getMaxWait().toMillis());
}
return config;
}
-
再来查看org.springframework.boot.autoconfigure.data.redis.RedisProperties.Pool 的属性清单如下, 发现也根本没有MinEvictableIdleTimeMillis这个字段。 也就是说properties文件反序列化成为RedisProperties.Pool对象的时候, 就会忽略掉 min-evictable-idle-time-millis这项配置!
private int maxIdle = 8;
private int minIdle = 0;
private int maxActive = 8;
private Duration maxWait = Duration.ofMillis(-1);
private Duration timeBetweenEvictionRuns;
-
其余的参数都被SpringBoot使用了如下默认值, 我们可以看到把**MinEvictableIdleTimeMillis**参数强制设置为了60s:
这里很有意思的一点, JedisPoolConfig 专门写了一行注释, 希望使用这个Default配置, 能让大家的生活更愉快一些。
然而但当看到这里, 我内心是抓狂的. 这个可恶的MinEvictableIdleTimeMillis参数, 使用SpringBoot还无法通过配置文件修改, 一点都不愉快!
public JedisPoolConfig() {
// defaults to make your life with connection pool easier :)
setTestWhileIdle(true);
setMinEvictableIdleTimeMillis(60000); // 这里设置了默认值60s!
setTimeBetweenEvictionRunsMillis(30000);
setNumTestsPerEvictionRun(-1);
}
至此问题已经明确: SpringBoot官方(version 2.5.6)对于JedisPool的配置Properties文件, 根本不支持**min-evictable-idle-time-millis**这个参数!!
如何解决该问题?
方案1: 升级spring-boot-autoconfig版本? --> 不可行
方案2: 修改LIFO为FIFO? --> 不可行
org.apache.commons.pool2.impl.BaseObjectPoolConfig#setLifo
方案3: 延长Evictor的运行间隔(甚至停止Evictor)? --> 不可行
-
当前配置是30s, 如果配置为60s,在稳定流量情况下,因为Idle而导致释放的Connection量会减少一半。
-
如果配置为-1,则代表禁用Evictor机制,也就不会因为Idle时间导致空闲连接被删除/重建。
Evictor机制
-
重新回顾下Evictor的流程:
-
总结下来, Evictor除了对于连接的Idle检测外, 还担任了 连接探活与重建这个重要的任务!!!
-
查看了Jedis相关源码, 发现Jedis对于池内的连接探活/重建, 只会发生在如下2种情况下:
-
场景1: testOnBorrow, testOnReturn 等时间点, 会与Redis服务端进行一次Ping/Pong的心跳校验. 但如果开启, 则在每次从池子里borrow的时候都执行一次Ping/Pong校验, 但会极大地影响性能. 因此默认都是关闭的.
-
场景2: 定时的Evictor: 即上述所讲的Evictor流程啦.
-
而我们线上为了性能考虑, follow默认的convention, 即 testOnBorrow, testOnReturn, testOnCreate都为false. 如果再把Evictor禁用, 会导致在Redis/网络故障时, 无法执行连接重建. 因此该方案不可行.
Evictor实际作用
如下, 这是近期发生的Redis主备切换故障, 如下应用日志, 都是基于Jedis的定时validate&evict机制才使得连接池内可以将故障的连接删除&重建的:
引申: 一个有意思的讨论点
-
克服了testOnBorrow, testOnReturn (类似与悲观锁) 等性能损耗。
-
克服了定时Evictor如果运行间隔时间过久, 导致的连接探活/重建不及时的问题。
简直是两全其美! 官方也针对这个进行了说明,参见[2]
总结下来就是, 如果为了实现上述方案,Jedis需要在内部维护一个Command队列,便于在Jedis对于连接重建之后将这些失败/未执行的Command重新执行,否则会导致应用已经提交的Command根本不执行,而应用也压根感知不到Command没有执行! 这个风险显然是不可接受的。所以Jedis会直接把这个链接异常抛出给外层应用,由应用感知并进行自定义的failover操作。
方案4: 扩展JedisConnectionConfiguration支持其他参数? --> 最终方案
-
虽然暂时官方仍在讨论是否添加这些Properties支持。
-
但实际上我们可以使用 JedisClientConfigurationBuilderCustomizer 这种高级的方式来扩展实现这些参数的注入。
效果验证
JedisConnection维持稳态, 不再重复销毁&创建
select * from org.springframework.data.redis.connection.jedis.JedisConnectionFactory
压测导致的超时现象消失
连接获取等待时间回归正常
总结
总结1: 在SpringBoot下, 如何来创建一个固定的JedisPool?
即我们希望连接池里的连接创建之后就不要再被销毁, 只在真正RedisServer故障时进行连接重建。
-
步骤1: 配置SpringBoot支持的核心Pool参数
spring.redis.jedis.pool.max-active=500 // 线上稳定保有4台, 4*500=2000, 仍然远小于Redis规格支持的3w spring.redis.jedis.pool.max-idle=500 spring.redis.jedis.pool.min-idle=500 // 连接池的稳定数量
spring.redis.jedis.pool.test-while-idle=true //定时心跳保活与检测
spring.redis.jedis.pool.time-between-eviction-runs-millis=30000 // 定时心跳保活与检测
spring.redis.jedis.pool.num-tests-per-eviction-run=-1 // 每次保活检测, 都需要把500个连接都检测一遍. 如果设置为-2, 则每次检测1/2比例的的连接. 但SpringBoot默认是不识别这个参数的!
spring.redis.jedis.pool.min-evictable-idle-time-millis=-1 // 不要因为idleTime大于某个阈值从而把连接给删除掉. 这样可以防止无意义的大规模连接重建. 但SpringBoot默认是不识别这个参数的!
-
步骤2: 实现自己的 JedisClientConfigurationBuilderCustomizer , 从而识别min-evictable-idle-time-millis参数, 详细参见: Provide more configuration properties for Jedis's connection pool · Issue #33814 · spring-projects/spring-boot[3]
-
拓展: 其他如下SpringBoot未官方支持的重要参数, 都可以通过相同的Customizer机制扩展&注入:
spring.redis.jedis.pool.num-tests-per-eviction-run=-2 # 每次evict执行的量/比例
spring.redis.jedis.pool.lifo=false # 将LIFO模式修改为FIFO模式
总结2:很惭愧,在第一篇文章中有如下问题
-
查看总体Connection的创建&销毁量, 应该查看CreatedCount, 而不是CreateCount.
-
没有仔细去分析SpringBoot对于JedisPool参数的注入流程, 导致想当然地按照convention配置了一个当前根本就不支持的配置! 导致问题只解决了一半.
-
重新研究了下JedisPool/CommonsPool2对于failover的机制与原理。这样在配置各个参数&调优时,才能做到心中有数。
-
重新研究了下SpringBoot对于JedisConnectionConfiguration以及其他属性文件注入的流程。这样在其他参数注入时可以按图索骥, 防止再次踩坑。
[1]https://github.com/spring-projects/spring-boot/blob/main/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/data/redis/RedisProperties.java
[2]https://github.com/redis/jedis/issues/83
阿里云开发者社区,千万开发者的选择
阿里云开发者社区,百万精品技术内容、千节免费系统课程、丰富的体验场景、活跃的社群活动、行业专家分享交流,欢迎点击【阅读原文】加入我们。