看完这篇文章,再次遇到Jedis「Redis客户端」异常相信你不再怕了!

2,493 阅读26分钟

本文导读:

[1] 疫情当前

[2] 应用异常监控

[3] Redis客户端异常分析

[4] Redis客户端问题引导分析

[5] 站在Redis客户端视角分析

[6] 站在Redis服务端视角分析

[7] 资源池生产配置合理性分析

[8] 本文总结

[1] 疫情当前

为响应国家抗击疫情的号召,全国有过亿的企业职员选择了远程办公,IT科技大厂们也纷纷开启了VPN模式,保障企业运营。

既然这样,我们该怎么做呢?苦逼的程序猿们只能加油干!来张图看看老板们担心的是什么?

BOSS担心的是什么?

不好意思,大BOSS们首先担心的可不是员工身心健康,而是工作效率的提升哦~

但是,据业内人士预估,新冠肺炎疫情很有可能激发国内企业信息化建设提速。

对于企业而言,值得期待的是,『远程办公』让企业看到办公形式的更多可能性,有助于企业未来办公形式的新尝试。

更为重要的是,企业可以此为契机,提升企业自身信息化建设,增强团队凝聚力与协同性,在『危机』中平稳运行,甚至是发现机会。

笔者也不例外,本周已依照公司要求,开启了远程办公模式,本周的感受来说,工作效率上肯定会收到一些影响。但因我们一季度目标明确,所以每天可以按部就班按计划如期进行。

而且,也因为疫情的影响,七大姑八大姨都被憋在家里了,本公司某端的服务 DAU 最近一段时间逆袭不断上涨,付费会员收入也随之增长不少,对于我们来说算是个好消息。

在家远程办公,不给国家添乱就好哇🤩!

接下来,我们继续聊聊线上环境遇到的一个问题以及分析过程。

[2] 应用异常监控

这不,项目中有一个Redis客户端的异常在疫情期间,出现在了你的面前,虽然该异常是偶发,有必要仔细分析下该异常出现的原由。

具体异常信息如下所示:

ISSUE异常

异常信息

大家看截图展示的异常信息,是不是很想问,这个异常显示怎么这么「友好」?

没错,是通过一款非常好用的实时异常监控工具:Sentry来监控到的,这款工具在我们的项目中已经接入并使用了很长一段时间了,对异常的监控非常到位。

比如针对发生的异常,将具体访问的整个URL、客户端上报的信息、设备型号等信息作为TAGS收集上来,尽情的展示给你,让你尽快结合这些信息快速定位问题。

该服务部署在k8s容器环境下,在截图中TAGS中,也能够看到 server_name 代表的是Pod的hostname,这样便能快速知道是哪个Pod出现的问题,进入容器平台直接进入到Pod内部进一步详细分析。

强烈推荐大家项目中接入Sentry,因为它不但有很好用的异常治理平台,更为重要的是Sentry支持跨语言客户端,比如支持Java、Andriod、C++、Python、Go等大部分语言,现成的客户端易于接入和使用。

我想只要你的服务不卡死,如果出现问题,项目里输出的日志中总会有一些 ERROR 级别的日志出现的,那么此时就交给Sentry,它会及时向你发出告警(邮件...)通知你。

[3] Redis客户端异常分析

本项目中使用的Jedis(Redis的Java客户端),提示异常信息 JedisConnectionException Unexpected end of stream,在使用Redis过程中我还很少遇到这个问题,既然遇到了,这是不是缘分啊 :)

其实异常栈中已经给出了详细的调用过程,在哪里出现的问题,顺藤摸瓜根据这个堆栈去查找线索

file

如何找到更为详细的堆栈?别担心,在上图中点击下 raw 会出现完整的异常堆栈的文本信息,也方便复制拷贝出来分析。

如下所示:

redis.clients.jedis.exceptions.JedisConnectionException: Unexpected end of stream.
    at redis.clients.util.RedisInputStream.ensureFill(RedisInputStream.java:199)
    at redis.clients.util.RedisInputStream.readByte(RedisInputStream.java:40)
    at redis.clients.jedis.Protocol.process(Protocol.java:151)
    at redis.clients.jedis.Protocol.read(Protocol.java:215)
    at redis.clients.jedis.Connection.readProtocolWithCheckingBroken(Connection.java:340)
    at redis.clients.jedis.Connection.getStatusCodeReply(Connection.java:239)
    at redis.clients.jedis.BinaryJedis.auth(BinaryJedis.java:2139)
    at redis.clients.jedis.JedisFactory.makeObject(JedisFactory.java:108)
    at org.apache.commons.pool2.impl.GenericObjectPool.create(GenericObjectPool.java:888)
    at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:432)
    at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:361)
...

根据以上信息,发现是调用到 BinaryJedis.auth 验证Redis密码时出错的,而且有 GenericObjectPool.borrowObject 表示借用对象的方法,GenericObjectPool是Apache开源项目的线程池,在很多开源项目中都能看到它的身影。

说明是在伸手向资源池索要对象时,在资源池里没有拿到对象,那就只能创建一个,调用了 GenericObjectPool.create ,调用具体实现方法 JedisFactory.makeObject 创建Jedis对象时出错的。

哦?这么一看,简单一想猜测下,在创建新的对象时验证密码时,可能因网络不稳定,Redis-Server没有正常返回异常信息导致的。

[4] Redis客户端问题引导分析

在上文中,我们在异常堆栈中发现使用了线程池,如果不使用资源池管理这些对象,会发生什么情况?

如下所示,每次使用Redis连接都会在客户端重新创建Jedis对象,创建Jedis对象后,连接Redis Server,这个过程会建立TCP连接(三次握手),完成操作后,断开TCP连接(四次挥手),当遇到并发量稍大的请求,就会吃不消了,消耗资源的同时,无法满足应用性能上的要求。

不使用资源池

如果使用了线程池,如下图所示的样子:

使用了资源池

按需在资源池中初始化一定数量的对象,当有客户端请求到达时,从资源池里获取对象,对象使用完成,再将对象丢回到资源池里,给其他客户端使用。

这就是所谓的 「池化技术」,相信在你的项目中一定会用到的,比如数据库连接池、应用服务器的线程池等等。

池化技术的优势就是能够复用池中的对象,比如上述图示中,避免了分配内存和创建堆中对象的开销;避免了因对象重复创建,进而能避免了TCP连接的建立和断开的资源开销;避免了释放内存和销毁堆中对象的开销,进而减少垃圾收集器的负担;避免内存抖动,不必重复初始化对象状态。

当然,我们也可以自己来实现,但是如果想写出比较完善的对象池的资源管理功能,也需要花费不少的精力,考虑的细节也是非常多的。

站在巨人的肩膀上,在前文中提到的Jedis内部是由 Apache Common Pool2 开源工具包来实现的,很多开源项目中应用也是很广泛的。

而Jedis客户端的很多参数都是来源于Apache Common Pool2的底层实现过程所需要的参数。

这也是Jedis或者说一些Redis客户端给用户使用简单的原因,但是简单的同时,我们也要根据不同场景去合理配置好连接池的参数,不合理的配置加上不合理的功能使用,可能会引起很多的问题。

在回归到前文的最开始的异常,这些异常跟什么有关系呢?

从图示中,我们能知道客户端使用了线程池,可能跟线程池有关系;创建对象时,auth 验证密码时出现了问题,而验证密码前已经发起了 connect 连接了,说明连接到了Redis Server,所以 Redis Server 也脱离不了干系的。

跟 Redis Client 有关系,我们就要进一步分析客户端的参数,连接池的参数是否合理。

跟 Redis Server 有关系,就要结合问题分析下服务端的参数,相关配置参数是否合理。

[5] 站在Redis客户端视角分析

既然讲到了Redis客户端,首先想到的是从客户端配置的参数入手。

直接从参数入手,不如我们可以先接着对异常栈的分析,从对象资源池入手去分析,看看这个对象池到底是怎样管理的?

1、资源池对象管理

资源池对象

资源池中创建对象的过程如上图所示。

Apache Common Pool2 既然是一个通用的资源池管理框架,内部会定义好资源池的接口和规范,具体创建对象实现交由具体框架来实现。

1)从资源池获取对象,会调用ObjectPool#borrowObject,如果没有空闲对象,则调用PooledObjectFactory#makeObject创建对象,JedisFactory是具体的实现类。

2)创建完对象放到资源池中,返回给客户端使用。

3)使用完对象会调用ObjectPool#returnObject,其内部会校验一些条件是否满足,验证通过,对象归还给资源池。

4)条件验证不通过,比如资源池已关闭、对象状态不正确(Jedis连接失效)、已超出最大空闲资源数,则会调用 PooledObjectFactory#destoryObject从资源池中销毁对象。

CommonPool2设计类图

ObjectPool 和 KeyedObjectPool 是两个基础接口。从定义的接口名上也能做下区分,ObjectPool 接口资源池列表里存储都是对象,默认实现类GenericObjectPool,KeyedObjectPool 接口用键值对的方式维护对象,默认实现类是GenericKeyedObjectPool。在实现过程会有很多公共的功能实现,放在了BaseGenericObjectPool基础实现类当中。

SoftReferenceObjectPool 是一个比较特殊的实现,在这个对象池实现中,每个对象都会被包装到一个 SoftReference 中。SoftReference 软引用,能够在JVM GC过程中当内存不足时,允许垃圾回收机制在需要释放内存时回收对象池中的对象,避免内存泄露的问题

PooledObject类设计图

PooledObject 是池化对象的接口定义,池化的对象都会封装在这里。DefaultPooledObject 是PooledObject 接口缺省实现类,PooledSoftReference 使用 SoftReference 封装了对象,供SoftReferenceObjectPool 使用。

2、对象池参数详解

查看对象池的参数配置,一种方式是直接查找代码或者官网文档中的说明去查看,另外介绍一种更为直观的方式,因为 Common Pool2 工具资源池的管理都接入到 JMX 中,所以可以通过如 Jconsole 等工具去查看暴露的属性和操作。

第一种方式:

查找对应配置类:

配置类

在 GenericObjectPoolConfig 和 BaseObjectPoolConfig 配置类对外提供的 setter 方法便是配置参数,并且代码里都有详细的注释说明。

setter方法

第二种方式:

前提是你的应用暴露了 JMX 的端口和IP,允许外部连接。

JVM 参数如下所示:

-Dcom.sun.management.jmxremote 
-Djava.rmi.server.hostname=IP地址
-Dcom.sun.management.jmxremote.port=端口
-Dcom.sun.management.jmxremote.authenticate=false 
-Dcom.sun.management.jmxremote.ssl=false

JMX工具查看属性

以上使用的是 Jconsole 工具类,点击 MBean 在左侧找到 org.apache.commons.pool2#GenericObjectPool#pool2 点击属性可以看到该类的所有属性信息,其中除包括核心的配置属性之外,还包括一些资源池的统计属性。

核心配置属性:

这些都是重点关注的属性,也是对外提供的可配置参数。

1)minIdle 资源池确保最少空闲的连接数,默认值: 0

2)maxIdle 资源池允许最大空闲的连接数,默认值: 8

3)maxTotal 资源池中最大连接数,默认值:8

4)maxWaitMillis 当资源池连接用尽后,调用者的最大等待时间,单位是毫秒,默认值:-1,建议设置合理的值

5)testOnBorrow 向资源池借用连接时,是否做连接有效性检测,无效连接会被移除,默认值:false ,业务量很大时建议为false,因为会多一次ping的开销

6)testOnCreate 创建新的资源连接后,是否做连接有效性检测,无效连接会被移除,默认值:false ,业务量很大时建议为false,因为会多一次ping的开销

7)testOnReturn 向资源池归还连接时,是否做连接有效性检测,无效连接会被移除,默认值:false,业务量很大时建议为false,因为会多一次ping的开销

8) testWhileIdle 是否开启空闲资源监测,默认值:false

9)blockWhenExhausted 当资源池用尽后,调用者是否要等待。默认值:true,当为true时,maxWaitMillis参数才会生效,建议使用默认值

10)lifo 资源池里放池对象的方式,LIFO Last In First Out 后进先出,true(默认值),表示放在空闲队列最前面,false:放在空闲队列最后面

空闲资源监测配置属性

当需要对空闲资源进行监测时, testWhileIdle 参数开启后与下列几个参数组合完成监测任务。

1)timeBetweenEvictionRunsMillis 空闲资源的检测周期,单位为毫秒,默认值:-1,表示不检测,建议设置一个合理的值,周期性运行监测任务

2)minEvictableIdleTimeMillis 资源池中资源最小空闲时间,单位为毫秒,默认值:30分钟(1000 60L 30L),当达到该值后空闲资源将被移除,建议根据业务自身设定

3)numTestsPerEvictionRun 做空闲资源检测时,每次的采样数,默认值:3,可根据自身应用连接数进行微调,如果设置为 -1,表示对所有连接做空闲监测

3、空闲资源监测源码剖析

在资源池初始化之后,有个空闲资源监测任务流程如下:

空闲资源监测任务

对应源代码:

创建资源池对象时,在构造函数中初始化配合和任务的。

this.internalPool = new GenericObjectPool<T>(factory, poolConfig);

public GenericObjectPool(final PooledObjectFactory<T> factory,
            final GenericObjectPoolConfig config) {

        super(config, ONAME_BASE, config.getJmxNamePrefix());

        if (factory == null) {
            jmxUnregister(); // tidy up
            throw new IllegalArgumentException("factory may not be null");
        }
        this.factory = factory;
        // 创建空闲资源链表
        idleObjects = new LinkedBlockingDeque<PooledObject<T>>(config.getFairness());
        // 初始化配置
        setConfig(config);
        
                // 开启资源监测任务
        startEvictor(getTimeBetweenEvictionRunsMillis());
    }
        
final void startEvictor(final long delay) {
        synchronized (evictionLock) {
                    // 当资源池关闭时会触发,取消evictor任务
            if (null != evictor) {
                EvictionTimer.cancel(evictor, evictorShutdownTimeoutMillis, TimeUnit.MILLISECONDS);
                evictor = null;
                evictionIterator = null;
            }
            if (delay > 0) {
                            // 启动evictor任务
                evictor = new Evictor();
                                // 开启定时任务
                EvictionTimer.schedule(evictor, delay, delay);
            }
        }
    }

Eviector 是个TimerTask,通过启用的调度器,每间隔 timeBetweenEvictionRunsMillis 运行一次。

class Evictor extends TimerTask {
@Override
public void run() {
    final ClassLoader savedClassLoader =
                    Thread.currentThread().getContextClassLoader();
    try {
         ...

        // Evict from the pool
        evict();

     // Ensure min idle num
     ensureMinIdle();

    } finally {
            // Restore the previous CCL
            Thread.currentThread().setContextClassLoader(savedClassLoader);
    }
}
}

evict() 移除方法源码:

@Override
public void evict() throws Exception {
        assertOpen();

    if (idleObjects.size() > 0) {

        PooledObject<T> underTest = null;
        // 获取清除策略
        final EvictionPolicy<T> evictionPolicy = getEvictionPolicy();

        synchronized (evictionLock) {
            final EvictionConfig evictionConfig = new EvictionConfig(
                            getMinEvictableIdleTimeMillis(),
                            getSoftMinEvictableIdleTimeMillis(),
                            getMinIdle());

            final boolean testWhileIdle = getTestWhileIdle();

            for (int i = 0, m = getNumTests(); i < m; i++) {
                 // ... 省略部分代码
                 // underTest 代表每一个资源
                    boolean evict;

                    evict = evictionPolicy.evict(evictionConfig, underTest,
                                    idleObjects.size());
                 // evict为true,销毁对象
                    if (evict) {
                        destroy(underTest);
                        destroyedByEvictorCount.incrementAndGet();
                    } else {
                            // testWhileIdle为true校验资源有效性
                            if (testWhileIdle) {
                                boolean active = false;
                                try {
                                        factory.activateObject(underTest);
                                        active = true;
                                } catch (final Exception e) {
                                        destroy(underTest);
                                        destroyedByEvictorCount.incrementAndGet();
                                }
                                if (active) {
                                        if (!factory.validateObject(underTest)) {
                                                destroy(underTest);
                                                destroyedByEvictorCount.incrementAndGet();
                                        } else {
                                                try {
                                                        factory.passivateObject(underTest);
                                                } catch (final Exception e) {
                                                        destroy(underTest);
                                                        destroyedByEvictorCount.incrementAndGet();
                                                }
                                        }
                                    }
                            }
                         //...
                     }
                }
        }
    }
 // ...
}

代码里的默认策略 evictionPolicy,由 org.apache.commons.pool2.impl.DefaultEvictionPolicy 提供默认实现。

// DefaultEvictionPolicy#evict()
@Override
public boolean evict(final EvictionConfig config, final PooledObject<T> underTest,
                final int idleCount) {
  
if ((config.getIdleSoftEvictTime() < underTest.getIdleTimeMillis() &&
                config.getMinIdle() < idleCount) ||
                config.getIdleEvictTime() < underTest.getIdleTimeMillis()) {
        return true;
}
return false;
}

1)当空闲资源列表大小超过 minIdle 最小空闲资源数时,并且资源配置的 idleSoftEvictTime 小于资源空闲时间,返回 true。

EvictionConfig 配置初始化时,idleSoftEvictTime 如果使用的默认值 -1 < 0,则赋予值为 Long.MAX_VALUE。

2)当检测的资源空闲时间过期后,即大于资源池配置的最小空闲时间,返回true。表示这些资源处于空闲状态,该时间段内一直未被使用到。

以上两个满足其中任一条件,则会销毁资源对象。

ensureIdle() 方法源代码:

private void ensureIdle(final int idleCount, final boolean always) throws Exception {
        if (idleCount < 1 || isClosed() || (!always && !idleObjects.hasTakeWaiters())) {
                return;
        }
    // 资源池里保留idleCount(minIdle)最小资源数量
        while (idleObjects.size() < idleCount) {
                final PooledObject<T> p = create();
                if (p == null) {
                        // Can't create objects, no reason to think another call to
                        // create will work. Give up.
                        break;
                }
                if (getLifo()) {
                        idleObjects.addFirst(p);
                } else {
                        idleObjects.addLast(p);
                }
        }
        if (isClosed()) {
                // Pool closed while object was being added to idle objects.
                // Make sure the returned object is destroyed rather than left
                // in the idle object pool (which would effectively be a leak)
                clear();
        }
}

以上就是对线程池的基本原理和参数的分析。

4、线程池对象状态

线程池对象的状态定义在 PooledObjectState ,是个枚举类型,有以下值:

IDLE 处于空闲状态

ALLOCATED 被使用中

EVICTION 正在被Evictor驱逐器验证

VALIDATION 正在验证

INVALID 驱逐测试或验证失败并将被销毁

ABANDONED 被抛弃状态,对象取出后,很久未归还

RETURNING 归还到对象池中

一张图来了解下线程池状态机转换:

线程池状态

5、对象池初始化时机

思考个问题,资源池里对象什么时候初始化进去的?这里的资源池就是指上文图中的 idleObjects 空闲资源对象缓存列表。是在创建对象时还是归还对象时?

答案是归还对象的时候

某些场景,启动后可能会出现超时现象,因为每次请求都会创建新的资源,这个过程会有一定的开销。

应用启动后我们可以提前做下线程池资源的预热,示例代码如下:

List<Jedis> minIdleList = new ArrayList<Jedis>(jedisPoolConfig.getMinIdle());

for (int i = 0; i < jedisPoolConfig.getMinIdle(); i++) {
    Jedis jedis = null;
    try {
        jedis = pool.getResource();
        minIdleList.add(jedis);
        jedis.ping();
    } catch (Exception e) {
        logger.error(e.getMessage(), e);
    } finally {
    }
}

for (int i = 0; i < jedisPoolConfig.getMinIdle(); i++) {
    Jedis jedis = null;
    try {
        jedis = minIdleList.get(i);
        jedis.close();
    } catch (Exception e) {
        logger.error(e.getMessage(), e);
    } finally {
    }
}

如果不了解原理,可能以为上面的预热代码不大对吧,怎么获取后又调用了 jedis.close() 呢?字面上理解是把资源关闭了嘛。

一起看下线程池资源归还对象的源码就明白了。

GenericObjectPool#returnObject() 归还对象方法源码:

// GenericObjectPool#returnObject() 归还方法
public void returnObject(final T obj) {
  // allObjects是存储所有对象资源的地方
    final PooledObject<T> p = allObjects.get(new IdentityWrapper<T>(obj));
  // ... 
    // 变更对象状态
    synchronized(p) {
            final PooledObjectState state = p.getState();
            if (state != PooledObjectState.ALLOCATED) {
                    throw new IllegalStateException(
                                    "Object has already been returned to this pool or is invalid");
            }
            p.markReturning(); // Keep from being marked abandoned
    }

    final long activeTime = p.getActiveTimeMillis();
  // testOnReturn为true,返还时验证资源有效性
    if (getTestOnReturn()) {
            if (!factory.validateObject(p)) {
                    try {
                            destroy(p);
                    } catch (final Exception e) {
                            swallowException(e);
                    }
                    try {
                            ensureIdle(1, false);
                    } catch (final Exception e) {
                            swallowException(e);
                    }
                    updateStatsReturn(activeTime);
                    return;
            }
    }
 // ...

    if (!p.deallocate()) {
            throw new IllegalStateException(
                            "Object has already been returned to this pool or is invalid");
    }
 // 获取maxIdle,限制空闲资源保留的上限数量
    final int maxIdleSave = getMaxIdle();
    if (isClosed() || maxIdleSave > -1 && maxIdleSave <= idleObjects.size()) {
            try {
                    destroy(p);
            } catch (final Exception e) {
                    swallowException(e);
            }
    } else {
        // 重点在这里,如果没有超过maxIdle,则会将归还的对象添加到 idleObjects 中
            if (getLifo()) {
                    idleObjects.addFirst(p);
            } else {
                    idleObjects.addLast(p);
            }
            if (isClosed()) {
                    // Pool closed while object was being added to idle objects.
                    // Make sure the returned object is destroyed rather than left
                    // in the idle object pool (which would effectively be a leak)
                    clear();
            }
    }
    updateStatsReturn(activeTime);
}

归还对象时,首先会变更对象状态从 ALLOCATED 到 RETURNING,如果 testOnReturn参数 为true,校验资源有效性(Jedis连接的有效性),如果无效,则调用 destroy() 方法销毁对象,当 maxIdle 未超过 idleObjects 资源列表大小时,则会将归还的对象添加到 idleObjects 中。

而在 borrorObject() 的借出对象方法中就是从 idleObjects#pollFirst() 获取对象的,没有的话就会去创建,对象最多不能超过 maxTotal 数量。

6、Jedis客户端线程池参数

我们了解完 Apache Common Pool2 框架的线程池原理之后,接下来看看 Jedis 里是如何包装的。

线程池里的参数都是基于 JedisPoolConfig 来构建的。

JedisPoolConfig Jedis资源池配置类默认构造函数:

public class JedisPoolConfig extends GenericObjectPoolConfig {
    public JedisPoolConfig() {
        // defaults to make your life with connection pool easier :)
        setTestWhileIdle(true);
        setMinEvictableIdleTimeMillis(60000);
        setTimeBetweenEvictionRunsMillis(30000);
        setNumTestsPerEvictionRun(-1);
    }
}

JedisPoolConfig 继承了 GenericObjectPoolConfig,JedisPoolConfig 默认构造函数中会将 testWhileIdle 参数设置为true(默认为false),minEvictableIdleTimeMillis设置为60秒(默认为30分钟),timeBetweenEvictionRunsMillis设置为30秒(默认为-1),numTestsPerEvictionRun设置为-1(默认为3)。

每个30秒执行一次空闲资源监测,发现空闲资源超过60秒未被使用,从资源池中移除。

创建 JedisPoolConfig 对象后,设置一些参数:

 // 创建 JedisPoolConfig 对象,设置参数
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig()
jedisPoolConfig.setMaxTotal(100);
jedisPoolConfig.setMaxIdle(60);
jedisPoolConfig.setMaxWaitMillis(1000);
jedisPoolConfig.setTestOnBorrow(false);
jedisPoolConfig.setTestOnReturn(true);

JedisPool 管理了Jedis 的线程池:

// JedisPool 构造函数
public JedisPool(final GenericObjectPoolConfig poolConfig, final String host, int port,
      int timeout, final String password) {
this(poolConfig, host, port, timeout, password, Protocol.DEFAULT_DATABASE, null);
}
    
public abstract class Pool<T> implements Closeable {
protected GenericObjectPool<T> internalPool;

// 抽象 Pool 构造函数
public Pool(final GenericObjectPoolConfig poolConfig, PooledObjectFactory<T> factory) {
    initPool(poolConfig, factory);
}
}

[6] 站在Redis服务端视角分析

既然猜测可能跟 Redis 服务端有关系,就需要从跟客户端的参数配置去分析下,是否会有所影响。

1、Redis客户端缓冲区满了

Redis有三种客户端缓冲区:

客户端缓冲区

普通客户端缓冲区(normal):

用于接受普通的命令,例如get、set、mset、hgetall等

slave客户端缓冲区(slave):

用于同步master节点的写命令,完成复制。

发布订阅缓冲区(pubsub):

pubsub不是普通的命令,因此有单独的缓冲区。

Redis的客户端缓冲区配置具体格式是:

client-output-buffer-limit <class> <hard limit> <soft limit> <soft seconds>

(1)class: 客户端类型: normal、 slave、 pubsub

(2)hard limit: 如果客户端使用的输出缓冲区大于hard limit,客户端会被立即关闭。

(3)soft limit和soft seconds: 如果客户端使用的输出缓冲区超过了soft limit并且持续了soft limit秒,客户端会被立即关闭

连接 Redis 查看 client-output-buffer-limit:

127.0.0.1:6379> config get client-output-buffer-limit
1) "client-output-buffer-limit"
2) "normal 0 0 0 slave 21474836480 16106127360 60 pubsub 33554432 8388608 60"

普通客户端缓冲区normal类型的class、hard limit、soft limit 都是 0,表示关闭缓冲区的限制。

如果缓冲期过小的,就可能会导致的 Unexpected end of stream 异常。

2、Redis服务器 timeout 设置不合理

Redis服务器会将超过 timeout 时间的闲置连接主动断开。

查看服务器的timeout配置:

127.0.0.1:6379> config get timeout
1) "timeout"
2) "600"

timeout 配置为 600 秒,同一个连接等待闲置 10 分钟后,发现还没有被使用,Redis 就将该连接中断掉了。

所以这里就会有个问题,这里的 timeout 时间是要与上文中的 Jedis 线程池里的 空闲资源监测任务 有关系的。

假设 JedisPoolConfig 里的 timeBetweenEvictionRunsMillis 不设置,会使用默认值 -1,不会启动 Evictor 空闲监测任务了。

当从资源池借出 Jedis 连接后,注意此时,如果过了 10 分钟,Redis 服务端已将这根连接给中断了。

而客户端还拿着这个 Jedis 连接去继续操作 set、get 之类的命令,就会出现 Unexpected end of stream 异常了。

示例演示:

为了方便演示,如下参数调整。

1)Redis服务器 timeout 初始化为 10秒

2)Java 测试代码如下所示

new Thread(new Runnable() {
        public void run() {
                for (int i = 0; i < 5; i++) {
                        System.out.println(" jedis.get(\"foo\"): " +  jedis.get("foo"));
                        try {
                                Thread.sleep(12000);
                        } catch (InterruptedException e) {
                                e.printStackTrace();
                        }
                }
        }
}).start();

输出结果:

// 第一次输出
 jedis.get("foo"): bar
 
 // sleep 12秒,Redis 服务器 timeout 等待 10秒断开 Jedis 连接
 
 // 再次执行 jedis.get("foo") ,异常出现了

Exception in thread "Thread-58" redis.clients.jedis.exceptions.JedisConnectionException: Unexpected end of stream.
    at redis.clients.util.RedisInputStream.ensureFill(RedisInputStream.java:199)
    at redis.clients.util.RedisInputStream.readByte(RedisInputStream.java:40)
    at redis.clients.jedis.Protocol.process(Protocol.java:151)
    at redis.clients.jedis.Protocol.read(Protocol.java:215)
    at redis.clients.jedis.Connection.readProtocolWithCheckingBroken(Connection.java:340)
    at redis.clients.jedis.Connection.getBinaryBulkReply(Connection.java:259)
    at redis.clients.jedis.Connection.getBulkReply(Connection.java:248)
    at redis.clients.jedis.Jedis.get(Jedis.java:153)
 

所以,JedisPoolConfig 缺省构造函数里,直接启动了 Evictor 任务,在客户端线程池里自身来监测空闲的连接,发现超过了 minEvictableIdleTimeMillis 设置的时间,从资源池里剔除。

避免客户端获取到了连接,但是无法正常使用,导致一些异常的出现。

Redis服务器里的 timeout 这个值是否合理,还是要结合自身业务场景来定。

据说阿里云Redis(公司内没用过)中 timeout 设置为 0,也就是不会主动关闭空闲连接;缓冲区设置为 0 0 0 ,也就是不会对客户端缓冲区进行限制,一般不会有问题。

3、网络不稳定因素

回到本文开头提到的 Sentry 告警的 JedisConnectionException 异常栈信息。

回顾异常栈如下所示:

redis.clients.jedis.exceptions.JedisConnectionException: Unexpected end of stream.
    at redis.clients.util.RedisInputStream.ensureFill(RedisInputStream.java:199)
    at redis.clients.util.RedisInputStream.readByte(RedisInputStream.java:40)
    at redis.clients.jedis.Protocol.process(Protocol.java:151)
    at redis.clients.jedis.Protocol.read(Protocol.java:215)
    at redis.clients.jedis.Connection.readProtocolWithCheckingBroken(Connection.java:340)
    at redis.clients.jedis.Connection.getStatusCodeReply(Connection.java:239)
    at redis.clients.jedis.BinaryJedis.auth(BinaryJedis.java:2139)
    at redis.clients.jedis.JedisFactory.makeObject(JedisFactory.java:108)
    at

是在创建新的资源连接时,connect 之后的 auth 验证密码时抛出了 Unexpected end of stream

经过上述细致的分析,排除了 Redis 客户端缓冲区满 和 timeout 参数设置合理性之后,剩下可能就跟网络因素有关系了。此前,在容器外的虚拟机、物理机部署的应用是没有出现过此问题,当前是在 k8s 容器内偶尔出现,需要运维配合熟悉下容器的网络架设,通过工具转包来排查网络问题,进一步明确原因。

根据最终分析结果,因「网络抖动」之类偶发的问题,可以在客户端增加重试机制来解决。

另外,我们也在 k8s 容器里对 Redis 集群做了多次测试,暂时也未能发现性能问题。

性能测试

[7] 资源池生产配置合理性分析

如果你拿不准 Jedis 线程池参数设置的是否合理,可以配置一些核心参数,线上通过 JMX 工具去观察。

再次看下 JMX 工具查看属性:

JMX监控

CreatedCount:已创建的资源池对象数量

DestoryedCount:已销毁的资源池对象总数量

DestoryedByEvictorCount:通过 Evictor 空闲监测任务销毁的资源池对象数量

BorrowedCount:从资源池借出对象的次数

ReturnedCount:归还给资源池对象的次数

通过监控可以看到 CreatedCount 为 6393, DestoryedByEvictorCount 为 6381,说明大部分对象刚刚创建之后,没过多久,都被空闲资源监测 Evictor 任务给销毁了。

根据前文中 Evictor 配置的参数「每隔 30 秒执行一次任务,如果池中对象超过 60 秒未使用,对象即被销毁掉」。

而 Redis 服务器端 timeout 是 10 分钟,如果我们不想让对象被销毁的那么快,尽量保留在资源池中,减少因创建新连接的开销时间,可以优化空闲监测任务的参数。

参数优化示例:

// defaults to make your life with connection pool easier :)
jedisPoolConfig.setTestWhileIdle(true);
// 增加连接最小空闲时间,在资源池里多保留一段时间
jedisPoolConfig.setMinEvictableIdleTimeMillis(180000);
// 检测任务执行时间周期
jedisPoolConfig.setTimeBetweenEvictionRunsMillis(30000);
// 检测任务执行时,每次的采样数,比如设置为5
jedisPoolConfig.setNumTestsPerEvictionRun(-1);

根据参数分析,显然 maxIdle 设置为 60, maxTotal 为 100过大了,适当调整该值。

jedisPoolConfig.setMaxTotal(30);
jedisPoolConfig.setMaxIdle(10);
jedisPoolConfig.setMinIdle(5);
jedisPoolConfig.setMaxWaitMillis(1000);

另外,根据空闲资源检测任务中的驱逐策略分析,可以利用 softMinEvictableIdleTimeMillisminIdle 两个参数组合使用,比如 softMinEvictableIdleTimeMillis 设置为 180 秒,minIdle 设置为 5,当资源空闲时间超过 180 秒,并且 idleObjects 空闲列表大小超过了 minIdle 最小空闲资源数,才会将资源从池中移除掉。

由此,保证了资源池有一定数量(minIdle)的资源连接存在,不会导致频繁创建新的资源连接。

遇到某些异常,你也可以去 Jedis github 的 ISSUE里去搜索下是否有了答案。

本文的主要分析思路也是源于ISSUE#932和ISSUE#1092展开分析的,但是每个人遇到的问题都不同,解决方式也不一样。

比如ISSUE#1092最后回复给出的答案:

ISSUE#1092

在Redis服务端将 timeout 设置为0,这样避免Redis主动断开连接,然后客户端 maxIdle 设置为 0。

这位仁兄对参数配置有些过于『暴力』,这样是不可取的,maxIdle 为 0,资源池没有充分利用起来,每次请求都会新建资源连接,归还后马上就销毁了。

不过他因此这么改,分析的原因是对的,就是一根连接被Redis给断开了,客户端还拿着在那使用呢,能不出问题嘛。

[8] 本文总结

本文由 Redis Java 客户端的一个异常引出,从监控到的异常堆栈整个过程进行了细致分析。

站在Jedis客户端视角,对 Jedis 客户端内部使用的 Apache Common Pool2 开源框架线程池的基本原理,包括创建对象、销毁对象、空闲资源监测任务机制做了具体分析。

由于线程池使用的配置参数,通过工具或源码分析 JedisPool 线程池里的参数合理性设置。

站在 Redis 服务端视角,分析了 Redis 服务器端的客户端缓冲区参数和 timeout 参数设置是否合理,什么情况下会导致 Unexpected end of stream 异常的出现。

通过本文了解到 Redis 客户端产生的异常,跟 Redis 客户端和服务端都是有关系的,对于客户端工具(框架)基本原理要有所了解,才能更好的应对各类异常,找到问题根源所在。

有时大部分应用的性能问题都可以通过参数来调优,前提是你要对这些参数配置以及背后的原理深入分析,才能斗胆尝试调优。

本文仅提到了 Unexpected end of Stream 异常,除了该异常外,其他 Jedis 客户端抛出的异常,本文的分析也是有帮助的。

这里汇总了一些常见的 Jedis 异常:

1)blockWhenExhausted = true 当等待 maxWaitMillis 时间仍然无法获取到连接,会抛出:

Caused by: java.util.NoSuchElementException: Timeout waiting for idle object

2)blockWhenExhausted = false 当无法获得连接,会抛出

Caused by: java.util.NoSuchElementException: Pool exhausted

一般检查Redis慢查询阻塞是否存在;maxWaitMillis设置是否过短;

3)Redis 无法连接,连接时会被拒绝,会抛出

Caused by: java.net.ConnectException: Connection refused

一般检查 Redis 域名配置正确性;排查该段时间网络是否有问题。

4)客户端读写超时,会抛出

JedisConnectionException: java.net.SocketTimeoutException: Read timed out

5)连接超时,会抛出

JedisConnectionException: java.net.SocketTimeoutException: connect timed out

4)、5)考虑读写超时设置的过短;有慢查询或者Redis发生阻塞;网络不稳定 方向去分析。

6)pipeline的错误使用,会抛出

JedisDataException: Please close pipeline or multi block before calling this method.

按照pipeline最佳实践去使用,比如批量结果的解析,建议使用pipeline.syncAndReturnAll()。

其他的异常,你就见招拆招吧。

文末了,码字不易,如有疏漏,还请指正,希望对大家有所帮助,谢谢。

参考资料:

https://yq.aliyun.com/articles/236384?spm=a2c4e.11155435.0.0.e21e2612uQAVoW#cc1

https://github.com/xetorthio/jedis/issues/932

https://github.com/xetorthio/jedis/issues/1029

https://www.cnblogs.com/benthal/p/10761868.html

欢迎关注我的公众号,扫二维码关注解锁更多精彩文章,与你一同成长~

Java爱好者社区