初探性能優化:2個月到4小時的性能提高

一直不知道性能優化都要作些什麼,從哪方面思考,直到最近接手了一個公司的小項目,可謂麻雀雖小五臟俱全。讓我這個編程小白學到了不少性能優化的知識,或者說一些思考方式。真的感覺到任何一點效率的損失放大必定倍數時,將會是天文數字。最初個人程序計算下來須要跑2個月才能跑完,通過2周不斷地調整架構和細節,將性能提高到了4小時完成。mysql

不少心得體會,但願和你們分享,也但願多多批評指正,共同進步。sql

項目描述數據庫

我將公司的項目內容抽象,大概是要作這樣一件事情。編程

  1. 數據庫A中有2000萬條用戶數據
  2. 將數據庫A中的用戶讀出,爲每條用戶生成guid,並保存到數據庫B中
  3. 同時在數據庫A中生成關聯表

 

初探性能優化:2個月到4小時的性能提高

 

 

項目要求爲:安全

  1. 將用戶存入數據庫B的過程須要調用sdk的註冊接口,不容許直接操做jdbc進行插入
  2. 數據要求可恢復:再次運行要跳過已成功的數據;出錯的數據要進行持久化以便下次能夠選擇恢復該部分數據
  3. 數據要保證一致性:在不出錯的狀況下,數據庫B的用戶必然一一對應數據庫A的關聯表。若是出錯,那麼正確的數據加上記錄下來的出錯數據後要保證一致性。
  4. 速度要儘量塊:共2000萬條數據,在保證正確性的前提下,至多一天內完成

初版,面向過程——2個月性能優化

特徵:面向過程、單一線程、不可拓展、極度耦合、逐條插入、數據不可恢復多線程

初探性能優化:2個月到4小時的性能提高

 

最初的一版簡直是匯聚了一個項目的全部缺點。整個流程就是從A庫讀出一條數據,馬上作處理,而後調用接口插入B庫,而後在拼一個關聯表的sql語句,插入A庫。沒有計數器,沒有錯誤信息處理。這樣下來的代碼最終預測2000萬條數據要處理2個月。若是中間哪怕一條數據出錯,又要從新再來2個月。簡直可怕。架構

這個流程圖就等同於廢話,是徹底基於面向過程的思想,整個代碼就是在一個大main方法裏寫的,實際業務流程徹底等同於代碼的流程。思考起來簡單,但實現和維護起來極爲困難,代碼結構冗長混亂。並且幾乎是不可擴展的。暫且不談代碼的設計美觀,它的效率如此低下主要有一下幾點:異步

  1. 每一條數據的速度受制於整個鏈條中最慢的一環。試想假若有一條A庫插入關聯表的數據卡住了,等待將近1分鐘(誇張了點),那這一分鐘jvm徹底就在傻等,它徹底能夠繼續進行以前的兩步。正如你等待雞蛋煮熟的過程當中能夠同時去作其餘的事同樣。
  2. 向B庫插入用戶須要調用sdk(HTTP請求)接口,那每一次調用都須要創建鏈接,等待響應,再釋放連接。正如你要給朋友送一箱蘋果,你分紅100次每次只送一個,時間全搭載路上了。

第二版,面向對象——21天jvm

特徵:面向對象、單一線程、可拓展、略微耦合、批量插入、數據可恢復

初探性能優化:2個月到4小時的性能提高

 

架構設計

根據初版設計的問題,第二版有了一些改進。固然最明顯的就是從面向過程的思想轉變爲面向對象。

我將整個過程抽離出來,分配給不一樣的對象去處理。這樣,我所分配的對象時這樣的:

  1. 一個配置對象:BatchStrategy。負責從配置文件中讀取本次任務的策略並傳遞給執行者,配置包括基礎配置如總條數,每次批量查詢的數量,每次批量插入的數量。還有一些數據源方面的,如來源表的表名、列名、等,這樣若是換成其餘數據庫的相似導入,就能供經過配置進行拓展了。
  2. 三個執行者:整個執行過程能夠分紅三個部分:讀數據--處理數據--寫數據,能夠分別交給三個對象Reader,Processor,Writer進行。這樣若是某一處邏輯變了,能夠單獨進行改變而不影響其餘環節。
  3. 一個失敗數據處理類:ErrorHandler。這樣每當有數據出現異常時,便把改數據扔給這個類,在這給類中進行寫入日誌,或者其餘的處理辦法。在必定程度上將失敗數據的處理解耦。

這種設計很大程度上解除了耦合,尤爲是失敗數據的處理基本上徹底解耦。但因爲整個執行過程仍然是須要有一個main來分別調用三個對象處理任務,所以三者之間仍是沒有徹底解耦,main部分的邏輯依然是面向過程的思想,比較複雜。即便把main中執行的邏輯抽出一個service,這個問題依然沒有解決。

效率問題

因爲將初版的逐條插入改成批量插入。其中sdk接口部分是批量傳入一組數據,減小了http請求的次數。生成關聯表的部分是用了jdbc batch操做,將以前逐條插入的excute改成excuteBatch,效率提高很明顯。這兩部分批量帶來的效率提高,將本來須要兩個月時間的代碼,提高到了21天,但依然是天文數字。

能夠看出,本次效率提高僅僅是在減小http請求次數,優化sql的插入邏輯方面作出來努力,但依然沒有解決初版的一個致命問題,就是一次循環的速度依然受制於整個鏈條中最慢的一環,三者沒有解耦也能夠從這一點看出,在其餘二者沒有將工做作完時,就只能傻等,這是效率損失最嚴重的地方了。

第三版,徹底解耦(隊列+多線程)——3天

特徵:面向對象、多線程、可拓展、徹底解耦、批量插入、數據可恢復

初探性能優化:2個月到4小時的性能提高

 

架構設計

該版並無代碼實現,但確是過分到下一版的重要思考過程,故記錄在次。這一版本較上一版的重大改進之處有兩點:隊列和多線程。

隊列:其中隊列的使用使上一版未徹底解耦的執行類之間,實現了徹底解耦,將同步過程變爲異步,同時也是多線程可以使用的前提。Reader作的事就是讀取數據,並放入隊列,至於它的下一個環節Processor如何處理隊列的數據,它徹底不用理會,這時即可以繼續讀取數據。這便作到了徹底解耦,處理隊列的數據也可以使用多線程了。

多線程:Processor和Writer所作的事情,就是讀取自身隊列中的數據,而後處理。只不過Processor比Writer還承擔了一個往下一環隊列裏放數據的過程。此處的隊列用的是多線程安全隊列ConcurrentLinkedQueue。所以能夠肆無忌憚地使用多線程來執行這二者的任務。因爲各個環節之間的徹底解耦,某一環上的偶爾卡主並再也不影響整個過程的進度,因此效率提高不知一兩點。

還有一點就是數據的可恢復性在這個設計中有了保障,成功過的用戶被保存起來以便再次運行不會衝突,失敗的關聯表數據也被記錄下來,在下次運行時Writer會先將這一部分加入到本身的隊列裏,整個數據的正確性就有了一個不是特別完善的方案,效率也有了可觀的提高。

效率問題

雖然效率從21天提高到了3天,但咱們還要思考一些問題。實際在執行的過程當中發現,Writer所完成的數據老是緊跟在Processor以後。這就說明Processor的處理速度要慢於Writer,由於Processor插入數據庫以前還要走一段註冊用戶的業務邏輯。

這就有個問題,當上一環的速度慢過下一環時,還有必要進行批量的操做麼?答案是不須要的。試想一下,若是你在生產線上,你的上一環2秒鐘處理一個零件,而你的速度是1秒鐘一個。這時即便你的批量處理速度更快,從系統最優的角度考慮,你也應該來一個零件就立刻處理,而不是等積攢到100個再批量處理。

還有一個問題是,咱們從未考慮過Reader的性能。實際上我用的是limit操做來批量讀取數據庫,而mysql的limit是先全表查再截取,當起始位置很大時,就會愈來愈慢。0-1000萬還算輕鬆,但1000萬到2000萬簡直是「步履維艱」。因此最終效率的瓶頸反而落到了讀庫操做上。

第四版,高度抽象(一鍵啓動)——4小時

特徵:面向接口、多線程、可拓展、徹底解耦、批量或逐條插入、數據可恢復、優化查詢的limit操做

架構的思考

優雅的代碼應該是整潔而美妙,不該是冗長而複雜的。這一版將會設計出簡潔度如初版,而性能和拓展性超越全部版本的架構。

經過總結前三版特徵,我發現不管是Reader,Processor,Writer,都有共同的特徵:啓動任務、處理任務、結束任務。而Reader和Processor又有一個共同的能夠向下一道工序傳遞數據,通知下一道工序數據傳遞結束的功能。

他們就像生產線上的一個個工序,相互關聯而又各自獨立地運行着。每一道工序均可以啓動,瘋狂地處理任務,直到上一道工序通知結束爲止。而第一個發起通知結束的即是Reader,以後便一個通知下一個,直到整個工序中止,這個過程就是美妙的。

初探性能優化:2個月到4小時的性能提高

 

所以咱們能夠將這三者都看作是Job,除了Reader外又都有與上一道工序交互的能力(其實Reader的上一道工序就是數據庫),所以便有了以下的接口設計。

初探性能優化:2個月到4小時的性能提高

 

/**
 * 工做步驟接口.
 */
public interface Job {
 void init();
 void start();
 void stop();
 void finish();
}

 

/**
 * 可交互的(傳入,通知結束).
 */
public interface Interactive<T> {
 /**
 * 開放與外界交互的通道
 */
 void openInteract();
 /**
 * 接收外界傳來的數據
 * @param t
 */
 void receive(T t);
 /**
 * 關閉交互的通道
 */
 void closeInteract();
 /**
 * 是否處於可交互的狀態
 * @return true可交互的 false不可交互的活已關閉交互狀態
 */
 boolean isInteractive();
}

有了這樣的接口設計,不論實現類具體怎麼寫,主方法已經能夠寫出了,變得異常整潔有序。

只提煉主幹部分,去掉了一些細枝末節,如日誌輸出、時間記錄等。

public static void main(String[] args) {
 Job reader = Reader.getInstance();
 Job processor = Processor.getInstance();
 Job writer = Writer.getInstance();
 reader.init();
 processor.init();
 writer.init();
 start(reader, processor, processor, processor, writer, writer);
}
private static void start(Job... jobs){
 for (Job job:jobs) {
 Thread thread = new Thread(new Runnable() {
 @Override
 public void run() {
 job.start();
 }
 });
 thread.start();
 }
}

接下來就是具體實現類的問題了,這裏實現類主要實現的是三個功能:

  1. 接收上一環的數據:屬於Interactive接口的receive方法的實現,基於以前的設計,便是對象中有一個ConcurrentLinkedQueue類型的屬性,用來接收上一環傳來的數據。
  2. 處理數據並傳遞給下一環:在每個(有下一環的)對象屬性中,放入下一環的對象。如Reader中要有Processor對象,Processor要有Writer,一旦有數據須要加入下一環的隊列,調用其receiive方法便可。
  3. 告訴下一環我結束了:本任務結束時,調用下一環對象的closeInteractive方法。而每一個對象判斷自身結束的方法視狀況而定,好比Reader結束的條件是批量讀取的數據超過了一開始設置的total,說明數據讀取完畢,能夠結束。而Processor結束的條件是,它被上一環通知告終束,而且從本身的隊列中poll不出東西了,證實應該結束,結束後再通知下一環節。這樣整個工序就安全有序地退出了。不過因爲是多線程,因此Processor不能貿然通知Writer結束信號,須要在Processor內部弄一個計數器,只有計數器達到預期的數量的那個線程的Processor,才能發起結束通知。

效率問題:

正如上一版提出的,Processor的處理速度要慢於Writer,因此Writer並不須要用batch去處理數據的插入,該成逐條插入反而是提升性能的一種方式。

大數據量limit操做十分耗時,因爲測試部分只是在前幾百萬條測試,因此仍是大大低估了效率的損失。在後幾百萬條能夠說每一次limit的讀取都步履維艱。考慮到這個問題,我選去了惟一一個有索引而且稍稍易於排序的字段「用戶的手機號」,(不想吐槽它們設計表的時候竟然沒有自增id。。。),每次全表將手機號排序,再limit查詢。

查詢以後將最後一條的手機號保存起來,成爲當前讀取的最後一條數據的一個標識。下次再limit操做就能夠從這個手機號以後開始查詢了。這樣每次查詢不論從哪裏開始,速度都是同樣的。雖然前面部分的數據速度與以前的方案相比慢了很多,但卻完美解決了大數據量limit操做的超長等待時間,預防了危險的發生。

至此,項目架構再次簡潔起來,但同初版相比,已經不是同一級別的簡潔了。

初探性能優化:2個月到4小時的性能提高

 

關於繼續優化的思考

  1. Reader部分是單線程在處理,因爲讀取是從數據庫中,並非隊列中,所以設計成多線程有些麻煩,但並非不可,這裏是優化點
  2. 日誌部分佔有很大一部分比例,2000萬條讀、處理、寫就要有至少6000萬第二天志輸出。若是設計成異步處理,效率會提高很多。
  3. 若是想學習Java工程化、高性能及分佈式、深刻淺出。微服務、Spring,MyBatis,Netty源碼分析的朋友能夠加個人Java高級交流:787707172,羣裏有阿里大牛直播講解技術,以及Java大型互聯網技術的視頻免費分享給你們。
相關文章
相關標籤/搜索