最近,我們前前後後封閉開發將近有一年的的產品臨近上線。不同於走小週期迭代出產品:每一個小的迭代都是單獨線上驗證過的,如果有問題其範圍也是有限。像這種一次性整體上線的需求,說實話心裡還是有點沒底的。再加上臨近年終,說不定一不小心一年就白乾了。因此更需要我們在上線之前做萬全的準備,今可能的將所有的問題提前暴漏出來,將bug撲滅在萌芽之中。雖然測試同學已經進行過多輪的迴歸和驗證,但這隻能儘可能的保證了低流量場景下的業務邏輯表現正常。有過高效能經驗的開發應該知道,有一些問題只能是會在高壓力場景下才會暴漏出來。一些系統雪崩的問題是很難由測試同學在測試環境下發現的。因此,為了保證平穩上線。我們就必須進行一次線上高壓場景的模擬,去探測系統雪崩點,並探查系統的容量上限。在這種背景下,我們進行了一次線上寫壓測。本文章就是對壓測過程和經驗的總結。
為什麼選擇線上壓測?
由於測試環境和線上環境的節點在單機規格和叢集規模上有很大的不同、應用之間錯綜複雜的依賴關係、各種基礎架構(db,redis,mq等)規格不同。測試環境壓測的結果參考的意義不大,無法正確反映線上部署的叢集的容量上限,同時並不富裕的機器資源和昂貴的開支在整個降本增效的背景下也不可能支援我們將線上的叢集1:1的部署一份單獨部署為一個效能環境。因此在這種情況下想要更加真實的探查系統的瓶頸,就必須直接的線上上進行的寫壓測。
線上寫壓測改造
要線上上進行寫壓測就需要隔離資料的汙染並保證使用者的無感。無法就將會是另外一個p0級事故了。
我們的專案工程是直接阿里雲上部署的springBoot工程,因此無法直接利用公司已經建設技術基礎能力設施。也因此我們需要自己完善專案寫壓測錄了的資料隔離的技術能力。
流量標記
第一個問題是要解決流量標記的問題。我一開始是構思了兩種方式來進行流量的標記。
- 第一種,壓測機在所有的請求的head中攜帶一個壓測標識,然後在工程中使用aop進行攔截,識別壓測標識,丟進threadlocal中,在請求結束後清理壓測標識。但是在通常的業務邏輯處理中,都必須要驗證使用者身份和許可權進行驗證,因此要求使用者必須是登入的狀態。也就說壓測請求裡的所有mock使用者必須在登入過的。壓測指令碼需要模擬登入過程,獲得cookie。
- 第二種:新增一個介面,這個新增的介面和目標壓測入口的入參和返回值幾乎一致,在新的入口中手動標記壓測標識,並記錄日誌,入參直接接收一個uid,從而跳過登入驗證流程。但需要針對每一個目標介面開發一個對應的壓測介面,同時需要增加一些技術手段防止壓測介面被異常訪問。
為了降低壓測指令碼的複雜度和方便我們mock線上的使用者。我們最終選擇了第二種方式,針對每一個目標介面開發一個壓測介面。
資料庫儲存隔離
標記流量以後,下面就需要針對這些流量做特殊的處理首先是資料儲存的隔離。
- 使用影子表:透過mybaits提供的外掛能力來動態的修改資料表。判斷sql的目標資料表名稱,統一在資料表後面增加字尾 \_TEST。 同時在資料庫中建立表結構一樣的影子表,這樣壓測操作下的所有資料都將從影子表讀寫。
- 大的偏移ID。透過影子表的隔離已經能夠達到隔離的要求,如果說擔心有資料表遺漏從而影響的了正常的資料。為了能夠在中情況出現時,快速識別出來哪些是壓測資料,我們可以修改id的生成規則,將壓測的id的起始值變的很大。
資料庫快取隔離
千萬別忘記對資料庫的快取進行改造。
我們專案的資料庫快取使用的是獨立的一組redis,同我們封裝了一個統一的快取操作入口工具類。因此我們只需要在緩衝設定的入口處,攔截所有的快取key生成,將所有的快取key拼接字首即可。
業務redis儲存隔離。
業務redis的隔離就顯得相對複雜一些,主要是因為操作分散,呼叫的姿勢太多,因此這裡並沒有什麼更好的辦法可以一勞永逸的完成整個的隔離。需要我們自己深入業務的鏈路找到沒一處redis的呼叫,因此需要我們對業務邏輯比較熟悉才行。當然我們可以透過大的範圍來保證隔離,比如單獨開一個測試使用者的邏輯服,單獨開一個房間,單獨開一個測試的場次等來完成大範圍的邏輯上的隔離。
非同步鏈路壓測標連貫
由於我們壓測是高併發的場景,因此鏈路中避免不了會使用非同步流程來分擔系統壓力達到填谷削峰的作用。比如我們會非同步的記使用者的榜單、非同步的進行結算獎勵。當壓測流量從同步轉到非同步時,對應的標識也必須在繼續傳遞下去。我們專案中使用rocketmq。rocketmq給我們提供的附近getProperties能力剛好可以來做這個事情。我們只需要攔截所有的訊息傳送點和所有的訊息接收點。訊息發出時,將壓測標識讀出來放到Properties,在消費時檢查Properties,並重新標記壓測標識(用於消費者使用執行緒組消費,所以要注意訊息消費完成後,壓測標識要清理掉)。
到此我們只能說是完成了技術基礎能力的建設,讓我們有能力完成的儲存和快取層的自動隔離。相當於是補了技術基礎設施的欠賬。接下來是要根據業務強耦合的業務上的寫邏輯改造,以更好的方便進行寫壓測。
業務壓測寫邏輯改造
為了進一步說明白講清楚接下來的程式碼改造,在這裡我需要簡略的將一下描述一下我們實際業務上的場景。
首先在業務上,一個邏輯服務內會有多個公會(就是遊戲中的公會),每一個公會大約會有一百多個使用者,而每一個公會都會有多個怪物可以讓公會的成員可以在特定的時間段內去挑戰,挑戰時需要扣減自己的挑戰次數並根據使用者和公會整體的傷害進行排榜,挑戰完成後可以獲得豐厚的獎勵。按照我們的預計,在挑戰玩法開放的一瞬間時間,挑戰流程的tps峰值將達到1w。而我們的壓測就是模擬這個峰值的挑戰流程。在能夠愉快的壓測之前,有幾個問題需要我們處理。
- 需要查詢使用者歸屬工會的,否則會被攔截,壓力無法到後續流程。
- 遊戲開發時間限制。若不在開放時間內的挑戰請求,將會被攔截。壓力無法走到後續流程。然而開放時間內,業務本身就是高峰期,不可能再進行額外壓測。因此必須在高峰期來之前完成壓測,找到系統瓶頸並修復問題。
- 使用者的挑戰次數只有n次,消耗完後將不能繼續發起挑戰,壓力無法繼續走到後續流程。
- 怪獸會死亡,怪獸死亡後會重新整理到下一個怪獸。怪獸死亡後,壓測指令碼需要重新整理怪獸,否則所有的壓測請求將會被校驗攔截,無法進行模擬整個流程。
我們需要一一處理這些問題。保證在方便壓測情況下保留對系統各個流程的壓力。
仔細思考一下,一般來說業務邏輯基本是兩個操作流
- 查詢-判斷攔截。
- 查詢-判斷-邏輯處理-修改為新值。
對於壓測流量我們可以改掉這個操作流程
- 查詢-判斷不攔截。
- 查詢-判斷不攔截-邏輯處理-修改為舊值。
因此針對上面的問題,我們可以逐一改造為下面的處理。
- 仍然查詢使用者歸屬工會,但中途改掉公會id
- 對於時間判斷,我們獲取場次後,如果是壓測流量則不進行判斷攔截,直接進行下面的流程。
- 對於攻擊次數:攻擊次數獲取到後,不判斷餘額,放過請求,在需要扣減時,允許次數餘額為負數。
- 怪獸扣減血量:對於壓測請求,仍然計算用於現在的裝備等級功能打出的傷害,但在傷害算出以後重新設定為0。對怪獸的血量進行零扣減,因此壓力保留了但怪獸不會死亡。
經過這個改造以後,系統就可以做到壓測流量隨時隨地愉快的打怪獸了。
壓測指令碼準備
壓測指令碼同樣有多重選擇,比如ApcheBench,Locust,JMeter,基於go的go-stree-test。為了充分壓榨cpu的資源,我們選擇了併發能力高的基於go的go-stree-test 。
但在我提前說明的是,我們專案是springBoot的體系的,其實我們對go並不熟悉,也因此在壓測過程中遇到了一些奇奇怪怪的問題,後面會詳細展開來講。
在所有的改造和指令碼都確定好了以後,我們就可以開始始壓測了。但在正式壓測之前仍然有幾個事情需要來做。
- 確定壓測的目標,目標上限的qps是多少。一般來說,我們都會將預估出來的業務tps * 2 來探索容量的瓶頸。
- 壓測計劃周知,提起通知好相關的上下游,否則突然的流量對上下游來說就是一個super suprice。
- 開啟所有的觀察指標(記憶體,cpu,頻寬,報錯日誌等),一旦指標爆表,就需要立即關停壓測指令碼。不能影響到正常的使用者是我們的底線。
壓測問題
整個壓測過程我們可以算是有諸多的收穫,成功找出了多個系統的問題。下面我將詳細介紹一下我們壓測時發現的問題。
但能夠讓你更好的代入我們接下來我們遇到的問題,我需要在這裡大概來介紹一下我們專案使用的通訊技術框架iogame
iogame是基於netty的高效能通訊框架,但這裡我不會展開來將iogame,感興趣的可以直接去翻看iogame的官網介紹。這裡只介紹跟接下來的問題相關的一些iogame的組成和訊息收發過程。
iogame的三個部分
- 邏輯服:業務服務,是業務處理的地方上訴的改造的攻擊場景是處於邏輯服務。邏輯服業務邏輯。
- 閘道器負責負載均衡邏輯服,並在對外服和邏輯服之間轉發訊息
- 對外服:負責維護和使用者之間的長連線,並將使用者的請求轉發給閘道器,或把閘道器的請求轉給使用者。
訊息在iogame中傳遞過程。
第一壓測:記憶體cpu爆漲
第一次壓測當qps達到1000時,對外服記憶體暴漲到80%,cpu也是快速增加。於是立馬停止了壓測,保留了一臺現場機器,dupm出hprof檔案進行分析。使用VisualVM分析如下圖所示
DefaultChannelPromise:代表一個非同步處理訊息的控制代碼。類似於Java的Future。
PooledUnsafeDirectByteBuf是netty用來管理直接記憶體的緩衝池。ChannelOutboundBuffer說明這個緩衝池是用於出站的處理。
一開始我們懷疑對外服務在往外部發訊息時申請的直接記憶體一致沒有釋放,導致記憶體暴漲。經過根據網上找到的一篇資料(https://blog.csdn.net/weixin_41778440/article/details/125309109)瞭解到netty的直接記憶體有兩種釋放方式
- 手動釋放
- 對應Clear物件被回收
初次懷疑直接記憶體沒有限制導致,於是按照文章裡的做法透過-XX:MaxDirectMemorySize後再次嘗試進行壓測,當達到1000qps時,瘋狂進行fullgc。說明不是直接記憶體的問題,而是請求量確實是太大了。而且是對外服往外部寫的訊息量太大。為了排除問題,我們更換了一個無返回值的壓測介面。此時qps能夠達到4000。說明確實是寫訊息量太大。但iogame是基於netty的,其本身的效能應該會很高,不至於1000qps就壓不上去了,經過網上的一番搜尋找到一篇文章 https://www.arloor.com/posts/netty/netty-direct-memory-leak/ 簡單來說是訊息發的太快了,沒有及時讀取會導致記憶體暴漲。
所以我當時就開始懷疑是壓測指令碼有問題,壓測指令碼中,沒有處理任何的讀取邏輯,那麼所有的訊息到達壓測機以後就會在接收緩衝區中排隊,而tcp的擁塞控制和可靠傳輸機制會根據下游的速度來調節整個傳送速度從而在對外服務上產生一個讀寫差,如果壓測指令碼一直髮送資料而不讀取資料,那麼會不會可能所有的訊息都積壓在了對外服務?按照這個思路,跟測試同學一起修改了壓測指令碼讀取來源端上的返回的訊息,並開始了第二次壓測
第二次壓測:廣播增加限流
但這時啟動壓測後,對外服務的記憶體仍然會飆升,但qps能夠從之前的1000達到1200。 說明上面的分析只能說是一部分的原因,而不是全部的關係。(後面才知道,壓測指令碼會維護一個接收緩衝區,並設定了3秒的讀超時時間)
到此,只能懷疑回過頭來排除是否是業務邏輯本身有問題。於是重新梳理攻擊流程,發現確實有點問題。
上面提到過,為了方便壓測,使用者uid和工會id的歸屬會按照一定的對映規則來。一開始是透過 uid%200 -1 而來的。而問題就在於這個uid上,uid的後幾位代表是遊戲的區服id,為了隔離影響,所有的uid都在專門的測試服中生成,因此所有的uid%200 -1值都相同,也就是說用於壓測的將近1萬個使用者都在同一個房間中。那麼每一次怪獸時,為了同步血量,就需要將訊息廣播1萬份給不同的使用者,廣播訊息被放大了1萬倍!這或許就是問題的根源。針對這一點我們做了兩個改造。
- 進行訊息限流,並不是每一次的血量變更都要嚴格同步廣播,我們只需要1秒傳送1次訊息即可,如果過多,客戶端也會處理不過來。
- 重新改造攻擊流程,透過uid的hash值,將uid平均分散到200個房間。
經過改造以後單機壓測能夠達到4千的qps。對外服務記憶體和cpu不再暴漲。說明確實廣播的原因。
但 單機達到4千qps並不像是go的語言的能達到的極限,說實話對於go來將4千並不是很高,按我們的預期應該可以達到1萬左右。但當時時間有限為了儘快達到我們的壓測目標,我們啟動了多臺壓測機器進行壓測。回頭再看壓測指令碼的效能問題。
第三次壓測:redis單點問題
第三次壓測達到qps 2w時,redis叢集開始告警。表現為redis叢集中的某一個特定的節點cpu利用率能達到85%,但其他的節點的cpu僅有40%。說明業務裡的key有明顯的單點問題。經過整個鏈路的排除,找到非同步鏈路中有一個記榜單的操作。而這個榜單是全域性公會傷害榜單,而這個熱點問題應該就是傷害榜單記榜的請求導致的,於是順手增加了一個消費限流。
再次壓測時,順利的達到了壓測目標,進而宣告了本次的壓測任務完成。
指令碼問題
現在我們回過頭來看壓測指令碼的問題,上面說過我們的技術棧都是java體系的對go語言並不是很熟悉,github上找了一個開原始碼,我們自己改改就拿來用了。
在我們壓測過程中,每當達到一個較高的qps時往往無法維持很久的時間就會斷開。而且壓測時,服務端回覆的訊息會集中迴流到同一臺壓測指令碼上,在網路頻寬的表現上,上行頻寬和下行頻寬都會打滿。上面也提到過,壓測指令碼開啟讀取時(讀取後清空核心的緩衝佇列)能夠提高qps上限。根據這些資訊,我們進行了多個方向的改造
- 增加讀取快取區,將原來的1000 改成10000.擴大10倍
- 將讀超時的時間從3秒改成1秒。讓訊息在沒有讀取的情況下儘快超時,
- 寫操作更換為直接傳送二進位制的底層低階操作。
- 增加一個心跳執行緒用於保活。
經過這一系列的改造,我們的壓測指令碼能夠達到9000千左右的qps,提升了1倍。到此,整個壓測算是圓滿完成了。