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

本文導讀:

[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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容