关于redis:springdataredis-上百万的-QPS-压力太大连接失败我-TM-人傻了

7次阅读

共计 11328 个字符,预计需要花费 29 分钟才能阅读完成。

大家好,咱们最近业务量暴涨,导致我最近始终 TM 人傻了。前几天早晨,发现因为业务压力激增,某个外围微服务新扩容起来的几个实例,在不同水平上,呈现了 Redis 连贯失败 的异样:

org.springframework.data.redis.RedisConnectionFailureException: Unable to connect to Redis; nested exception is io.lettuce.core.RedisConnectionException: Unable to connect to redis.production.com
    at org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory$ExceptionTranslatingConnectionProvider.translateException(LettuceConnectionFactory.java:1553) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory$ExceptionTranslatingConnectionProvider.getConnection(LettuceConnectionFactory.java:1461) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at org.springframework.data.redis.connection.lettuce.LettuceConnection.doGetAsyncDedicatedConnection(LettuceConnection.java:1027) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at org.springframework.data.redis.connection.lettuce.LettuceConnection.getOrCreateDedicatedConnection(LettuceConnection.java:1013) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at org.springframework.data.redis.connection.lettuce.LettuceConnection.openPipeline(LettuceConnection.java:527) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at org.springframework.data.redis.connection.DefaultStringRedisConnection.openPipeline(DefaultStringRedisConnection.java:3245) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at jdk.internal.reflect.GeneratedMethodAccessor319.invoke(Unknown Source) ~[?:?]
    at jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[?:?]
    at java.lang.reflect.Method.invoke(Method.java:566) ~[?:?]
    at org.springframework.data.redis.core.CloseSuppressingInvocationHandler.invoke(CloseSuppressingInvocationHandler.java:61) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at com.sun.proxy.$Proxy355.openPipeline(Unknown Source) ~[?:?]
    at org.springframework.data.redis.core.RedisTemplate.lambda$executePipelined$1(RedisTemplate.java:318) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:222) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:189) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:176) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at org.springframework.data.redis.core.RedisTemplate.executePipelined(RedisTemplate.java:317) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at org.springframework.data.redis.core.RedisTemplate.executePipelined(RedisTemplate.java:307) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at org.springframework.data.redis.core.RedisTemplate$$FastClassBySpringCGLIB$$81812bd6.invoke(<generated>) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    // 省略一些堆栈
Caused by: org.springframework.dao.QueryTimeoutException: Redis command timed out
    at org.springframework.data.redis.connection.lettuce.LettuceConnection.closePipeline(LettuceConnection.java:592) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    ... 142 more

同时,也有业务调用 Redis 命令超时 的异样:

org.springframework.data.redis.connection.RedisPipelineException: Pipeline contained one or more invalid commands; nested exception is org.springframework.data.redis.connection.RedisPipelineException: Pipeline contained one or more invalid commands; nested exception is org.springframework.dao.QueryTimeoutException: Redis command timed out
    at org.springframework.data.redis.connection.lettuce.LettuceConnection.closePipeline(LettuceConnection.java:594) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at org.springframework.data.redis.connection.DefaultStringRedisConnection.closePipeline(DefaultStringRedisConnection.java:3224) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at jdk.internal.reflect.GeneratedMethodAccessor198.invoke(Unknown Source) ~[?:?]
    at jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[?:?]
    at java.lang.reflect.Method.invoke(Method.java:566) ~[?:?]
    at org.springframework.data.redis.core.CloseSuppressingInvocationHandler.invoke(CloseSuppressingInvocationHandler.java:61) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at com.sun.proxy.$Proxy355.closePipeline(Unknown Source) ~[?:?]
    at org.springframework.data.redis.core.RedisTemplate.lambda$executePipelined$1(RedisTemplate.java:326) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:222) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:189) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:176) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at org.springframework.data.redis.core.RedisTemplate.executePipelined(RedisTemplate.java:317) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at org.springframework.data.redis.core.RedisTemplate.executePipelined(RedisTemplate.java:307) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at org.springframework.data.redis.core.RedisTemplate$$FastClassBySpringCGLIB$$81812bd6.invoke(<generated>) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218) ~[spring-core-5.3.7.jar!/:5.3.7]
    at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.invokeJoinpoint(CglibAopProxy.java:779) ~[spring-aop-5.3.7.jar!/:5.3.7]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) ~[spring-aop-5.3.7.jar!/:5.3.7]
    at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:750) ~[spring-aop-5.3.7.jar!/:5.3.7]
    at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97) ~[spring-aop-5.3.7.jar!/:5.3.7]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.7.jar!/:5.3.7]
    at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:750) ~[spring-aop-5.3.7.jar!/:5.3.7]
    at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:692) ~[spring-aop-5.3.7.jar!/:5.3.7]
    at org.springframework.data.redis.core.StringRedisTemplate$$EnhancerBySpringCGLIB$$c9b8cc15.executePipelined(<generated>) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
// 省略一部分堆栈
Caused by: org.springframework.data.redis.connection.RedisPipelineException: Pipeline contained one or more invalid commands; nested exception is org.springframework.dao.QueryTimeoutException: Redis command timed out
    at org.springframework.data.redis.connection.lettuce.LettuceConnection.closePipeline(LettuceConnection.java:592) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    ... 142 more
Caused by: org.springframework.dao.QueryTimeoutException: Redis command timed out
    at org.springframework.data.redis.connection.lettuce.LettuceConnection.closePipeline(LettuceConnection.java:592) ~[spring-data-redis-2.4.9.jar!/:2.4.9]
    ... 142 more

咱们的 spring-data-redis 的配置是:

spring:
  redis:
    host: redis.production.com
    port: 6379
    # 命令超时
    timeout: 3000
    lettuce:
      pool:
        max-active: 128
        max-idle: 128
        max-wait: 3000

这些申请尽管在第一次申请发送到的实例失败了,然而咱们有重试的机制,申请最初还是胜利了。然而比失常的申请多了 3s,这部分申请占了所有申请的 3% 左右。

从异样堆栈下面能够看出,异样的本源都是 redis 命令超时,然而为何建设 Redis 连贯的时候,也会有 Redis 命令执行呢?

lettuce 建设连贯的流程

咱们的 Redis 拜访,应用的是 spring-data-redis + Lettuce 连接池。默认状况下,Lettuce 中的 Redis 连贯建设的流程是:

  1. 建设 TCP 连贯
  2. 进行必要的握手:

    1. 针对 Redis 2.x ~ 5.x 的版本:
    1. 如果须要用户名明码,则发送用户名明码信息
    2. 如果开启了连贯应用前心跳,则发送 PING

      1. 针对 Redis 6.x 的版本:6.x 之后引入了新命令 HELLO,应用这个命令来对立初始化 Redis 连贯:REDIS HELLO,这个命令参数中能够带用户名与明码,实现验证。

针对 Redis 2.x ~ 5.x 的版本,咱们能够配置是否在启用连贯前发送 PING 心跳,默认为

ClientOptions

public static final boolean DEFAULT_PING_BEFORE_ACTIVATE_CONNECTION = true;

咱们应用的 Redis 版本是最新的 6.x,所以在建设连贯,握手的阶段,肯定须要发送一个 HELLO 命令,并期待响应胜利才算连贯创立胜利。

那么为何这个简略的命令也会超时呢?

通过 JFR 查看 Redis 命令压力

咱们的我的项目中 redis 操作是通过 spring-data-redis + Lettuce 连接池,启用并且减少了对于 Lettuce 命令的 JFR 监控,能够参考我的这篇文章:这个 Redis 连接池的新监控形式针不戳~ 我再加一点佐料,截至目前我的 pull request 曾经合并,这个个性会在 6.2.x 版本公布。咱们看下出问题工夫左近的 Redis 命令采集,如下图所示:

能够看出,这时候 Redis 压力还是比拟大的(图中的 firstResponsePercentiles 的单位是微秒)。咱们这个时候,有 7 个实例,这个实例时刚启动的,压力绝对于其余实例还比拟小,就曾经呈现了连贯命令超时。而且咱们这里只截取了 HGET 命令,还有 GET 命令执行的次数和 HGET 是同一量级的,而后剩下其余的命令加起来相当于 HGET 的一半。这时候从客户端看,发往 Redis 的命令的 QPS 曾经超过了百万。

从 Redis 的监控来看,压力的确有一些,可能会造成某些命令期待过长时间导致超时异样。

优化思路思考

咱们先明确一点,针对 spring-data-redis + lettuce,如果咱们没有应用须要独占连贯的命令(包含 Redis 事务以及 Redis Pipeline),那么 咱们不须要连接池 ,因为 lettuce 是异步响应式的,对于能够应用共享连贯的申请,都会应用同一个理论的 redis 连贯进行申请,不须要连接池。然而这个微服务中,应用了大量的 pipeline 命令来进步查问效率。如果咱们不应用连接池,那么会导致频繁的连贯敞开与创立(每秒几十万个),这样会重大升高效率。 尽管官网说,lettuce 不须要连接池,然而这是在你没有应用事务以及 Pipeline 的状况下

首先,Redis 扩容 :咱们的 Redis 部署在私有云上,如果扩容也就是进步机器配置,下一个更高的配置指标绝对于以后多了一倍,老本也是高了差不多一倍。目前只有在刹时压力的时候,会呈现少于 3% 的申请失败并重试下一实例,最初还是胜利,针对这个对 Redis 进行扩容, 从老本思考并不值得

而后,对于压力过大的利用,咱们是有动静扩容机制存在的。对于失败的申请,咱们也是有重试的。然而这个问题给咱们带来的影响是:

  1. 因为刹时压力到来,新启动的实例可能一开始就会有大量申请到来,导致接口申请和建设连贯之后的心跳申请混合。并且因为这些申请并没有偏心队列排序,某些心跳申请响应过慢从而导致失败,从新建设连贯仍然可能失败。
  2. 有些实例可能建设的连贯比拟少,不能满足并发度需要。导致很多申请其实阻塞在期待连贯的过程,从而使 CPU 压力没有一下子变很大,所以没有持续触发扩容。这样对于扩容带来了更大的滞后性。

其实,如果咱们有方法尽量减少或者防止连贯创立失败,那么就能很大水平优化这个问题。即在微服务实例开始提供服务前,就将连接池中所有的连贯创立好。

如何实现 Redis 连接池连贯预创立

咱们首先看看,是否能够借助于官网配置,实现这个连接池。

咱们查看官网文档,发现了这样两个配置:

min-idle 即连接池中起码的连接数。time-between-eviction-runs 是定时工作,查看连接池中的连贯是否满足至多有 min-idle 的个数,同时,不超过 max-idle 那么多个数。官网文档中说,min-idle 只有配合 time-between-eviction-runs 都配置,才会失效。究其原因是:lettuce 的链接池是基于 commons-pool 实现的。连接池能够配置 min-idle,然而须要手动调用 preparePool,才会创立至多 min-idle 个数的对象:

GenericObjectPool

public void preparePool() throws Exception {
    // 如果配置了无效的 min-idle,则调用 ensureMinIdle 保障创立至多 min-idle 个数的对象
    if (this.getMinIdle() >= 1) {this.ensureMinIdle();
    }
}

那么这个是在什么时候调用呢?commons-pool 有定时工作,初始提早和定时距离都是 time-between-eviction-runs,配置的,其内容是:

public void run() {
    final ClassLoader savedClassLoader =
            Thread.currentThread().getContextClassLoader();
    try {if (factoryClassLoader != null) {
            // Set the class loader for the factory
            final ClassLoader cl = factoryClassLoader.get();
            if (cl == null) {
                // The pool has been dereferenced and the class loader
                // GC'd. Cancel this timer so the pool can be GC'd as
                // well.
                cancel();
                return;
            }
            Thread.currentThread().setContextClassLoader(cl);
        }

        // Evict from the pool
        try {evict();
        } catch(final Exception e) {swallowException(e);
        } catch(final OutOfMemoryError oome) {
            // Log problem but give evictor thread a chance to continue
            // in case error is recoverable
            oome.printStackTrace(System.err);
        }
        // Re-create idle instances.
        try {ensureMinIdle();
        } catch (final Exception e) {swallowException(e);
        }
    } finally {
        // Restore the previous CCL
        Thread.currentThread().setContextClassLoader(savedClassLoader);
    }
}

能够看出,这个定时工作执行次要保障以后池内闲暇对象个数不超过 max-idle,同时至多有 min-idle 个链接。这些都是 common-pools 本人带的机制。然而没有咱们须要的,在连接池一创立就去初始化所有链接。

这就须要咱们本人实现了,咱们首先配置 min-idle = max-idle = max-active,这样无论何时连接池中都有同样最大个数的链接。之后,咱们在连接池创立进去的中央,批改源码,强制调用 preparePool 去初始化所有链接,即:

ConnectionPoolSupport

// lettuce 初始化创立连接池的时候,会调用这个办法
public static <T extends StatefulConnection<?, ?>> GenericObjectPool<T> createGenericObjectPool(Supplier<T> connectionSupplier, GenericObjectPoolConfig<T> config, boolean wrapConnections) {
    // 省略其余代码
     GenericObjectPool<T> pool = new GenericObjectPool<T>(new RedisPooledObjectFactory<T>(connectionSupplier), config) {

        @Override
        public T borrowObject() throws Exception {return wrapConnections ? ConnectionWrapping.wrapConnection(super.borrowObject(), poolRef.get())
                    : super.borrowObject();}

        @Override
        public void returnObject(T obj) {if (wrapConnections && obj instanceof HasTargetConnection) {super.returnObject((T) ((HasTargetConnection) obj).getTargetConnection());
                return;
            }
            super.returnObject(obj);
        }

    };
    // 创立好后,调用 preparePool
    try {pool.preparePool();
    } catch (Exception e) {throw new RedisConnectionException("prepare connection pool failed",e);
    }
    // 省略其余代码
}

这样,咱们就能够实现初始化 Redis 的时候,在微服务真正提供服务之前,初始化所有 Redis 链接。因为这里波及源码批改,大家目前能够通过在我的项目中增加同名同门路的类,进行依赖库源码替换。针对这个优化,我也向 lettuce 提了 issue 以及对应的 pull request:

  • ConnectionPool would be better if prepared before used
  • fix 1870,ConnectionPool would be better if prepared before used

微信搜寻“我的编程喵”关注公众号,每日一刷,轻松晋升技术,斩获各种 offer

正文完
 0