Skip to content

Redis 服务器 CPU 100% 问题调查

🏷️ Redis

旁边的一个项目组在做压测时发现 TPS 一直上不去,经过几次测试发现有如下几个现象:

  • Redis 的 CPU 升到了 100% ,提了工单发现是短时间内创建了大量连接导致的。
  • 即使没有请求,仍然会不停的创建新连接,只是数量没有压测时大。
  • 总的客户端数( connected_clients )没有变化。

项目使用的是 SpringBoot 1.5.9.RELEASE ,连接池使用的是 JedisPool

刚开始以为是连接池没有起作用,但经过测试配置项是有效的(如 minIdlemaxTotal 等)。

仔细查看配置发现有几个配置项设置的好像有问题:

  • 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 的异常,而且仍然会创建不少新的连接。

java
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 中调用了一次 Jedisclose 方法,在 finally 中又调用了一次,导致在第二次 close 时发生了这个异常。在多线程的情况下,第二次 close 时这个连接可能已经被别的线程 borrow 出去了,这样就有可能引发一些意想不到的情况,甚至是业务错误。

这个缺陷修复后再次压测,从结果看基本上算是正常了,Redis 的 CPU 和连接数也比较正常,没有出现很大的波动。

本次调查过程中,发现了一些个人对一些配置项的错误理解:

  • minIdlemaxIdle 分别表示最小空闲连接数和最大空闲连接数,maxTotal 则表示允许的最大连接数。本以为是在应用启动后就会创建 minIdle 数量的连接,但其实并没有,而是在之后使用时才会创建需要的连接,才会逐渐达到 minIdle 的数量。
  • 在连接数超过 maxIdle 时本以为连接会在空闲时间超过 minEvictableIdleTimeMillis 后才会销毁,但查看过源码才发现每次在 return 回连接池时都有可能被销毁。每次 return 回连接池时都会判断 idleObjects 中的元素数量,超过 maxIdle 就会执行 destroy 处理。
    也就是说如果要避免这种现象,需要将 maxIdlemaxTotal 设置成相同的值。或者将两个值设置的不要差别太大,应该也可以减少注销的次数。

在这个项目中还发现了另外几个比较常见的 Redis 错误用法:

  • 把功能开关的配置项保存在 Redis 中,并且每次都从 Redis 获取实时的值。
    这个会导致本来纳秒级(如使用获取全局配置变量)的操作变成了毫秒级,会极大的影响效率,而且也会导致这个 key 变成热点 Key,从而引发其它问题。
    配置的开关还是建议通过配置中心来实现。如果没有配置中心,也可以临时通过缓存来配置,但是一定要添加对应的本地缓存,并且设置一个可以接受的本地缓存的过期时间。
  • 使用了多个 db 但使用的是相同的连接池,导致每次都要执行一次 select db 操作。
    select 操作本身就是一个命令,未使用 multi 模式时会单独发送一次请求到 Redis。如果能省掉这个操作,可以提升约一倍的性能。
    这个可以考虑使用多个连接池,分别维护每个 db 的连接。另外也可以考虑使用同一个 db,毕竟我看这个项目中使用的 key 并不多,总共也就百十个,完全没必要分成多个 db。
  • testOnBorrowtestOnReturn 都设置为了 true
    这会导致每个 Redis 操作都会额外发送两个 PING-PONG 请求。
    发送网络请求之类的 IO 操作耗时肯定是相对较长的,虽然可以避免一些由于服务器关闭了连接导致的错误问题,但是对性能的影响有些得不偿失,毕竟服务器出问题的几率很小。
    个人感觉即使要开,也只需要把 testOnBorrow 的开关打开就足够了。一般情况下这两个都不需要开,只需要把 testWhileIdle 设置为 true 就可以了。

另外顺便提醒一下,使用 JedisPoolConfig 时需要注意:它在初始化时修改了 BaseObjectPoolConfig 的几个默认配置。

java
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 之类的模板类。使用起来比较方便,还可以极大的减少因为使用不当而导致的奇怪问题。