旁边的一个项目组在做压测时发现 TPS 一直上不去,经过几次测试发现有如下几个现象:
- Redis 的 CPU 升到了 100% ,提了工单发现是短时间内创建了大量连接导致的。
- 即使没有请求,仍然会不停的创建新连接,只是数量没有压测时大。
- 总的客户端数(
connected_clients
)没有变化。
项目使用的是 SpringBoot 1.5.9.RELEASE ,连接池使用的是 JedisPool
。
刚开始以为是连接池没有起作用,但经过测试配置项是有效的(如 minIdle
、maxTotal
等)。
仔细查看配置发现有几个配置项设置的好像有问题:
minEvictableIdleTimeMillis
:500
连接的最小空闲时间,达到此值后空闲连接将被移除。softMinEvictableIdleTimeMillis
:1000
连接的最小空闲时间,达到此值后空闲连接将被移除,但是只有在空闲连接超过timeBetweenEvictionRunsMillis
指定的时间后才会移除。timeBetweenEvictionRunsMillis
:1000
空闲连接检查线程的运行时间间隔。
这几个配置项的单位都是毫秒,也就是说每一秒钟就会执行一次驱逐空闲超过半秒钟的连接。而由于 JedisPool 默认是 LIFO 的,导致连接很容易被驱逐。
但是将这几个配置项设置为 1800000 (即 30 分钟)后,压测时出现了一个更奇怪的现象:所有的请求都超时了(50s+),感觉像是在哪里卡住了。压测前单个请求时接口是这个正常访问的,压测后单个请求也无法响应。
仔细看了下代码,发现代码里获取缓存的方式有些混乱,有的地方直接使用了 Jedis
来操作缓存,有的地方使用了封装的工具类。这个本身并不会导致缺陷,但是我发现有个别地方出现了嵌套的缓存操作,导致获取的 Jedis 还没有关闭的时候再次尝试获取新的连接。压测时会导致所有的连接很快就被 borrow 了且创建的连接数也达到了 maxTotal
的上限,从而导致所有的请求都被卡住了。这个现象有些类似于多线程时资源争用产生的死锁,大量线程 hold 了一个 Redis 连接,再次尝试 borrow 一个新连接时已经没有资源可以获取了,只有等到 borrow 超时处理才会继续。而 borrow 的超时时间是通过 maxWaitMillis
配置的,默认值为 -1
,即永不超时。
将上述问题修改后大部分处理都正常了,但是偶尔还是会出现 Could not return the resource to the pool 的异常,而且仍然会创建不少新的连接。
redis.clients.jedis.exceptions.JedisException: Could not return the resource to the pool
at redis.clients.util.Pool.returnBrokenResourceObject(Pool.java:103)
at redis.clients.jedis.JedisPool.returnBrokenResource(JedisPool.java:239)
at redis.clients.jedis.JedisPool.returnResource(JedisPool.java:255)
at redis.clients.jedis.JedisPool.returnResource(JedisPool.java:16)
at redis.clients.jedis.Jedis.close(Jedis.java:3409)
经调查发现这个还是由于代码的写法问题导致的。有一个方法在 try
中调用了一次 Jedis
的 close
方法,在 finally
中又调用了一次,导致在第二次 close
时发生了这个异常。在多线程的情况下,第二次 close
时这个连接可能已经被别的线程 borrow 出去了,这样就有可能引发一些意想不到的情况,甚至是业务错误。
这个缺陷修复后再次压测,从结果看基本上算是正常了,Redis 的 CPU 和连接数也比较正常,没有出现很大的波动。
本次调查过程中,发现了一些个人对一些配置项的错误理解:
minIdle
和maxIdle
分别表示最小空闲连接数和最大空闲连接数,maxTotal
则表示允许的最大连接数。本以为是在应用启动后就会创建minIdle
数量的连接,但其实并没有,而是在之后使用时才会创建需要的连接,才会逐渐达到minIdle
的数量。- 在连接数超过
maxIdle
时本以为连接会在空闲时间超过minEvictableIdleTimeMillis
后才会销毁,但查看过源码才发现每次在 return 回连接池时都有可能被销毁。每次 return 回连接池时都会判断idleObjects
中的元素数量,超过maxIdle
就会执行destroy
处理。
也就是说如果要避免这种现象,需要将maxIdle
和maxTotal
设置成相同的值。或者将两个值设置的不要差别太大,应该也可以减少注销的次数。
在这个项目中还发现了另外几个比较常见的 Redis 错误用法:
- 把功能开关的配置项保存在 Redis 中,并且每次都从 Redis 获取实时的值。
这个会导致本来纳秒级(如使用获取全局配置变量)的操作变成了毫秒级,会极大的影响效率,而且也会导致这个 key 变成热点 Key ,从而引发其它问题。
配置的开关还是建议通过配置中心来实现。如果没有注册中心,也可以临时通过缓存来配置,但是一定要添加对应的本地缓存,并且设置一个可以接受的本地缓存的过期时间。 - 使用了多个 db 但使用的是相同的连接池,导致每次都要执行一次
select db
操作。
select
操作本身就是一个命令,未使用 multi 模式时会单独发送一次请求到 Redis 。如果能省掉这个操作,可以提升约一倍的性能。
这个可以考虑使用多个连接池,分别维护每个 db 的连接。另外也可以考虑使用同一个 db ,毕竟我看这个项目中使用的 key 并不做,总共也就百十个,完全没必要分成多个 db 。 - 把
testOnBorrow
和testOnReturn
都设置为了true
。
这会导致每个 Redis 操作都会额外发送两个 PING-PONG 请求。
发送网络请求之类的 IO 操作耗时肯定是相对较长的,虽然可以避免一些由于服务器关闭了连接导致的错误问题,但是对性能的影响有些得不偿失,毕竟服务器出问题的几率很小。
个人感觉即使要开,也只需要把testOnBorrow
的开关打开就足够了。一般情况下这两个都不需要开,只需要把testWhileIdle
设置为true
就可以了。
另外顺便提醒一下,使用 JedisPoolConfig
时需要注意:它在初始化时修改了 BaseObjectPoolConfig
的几个默认配置。
public class JedisPoolConfig extends GenericObjectPoolConfig {
public JedisPoolConfig() {
// defaults to make your life with connection pool easier :)
setTestWhileIdle(true);
setMinEvictableIdleTimeMillis(60000);
setTimeBetweenEvictionRunsMillis(30000);
setNumTestsPerEvictionRun(-1);
}
}
从上面的代码可以很直观的看到修改的配置项,大多数情况下使用这个配置基本上没啥问题,如果需要修改再次设置一下就可以了,剩下的就是根据业务需求设置合适的连接数量就行了。
最后建议不要直接操作 JedisPool
来获取连接并操作 Redis ,还是推荐使用 RedisTemplate
之类的模板类。使用起来比较方便,还可以极大的减少因为使用不当而导致的奇怪问题。