1. 程式人生 > 實用技巧 >如何把一個15分鐘的程式優化到了10秒

如何把一個15分鐘的程式優化到了10秒

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

優化前

我們有一個定時任務,迴圈從資料庫撈一批資料(業務上稱它為資源)出來處理,一次撈取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秒鐘了。

優化

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

使用快取避免重複查詢

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

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

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

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

序列改並行

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

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

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

迴圈

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

List<Result> 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<Result> 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<Result> list = new CopyOnWriteArrayList<>();

List<Callable<Void>> callables = resources.stream()
    .map(s -> (Callable<Void>) () -> {
        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個資源,然後自己處理了,其它機器比較空閒。

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

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

轉自:https://yasinshaw.com/articles/110