請求量突增一下,系統有效QPS為何下降很多?

来源:https://www.cnblogs.com/codelogs/archive/2023/01/16/17056485.html
-Advertisement-
Play Games

原創:扣釘日記(微信公眾號ID:codelogs),歡迎分享,轉載請保留出處。 簡介 最近我觀察到一個現象,當服務的請求量突發的增長一下時,服務的有效QPS會下降很多,有時甚至會降到0,這種現象網上也偶有提到,但少有解釋得清楚的,所以這裡來分享一下問題成因及解決方案。 隊列延遲 目前的Web伺服器, ...


原創:扣釘日記(微信公眾號ID:codelogs),歡迎分享,轉載請保留出處。

簡介

最近我觀察到一個現象,當服務的請求量突發的增長一下時,服務的有效QPS會下降很多,有時甚至會降到0,這種現象網上也偶有提到,但少有解釋得清楚的,所以這裡來分享一下問題成因及解決方案。

隊列延遲

目前的Web伺服器,如Tomcat,請求處理過程大概都類似如下:
image_2023-01-15_20230115173654
這是Tomcat請求處理的過程,如下:

  1. Acceptor線程:線程名類似http-nio-8080-Acceptor-0,此線程用於接收新的TCP連接,並將TCP連接註冊到NIO事件中。
  2. Poller線程:線程名類似http-nio-8080-ClientPoller-0,此線程一般有CPU核數個,用於輪詢已連接的Socket,接收新到來的Socket事件(如調用端發請求數據了),並將活躍Socket放入exec線程池的請求隊列中。
  3. exec線程:線程名類似http-nio-8080-exec-0,此線程從請求隊列中取出活躍Socket,並讀出請求數據,最後執行請求的API邏輯。

這裡不用太關心AcceptorPoller線程,這是nio編程時常見的線程模型,我們將重點放在exec線程池上,雖然Tomcat做了一些優化,但它還是從Java原生線程池擴展出來的,即有一個任務隊列與一組線程。

當請求量突發增長時,會發生如下的情況:

  1. 當請求量不大時,任務隊列基本是空的,每個請求都能得到及時的處理。
  2. 但當請求量突發時,任務隊列中就會有很多請求,這時排在隊列後面的請求,就會被處理得越晚,因而請求的整體耗時就會變長,甚至非常長。

可是,exec線程們還是在一刻不停歇的處理著請求的呀,按理說服務QPS是不會減少的呀!

簡單想想的確如此,但調用端一般是有超時時間設置的,不會無限等待下去,當客戶端等待超時的時候,這個請求實際上Tomcat就不用再處理了,因為就算處理了,客戶端也不會再去讀響應數據的。
image_2023-01-15_20230115175826
因此,當隊列比較長時,隊列後面的請求,基本上都是不用再處理的,但exec線程池不知道啊,它還是會一如既往地處理這些請求。

當exec線程執行這些已超時的請求時,若又有新請求進來,它們也會排在隊尾,這導致這些新請求也會超時,所以在流量突發的這段時間內,請求的有效QPS會下降很多,甚至會降到0。

這種超時也叫做隊列延遲,但隊列在軟體系統中應用得太廣泛了,比如操作系統調度器維護了線程隊列,TCP中有backlog連接隊列,鎖中維護了等待隊列等等。

因此,很多系統也會存在這種現象,平時響應時間挺穩定的,但偶爾耗時很高,這種情況有很多都是隊列延遲導致的。

優化隊列延遲

知道了問題產生的原因,要優化它就比較簡單了,我們只需要讓隊列中那些長時間未處理的請求暫時讓路,讓線程去執行那些等待時間不長的請求即可,畢竟這些長時間未處理的請求,讓它們再等等也無防,因為客戶端可能已經超時了而不需要請求結果了,雖然這破壞了隊列的公平性,但這是我們需要的。

對於Tomcat,在springboot中,我們可以如下修改:
使用WebServerFactoryCustomizer自定義Tomcat的線程池,如下:

@Component
public class TomcatExecutorCustomizer implements WebServerFactoryCustomizer<TomcatServletWebServerFactory> {
    @Resource
    ServerProperties serverProperties;

    @Override
    public void customize(TomcatServletWebServerFactory factory) {
        TomcatConnectorCustomizer tomcatConnectorCustomizer = connector -> {
            ServerProperties.Tomcat.Threads threads = serverProperties.getTomcat().getThreads();
            TaskQueue taskqueue = new SlowDelayTaskQueue(1000);
            ThreadPoolExecutor executor = new org.apache.tomcat.util.threads.ThreadPoolExecutor(
                    threads.getMinSpare(), threads.getMax(), 60L, TimeUnit.SECONDS,
                    taskqueue, new CustomizableThreadFactory("http-nio-8080-exec-"));
            taskqueue.setParent(executor);
            ProtocolHandler handler = connector.getProtocolHandler();
            if (handler instanceof AbstractProtocol) {
                AbstractProtocol<?> protocol = (AbstractProtocol<?>) handler;
                protocol.setExecutor(executor);
            }
        };
        factory.addConnectorCustomizers(tomcatConnectorCustomizer);
    }
}

註意,這裡還是使用的Tomcat實現的線程池,只是將任務隊列TaskQueue擴展為了SlowDelayTaskQueue,它的作用是將長時間未處理的任務移到另一個慢隊列中,待當前隊列中無任務時,再把慢隊列中的任務移回來。

為了能記錄任務入隊列的時間,先封裝了一個記錄時間的任務類RecordTimeTask,如下:

@Getter
public class RecordTimeTask implements Runnable {
    private Runnable run;
    private long createTime;
    private long putQueueTime;

    public RecordTimeTask(Runnable run){
        this.run = run;
        this.createTime = System.currentTimeMillis();
        this.putQueueTime = this.createTime;
    }
    @Override
    public void run() {
        run.run();
    }

    public void resetPutQueueTime() {
        this.putQueueTime = System.currentTimeMillis();
    }

    public long getPutQueueTime() {
        return this.putQueueTime;
    }
}

然後隊列的擴展實現如下:

public class SlowDelayTaskQueue extends TaskQueue {
    private long timeout;
    private BlockingQueue<RecordTimeTask> slowQueue;

    public SlowDelayTaskQueue(long timeout) {
        this.timeout = timeout;
        this.slowQueue = new LinkedBlockingQueue<>();
    }

    @Override
    public boolean offer(Runnable o) {
        // 將任務包裝一下,目的是為了記錄任務放入隊列的時間
        if (o instanceof RecordTimeTask) {
            return super.offer(o);
        } else {
            return super.offer(new RecordTimeTask(o));
        }
    }

    public void pullbackIfEmpty() {
        // 如果隊列空了,從慢隊列中取回來一個
        if (this.isEmpty()) {
            RecordTimeTask r = slowQueue.poll();
            if (r == null) {
                return;
            }
            r.resetPutQueueTime();
            this.add(r);
        }
    }

    @Override
    public Runnable poll(long timeout, TimeUnit unit) throws InterruptedException {
        pullbackIfEmpty();
        while (true) {
            RecordTimeTask task = (RecordTimeTask) super.poll(timeout, unit);
            if (task == null) {
                return null;
            }
            // 請求在隊列中長時間等待,移入慢隊列中
            if (System.currentTimeMillis() - task.getPutQueueTime() > this.timeout) {
                this.slowQueue.offer(task);
                continue;
            }
            return task;
        }
    }

    @Override
    public Runnable take() throws InterruptedException {
        pullbackIfEmpty();
        while (true) {
            RecordTimeTask task = (RecordTimeTask) super.take();
            // 請求在隊列中長時間等待,移入慢隊列中
            if (System.currentTimeMillis() - task.getPutQueueTime() > this.timeout) {
                this.slowQueue.offer(task);
                continue;
            }
            return task;
        }
    }
}

邏輯其實挺簡單的,如下:

  1. 當任務入隊列時,包裝一下任務,記錄一下入隊列的時間。
  2. 然後線程從隊列中取出任務時,若發現任務等待時間過長,就將其移入慢隊列。
  3. 而pullbackIfEmpty的邏輯,就是當隊列為空時,再將慢隊列中的任務移回來執行。

為了將請求的隊列延遲記錄在access.log中,我又修改了一下Task,並加了一個Filter,如下:

  1. 使用ThreadLocal將隊列延遲先存起來
@Getter
public class RecordTimeTask implements Runnable {
    private static final ThreadLocal<Long> WAIT_IN_QUEUE_TIME = new ThreadLocal<>();

    private Runnable run;
    private long createTime;
    private long putQueueTime;
    public RecordTimeTask(Runnable run){
        this.run = run;
        this.createTime = System.currentTimeMillis();
        this.putQueueTime = this.createTime;
    }
    @Override
    public void run() {
        try {
            WAIT_IN_QUEUE_TIME.set(System.currentTimeMillis() - this.createTime);
            run.run();
        } finally {
            WAIT_IN_QUEUE_TIME.remove();
        }
    }

    public void resetPutQueueTime() {
        this.putQueueTime = System.currentTimeMillis();
    }

    public long getPutQueueTime() {
        return this.putQueueTime;
    }

    public static long getWaitInQueueTime(){
        return ObjectUtils.defaultIfNull(WAIT_IN_QUEUE_TIME.get(), 0L);
    }
}
  1. 再在Filter中將隊列延遲取出來,放入Request對象中
@WebFilter
@Component
public class WaitInQueueTimeFilter extends HttpFilter {

    @Override
    public void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws
                                                                                                      IOException,
                                                                                                      ServletException {
        long waitInQueueTime = RecordTimeTask.getWaitInQueueTime();
        // 將等待時間設置到request的attribute中,給access.log使用
        request.setAttribute("waitInQueueTime", waitInQueueTime);

        // 如果請求在隊列中等待了太長時間,客戶端大概率已超時,就沒有必要再執行了
        if (waitInQueueTime > 5000) {
            response.sendError(503, "service is busy");
            return;
        }
        chain.doFilter(request, response);
    }

}
  1. 然後在access.log中配置隊列延遲
server:
  tomcat:
    accesslog:
      enabled: true
      directory: /home/work/logs/applogs/java-demo
      file-date-format: .yyyy-MM-dd
      pattern: '%h %l %u %t "%r" %s %b %Dms %{waitInQueueTime}rms "%{Referer}i" "%{User-Agent}i" "%{X-Forwarded-For}i"'

註意,在access.log中配置%{xxx}r表示取請求xxx屬性的值,所以,%{waitInQueueTime}r就是隊列延遲,後面的ms是毫秒單位。

優化效果

我使用介面壓測工具wrk壓了一個測試介面,此介面執行時間100ms,使用1000個併發去壓,1s的超時時間,如下:

wrk -d 10d -T1s --latency http://localhost:8080/sleep -c 1000

然後,用arthas看一下線程池的隊列長度,如下:

[arthas@619]$ vmtool --action getInstances \
    --classLoaderClass org.springframework.boot.loader.LaunchedURLClassLoader \
    --className org.apache.tomcat.util.threads.ThreadPoolExecutor \
    --express 'instances.{ #{"ActiveCount":getActiveCount(),"CorePoolSize":getCorePoolSize(),"MaximumPoolSize":getMaximumPoolSize(),"QueueSize":getQueue().size()} }' \
    -x 2

image_2023-01-16_20230116003607
可以看到,隊列長度遠小於1000,這說明隊列中積壓得不多。

再看看access.log,如下:
image_2023-01-15_20230115233508
可以發現,雖然隊列延遲任然存在,但被控制在了1s以內,這樣這些請求就不會超時了,Tomcat的有效QPS保住了。

而最後面那些隊列延遲極長的請求,則是被不公平對待的請求,但只能這麼做,因為在請求量超出Tomcat處理能力時,只能犧牲掉它們,以保全大局。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • JavaScript 中有多種方法來判斷變數的類型,如 typeof、instanceof、Object.prototype.toString.call()、constructor屬性、Symbol.toStringTag屬性以及 lodash 等第三方庫 ...
  • JavaScript 中的拷貝分為兩種:淺拷貝和深拷貝。 淺拷貝是指在拷貝過程中,只拷貝一個對象中的指針,而不拷貝實際的數據。所以,淺拷貝中修改新對象中的數據時,原對象中的數據也會被改變。 深拷貝是指在拷貝過程中,拷貝一個對象中的所有數據,並創建一個新對象,對新對象進行操作並不會影響到原對象。 ...
  • 1、介紹 Vue(讀音/vju/,類似view),是中國的大神尤雨溪開發的,為數不多的國人開發的世界頂級開源軟體。是一套用於構建用戶界面的漸進式框架,Vue 被設計為可以自底向上逐層應用。MVVM響應式編程模型,避免直接操作DOM,降低DOM操作的複雜性。 Vue官網地址:https://cn.vu ...
  • node和npm在某種意義上,早已成為當前前端開發中不可或缺的工具。 本文將介紹如何進行node和npm的版本升級和指定等等操作。 查看node和npm版本: node -v npm -v 清除npm緩存: npm cache clean -f 如何升級npm 當只需要簡單的升級 npm 的時候,可 ...
  • CAP特性 ​ CAP理論是在設計分散式系統的過程中,處理數據一致性問題時必須考慮的理論,一個分散式系統最多只能同時滿足一致性(Consistence)、可用性(Availability)和分區容錯性(Partition tolerance)這三項中的兩項。 2000年7月Eric Brewer教授 ...
  • StringBuilder類 一、結構剖析 一個可變的字元序列。此類提供一個與 StringBuffer 相容的 API,但不保證同步(StringBuilder 不是線程安全的)。該類被設計用作 StringBuffer 的一個簡易替換,==用在字元串緩衝區被單個線程使用的時候==。如果可能,建議 ...
  • Typora軟體與Markdown語法 Typora軟體的安裝 ​ ==Typora是什麼軟體:== ​ Typora是一款很火的輕量級支持Markdown語法的文本編輯器 ​ ==Typora下載:== ​ mac:https://mac.qdrayst.com/02/Typora_1.1.4_m ...
  • pycharm下載安裝與基本配置 1.簡介 PyCharm是一種Python IDE(Integrated Development Environment,集成開發環境),帶有一整套可以幫助用戶在使用Python語言開發時提高其效率的工具,比如調試、語法高亮、項目管理、代碼跳轉、智能提示、自動完成、 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...