一次 Jedis 引數異常引發服務雪崩

Linksla發表於2023-10-24

Redis 作為網際網路業務的遠端快取工具而被大面積使用,作為訪問客戶端的 Jedis 同樣被大面積使用。本文主要分析 Redis3.x 版本叢集模式發生主從切換場景下 Jedis 的引數設定不合理引發服務雪崩的過程。

一、背景介紹

Redis 作為網際網路業務的遠端快取工具而被被大家熟知和使用,在客戶端方面湧現了Jedis、Redisson、Lettuce等,而Jedis屬於其中的佼佼者。
目前筆者的專案採用 Redis 的 3.x 版本部署的叢集模式(多節點且每個節點存在主從節點),使用 Jedis 作為 Redis 的訪問客戶端。
日前 Redis 叢集中的某節點因為宿主物理機故障導致發生主從切換,在主從切換過程中觸發了 Jedis 的重試機制進而引發了服務的雪崩。
本文旨在剖析Redis叢集模式下節點發生主從切換進而引起服務雪崩的整個過程,希望能夠幫助讀者規避此類問題。

二、故障現場記錄

  • 訊息堆積告警

【MQ-訊息堆積告警】
告警時間:2022-11-29 23:50:21
檢測規則: 訊息堆積閾值:-》異常( > 100000)
告警服務:xxx-anti-addiction
告警叢集:xx公共
告警物件:xxx-login-event-exchange/xxx-login-event-queue
異常物件(當前值): 159412

說明:
2022-11-29 23:50:21收到一條RMQ訊息堆積的告警,正常情況下服務是不會有這類異常告警,出於警覺性開始進入系統排查過程。
排查的思路基本圍繞系統相關的指標:系統的請求量,響應時間,下游服務的響應時間,執行緒數等指標。

說明:
排查系統監控之後發現在故障發生時段服務整體的請求量有大幅下跌,響應的介面的平均耗時接近1分鐘。
服務整體出於雪崩狀態,請求耗時暴漲導致服務不可用,進而導致請求量下跌。

說明:

  • 排查系統對應的執行緒數,發現在故障期間處於wait的執行緒數大量增加。

說明:

  • 事後運維同學反饋在故障時間點Redis叢集主從切換,整體時間和故障時間較吻合。

綜合各方面的指標資訊,判定此次服務的雪崩主要原因應該是 Redis 主從切換導致,但是引發服務雪崩原因需要進一步的分析。

三、故障過程分析

在進行故障的過程分析之前,首先需要對目前的現象進行分析,需要回答下面幾個問題:

  • 介面響應耗時增加為何會引起請求量的陡增?

  • Redis主從切換期間大部分的耗時為啥是2s?

  • 介面的平均響應時間為啥接近60s?

3.1 流量陡降

說明:

  • 透過nginx的日誌可以看出存在大量的connection timed out的報錯,可以歸因為由於後端服務的響應時間過程導致nginx層和下游服務之間的讀取超時。

  • 由於大量的讀取超時導致nginx判斷為後端的服務不可用,進而觸發了no live upstreams的報錯,ng無法轉發到合適的後端服務。

  • 透過nginx的日誌可以將問題歸因到後端服務異常導致整體請求量下跌。

3.2 耗時問題

說明:

  • 透過報錯日誌定位到Jedis在獲取連線的過程中丟擲了connect timed out的異常。

  • 透過定位Jedis的原始碼發現預設的設定連線超時時間 DEFAULT_TIMEOUT = 2000。

<redis-cluster name="redisCluster" timeout="3000" maxRedirections="6"> // 最大重試次數為6    <properties>        <property name="maxTotal" value="20" />        <property name="maxIdle" value="20" />        <property name="minIdle" value="2" />    </properties></redis-cluster>

說明:

  • 透過報錯日誌定位Jedis執行了6次重試,每次重試耗時參考設定連線超時預設時長2s,單次請求約耗時12s。

  • 排查部分對外介面,發現一次請求內部總共訪問的Redis次數有5次,那麼整體的響應時間會達到1m=60s。

  • 結合報錯日誌和監控指標,判定服務的雪崩和Jedis的連線重試機制有關,需要從Jedis的原始碼進一步進行分析。

四、Jedis 執行流程

4.1 流程解析

說明:

  • Jedis處理Redis的命令請求如上圖所示,整體在初始化連線的基礎上根據計算的slot槽位獲取連線後傳送命令進行執行。

  • 在獲取連線失敗或命令傳送失敗的場景下觸發異常重試,重新執行一次命令。

  • 異常重試流程中省略了重新獲取Redis叢集分佈的邏輯,避免複雜化整體流程。

4.2 原始碼解析

(1)整體流程

    
    public class JedisCluster extends BinaryJedisCluster implements JedisCommands,
    
        MultiKeyJedisClusterCommands, JedisClusterScriptingCommands {
    
    
    
     @Override  public String set(final String key, final String value, final String nxxx, final String expx,      final long time) {    return new JedisClusterCommand<String>(connectionHandler, maxAttempts) {      @Override      public String execute(Jedis connection) {        // 真正傳送命令的邏輯        return connection.set(key, value, nxxx, expx, time);      }    }.run(key); // 透過run觸發命令的執行  } }

    public abstract class JedisClusterCommand<T> {
     public abstract T execute(Jedis connection);
     public T run(String key) {    // 執行帶有重試機制的方法    return runWithRetries(SafeEncoder.encode(key), this.maxAttempts, false, false);  } }

    public abstract class JedisClusterCommand<T> {
     private T runWithRetries(byte[] key, int attempts, boolean tryRandomNode, boolean asking) {
       Jedis connection = null;    try {
         if (asking) {        // 省略相關的程式碼邏輯      } else {        if (tryRandomNode) {          connection = connectionHandler.getConnection();        } else {          // 1、嘗試獲取連線          connection = connectionHandler.getConnectionFromSlot(JedisClusterCRC16.getSlot(key));        }      }      // 2、執行JedisClusterCommand封裝的execute命令      return execute(connection);
       } catch (JedisNoReachableClusterNodeException jnrcne) {      throw jnrcne;    } catch (JedisConnectionException jce) {      // 省略程式碼    } finally {      releaseConnection(connection);    }  } }

    說明:

    JedisCluster 執行 set 命令為例,封裝成 JedisClusterCommand 物件透過run觸發 runWithRetries 進而執行set命令的 execute 方法。

    runWithRetries 方法封裝了具體的重試邏輯,內部透過 connectionHandler.getConnectionFromSlot

    獲取對應的Redis節點的連線。

    (2)計算槽位

      
      public final class JedisClusterCRC16 {
      
      
      
       public static int getSlot(byte[] key) {    int s = -1;    int e = -1;    boolean sFound = false;    for (int i = 0; i < key.length; i++) {      if (key[i] == '{' && !sFound) {        s = i;        sFound = true;      }      if (key[i] == '}' && sFound) {        e = i;        break;      }    }    if (s > -1 && e > -1 && e != s + 1) {      return getCRC16(key, s + 1, e) & (16384 - 1);    }    return getCRC16(key) & (16384 - 1);  } }

      (3)連線獲取

        
        public class JedisSlotBasedConnectionHandler extends JedisClusterConnectionHandler {
        
        
        
         @Override  public Jedis getConnectionFromSlot(int slot) {    JedisPool connectionPool = cache.getSlotPool(slot);    if (connectionPool != null) {      // 嘗試獲取連線      return connectionPool.getResource();    } else {      renewSlotCache();      connectionPool = cache.getSlotPool(slot);      if (connectionPool != null) {        return connectionPool.getResource();      } else {        return getConnection();      }    }  } }
        class JedisFactory implements PooledObjectFactory<Jedis> {
         @Override  public PooledObject<Jedis> makeObject() throws Exception {    // 1、建立Jedis連線    final HostAndPort hostAndPort = this.hostAndPort.get();    final Jedis jedis = new Jedis(hostAndPort.getHost(), hostAndPort.getPort(), connectionTimeout,        soTimeout, ssl, sslSocketFactory, sslParameters, hostnameVerifier);
           try {       // 2、嘗試進行連線      jedis.connect();    } catch (JedisException je) {      jedis.close();      throw je;    }
           return new DefaultPooledObject<Jedis>(jedis);
         } }
        public class Connection implements Closeable {
         public void connect() {    if (!isConnected()) {      try {        socket = new Socket();        socket.setReuseAddress(true);        socket.setKeepAlive(true); // Will monitor the TCP connection is        socket.setTcpNoDelay(true); // Socket buffer Whetherclosed, to        socket.setSoLinger(true, 0); // Control calls close () method,
               // 1、設定連線超時時間 DEFAULT_TIMEOUT = 2000;        socket.connect(new InetSocketAddress(host, port), connectionTimeout);        // 2、設定讀取超時時間        socket.setSoTimeout(soTimeout);
               outputStream = new RedisOutputStream(socket.getOutputStream());        inputStream = new RedisInputStream(socket.getInputStream());      } catch (IOException ex) {        broken = true;        throw new JedisConnectionException(ex);      }    }  } }

        說明:
        Jedis透過connectionPool維護和Redis的連線資訊,在可複用的連線不夠的場景下會觸發連線的建立和獲取。

        建立連線物件透過封裝成Jedis物件並透過connect進行連線,在Connection的connect的過程中設定連線超時connectionTimeout和讀取超時soTimeout。

        建立連線過程中如果異常會丟擲JedisConnectionException異常,注意這個異常會在後續的分析中多次出現。

        (4)傳送命令

          
          public class Connection implements Closeable {
          
          
          
           protected Connection sendCommand(final Command cmd, final byte[]... args) {    try {      // 1、必要時嘗試連線      connect();      // 2、傳送命令      Protocol.sendCommand(outputStream, cmd, args);      pipelinedCommands++;      return this;    } catch (JedisConnectionException ex) {      broken = true;      throw ex;    }  }
           private static void sendCommand(final RedisOutputStream os, final byte[] command,      final byte[]... args) {    try {      // 按照redis的命令格式傳送資料      os.write(ASTERISK_BYTE);      os.writeIntCrLf(args.length + 1);      os.write(DOLLAR_BYTE);      os.writeIntCrLf(command.length);      os.write(command);      os.writeCrLf();
               for (final byte[] arg : args) {        os.write(DOLLAR_BYTE);        os.writeIntCrLf(arg.length);        os.write(arg);        os.writeCrLf();      }    } catch (IOException e) {      throw new JedisConnectionException(e);    }  } }

          說明:

          • Jedis透過sendCommand向Redis傳送Redis格式的命令。

          • 傳送過程中會執行connect連線動作,邏輯和獲取連線時的connect過程一致。

          • 傳送命令異常會丟擲JedisConnectionException 的異常資訊。

          (5)重試機制

            
            public abstract class JedisClusterCommand<T> {
            
            
            
             private T runWithRetries(byte[] key, int attempts, boolean tryRandomNode, boolean asking) {
               Jedis connection = null;    try {
                 if (asking) {      } else {        if (tryRandomNode) {          connection = connectionHandler.getConnection();        } else {          // 1、嘗試獲取連線          connection = connectionHandler.getConnectionFromSlot(JedisClusterCRC16.getSlot(key));        }      }      // 2、透過連線執行命令      return execute(connection);
               } catch (JedisNoReachableClusterNodeException jnrcne) {      throw jnrcne;    } catch (JedisConnectionException jce) {      releaseConnection(connection);      connection = null;      // 4、重試到最後一次丟擲異常      if (attempts <= 1) {        this.connectionHandler.renewSlotCache();
                   throw jce;      }      // 3、進行第一輪重試      return runWithRetries(key, attempts - 1, tryRandomNode, asking);    } finally {      releaseConnection(connection);    }  } }

            說明:

            • Jedis執行Redis的命令時按照先獲取connection後透過connection執行命令的順序。
            • 在獲取connection和透過connection執行命令的過程中如果發生異常會進行重試且在達到最大重試次數後丟擲異常。
            • attempts=5為例,如果在獲取 connection過程中發生異常,那麼最多重試5次後丟擲異常。

            綜合上述的分析,在使用Jedis的過程中需要合理設定引數包括connectionTimeout & soTimeout & maxAttempts。

            • maxAttempts: 出現異常最大重試次數。

            • connectionTimeout: 表示連線超時時間。

            • soTimeout: 讀取資料超時時間。

            五、總結

            本文透過線上故障現場記錄和分析,並最終引申到Jedis原始碼的底層邏輯分析,剖析了Jedis的不合理引數設定包括連線超時和最大重試次數導致服務雪崩的整個過程。
            在Redis本身只作為快取且後端的MySQL等DB能夠承載非高峰期流量的場景下,建議合理設定Jedis超時引數進而減少Redis主從切換訪問Redis的耗時,避免服務雪崩。
            線上環境筆者目前的連線和讀取超時時間設定為100ms,最大重試次數為2,按照現有的業務邏輯如遇Redis節點故障訪問異常最多耗時1s,能夠有效避免服務發生雪崩。


            本文轉自公眾號 vivo網際網路技術

            來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70013542/viewspace-2990613/,如需轉載,請註明出處,否則將追究法律責任。

            相關文章