您當前的位置:首頁 > 繪畫

我是如何把一個15分鐘的程式最佳化到了10秒的

作者:由 java灬小不點 發表于 繪畫時間:2020-10-03

今天這篇文章是講效能最佳化的。前段時間我優化了一個程式,感覺收穫還是蠻大的,所以總結了一些用到的最佳化思路,主要集中在程式碼層面,希望可以和大家一起交流探討。

最佳化前

我們有一個定時任務,迴圈從資料庫撈一批資料(業務上稱這它為資源)出來處理,一次撈取1000條。處理流程較長,需要查詢這批資源的各種關聯資訊,還要根據組織查詢一批使用者,根據特定的演算法計算出每一條資源需要分發給哪個使用者,最後執行分發,然後把分發結果落庫,併發送釘釘通知。

任務的效能很差。僅僅1000條資源,就需要十多分鐘才能分發完。目前的業務一般一天會分發2w條資源左右,有時候會分發幾個小時才發完,非常不合理。

定位耗時環節

於是我們打算最佳化一下這個任務。那首先要做的是理清楚程式碼邏輯和架構,第二步就是定位程式中比較耗時的環節,好做針對性的最佳化。

使用Arthas的

trace

命令可以查詢某個方法的內部呼叫路徑,並輸出方法路徑上的每個節點耗時。非常適合於用來定位任務的耗時環節。

儘量不要在生產環境的機器上執行trace命令,尤其是呼叫量較大的業務。

# 使用trace

trace demo。MathGame run

# 過濾掉jdk的函式

trace -j demo。MathGame run

# 根據呼叫耗時過濾

trace demo。MathGame run ‘#cost > 10’

使用trace命令後,可以明顯發現一些環節呼叫耗時不太正常,比如每個資源都會去撈取某些組織相應的使用者列表,大概幾千個使用者,然後再遍歷查詢和組裝每個使用者的資訊,這一套下來就差不多3秒鐘了。

我是如何把一個15分鐘的程式最佳化到了10秒的

最佳化

在分析程式碼過程中,發現還有其它一些不合理的設計,後面對這些不合理的地方都進行了一定程度的最佳化。

使用快取避免重複查詢

我們發現,在組裝使用者資訊的時候耗時比較嚴重,因為要去請求其它服務,然後還要去資料庫拿資料。

我是如何把一個15分鐘的程式最佳化到了10秒的

最佳化前,重複請求

但每個資源都要去做同樣的事情:拿某幾個組織下所有使用者的資訊,產生了大量的重複查詢。

很明顯,我們只需要第一次去查詢就夠了,查詢出來後,放入到快取裡面,後續資源只需要去快取裡面取就行了。

這裡的快取我們使用的是Redis,而不是記憶體快取。因為我們在分發完成後會對使用者資訊做修改,而後面打算把它做成分散式的,多臺機器共享使用者資訊,所以沒有用記憶體快取。

我是如何把一個15分鐘的程式最佳化到了10秒的

最佳化後

序列改並行

在程式碼分析過程中,發現很多是透過迴圈序列去做的。比如查詢使用者的詳細資訊並拼裝,還有分發資源的時候、以及一些計算的時候。

雖然程式中在某些環節使用了多執行緒,但還是有些比較耗時的地方是序列的,導致整個程式比較慢。我們的機器是4核的,所以可以重複利用多核的優勢,使用多執行緒去做一些效能上的最佳化。

主要有兩種場景的序列可以改成並行:

迴圈

對於一個集合,我們下意識地通常會使用for迴圈去遍歷它,做一些事情:

List list = new LinkedList<>();

for(Resource resource : resources) {

User user = computeTarget(resource, users);

Result result = distribute(resource, user);

list。add(result);

}

如果迴圈體裡面的操作比較耗時,這種序列迴圈就是比較慢的。這種情況可以簡單地使用Java 8的並行Stream(其底層是Fork/Join框架),來達到一個並行的效果。不過如果改成了並行以後,需要注意執行緒安全的問題,比如上述程式碼,我們會把結果加到一個List裡面,原本序列的時候使用一個簡單的ArrayList就行了。但我們常用的ArrayList和LinkedList都不是執行緒安全的。所以這裡需要替換成一個高效能的執行緒安全的List:

List list = new CopyOnWriteArrayList<>();

resources。parallelStream()。forEach(resource -> {

User user = computeTarget(resource, users);

Result result = distribute(resource, user);

list。add(result);

})

使用Java 8的並行Stream有一個問題,就是一個程式內部是用的同一個Fork/Join執行緒池,使用者不好去調參。所以我們可以使用自定義的執行緒池來實現序列改並行的需求:

List list = new CopyOnWriteArrayList<>();

List> callables = resources。stream()

。map(s -> (Callable) () -> {

User user = computeTarget(resource, users);

Result result = distribute(resource, user);

list。add(result);

return null;

})。collect(Collectors。toList());

executorService。invokeAll(callables, 20, TimeUnit。SECONDS);

沒有前後關係的耗時操作

另一種典型的序列方式就是在程式碼中要呼叫多個API,但它們可能彼此並不需要有前後關係。比如我們可能要呼叫多個服務或者查詢資料庫,來最後拼裝成一個東西,但每個操作要拼裝的屬性彼此是獨立的,這個時候我們也可以改成並行的。

舉個例子,改造前的程式碼可能是這樣:

// 序列方式:

OneDTO one = oneService。get();

TwoDTO two = twoService。get();

ThreeDTO three = threeService。get();

nextHandle(new Result(one, two, three));

我們使用JDK自帶的神器CompletableFuture來簡單改造一下:

// 並行方式:

Result result = new Result();

CompletableFuture oneFuture = CompletableFuture。runAsync(

() -> result。setOne(oneService。get()));

CompletableFuture twoFuture = CompletableFuture。runAsync(

() -> result。setTwo(twoService。get()));

CompletableFuture threeFuture = CompletableFuture。runAsync(

() -> result。setThree(threeService。get()));

CompletableFuture。allOf(oneFuture, twoFuture, threeFuture)

。thenRun( () -> nextHandle(result))

同步改非同步

同步改成非同步有時候能夠帶來巨大的效能提升。一個操作不管你在同步的時候會消耗多少時間,一旦我改成了非同步,那對於當前的程式來說,它就是無限趨近於0。

什麼情況下同步可以改成非同步?這個其實是業務場景決定的。在我這個場景,資源分發完成後的一些後置操作其實是可以直接改成非同步的,比如:通知使用者、分發結果寫入資料庫等。

同步改非同步也非常簡單,丟到執行緒池裡面去做就完事了。

executorService。submit(() -> {

someAction();

});

單機改分散式

前面介紹了序列改並行。比如我們1000個資源,如果一個執行1秒鐘,那序列是不是就是1000秒。如果用10個執行緒去並行,就程式設計了100秒。

執行緒數量是收到機器限制的,不可能擴增到很大。但機器可以,並且多個機器和每臺機器上的執行緒數量是可以相乘的。

我們這個服務假設有10臺機器,然後再每臺機器用10個執行緒去並行,那1000個資源分散到10臺機器上去處理,只需要10秒。如果我們擴充套件到了100臺機器,它只需要1秒。

我們來看看單機下的執行模式:我們從庫裡面撈1000個資源,然後自己處理了,其它機器比較空閒。

我是如何把一個15分鐘的程式最佳化到了10秒的

單機

單機改分散式其實很簡單,我們只需要在入口處去改造就行了。撈取資源後,透過訊息發出去,然後其它機器接收訊息,獲取資源開始處理。

或者傳送端不撈取資源,直接切割好每個訊息的start和offset,透過訊息傳送出去,讓接收端去撈資源。

我是如何把一個15分鐘的程式最佳化到了10秒的

分散式

連結:我是如何把一個15分鐘的程式最佳化到了10秒的

出處:掘金

作者:Yasin

標簽: Result  序列  執行緒  並行  耗時