多線程系列七:記錄一次學習項目性能優化的過程及心得
一、項目背景和問題
有一個自適應的考試學習系統,對學員的學習要求經常考試進行檢查,學員的成績出來以後,老師會要求系統根據每個學員的考卷上錯誤的題目從容量為10萬左右的題庫中抽取題目,為每個學員生成一套各自個性化的考後復習和練習的離線練習冊。所以,每次考完試,特別是比較大型的考試後,要求生成的離線文檔數量是比較多的,一個考試2000多人,就要求生成2000多份文檔。
問題是離線文檔生成的速度非常慢,慢到什麽程度呢?一份離線文檔的生成平均時長在45秒左右,遇到成績不好的學生,文檔內容多的,生成甚至需要3分鐘,2000人,平均45秒,全部生成完,需要2000*45=90000秒,大約是25個小時。為什麽如此之慢?這跟離線文檔的生成機制密切相關,對於每一個題目要從保存題庫的數據庫中找到需要的題目,單個題目的表現形式如圖,數據庫中存儲則采用類
二、分析和改進
第一版的實現,服務器在接收到老師的請求後,就會把批量生成請求分解為一個個單獨的任務,然後串行的完成。
首先,做服務拆分:
將生成離線文檔的功能拆了出來成為了單獨的服務,對外提供RPC接口,在WEB服務器接收到了老師們提出的批量生成離線文檔的要求以後,將請求拆分後再一一調用離線文檔生成RPC服務,這個RPC服務在實現的時候有一個緩沖的機制,會將收到的請求進行緩存,然後迅速返回一個結果給調用者,這樣WEB
我們看這個離線文檔:
每份文檔的生成獨立是很高的,天生就適用於多線程並發進行。所以在RPC服務實現的時候,使用了生產者消費者模式,RPC接口的實現收到了一個調用方的請求時,會把請求打包放入一個容器,然後會有多個線程進行消費處理,也就是每個具體文檔的生成。當文檔生成後,再使用一次生產者消費者模式,投入另一個阻塞隊列,由另外的一組線程負責進行上傳。當上傳成功完成後,由上傳線程返回文檔的下載地址,表示當前文檔已經成功完成。
對於每個離線文檔生成本身:
我們來看看它的業務:
1、從容量為10萬左右的題庫中為每個學生抽取適合他的題目,
2、每道題目都含有大量的圖片需要下載到本地,和文字部分一起渲染。但是我們仔細考察整個系統的業務就會發現,我們是在一次考試後為學員生成自適應的練習冊,換句話說,不管考試考察的內容如何,學生的成績如何,每次考試的知識點是有限的,而從這些知識點中可以抽取的相關聯的題目數也總是有限的,不同的學生之間所需要的題目會有很大的重復性。舉個例子我們為甲學生因為他考卷上的錯誤部分抽取了
具體怎麽做?要避免重復工作:
肯定是使用緩存機制,對已處理過的題目進行緩存。我們看看怎麽使用緩存機制進行優化。這個業務,毋庸置疑,map肯定是最適合的,因為我們要根據題目的id來找題目的詳情,用哪個map?我們現在是在多線程下使用,考慮的是並發安全的concurrentHashMap。
當服務接收到處理一個題目的請求,首先會在緩存中get一次,沒有找到,可以認為這是個新題目,準備向數據庫請求題目數據並進行題目的解析,圖片的下載。這裏有一個並發安全的點需要註意,因為是多線程的應用,會發生多個線程在處理多個文檔時有同時進行處理相同題目的情況,這種情況下不做控制,一是會造成數據沖突和混亂,比如同時讀寫同一個磁盤文件,二是會造成計算資源的浪費,同時為了防止文檔的生成阻塞在當前題目上,因此每個新題目的處理過程會包裝成一個Callable投入一個線程池中 而把處理結果作為一個Future返回,等到線程在實際生成文檔時再從Future中get出結果進行處理。因此在每個新題目實際處理前,還會檢查當前是否有這個題目的處理任務正在進行。
如果題目在緩存中被找到,並不是直接引用就可以了,因為題庫中的題目因為種種關系存在被修改的可能,比如存在錯誤,比如可能內容被替換,這個時候緩存中數據其實是失效過期的,所以需要先行檢查一次。如何檢查?我們前面說過題庫中的題目平均長度在800個字節左右,直接equals來檢查題目正文是否變動過,明顯效率比較低,所以這裏又做了一番處理,什麽處理?對題目正文事先做了一次SHA的摘要並保存在數據庫,並且要求題庫開發小組在處理題目數據入庫的時候進行SHA摘要。在本機緩存中同樣保存了這個摘要信息,在比較題目是否變動過時,首先檢查摘要是否一致,摘要一致說明題目不需要更新,摘要不一致時,才需要更新題目文本,將這個題目視為新題目,進入新題目的處理流程,這樣的話就減少了數據的傳輸量,也降低了數據庫的壓力。
這裏只貼出部分優化的主要代碼,註意這裏的業務場景是模擬的,需要完整代碼的可去我的githup上獲取:https://github.com/leeSmall/MultiThreadStudyDemo/tree/master/PerformanceOptimization
優化前:
package com.study; import com.study.aim.MakeSrcDoc; import com.study.vo.PendingDocVo; import com.study.aim.ProblemBank; import com.study.service.DocService; import java.util.List; /** * * 優化前 */ public class SingleWeb { public static void main(String[] args) { System.out.println("題庫開始初始化..........."); ProblemBank.initBank(); System.out.println("題庫初始化完成。"); //需要生成的總文檔 List<PendingDocVo> docList = MakeSrcDoc.makeDoc(2); long startTotal = System.currentTimeMillis(); for(PendingDocVo doc:docList){ System.out.println("開始處理文檔:"+doc.getDocName()+"......."); long start = System.currentTimeMillis(); String localName = DocService.makeDoc(doc); System.out.println("文檔"+localName+"生成耗時:" +(System.currentTimeMillis()-start)+"ms"); start = System.currentTimeMillis(); String remoteUrl = DocService.upLoadDoc(localName); System.out.println("已上傳至["+remoteUrl+"]耗時:" +(System.currentTimeMillis()-start)+"ms"); } System.out.println("共耗時:"+(System.currentTimeMillis()-startTotal)+"ms"); } }
生成2份文檔耗時94秒左右
優化後:
package com.study.rpcmode; import com.study.aim.Consts; import com.study.aim.MakeSrcDoc; import com.study.aim.ProblemBank; import com.study.service.DocService; import com.study.vo.PendingDocVo; import java.util.List; import java.util.concurrent.*; /** * * 優化後:服務的拆分,rpc服務 */ public class RpcMode { //生成文檔的線程池 private static ExecutorService docMakeService = Executors.newFixedThreadPool(Consts.THREAD_COUNT_BASE*2); //上傳文檔的線程池 private static ExecutorService docUploadService = Executors.newFixedThreadPool(Consts.THREAD_COUNT_BASE*2); private static CompletionService docCompletionService = new ExecutorCompletionService(docMakeService); private static CompletionService uploadCompletionService = new ExecutorCompletionService(docUploadService); //生成單個文檔的任務 private static class MakeDocTask implements Callable<String>{ private PendingDocVo pendingDocVo; public MakeDocTask(PendingDocVo pendingDocVo) { this.pendingDocVo = pendingDocVo; } @Override public String call() throws Exception { long start = System.currentTimeMillis(); //一篇文檔是由很多的題目組成的,每個題目相互之間是獨立的,所以並行的、異步的處理每個題目。 String localName = DocService.makeAsyn(pendingDocVo); System.out.println("文檔"+localName+"生成耗時:" +(System.currentTimeMillis()-start)+"ms"); return localName; } } //上傳單個文檔的任務 private static class UploadDocTask implements Callable<String>{ private String localName; public UploadDocTask(String localName) { this.localName = localName; } @Override public String call() throws Exception { long start = System.currentTimeMillis(); String remoteUrl = DocService.upLoadDoc(localName); System.out.println("已上傳至["+remoteUrl+"]耗時:" +(System.currentTimeMillis()-start)+"ms"); return remoteUrl; } } public static void main(String[] args) throws InterruptedException, ExecutionException { System.out.println("題庫開始初始化..........."); ProblemBank.initBank(); System.out.println("題庫初始化完成。"); //需要生成的總文檔 List<PendingDocVo> docList = MakeSrcDoc.makeDoc(2); long startTotal = System.currentTimeMillis(); //每份文檔的生成是非常獨立的,所以吧文檔的生成放到線程池裏面去完成 for(PendingDocVo doc:docList){ docCompletionService.submit(new MakeDocTask(doc)); } //每份文檔的上傳是非常獨立的,所以吧文檔的生成放到線程池裏面去完成 for(PendingDocVo doc:docList){ Future<String> futureLocalName = docCompletionService.take(); uploadCompletionService.submit(new UploadDocTask(futureLocalName.get())); } for(PendingDocVo doc:docList){ //把上傳後的網絡存儲地址拿到 uploadCompletionService.take().get(); } System.out.println("共耗時:"+(System.currentTimeMillis()-startTotal)+"ms"); } }
生成2份文檔耗時15秒左右
三、繼續改進
首先,是數據結構的選擇:
我們仔細分析就會發現,作為一個長期運行的服務,如果我們使用concurrentHashMap,意味著隨著時間的推進,緩存對內存的占用會不斷的增長。最極端的情況,十萬個題目全部被加載到內存,這種情況下會占據多少內存呢?我們做了統計,題庫中題目的平均長度在800個字節左右,十萬個題目大約會使用75M左右的空間。看起來還好,但是有幾點,第一,我們除了題目本身還會有其他的一些附屬信息需要緩存,比如題目圖片在本地磁盤的存儲位置等等,那就說,實際緩存的數據內容會遠遠超過800個字節,第二,map類型的的內存使用效率是比較低的,以hashmap為例,內存利用率一般只有20%到40%左右,而concurrentHashMap只會更低,有時候只有hashmap的十分之一到4分之一,這也就是說十萬個題目放在concurrentHashMap中會實際占據幾百兆的內存空間,是很容易造成內存溢出的,也就是大家常見的OOM。考慮到這種情況,我們需要一種數據結構有map的方便但同時可以限制內存的占用大小或者可以根據需要按照某種策略刷新緩存。最後我們選擇了ConcurrentLinkedHashMap,這是由Google開源一個線程安全的hashmap,它本身是對ConcurrentHashMap的封裝,可以限定最大容量,並實現一個了基於LRU也就是最近最少使用算法策略的進行更新的緩存。很完美的契合了我們的要求,對於已經緩沖的題目,越少使用的就可以認為這個題目離當前考試考察的章節越遠,被再次選中的概率就越小,在容量已滿,需要騰出空間給新緩沖的題目時,越少使用就會優先被清除。
服務重啟後怎麽辦?
在這裏我們除了本地內存緩存還可以使用本地文件存儲,啟用了一個二級緩存機制。為什麽要使用本地文件存儲?因為考慮到服務器會升級、會宕機,已經在內存中緩存的數據會丟失,為了避免這一點,我們將相關的數據在本地進行了一個持久化的操作,保存在了本地磁盤。
四、經過本次性能優化得到的啟示
這次項目的優化給我們帶來了什麽樣的啟示呢?
性能優化一定要建立在對業務的深入分析上,比如我們在性能優化的切入點,在緩存數據結構的選擇就建立在對業務的深入理解上;
性能優化要善於利用語言的高並發特性,性能優化多多利用緩存,異步任務等機制,正是因為我們使用這些特性和機制,才讓我們的應用在性能上有個了質的飛躍;
引入各種機制的同時要註意避免帶來新的不安全因素和瓶頸,比如說緩存數據過期的問題,並發時的線程安全問題,都是需要我們去克服和解決的。
多線程系列七:記錄一次學習項目性能優化的過程及心得