記5.28大促壓測的性能優化—線程池相關問題

目錄:java

1.環境介紹redis

2.症狀json

3.診斷服務器

4.結論網絡

5.解決多線程

6.對比java實現架構

廢話就很少說了,本文分享下博主在5.28大促壓測期間解決的一個性能問題,以爲這個仍是比較有意思的,值得總結拿出來分享下。併發

博主所服務的部門是做爲公共業務平臺,公共業務平臺支持上層全部業務系統(2C、UGC、直播等)。平臺中核心之一的就是訂單域相關服務,下單服務、查單服務、支付回調服務,固然結算頁暫時仍是咱們負責,結算頁負責承上啓下進行下單、結算、跳支付中心。每次業務方進行大促期間平臺都要進行一次常規壓測,作到內心有底。app

在壓測的上半場,陸續的解決一些不是太奇怪的問題,定位到問題時間都在計劃內。下單服務、查單服務、結算頁都順利壓測經過。可是到了支付回調服務壓測的時候,有個奇怪的問題出現了。異步

1.環境介紹

咱們每一年基本兩次大促,5.2八、雙12。兩次大促期間相隔時間也就只有半年左右,因此每次大促壓測都會內心有點低,基本就是摸底檢查下。由於以前的壓測性能在這半年期間通常不會出現太大的性能問題。這前提是由於咱們每次發佈重大的項目的時候都會進行性能壓測,因此壓測慢慢變得常規化、自動化,遺漏的性能問題應該不會太多。性能指標其實在平時就關注了,而不是大促纔來臨時抱佛腳,那樣其實爲時已晚,只能拆東牆補西牆。

應用服務器配置,物理機、32core、168g、千兆網卡、壓測網絡帶寬千兆、IIS 7.五、.NET 4.0,這臺壓測服務器仍是很強的。

咱們本地會用JMeter進行問題排查。因爲這篇文章不是講怎麼作性能壓測的,因此其餘跟本篇文章關係的不大的狀況就不介紹了。包括壓測網絡隔離、壓測機器的配置和節點數等。

咱們的要求,頂層服務在200併發下,平均響應時間不能超過50毫秒,TPS要到3000左右。一級服務,也就是最底層服務的要求更高,商品系統、促銷系統、卡券系統平均響應時間基本保持在20毫秒之內才能接受。由於一級服務的響應速度直接決定了上層服務的響應速度,這裏還要去掉一些其餘的調用開銷。 

2.症狀

這個性能問題的症狀仍是比較奇怪的,狀況是這樣的:200併發、2000loop,40w的調用量。一開始前幾秒速度是比較快的,基本上TPS到了2500左右。服務器的CPU也到了60左右,仍是比較正常的,可是幾秒事後處理速度陡降,TPS慢慢在往下掉。從服務器的監控中發現,服務器的CPU是0%消耗。這很嚇人,怎麼忽然不處理了。TPS掉到100多了,顯然會一直掉下去。等了大概不到4分鐘,一會兒CPU又上來了。TPS能夠到2000左右。

咱們仔細分析查看,首先JMeter的吞吐量的問題,吞吐量是按照你的請求平均響應時間計算的,因此這裏看起來TPS是慢慢在減慢其實已經基本中止了。若是你的平均響應時間爲20毫秒,那麼在單位時間內你的吞吐量是基本能夠計算出來的。

症狀主要就是這樣的,咱們接下來對它進行診斷。

3.診斷

開始經過走查代碼,看能不能發現點什麼。

這是支付回調服務,代碼的先後沒有太多的業務處理,鑑權檢查、訂單支付狀態修改、觸發支付完成事件、調用配送、周邊業務通知(這裏有一部分須要兼容老代碼、老接口)。咱們首先主要是查看對外依賴的部分,發現有redis讀寫的代碼,就將redis的部分代碼註釋掉在進行壓測試試看。結果一會兒就正常了,這就比較奇怪了,redis是咱們其餘壓測服務共用的,以前壓測怎麼沒有問題。沒管那麼多了,多是代碼的執行序列不一樣,在併發領域裏面,這也說得通。

咱們再經過打印redis執行的時間,看處理須要多久。結果顯示,處理速度不均勻,前面的很快,後面的時間都在5-6秒,雖然不均勻可是頗有規律。

因此咱們都認爲是redis的相關問題,就開始一頭扎進去檢查redis的問題了。開始對redis進行檢查,首先是開啓Wireshark TCP鏈接監控,檢查鏈路、redis服務器的Slowlog查看處理時間。redis客戶端庫的源代碼查看(redis客戶端排除原生的StackExhange.Redis的有兩層封裝,一共三層),重點關注有鎖的地方和thread wait的地方。同時排查網絡問題,再進行壓測的時候ping redis服務器看是否有延遲。(此時是晚上21點左右,這個時候的大腦狀況你們都懂的。)

就是這樣地毯式的搜查,覺得是確定能定位到問題。可是咱們卻忽視了代碼的層次結構,一會兒專到了太細節的地方,忽視了總體的架構(指開發架構,由於代碼不是咱們寫的,對代碼周邊狀況不是太瞭解)。

先看redis服務器的創建狀況,tcp抓包查看,鏈接創建正常,沒有丟包,速度也很快。redis的處理速度也沒問題,slowlog查看基本get key也就1毫秒不到。(這裏須要注意,redis的處理時間還包括隊列裏等待的時間。slowlog只能看到redis處理的時間,看不到blocking的時間,這裏面還包括redis的command在客戶端隊列的時間。)

因此打印出來的redis處理時間很慢,不純粹是redis服務器的處理時間,中間有幾個環節須要排查的。

通過一番折騰,排查,問題沒定位到,已經是深夜,精力嚴重不足了,也要到地鐵最後一班車發車時間了,再不走趕不上了,下班回家,上到最後一班地鐵沒耽誤三分鐘~~。

重整思路,次日繼續排查。

咱們定位到redis客戶端的鏈接是能夠先預熱的,在global application_begin啓動的時候先預熱好,而後性能一會兒也正常了。

範圍進一步縮小,問題出在鏈接上,這裏咱們又反思了(一晚上覺睡過了,腦子清醒了),那爲何咱們以前的壓測沒出現過這個問題。對技術狂熱愛好的咱們,哪能善罷甘休。此時問題算是解決了,可是背後所涉及到的相關線索穿不起來,老是不太舒服。(中場休息片刻,已經是次日的下午快傍晚了~~。)技術人員要有這種征服欲,必須搞清楚。

咱們開始還原現場,而後開始出大招,開始dump進程文件,分不一樣的時間段,抓取了幾份dump文件down到本地進行分析。

首先查看了線程狀況,!runaway,發現大多數線程執行時間都有點長。接着切換到某個線程中~xxs,查看線程調用堆棧。發如今等一把monitor鎖。同時切換到其餘幾個線程中查看下是否是都在等待這把鎖。結果確實都在等這把鎖。

結論,發現一半的線程都在等待moniter監視器鎖,隨着時間增長,是否是都在等待這把鎖。這比較奇怪。

這把鎖是redis庫的第三層封裝的時候用來lock獲取redis connectioin時候用的。咱們直接註釋掉這把鎖,繼續壓測繼續dump,而後又發現一把monitor,這把鎖是StackExchange.Redis中的,代碼一時半會沒法消化,只查了主體代碼和周邊代碼狀況,沒有時間查看全局狀況。(由於時間緊迫)。暫且徹底信任第三方庫,而後查看redis connection string 的各個參數,是否是能夠調整超時時間、鏈接池大小等。可是仍是未能解決。

回過頭繼續查看dump,查看了下CLR鏈接池,!ThreadPool,一會兒看到問題了。

1

繼續查看其餘幾個dump文件,Idle都是0,也就是說CLR線程池沒有線程來處理請求了,至少CLR線程池的建立速率和併發速率不匹配了。

CLR線程池的建立速率通常是1秒2個線程,線程池的建立速率是否存在滑動時間不太清楚。線程池的大小能夠經過 C:\Windows\Microsoft.NET\Framework64\v4.0.30319\Config\machine.config 配置來設置,默認是自動配置的。最小的線程數通常是當前機器的CPU 核數。固然你也能夠經過ThreadPool相關方法來設置,ThreadPool.SetMaxThreads(), ThreadPool.SetMinThreads()。

而後咱們繼續排查代碼,發現代碼中有用Action的委託的地方,而這個Action是處理異步代碼的,上面說的redis的讀寫都在這個Action裏面的。一下咱們明白了,全部的線索都連起來了。

4.結論

.NET CLR線程池是共享線程池,也就是說ASP.NET、委託、Task背後都是一個線程池在處理。線程池分爲兩種,Request線程池、IOCP線程池(完成端口線程池)。

咱們如今理下線索:

1.從最開始的JMeter壓測吞吐量慢慢變低是個假象,而此時處理已經全面中止,服務器的CPU處理爲0%。肉眼看起來變慢是由於請求延遲時間增長了。

2.redis的TCP鏈路沒問題,Wireshark查看沒有任何異常、Slowlog沒有問題、redis的key comnand慢是由於blocking住了。

3.其餘服務壓測之全部沒問題是由於咱們是同步調用redis,當首次TCP鏈接創建以後速度會上來。

4.Action看起來速度是上去了,可是全部的Action都是CLR線程池中的線程,看起來快是由於尚未到CLR線程池的瓶頸。

Action asyncAction = () => 
           { 
               //讀寫redis 
               //發送郵件 
               //...

           };

asyncAction.BeginInvoke();

5.JMeter壓測的時候沒有延遲,在壓測的時候程序沒有預熱,致使全部的東西須要初始化,IIS、.NET等。這些都會讓第一次看起來很快,而後慢慢降低的錯覺。

總結:首次創建TCP鏈接是須要時間的,此時併發過大,全部的線程在wait,wait以後CPU會將這些線程交換出去,此時是明顯的所線程上下文切換過程,是一部分開銷。當CLR線程池的線程所有耗光吞吐量開始陡降。每次調用實際上是開啓力了兩個線程,一個處理請求的Request,還有一個是Action委託線程。當你覺得線程還夠的時候,其實線程池已經滿了。

5.解決

針對這個問題咱們進行了隊列化處理。至關於在CLR線程池基礎上抽象一個工做隊列出來,而後隊列的消費線程控制在必定數量以內,初始化的時候默認一個線程,會提供接口建立頂多6個線程。這樣當隊列的處理速度跟不上的時候能夠調用。大體代碼以下(已進行適當的修改,非源碼模樣,僅供參考):

Service 部分:

private static readonly ConcurrentQueue<NoticeParamEntity> AsyncNotifyPayQueue = new ConcurrentQueue<NoticeParamEntity>(); 
private static int _workThread;

static ChangeOrderService() 
{ 
    StartWorkThread(); 
}

public static int GetPayNoticQueueCount() 
{ 
    return AsyncNotifyPayQueue.Count; 
}

public static int StartWorkThread() 
{ 
    if (_workThread > 5) return _workThread;

    ThreadPool.QueueUserWorkItem(WaitCallbackImpl); 
    _workThread += 1;

    return _workThread;; 
}

public static void WaitCallbackImpl(object state) 
{ 
    while (true) 
    { 
        try 
         { 
            PayNoticeParamEntity payParam; 
            AsyncNotifyPayQueue.TryDequeue(out payParam);

            if (payParam == null) 
            { 
                Thread.Sleep(5000); 
                continue; 
            }

            //獲取訂單詳情

            //結轉分攤

            //發短信

            //發送消息

            //配送 
        } 
        catch (Exception exception) 
        { 
            //log 
        } 
    } 
}

原來調用的地方直接改爲入隊列:

private void AsyncNotifyPayCompleted(NoticeParamEntity payNoticeParam) 
{ 
    AsyncNotifyPayQueue.Enqueue(payNoticeParam); 
}

 

Controller 代碼:

public class WorkQueueController : ApiController 
    { 
        [Route("worker/server_work_queue")] 
        [HttpGet] 
        public HttpResponseMessage GetServerWorkQueue() 
        { 
            var payNoticCount = ChangeOrderService.GetPayNoticQueueCount();

            var result = new HttpResponseMessage() 
            { 
                Content = new StringContent(payNoticCount.ToString(), Encoding.UTF8, "application/json") 
            };

            return result; 
        }

        [Route("worker/start-work-thread")] 
        [HttpGet] 
        public HttpResponseMessage StartWorkThread() 
        { 
            var count = ChangeOrderService.StartWorkThread();

            var result = new HttpResponseMessage() 
             { 
                Content = new StringContent(count.ToString(), Encoding.UTF8, "application/json") 
            };

            return result; 
        } 
    }

 

上述代碼是未通過抽象封裝的,僅供參考。思路是不變的,將線程利用率最大化,延遲任務無需佔用過多線程,將CPU密集型和IO密集型分開。讓速度不匹配的動做分開。

優化後的TPS能夠到7000,比原來快近三倍。

6.對比JAVA實現

這個問題其實若是在JAVA裏也許不太容易出現,JAVA的線程池功能是比較強大的,併發庫比較豐富。在JAVA裏兩行代碼就能夠搞定了。

ExecutorService fiexdExecutorService = Executors.newFixedThreadPool(Thread_count);

直接構造一個指定數量的線程池,固然咱們也能夠設置線程池的隊列類型、大小、包括隊列滿了以後、線程池滿了以後的拒絕策略。這些用起來仍是比較方便的。

相關文章
相關標籤/搜索