溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點擊 登錄注冊 即表示同意《億速云用戶服務條款》

記5.28大促壓測的性能優(yōu)化—線程池相關問題

發(fā)布時間:2020-06-25 14:19:50 來源:網絡 閱讀:1563 作者:王清培 欄目:建站服務器

目錄:

1.環(huán)境介紹

2.癥狀

3.診斷

4.結論

5.解決

6.對比java實現

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

博主所服務的部門是作為公共業(yè)務平臺,公共業(yè)務平臺支持上層所有業(yè)務系統(tǒng)(2C、UGC、直播等)。平臺中核心之一的就是訂單域相關服務,下單服務、查單服務、支付回調服務,當然結算頁暫時還是我們負責,結算頁負責承上啟下進行下單、結算、跳支付中心。每次業(yè)務方進行大促期間平臺都要進行一次常規(guī)壓測,做到心里有底。

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

1.環(huán)境介紹

我們每年基本兩次大促,5.28、雙12。兩次大促期間相隔時間也就只有半年左右,所以每次大促壓測都會心里有點低,基本就是摸底檢查下。因為之前的壓測性能在這半年期間一般不會出現太大的性能問題。這前提是因為我們每次發(fā)布重大的項目的時候都會進行性能壓測,所以壓測慢慢變得常規(guī)化、自動化,遺漏的性能問題應該不會太多。性能指標其實在平時就關注了,而不是大促才來臨時抱佛腳,那樣其實為時已晚,只能拆東墻補西墻。

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

我們本地會用JMeter進行問題排查。由于這篇文章不是講怎么做性能壓測的,所以其他跟本篇文章關系的不大的情況就不介紹了。包括壓測網絡隔離、壓測機器的配置和節(jié)點數等。

我們的要求,頂層服務在200并發(fā)下,平均響應時間不能超過50毫秒,TPS要到3000左右。一級服務,也就是最底層服務的要求更高,商品系統(tǒng)、促銷系統(tǒng)、卡券系統(tǒng)平均響應時間基本保持在20毫秒以內才能接受。因為一級服務的響應速度直接決定了上層服務的響應速度,這里還要去掉一些其他的調用開銷。 

2.癥狀

這個性能問題的癥狀還是比較奇怪的,情況是這樣的:200并發(fā)、2000loop,40w的調用量。一開始前幾秒速度是比較快的,基本上TPS到了2500左右。服務器的CPU也到了60左右,還是比較正常的,但是幾秒過后處理速度陡降,TPS慢慢在往下掉。從服務器的監(jiān)控中發(fā)現,服務器的CPU是0%消耗。這很嚇人,怎么突然不處理了。TPS掉到100多了,顯然會一直掉下去。等了大概不到4分鐘,一下子CPU又上來了。TPS可以到2000左右。

我們仔細分析查看,首先JMeter的吞吐量的問題,吞吐量是按照你的請求平均響應時間計算的,所以這里看起來TPS是慢慢在減慢其實已經基本停止了。如果你的平均響應時間為20毫秒,那么在單位時間內你的吞吐量是基本可以計算出來的。

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

3.診斷

開始通過走查代碼,看能不能發(fā)現點什么。

這是支付回調服務,代碼的前后沒有太多的業(yè)務處理,鑒權檢查、訂單支付狀態(tài)修改、觸發(fā)支付完成事件、調用配送、周邊業(yè)務通知(這里有一部分需要兼容老代碼、老接口)。我們首先主要是查看對外依賴的部分,發(fā)現有redis讀寫的代碼,就將redis的部分代碼注釋掉在進行壓測試試看。結果一下子就正常了,這就比較奇怪了,redis是我們其他壓測服務共用的,之前壓測怎么沒有問題。沒管那么多了,可能是代碼的執(zhí)行序列不同,在并發(fā)領域里面,這也說得通。

我們再通過打印redis執(zhí)行的時間,看處理需要多久。結果顯示,處理速度不均勻,前面的很快,后面的時間都在5-6秒,雖然不均勻但是很有規(guī)律。

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

就是這樣地毯式的搜查,以為是肯定能定位到問題。但是我們卻忽視了代碼的層次結構,一下子專到了太細節(jié)的地方,忽視了整體的架構(指開發(fā)架構,因為代碼不是我們寫的,對代碼周邊情況不是太了解)。

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

所以打印出來的redis處理時間很慢,不純粹是redis服務器的處理時間,中間有幾個環(huán)節(jié)需要排查的。

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

重整思路,第二天繼續(xù)排查。

我們定位到redis客戶端的連接是可以先預熱的,在global application_begin啟動的時候先預熱好,然后性能一下子也正常了。

范圍進一步縮小,問題出在連接上,這里我們又反思了(一夜覺睡過了,腦子清醒了),那為什么我們之前的壓測沒出現過這個問題。對技術狂熱愛好的我們,哪能善罷甘休。此時問題算是解決了,但是背后所涉及到的相關線索穿不起來,總是不太舒服。(中場休息片刻,已是第二天的下午快傍晚了~~。)技術人員要有這種征服欲,必須搞清楚。

我們開始還原現場,然后開始出大招,開始dump進程文件,分不同的時間段,抓取了幾份dump文件down到本地進行分析。

首先查看了線程情況,!runaway,發(fā)現大多數線程執(zhí)行時間都有點長。接著切換到某個線程中~xxs,查看線程調用堆棧。發(fā)現在等一把monitor鎖。同時切換到其他幾個線程中查看下是不是都在等待這把鎖。結果確實都在等這把鎖。

結論,發(fā)現一半的線程都在等待moniter監(jiān)視器鎖,隨著時間增加,是不是都在等待這把鎖。這比較奇怪。

這把鎖是redis庫的第三層封裝的時候用來lock獲取redis connectioin時候用的。我們直接注釋掉這把鎖,繼續(xù)壓測繼續(xù)dump,然后又發(fā)現一把monitor,這把鎖是StackExchange.Redis中的,代碼一時半會無法消化,只查了主體代碼和周邊代碼情況,沒有時間查看全局情況。(因為時間緊迫)。暫且完全信任第三方庫,然后查看redis connection string 的各個參數,是不是可以調整超時時間、連接池大小等。但是還是未能解決。

回過頭繼續(xù)查看dump,查看了下CLR連接池,!ThreadPool,一下子看到問題了。

記5.28大促壓測的性能優(yōu)化—線程池相關問題

繼續(xù)查看其他幾個dump文件,Idle都是0,也就是說CLR線程池沒有線程來處理請求了,至少CLR線程池的創(chuàng)建速率和并發(fā)速率不匹配了。

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

然后我們繼續(xù)排查代碼,發(fā)現代碼中有用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    
               //發(fā)送郵件 
               //...
           };
asyncAction();

5.JMeter壓測的時候沒有延遲,在壓測的時候程序沒有預熱,導致所有的東西需要初始化,IIS、.NET等。這些都會讓第一次看起來很快,然后慢慢下降的錯覺。

總結:首次建立TCP連接是需要時間的,此時并發(fā)過大,所有的線程在wait,wait之后CPU會將這些線程交換出去,此時是明顯的所線程上下文切換過程,是一部分開銷。當CLR線程池的線程全部耗光吞吐量開始陡降。每次調用其實是開啟力了兩個線程,一個處理請求的Request,還有一個是Action委托線程。當你以為線程還夠的時候,其實線程池已經滿了。

5.解決

針對這個問題我們進行了隊列化處理。相當于在CLR線程池基礎上抽象一個工作隊列出來,然后隊列的消費線程控制在一定數量之內,初始化的時候默認一個線程,會提供接口創(chuàng)建頂多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;    
            }
            //獲取訂單詳情
            //結轉分攤    
            //發(fā)短信    
            //發(fā)送消息    
            //配送    
        }    
        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密集型分開。讓速度不匹配的動作分開。

優(yōu)化后的TPS可以到7000,比原來快近三倍。

6.對比JAVA實現

這個問題其實如果在JAVA里也許不太容易出現,JAVA的線程池功能是比較強大的,并發(fā)庫比較豐富。在JAVA里兩行代碼就可以搞定了。

ExecutorService fiexdExecutorService = Executors.newFixedThreadPool(Thread_count);

直接構造一個指定數量的線程池,當然我們也可以設置線程池的隊列類型、大小、包括隊列滿了之后、線程池滿了之后的拒絕策略。這些用起來還是比較方便的。


向AI問一下細節(jié)

免責聲明:本站發(fā)布的內容(圖片、視頻和文字)以原創(chuàng)、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。

AI