榨乾服務器:一次慘無人道的性能優化

背景

做過2B類系統的同學都知道,2B系統最噁心的操作就是什麼都喜歡批量,這不,我最近就遇到了一個噁心的需求——50個用戶同時每人導入1萬條單據,每個單據七八十個字段,請給我優化。

Excel導入技術選型

說起Excel導入的需求,很多同學都做過,也很熟悉,這裏面用到的技術就是POI系列了。

但是,原生的POI很難用,需要自己去調用POI的API解析Excel,每換一個模板,你都要寫一堆重複而又無意義的代碼。

所以,後面出現了EasyPOI,它基於原生POI做了一層封裝,使用註解即可幫助你自動解析Excel到你的Java對象。

EasyPOI雖然好用,但是數據量特別大之後呢,會時不時的來個內存溢出,甚是煩惱。

所以,後面某里又做了一些封裝,搞出來個EasyExcel,它可以配置成不會內存溢出,但是解析速度會有所下降。

如果要扣技術細節的話,就是DOM解析和SAX解析的區別,DOM解析是把整個Excel加載到內存一次性解析出所有數據,針對大Excel內存不夠用就OOM了,而SAX解析可以支持逐行解析,所以SAX解析操作得當的話是不會出現內存溢出的。

因此,經過評估,我們系統的目標是每天500萬單量,這裏面導入的需求非常大,為了穩定性考慮,我們最後選擇使用EasyExcel來作為Excel導入的技術選型。

導入設計

我們以前也做過一些系統,它們都是把導入的需求跟正常的業務需求耦合在一起的,這樣就會出現一個非常嚴重的問題:一損俱損,當大導入來臨的時候,往往系統特別卡。

導入請求同其它的請求一樣只能打到一台機器上處理,這個導入請求打到哪台機器哪台機器倒霉,其它同樣打到這台機器的請求就會受到影響,因為導入佔用了大量的資源,不管是CPU還是內存,通常是內存。

還有一個很操蛋的問題,一旦業務受到影響,往往只能通過加內存來解決,4G不行上8G,8G不行上16G,而且,是所有的機器都要同步調大內存,而實際上導入請求可能也就幾個請求,導致浪費了大量的資源,大量的機器成本。

另外,我們導入的每條數據有七八十個字段,且在處理的過程中需要寫數據庫、寫ES、寫日誌等多項操作,所以每條數據的處理速度是比較慢的,我們按50ms算(實際比50ms還長),那1萬條數據光處理耗時就需要 10000 * 50 / 1000 = 500秒,接近10分鐘的樣子,這個速度是無論如何都接受不了的。

所以,我一直在思考,有沒有什麼方法既可以縮減成本,又可以加快導入請求的處理速度,同時,還能營造良好的用戶體驗?

經過苦思冥想,還真被我想出來一種方案:獨立出來一個導入服務,把它做成通用服務。

導入服務只負責接收請求,接收完請求直接告訴前端收到了請求,結果後面再通知。

然後,解析Excel,解析完一條不做其它處理直接就把它扔到Kafka中,下游的服務去消費,消費完了,再發一條消息給Kafka告訴導入服務這條數據的處理結果,導入服務檢測到所有行數都收到了反饋,再通知前端這次導入完成了。(前端輪詢)

如上圖所示,我們以導入XXX為例描述下整個流程:

  1. 前端發起導入XXX的請求;
  2. 後端導入服務接收到請求之後立即返回,告訴前端收到了請求;
  3. 導入服務每解析一條數據就寫入一行數據到數據庫,同時發送該數據到Kafka的XXX_IMPORT分區;
  4. 處理服務的多個實例從XXX_IMPORT的不同分區拉取數據並處理,這裡的處理可能涉及數據合規性檢查,調用其他服務補齊數據,寫數據庫,寫ES,寫日誌等;
  5. 待一條數據處理完成後給Kafka的IMPORT_RESULT發送消息說這條數據處理完了,或成功或失敗,失敗需要有失敗原因;
  6. 導入服務的多個實例從IMPORT_RESULT中拉取數據,更新數據庫中每條數據的處理結果;
  7. 前端輪詢的接口在某一次請求的時候發現這次導入全部完成了,告訴用戶導入成功;
  8. 用戶可以在頁面上查看導入失敗的記錄並下載;

這就是整個導入的過程,下面就開始了踩坑之旅,你準備好了嗎?

聰明的同學會發現,(關注公號彤哥讀源碼一起學習一起浪)其實大批量導入跟電商中的秒殺是有些類似的,所以,整個過程引入Kafka來在削峰和異步。

初步測試

經過上面的設計,我們測試導入1萬條數據只需要20秒,比之前預估的10分鐘快了不止一星半點。

但是,我們發現一個很嚴重的問題,當我們導入數據的時候,查詢界面卡到爆,需要等待10秒的樣子查詢界面才能刷出來,從表象來看,是導入影響了查詢。

初步懷疑

因為我們查詢只走了ES,所以,初步懷疑是ES的資源不夠。

但是,當我們查看ES的監控時發現,ES的CPU和內存都還很充足,並沒有什麼問題。

然後,我們又仔細檢查了代碼,也沒有發現明顯的問題,而且服務本身的CPU、內存、帶寬也沒有發現明顯的問題。

真的神奇了,完全沒有了任何思路。

而且,我們的日誌也是寫ES的,日誌的量比導入的量還更大,查日誌的時候也沒有發現卡過。

所以,我想,直接通過Kibana查詢數據試試。

說干就干,在導入的同時,在Kibana上查詢數據,並沒有發現卡,結果顯示只需要幾毫秒數據就查出來了,更多的耗時是在網絡傳輸上,但是整體也就1秒左右數據就刷出來了。

因此,可以排除是ES本身的問題,肯定還是我們的代碼問題。

此時,我做了個簡單的測試,我把查詢和導入的處理服務分開,發現也不卡,秒級返回。

答案已經快要浮出水面了,一定是導入處理的時候把ES的連接池資源佔用完了,導致查詢的時候拿不到連接,所以,需要等待。

通過查看源碼,最終發現ES的連接數是在RestClientBuilder類中寫死的,DEFAULT_MAX_CONN_PER_ROUTE=10,DEFAULT_MAX_CONN_TOTAL=30,每個路由最大10,總連接數最大30,而且更操蛋的是,這兩個配置是寫死在代碼裏面的,沒有參數可以配置,只能通過修改代碼來實現了。

這裡也可以做個簡單的估算,我們的處理服務部署了4台機器,每台機器一共可以建立30條連接,4台機器就是120條連接,導入一萬單如果平均分配,每條連接需要處理 10000 / 120 = 83條數據,每條數據處理100ms(上面用的50ms,都是估值)就是8.3秒,所以,查詢的時候需要等待10秒左右,比較合理。

直接把這兩個參數調大10倍到100和300,(關注公號彤哥讀源碼一起學習一起浪)再部署服務,測試發現導入的同時,查詢也正常了。

接下來,我們又測試了50個用戶同時導入1萬單,也就是並發導入50萬單,按1萬單20秒來算,總共耗時應該在 50*20=1000秒/60=16分鐘,但是,測試發現需要耗時30分鐘以上,這次瓶頸又在哪裡呢?

再次懷疑

我們之前的壓測都是基於單用戶1萬單來測試的,當時的服務器配置是導入服務4台機器,處理服務4台機器,根據上面我們的架構圖,按理說導入服務和處理服務都是可以無限擴展的,只要加機器,性能就能上去。

所以,首先,我們把處理服務的機器加到了25台(我們基於k8s,擴容非常方便,改個數字的事),跑一下50萬單,發現沒有任何效果,還是30分鐘以上。

然後,我們把導入服務的機器也加到25台,跑了一下50萬單,同樣地,發現也沒有任何效果,此時,有點懷疑人生了。

通過查看各組件的監控,發現,此時導入服務的數據庫有個指標叫做IOPS,已經達到了5000,並且持續的在5000左右,IOPS是什麼呢?

它表示一秒讀寫IO多少次,跟TPS/QPS差不多,說明MySQL一秒與磁盤的交互次數,一般來說,5000已經是非常高的了。

目前來看,瓶頸可能在這裡,再次查看這個MySQL實例的配置,發現它使用的是超高IO,實際上還是普通的硬盤,想着如果換成SSD會不會好點呢。

說干就干,聯繫運維重新購買一個磁盤是SSD的MySQL實例。

切換配置,重新跑50萬單,這次的時間果然降下來了,只需要16分鐘了,接近降了一半。

所以,SSD還是要快不少的,查看監控,當我們導入50萬單的時候,SSD的MySQL的IOPS能夠達到12000左右,快了一倍多。

後面,我們把處理服務的MySQL磁盤也換成SSD,時間再次下降到了8分鐘左右。

你以為到這裡就結束了嘛(關注公號彤哥讀源碼一起學習一起浪)?

思考

上面我們說了,根據之前的架構圖,導入服務和處理服務是可以無限擴展的,而且我們已經分別加到了25台機器,但是性能並沒有達到理想的情況,讓我們來計算一下。

假設瓶頸全部在MySQL,對於導入服務,我們一條數據大概要跟MySQL交互4次,整個Excel分成頭表和行表,第一條數據是插入頭表,後面的數據是更新頭表、插入行表,等處理完了會更新頭表、更新行表,所以按12000的IOPS來算的話,MySQL會消耗我們 500000 * 4 / 12000 / 60= 2.7分鐘,同樣地,處理服務也差不多,處理服務還會去寫ES,但處理服務沒有頭表,所以時間也按2.7分鐘算,但是這兩個服務本質上是並行的,沒有任何關係,所以總的時間應該可以控制在4分鐘以內,因此,我們還有4分鐘的優化空間。

再優化

經過一系列排查,我們發現Kafka有個參數叫做kafka.listener.concurrency,處理服務設置的是20,而這個Topic的分區是50,也就是說實際上我們25台機器只使用了2.5台機器來處理Kafka中的消息(猜測)。

找到了問題點,就很好辦了,先把這個參數調整成2,保持分區數不變,再次測試,果然時間降下來了,到5分鐘了,後面經過一系列調整測試,發現分區數是100,concurrency是4的時候效率是最高的,最快可以達到4分半的樣子。

至此,整個優化過程告一段落。

總結

現在我們來總結一下一共優化了哪些地方:

  1. 導入Excel技術選型為EasyExcel,確實非常不錯,從來沒出現過OOM;
  2. 導入架構設計修改為異步方式處理,參考秒殺架構;
  3. Elasticsearch連接數調整為每個路由100,最大連接數300;
  4. MySQL磁盤更換為SSD;
  5. Kafka優化分區數和kafka.listener.concurrency參數;

另外,還有很多其它小問題,限於篇幅和記憶,無法一一講出來。

後期規劃

通過這次優化,我們也發現了當數據量足夠大的時候,瓶頸還是在存儲這塊,所以,是不是優化存儲這塊,性能還可以進一步提升呢?

答案是肯定的,比如,有以下的一些思路:

  1. 導入服務和處理服務都修改為分庫分表,不同的Excel落入不同的庫中,減輕單庫壓力;
  2. 寫MySQL修改為批量操作,減少IO次數;
  3. 導入服務使用Redis來記錄,而不是MySQL;

但是,這次要不要把這些都試一遍呢,其實沒有必要,通過這次壓測,我們至少能做到心裏有數就可以了,真的等到量達到了那個級別,再去優化也不遲。

好了,今天的文章就到這裡了。