[享學Netflix] 四十五、Ribbon服務器狀態:ServerStats及其斷路器原理

  • 2020 年 3 月 18 日
  • 筆記

靠代碼行數來衡量開發進度,就像是憑重量來衡量飛機製造的進度——比爾·蓋茨

–> 返回專欄總目錄 <– 代碼下載地址:https://github.com/f641385712/netflix-learning

前言

我們知道Ribbon它是一個客戶端負載均衡器,因此它內部維護着一個服務器列表ServerList,當實例出現問題時候,需要將這部分異常的服務Server從負載均衡列表中T除掉,那麼Ribbon是以什麼作為參考,決定T除/不T除Server的呢???這就是本文將要講述的服務器狀態的管理:ServerStats

負載均衡LB需要依賴這些統計信息做為判斷的策略,負載均衡器的統計類主要是LoadBalancerStats,其內部持有ServerStats對每個Server運行情況做了相關統計如:平均響應時間、累計失敗數、熔斷(時間)控制等。


正文

Stat中文釋義:統計,Statistic單詞的簡寫形式。另外,希望讀者在閱讀本文之前,已經了解了netflix-statistics的知識,你可以參考這篇文章:[享學Netflix] 四十四、netflix-statistics詳解,手把手教你寫個超簡版監控系統

服務狀態。在LoadBalancer中捕獲每個服務器(節點)的各種狀態,每個Server就對應着一個ServerStats實例。ServerStats表示一台Server的狀態,各種緯度的統計數據才能使得你最終挑選出一個最適合的Server供以使用,以及計算其當前訪問壓力(並發數)、成功數、失敗數、是否熔斷、熔斷了多久等等。


統計數據/屬性

到底統計了哪些數據呢?對Server進行多維度的數據統計,均體現在它的成員屬性上:

public class ServerStats {        private final CachedDynamicIntProperty connectionFailureThreshold;      private final CachedDynamicIntProperty circuitTrippedTimeoutFactor;      private final CachedDynamicIntProperty maxCircuitTrippedTimeout;      private static final DynamicIntProperty activeRequestsCountTimeout =          DynamicPropertyFactory.getInstance().getIntProperty("niws.loadbalancer.serverStats.activeRequestsCount.effectiveWindowSeconds", 60 * 10);        long failureCountSlidingWindowInterval = 1000;      private MeasuredRate serverFailureCounts = new MeasuredRate(failureCountSlidingWindowInterval);      private MeasuredRate requestCountInWindow = new MeasuredRate(300000L);        Server server;        AtomicLong totalRequests = new AtomicLong();      AtomicInteger successiveConnectionFailureCount = new AtomicInteger(0);      AtomicInteger activeRequestsCount = new AtomicInteger(0);      AtomicInteger openConnectionsCount = new AtomicInteger(0);        private volatile long lastConnectionFailedTimestamp;      private volatile long lastActiveRequestsCountChangeTimestamp;      private AtomicLong totalCircuitBreakerBlackOutPeriod = new AtomicLong(0);      private volatile long lastAccessedTimestamp;      private volatile long firstConnectionTimestamp = 0;  }

對這些統計數據/屬性分別做如下解釋說明:

  • connectionFailureThreshold:連接失敗閾值,默認值3(超過就熔斷)
    • 默認值配置:niws.loadbalancer.default.connectionFailureCountThreshold此key指定
    • 個性化配置:"niws.loadbalancer." + name + ".connectionFailureCountThreshold"
  • circuitTrippedTimeoutFactor:斷路器超時因子,默認值10s。
    • 默認值配置: niws.loadbalancer.default.circuitTripTimeoutFactorSeconds
    • 個性化配置:"niws.loadbalancer." + name + ".circuitTripTimeoutFactorSeconds"
  • maxCircuitTrippedTimeout:斷路器最大超時秒數(默認使用超時因子計算出來),默認值是30s。
    • 默認值配置:niws.loadbalancer.default.circuitTripMaxTimeoutSeconds
    • 個性化配置:"niws.loadbalancer." + name + ".circuitTripMaxTimeoutSeconds"
  • totalRequests:總請求數量。每次請求結束/錯誤時就會+1。
  • successiveConnectionFailureCount連續(successive)請求異常數量(這個連續發生在Retry重試期間)。
    • 在重試期間,但凡有一次成功了,就會把此參數置為0(失敗的話此參數就一直加)
    • 說明:只有在異常類型是callErrorHandler.isCircuitTrippingException(e)的時候,才會算作失敗,才會+1
      • 默認情況下只有SocketException/SocketTimeoutException這兩種異常才算失敗哦~
  • activeRequestsCount:活躍請求數量(正在請求的數量,它能反應該Server的負載、壓力)。
    • 但凡只要開始執行Sever了,就+1
    • 但凡只要請求完成了/出錯了,就-1
    • 注意:它有時間窗口的概念,後面講具體邏輯
  • openConnectionsCount:暫無任何使用處,可忽略。
  • lastConnectionFailedTimestamp:最後一次失敗的時間戳。至於什麼叫失敗,參考successiveConnectionFailureCount對失敗的判斷邏輯
  • lastActiveRequestsCountChangeTimestamp:簡單的說就是activeRequestsCount的值最後變化的時間戳
  • totalCircuitBreakerBlackOutPeriod:斷路器斷電總時長(連續失敗>=3次,增加20~30秒。具體增加多少秒,後面有計算邏輯)。
  • lastAccessedTimestamp:最後訪問時間戳。和lastActiveRequestsCountChangeTimestamp的區別是,它增/減都update一下,而lastAccessedTimestamp只有在增的時候才會update一下。
  • firstConnectionTimestamp:首次連接時間戳,只會記錄首次請求進來時的時間。
  • failureCountSlidingWindowInterval:失敗次數統計時間窗。默認值1000ms
  • serverFailureCounts:上一秒失敗次數(上一秒是因為failureCountSlidingWindowInterval默認自是1000ms)
    • successiveConnectionFailureCount增它就增,只不過它有時間窗口(1s)
  • requestCountInWindow:一個窗口期內的請求總數,窗口期默認為5分鐘(300秒)
    • activeRequestsCount增它就增,只不過它有時間窗口(300s)

當然,它還有幾個基於netflix-statistics數據統計的指標屬性:

ServerStats:    	// 默認60s(1分鐘)publish一次數據      private static final int DEFAULT_PUBLISH_INTERVAL =  60 * 1000; // = 1 minute      // 緩衝區大小。這個默認大小可謂非常大呀,就算你QPS是1000,也能抗1分鐘      private static final int DEFAULT_BUFFER_SIZE = 60 * 1000; // = 1000 requests/sec for 1 minute      int bufferSize = DEFAULT_BUFFER_SIZE;      int publishInterval = DEFAULT_PUBLISH_INTERVAL;    	private static final double[] PERCENTS = makePercentValues();      private DataDistribution dataDist = new DataDistribution(1, PERCENTS);      private DataPublisher publisher = null;      private final Distribution responseTimeDist = new Distribution();
  • PERCENTS:百分比,可參見枚舉類Percent:[10,20…,90…,99.5]
  • dataDist:它是一個DataAccumulator,數據累加器。
  • publisher:定時publish發佈數據,默認1分鐘發佈一次
  • responseTimeDist:它是個Distribution類型,因為它僅僅只需要持續累加數據,然後提供最大最小值、平均值的訪問而已

dataDistresponseTimeDist統一通過noteResponseTime(double msecs)來記錄每個請求的響應時間,dataDist按照時間窗口統計,responseTimeDist一直累加


成員方法

已經知道了每個字段的含義,再來看其提供的方法,就輕鬆很多了。

ServerStats:    	// 默認構造器:connectionFailureThreshold等參數均使用默認值 該構造器默認無人調用  	public ServerStats() { ... }  	// 參數值來自於lbStats,可以和ClientName掛上鉤  	// 它在LoadBalancerStats#createServerStats()方法里被唯一調用  	public ServerStats(LoadBalancerStats lbStats) { ... }    	// 初始化對象,開始數據收集和報告。**請務必調用此方法** 它才是一個完整的實例      public void initialize(Server server) {          serverFailureCounts = new MeasuredRate(failureCountSlidingWindowInterval);          requestCountInWindow = new MeasuredRate(300000L);          if (publisher == null) {              dataDist = new DataDistribution(getBufferSize(), PERCENTS);              publisher = new DataPublisher(dataDist, getPublishIntervalMillis());              // 啟動任務:開始發佈數據。1分鐘發佈一次              publisher.start();          }          // 和Server關聯          this.server = server;      }      // 停止數據方法      public void close() {          if (publisher != null)              publisher.stop();      }      	// 收集每一次請求的響應時間      public void noteResponseTime(double msecs){          dataDist.noteValue(msecs);          responseTimeDist.noteValue(msecs);      }    	// 獲得當前時間的活躍請求數(也就是Server的當前負載)      public int  getActiveRequestsCount() {          return getActiveRequestsCount(System.currentTimeMillis());      }      // 強調:如果當前時間currentTime距離上一次請求進來已經超過了時間窗口60s,那就返回0      // 簡單一句話:如果上次請求距今1分鐘了,那就一個請求都不算(強制歸零)      public int getActiveRequestsCount(long currentTime) {          int count = activeRequestsCount.get();          if (count == 0) {              return 0;          } else if (currentTime - lastActiveRequestsCountChangeTimestamp > activeRequestsCountTimeout.get() * 1000 || count < 0) {              activeRequestsCount.set(0);              return 0;          } else {              return count;          }      }

這些是ServerStats提供的基本方法,能訪問到所有的成員屬性。下面介紹分別介紹兩個主題方法:


CircuitBreaker斷路器的原理

本處的斷路器解釋:當有某個服務存在多個實例時,在請求的過程中,負載均衡器會統計每次請求的情況(請求響應時間,是否發生網絡異常等),當出現了請求出現累計重試時,負載均衡器會標識當前服務實例,設置當前服務實例的斷路的時間區間,在此區間內,當請求過來時,負載均衡器會將此服務實例從可用服務實例列表中暫時剔除(其實就是暫時忽略此Server),優先選擇其他服務實例。

該斷路器和Hystrix無任何關係,無任何關係,無任何關係。它是ServerStats內部維護的一套熔斷機制,體現在如下方法上:

ServerStats:    	// 看看該斷路器到哪個時間點戒指(關閉)的時刻時間戳  	// 比如斷路器要從0點開30s,那麼返回值就是00:00:30s這個時間戳唄      private long getCircuitBreakerTimeout() {          long blackOutPeriod = getCircuitBreakerBlackoutPeriod();          if (blackOutPeriod <= 0) {              return 0;          }          return lastConnectionFailedTimestamp + blackOutPeriod;      }    	// 返回需要中斷的持續時間(毫秒值)      private long getCircuitBreakerBlackoutPeriod() {          int failureCount = successiveConnectionFailureCount.get();          int threshold = connectionFailureThreshold.get();          if (failureCount < threshold) {              return 0;          }          int diff = (failureCount - threshold) > 16 ? 16 : (failureCount - threshold);          int blackOutSeconds = (1 << diff) * circuitTrippedTimeoutFactor.get();          if (blackOutSeconds > maxCircuitTrippedTimeout.get()) {              blackOutSeconds = maxCircuitTrippedTimeout.get();          }          return blackOutSeconds * 1000L;      }

目前斷路器統計失敗是靠連續失敗次數去判斷斷路邏輯的。此方法邏輯可總結如下:

  1. 連續失敗次數還小於閾值(默認3次),那麼就不用斷路。否則打開斷路,執行計算要斷開多久的邏輯
  2. 計算失敗基數,最大不能超過16(就算你連續失敗100次,此基數也是16)
  3. 根據超時因子circuitTrippedTimeoutFactor(默認是10)計算出時間值blackOutSeconds,該值不能大於上限connectionFailureCircuitTimeout(默認30s)
    1. 也就是說保證了斷路器最長不能打開超過30s

此方法不僅判斷了斷路器的打開與否,若打開順便打開斷路器應該打開多長時間(單位s)的方法,有了這個方法的理論做支撐,判斷當前斷路器是否開啟就非常簡單了:

ServerStats:        public boolean isCircuitBreakerTripped() {          return isCircuitBreakerTripped(System.currentTimeMillis());      }      public boolean isCircuitBreakerTripped(long currentTime) {          long circuitBreakerTimeout = getCircuitBreakerTimeout();          if (circuitBreakerTimeout <= 0) {              return false;          }          return circuitBreakerTimeout > currentTime;      }

當觸發了熔斷器(連續失敗次數過多),斷路器開啟的時間範圍是:

  • 最大值:1<<16 * 10 = 320s
  • 最小值:1<<1 * 10 =100s

當然這值是根據配置走的,並且還有最大時間30s的限制哦~

在Server被熔斷期間,負載均衡器都將忽略此Server


斷路器如何閉合?

倘若斷路器打開了,它如何恢復呢?有如下3種情形它會恢復到正常狀態:

  1. 不是連續失敗了,也就是成功了一次,那麼successiveConnectionFailureCount就會立馬歸0,所以熔斷器就閉合了
  2. 即使請求失敗了,但是並非是斷路器類異常,即不是RetryHandler#isCircuitTrippingException這種類型的異常時(比如RuntimeException就不是這種類型的異常),那就也不算連續失敗,所以也就閉合了
  3. 到時間了,斷路器自然就自動閉合了

該斷路器和Hystrix的斷路器有何區別?

很明顯,該斷路器規則非常簡單,開啟與否完全由連續失敗來決定,而是否算失敗由RetryHandler#isCircuitTrippingException來決定,默認它只認為SocketException/SocketTimeoutException(或者其子類異常)屬於該種類型的異常哦~

所以:你的程序在執行時的任何業務異常(如NPE)和此斷路器沒有半毛錢關係

當然它們最大最大的區別是斷的對象不一樣:

  • 本斷路器斷的是Server,也就是遠程服務器
  • Hystrix斷路器斷的是Client,也就是客戶端的調用

當然,關於Hystrix斷路器的內容詳解請參考:[享學Netflix] 二十七、Hystrix何為斷路器的半開狀態?HystrixCircuitBreaker詳解


獲取響應時間邏輯

一個Server服務器的響應是最重要的衡量指標,因此它提供了大量的獲取響應時間的方法:

ServerStats:    	// 重要。獲取累計的,累計的,平均響應時間  	// responseTimeDist里獲得的均是所有請求累計的      public double getResponseTimeAvg() {          return responseTimeDist.getMean();      }      public double getResponseTimeMax() {          return responseTimeDist.getMaximum();      }      ...      // 樣本大小(每次獲取的值可能不一樣的哦,因為dataDist是時間窗口嘛)      public int getResponseTimePercentileNumValues() {          return dataDist.getSampleSize();      }      // 這段時間窗口內(1分鐘)的平均響應時間      public double getResponseTimeAvgRecent() {          return dataDist.getMean();      }    	// ========下面是各個分位數的值======      public double getResponseTime10thPercentile() {          return getResponseTimePercentile(Percent.TEN);      }      ...      public double getResponseTime99point5thPercentile() {          return getResponseTimePercentile(Percent.NINETY_NINE_POINT_FIVE);      }

狀態/指標信息使用場景舉例

統計信息都是非常有用的,這裡先簡單介紹,過個眼癮即可。它的使用均在負載均衡策略上,舉例:

  • WeightedResponseTimeRule:使用指標ServerStats.responseTimeDist,獲取該Server的平均響應時間來決策
  • AvailabilityFilteringRule:它用到了兩個指標信息
    • 通過ServerStats.isCircuitBreakerTripped()判斷當前斷路器是否打開作為該Server是否可用的判斷
    • ServerStats.activeRequestsCount找個活躍請求數最小的Server
  • ZoneAvoidanceRule:使用到了ServerStats.upServerListZoneMapLoadBalancerStats.getZoneSnapshot

默認值不合理

private static final int DEFAULT_PUBLISH_INTERVAL =  60 * 1000;  private static final int DEFAULT_BUFFER_SIZE = 60 * 1000;

這兩個默認值決定了樣本量,以及樣本時間窗口。按這麼設置:每收集一次持續1分鐘(問題不大),但是樣本大小是60 * 1000這個太高了:單台機器QPS1000持續1分鐘才能填滿此窗口,我相信絕大部分情況下都是這麼高的QPS的,所以此默認值並不合理

但是,但是,但是:ServerStats唯一創建地方是LoadBalancerStats里:

protected ServerStats createServerStats(Server server) {      ServerStats ss = new ServerStats(this);      //configure custom settings      ss.setBufferSize(1000);      ss.setPublishInterval(1000);      ss.initialize(server);      return ss;  }

兩個值均為1000,說明:每秒鐘收集一次(這個頻率太高了吧),然後樣本1000表示這1s內要有1000的請求打進來能打滿(QPS1000,也特高了)。所以實際上的默認值真的也很不合理,它們均只適合高並發場景。。。

坑爹的是,這兩個值並沒有提供鉤子or外部化配置讓我們可以隨意更改,唯一的鉤子是它是個protected方法,你只能通過繼承 + 複寫才行,而實際上我們很小概率回去複寫它(它在BaseLoadBalancer里創建)。

說明:若你想更好的監控,使得負載均衡效果更好點,那麼作為架構師的你可以考慮定製定製哦~


代碼示例

@Test  public void fun4() throws InterruptedException {      ServerStats serverStats = new ServerStats();      // 緩衝區大小最大1000。 若QPS是200,5s能裝滿它  這個QPS已經很高了      serverStats.setBufferSize(1000);      // 5秒收集一次數據      serverStats.setPublishInterval(5000);      // 請務必調用此初始化方法      serverStats.initialize(new Server("YourBatman", 80));        // 多個線程持續不斷的發送請求      request(serverStats);      // 監控ServerStats狀態      monitor(serverStats);        // hold主線程      TimeUnit.SECONDS.sleep(10000);  }    // 單獨線程模擬刷頁面,獲取監控到的數據  private void monitor(ServerStats serverStats) {      new Thread(() -> {          ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1);          executorService.scheduleWithFixedDelay(() -> {              System.out.println("=======時間:" + serverStats.getResponseTimePercentileTime() + ",統計值如下=======");              System.out.println("請求總數(持續累計):" + serverStats.getTotalRequestsCount());              System.out.println("平均響應時間:" + serverStats.getResponseTimeAvg());              System.out.println("最小響應時間:" + serverStats.getResponseTimeMin());              System.out.println("最大響應時間:" + serverStats.getResponseTimeMax());                  System.out.println("樣本大小(取樣本):" + serverStats.getResponseTimePercentileNumValues());              System.out.println("樣本下的平均響應時間:" + serverStats.getResponseTimeAvgRecent());              System.out.println("樣本下的響應時間中位數:" + serverStats.getResponseTime50thPercentile());              System.out.println("樣本下的響應時間90分位數:" + serverStats.getResponseTime90thPercentile());          }, 5, 5, TimeUnit.SECONDS);      }).start();  }      // 模擬請求(開啟5個線程,每個線程都持續不斷的請求)  private void request(ServerStats serverStats) {      for (int i = 0; i < 5; i++) {          new Thread(() -> {              while (true) {                  // 請求之前 記錄活躍請求數                  serverStats.incrementActiveRequestsCount();                  serverStats.incrementNumRequests();                  long rt = doSomething();                  // 請求結束, 記錄響應耗時                  serverStats.noteResponseTime(rt);                  serverStats.decrementActiveRequestsCount();              }          }).start();      }  }    // 模擬請求耗時,返回耗時時間  private long doSomething() {      try {          int rt = randomValue(10, 200);          TimeUnit.MILLISECONDS.sleep(rt);          return rt;      } catch (InterruptedException e) {          e.printStackTrace();          return 0L;      }  }    // 本地使用隨機數模擬數據收集  private int randomValue(int min, int max) {      return min + (int) (Math.random() * ((max - min) + 1));  }

運行程序,控制台打印:

=======時間:Tue Mar 17 21:27:49 CST 2020,統計值如下=======  請求總數(持續累計):240  平均響應時間:103.43404255319149  最小響應時間:10.0  最大響應時間:199.0  樣本大小(取樣本):225  樣本下的平均響應時間:102.38666666666667  樣本下的響應時間中位數:105.0  樣本下的響應時間90分位數:178.5  =======時間:Tue Mar 17 21:27:54 CST 2020,統計值如下=======  請求總數(持續累計):465  平均響應時間:106.75869565217391  最小響應時間:10.0  最大響應時間:199.0  樣本大小(取樣本):225  樣本下的平均響應時間:110.59555555555555  樣本下的響應時間中位數:115.5  樣本下的響應時間90分位數:185.0  =======時間:Tue Mar 17 21:27:59 CST 2020,統計值如下=======  請求總數(持續累計):701  平均響應時間:106.35488505747126  最小響應時間:10.0  最大響應時間:200.0  樣本大小(取樣本):235  樣本下的平均響應時間:105.39574468085107  樣本下的響應時間中位數:105.0  樣本下的響應時間90分位數:179.0  =======時間:Tue Mar 17 21:28:04 CST 2020,統計值如下=======  請求總數(持續累計):939  平均響應時間:105.98929336188436  最小響應時間:10.0  最大響應時間:200.0  樣本大小(取樣本):240  樣本下的平均響應時間:104.45  樣本下的響應時間中位數:104.0  樣本下的響應時間90分位數:181.0  =======時間:Tue Mar 17 21:28:09 CST 2020,統計值如下=======  請求總數(持續累計):1187  平均響應時間:104.72673434856176  最小響應時間:10.0  最大響應時間:200.0  樣本大小(取樣本):246  樣本下的平均響應時間:101.32926829268293  樣本下的響應時間中位數:103.0  樣本下的響應時間90分位數:177.0

稍微核對一下數據:

  • 平均rt大概100ms,所以1s鍾可以收到10次請求,5s的窗口就是收到50次請求
  • 公開啟5個線程,所以每個窗口內收到的請求是50 * 5 = 250個左右
  • 觀察每次樣本大小數:250左右

可以看到數值都是吻合的,證明我們的示例木有啥問題。從控制台看到Server的歷史持續狀態、抽樣的狀態值一覽無餘,這就是監控,這就是負載均衡的「糧食」。


總結

關於Ribbon對服務器狀態的管理ServerStats的介紹就到這了。本文花大篇幅介紹了很少人關注的Server狀態統計這塊的知識點,是因為這對理解Ribbon的核心非常之重要,對Ribbon是如何負載均衡選擇Server的策略研究更是非常關鍵。

建議小夥伴可以不僅局限於當個「配置工程師」,而是花時間花精力深入其內了解起來,內部才是星辰大海,才有財富寶石。