【文章筆記】效能最佳化技巧參考

Xander發表於2022-12-16

文章筆記

引言

原文如下:https://mp.weixin.qq.com/s/yXVkHSRdwjXFM7Xv03x3-Q

主要內容是介紹一些常見的調優技巧,有部分根據一些簡單例子介紹,雖然都不叫淺但是可以作為一個思路引導。這裡簡單記錄筆記內容。

應急鏈路設計

個人偏向於先實戰後理論,這樣讀著比較有意思。筆記也同樣這樣安排。

案例給出的是上游多個系統呼叫異常處理系統執行應急的業務場景。下游的工作是把“差異日誌資料”給到訊息佇列, 異常處理系統訂閱並消費訊息佇列中的“錯誤日誌資料”,然後對這部分資料進行解析、加工聚合等操作,完成異常的傳送及應急處理。

最開始的系統設計如下:

如果是高可用設計,進行拆分如下,構建守護程式,異常資料推送到本地佇列,然後守護程式定期批次拉取傳送。

接著是訊息壓縮傳送:異常規則複用用一份組裝的模型,按照規則 Code 聚合壓縮上報(最佳化業務層資料壓縮複用能力)

後續則只需要依賴訊息中介軟體的序列化和零複製機制。

儲存階段

本地訊息佇列使用Kafka儲存。

  • 依託Kafka 實現 IO 多路複用+磁碟順序寫資料的機制,保證 IO 效能。
  • 分割槽分段儲存機制,提升儲存效能。

消費階段

在訊息佇列的消費段也是分批拉取資料消費。處理之後上報消費位點,然後繼續完成計算操作。

訊息佇列需要做冪等處,同時需要保證節點抖動重複推送訊息問題處理

最後是入庫的處理,提高DB處理效能,節奏Hbase進行異常數量累加,定期獲取執行緒update操作。提高DB查詢,都會把首次查詢放到本地快取儲存20分鐘,資料更新則立即失效。

在DB的儲存上也進行針對性處理。統計類的計算採用 explorer 儲存,對於非結構化的異常明細採用 Hbase 儲存,對於結構化且可靠性要求高的異常資料採用 OB 儲存。

整套架構搭建完成之後,是壓力測試,整體架構穩定度測試,測試的內容如下:

  1. 異常資料量成倍提高測試,對於異常流量拆分,執行緒隔離等。
  2. 單點模組計算進行冗餘、故障轉移和限流
  3. 可以最佳化的地方參考高可用效能最佳化策略(參考上面的最佳化策略)去逐個突破。

流量拆分和異常隔離

限流

高併發和高效能

高併發是什麼?一般用響應時間、併發吞吐量 TPS, 併發使用者數等指標來衡量。

高效能是什麼?高效能是指程式處理速度非常快,所佔記憶體少,CPU 佔用率低

針對高併發和高效能,常見的手段不管是IO 多路複用、零複製、執行緒池、冗餘等等都是真是某兩個大維度的處理,這兩個大維度就是CPU和IO,歸根結底目的其實儘可能的縮短磁碟和CPU之間的處理差距。

比如CPU用更短的時間完善任務,就需要從時間複雜度空間複雜度入手,大部分人即使遇到非常複雜業務,需要自己手寫演算法的場景也是比較少的。

而針對IO磁碟的場景,在資料庫的設計上展現的淋漓盡致,不管作業系統還是演算法還是資料結構的設計,基本都要考慮磁碟的儲存,所以磁碟IO這一塊有非常多的最佳化空間。

不過從個人來看這篇文章脫離了另一個角度那就是SQL,SQL是集CPU、記憶體、硬碟計算機三大核心的統領,如果沒有SQL現今的業務開發不知道要複雜多少倍。可謂是軟體領域最具影響力也是最為難啃的一塊。

綜上所述,這篇文章的高效能和高併發,都是針對業務場景來講述的。

高效能策略

案例一:迴圈查庫和業務判斷

第一個案例是常見的for迴圈查庫,而查庫之後又繼續業務判斷過濾資料,這時候就可以使用先過濾資料再查庫,或者查庫提到迴圈外面,先蒐集資料再一次查詢。

這裡直接對比最佳化前後的程式碼:

boolean result = true;  
// 迴圈遍歷請求的requests, 判斷如果是A業務且A業務未達到終態返回false, 否則返回true  
for(Requet request: requests){  
     // 1. query DB 獲取TestDO  
     String id = request.getId();  
     TestDO testDO = queryDOById(id);  
     // 2. 如果是A業務且testDO未到達中態記錄為false  
     if(StringUtils.equals("A", request.getBizType())){  
         // check是否到達終態  
         if(!StringUtils.equals("FINISHED", testDO.getStatus)){  
             result = result && false;  
         }  
     }  
}  
return result;
boolean result = true;  
// 迴圈遍歷請求的requests, 判斷如果是A業務且A業務未達到終態返回false, 否則返回true  
for(Requet request: requests){  
// 1. 不是A業務的不走查詢DB的邏輯  
if(!StringUtils.equals("A", request.getBizType())){  
continue;  
     }  
     // 2. query DB 獲取TestDO  
     String id = request.getId();  
     TestDO testDO = queryDOById(id);  
     // check是否到達終態  
     if(!StringUtils.equals("FINISHED", testDO.getStatus)){  
         result = false;  
         break;  
     }  
}  
return result;

主要的最佳化點是:

  • 提前業務邏輯判斷並且過濾無效的資料。
  • 儘可能的減少迴圈次數,也就是計算次數,讓迴圈儘可能結束。
  • 如果允許個人更建議把queryDOById改為queryDOByIds,不建議在for迴圈中做查庫。如果是for迴圈裡面更新,或者需要大批次的更新資料,可以使用下面的套路程式碼,假設我們下面的程式碼是分批大批次更新訂單的狀態。下面這個程式碼在個人處理業務過程中屢試不爽。
if (CollectionUtils.size(tradeNos) > 0) {  
    // 獲取每次分批運算元量
    long batchInsertSize = getBatchInsertSize();  
    if (size > batchInsertSize) {  
        // 取整,進行 N - 1次的更新動作
        long p = size / batchInsertSize;  
        for (int i = 1; i <= p; i++) {  
            long rows = mapper.update(tradeNos.stream().limit(batchInsertSize * i)  
                    .skip((i - 1) * batchInsertSize).collect(Collectors.toList()));  
            log.info("分批更新:{} 條", rows);  
            result += rows;  
        }  
        // 如果發現還有剩餘但是不滿一批資料的數量,也進行操作。
        if (size % batchInsertSize > 0) {  
            long rows = mapper.update(tradeNos.stream()  
                    .limit(size).skip(batchInsertSize * p).collect(Collectors.toList()));  
            log.info("分批更新:{} 條", rows);  
            result += rows;  
        }  
    } else {  
        // 如果可以一批次完成,直接完成即可
        long rows = mapper.update(tradeNos);  
        log.info("分批更新:{} 條", rows);  
        result += rows;  
    }  
}

原文第一個案例整個最佳化過程圖如上。日常最佳化程式碼可以用 ARTHAS 工具分析下程式的呼叫耗時,耗時大的任務儘可能做好過濾,減少不必要的系統呼叫。

評價:這個例子比較入門和基礎,但是確實很多時候寫程式碼寫入神了會出現這樣的程式碼。

案例二:同步非同步處理

第二個案例是合理同步和非同步。分析業務鏈路中,哪些需要同步等待結果,哪些不需要,主要的處理套路是:核心依賴的排程可以同步,非核心依賴儘量非同步。或者核心功能非同步但是存在兜底機制可以及時處理,比如存庫定期執行任務重試,依賴資料庫事務完成原子操作等。

這個案例的思路是遇到了多個業務系統依賴呼叫的情況,需要儘可能找出非核心業務,把同步呼叫改為非同步呼叫,

最終出現下面的改動程式碼:

featureThreadPool.execute(()->{

   try{

      dSystemClient.updateResult(resultDTO);

   }catch (Exception exception){

      LogUtil.error(exception, logger, "dSystemClient.updateResult failed! resultDTO = {0}", JSON.toJSONString(resultDTO));

   }

});

第三個案例:限流保護

這一塊過於籠統和簡單,這裡透過一些資料先給一些總結:

目前主流的限流演算法為視窗演算法和桶演算法,而限流方式又劃分為單機限流與分散式限流

視窗演算法實現簡單,也就是計時器的方式,和我們尋找透過電磁爐定時一個道理,但是時間視窗演算法因為沒有緩衝所以存在臨界區的問題。

桶演算法稍微複雜一些,雖然沒視窗演算法直觀,但是有一些別的優勢:

  • 消費速率恆定,是保護自身系統的前提。
  • 令牌可以面對突發暴增流量,緩慢加速問題也存在多種解決手段。

視窗演算法和桶演算法限流都適用於單機限流,分散式限流可以結合註冊中心、負載均衡計算每個服務的限流閾值,比較經典的限流實現比如Sentinel的限流手段就比較值得學習,Sentinel 以流量為切入點,從流量控制、熔斷降級、系統負載保護等多個維度保護服務的穩定性,這個元件對付大部分的業務場景是足夠使的,當然原始碼也十分優秀,值得學習和借鑑設計思路。

更為細緻的限流手段比如Redis 限流,Redis本身極強的單機效能對付分散式場景十分不錯,如果不想重造輪子,可以直接使用開源工具如 redisson,已經封裝了基於 Redis 的限流。

其他的限流工具比如耳熟能詳的Guava,雖然演算法效率很高但是也是隻適用單機限流,

最後上面這段總結來自於:https://www.cnblogs.com/niumoo/p/16007224.html 這篇文章。

第四個案例:單執行緒改多執行緒

一個診斷任務500內完成,如果一個服務呼叫需要100毫秒,那麼整個呼叫會隨著時間增長

// 提交future任務併發執行

futures = executor.invokeAll(tasks, timeout, timeUnit);

// 遍歷讀取結果

for (Future<Res> future : futures) {

    try {

        // 獲取結果

        Res singleResult = future.get();

        if (singleResult != null) {

            result.add(singleResult);

        }

    } catch (Exception e) {

        LogUtil.error(e, logger, "併發執行發生異常!,poolName={0}.", threadPoolName);

    }

叢集計算代替單機

Map-Reduce 思想,減少單機的計算壓力。

系統 IO 效能最佳化策略

IO最佳化策略講的是JVM的最佳化,JVM的最佳化首先需要搞清楚,這部分內容是JVM基礎內容,這裡不過多擴充套件新生代、老年代,垃圾回收等等概念,直接給出一個老年代回收的條件條件判斷流程。

大List更新/刪

有時候因為臨時的需求調整或者一些緊急情況需要對於資料庫做下面的操作。

如果查庫一次性讀取所有的資料到List,必然會把JVM撐爆,並且這樣的任務會導致大物件頻繁的GC情況。

為此可以使用上面提到的分批方法套路進行改善,比如下面的方式先把所有的唯一ID查出來,然後進行分批,在分批的同事把這一批次的資料直接幹掉,也就是所謂的大事化小的處理方式。這個改造對於大部分開發來說應該不難想到,這裡就不過多介紹了。

if (CollectionUtils.size(tradeNos) > 0) {  
    // 獲取每次分批運算元量
    long batchInsertSize = getBatchInsertSize();  
    if (size > batchInsertSize) {  
        // 取整,進行 N - 1次的更新動作
        long p = size / batchInsertSize;  
        for (int i = 1; i <= p; i++) {  
            long rows = mapper.update(tradeNos.stream().limit(batchInsertSize * i)  
                    .skip((i - 1) * batchInsertSize).collect(Collectors.toList()));  
            log.info("分批更新:{} 條", rows);  
            result += rows;  
        }  
        // 如果發現還有剩餘但是不滿一批資料的數量,也進行操作。
        if (size % batchInsertSize > 0) {  
            long rows = mapper.update(tradeNos.stream()  
                    .limit(size).skip(batchInsertSize * p).collect(Collectors.toList()));  
            log.info("分批更新:{} 條", rows);  
            result += rows;  
        }  
    } else {  
        // 如果可以一批次完成,直接完成即可
        long rows = mapper.update(tradeNos);  
        log.info("分批更新:{} 條", rows);  
        result += rows;  
    }  
}

無法回收的 static物件

有時候我們考慮的最簡單的快取方式應該是像下面這樣:

毫無疑問,這就是一個最簡單的快取,然後這個快取存在致命缺陷,那就是執行緒不安全

private staic final Map<String, Object> cache = new HashMap<>();

而在文章的案例中,也出現了因為查詢配置使用靜態物件儲存的情況,當配置內物件積累,會導致這個不可回收的static物件出現GC,但是GC根據判斷又不能被移除的情況!!

當執行 Full GC 後空間仍然不足,則丟擲如下錯誤【java.lang.OutOfMemoryError: Java heap space

在這個例子中,首先不應該使用靜態物件,而是改為可以被新生代回收的程式碼塊物件,這樣方法出棧之後可以被快速回收,也可以使用類似LRU淘汰機制儲存這些物件,當佇列已滿就自動“末位淘汰”。

順序讀寫代替隨機讀寫

這裡提到兩個點:

  • 合理表設計和提前規劃業務會用到的熱欄位,在熱欄位合理使用索引。
  • 訣竅是提前編寫一些偽業務查詢程式碼,可以很直觀的驗證索引設計是否合理

設計和使用索引有下面這些技巧:

  • 越是具備唯一性的欄位優先考慮,舉例來說就是比如男女就不適合作為索引,哪怕來幾百萬資料,他們的區分度也就是1和0,而訂單ID則不同,通常每個使用者有自己唯一的流水訂單,資料庫建立索引也更佳合理,各種查詢的覆蓋面也會更廣。
  • 避免like "%***"以及like "%***%",但是注意字首索引的like "固定值%"這種情況是可以走索引的。
  • 關注or、group、sort,子查詢 in,多列索引查詢、函式操作等等情況。

減少業務流水錶大量耗時計算

涉及到多個表 JOIN 的建議採用離線表進行 Map-Reduce 計算,之後再進行迴流計算。

資料過期策略

資料過期策略是定期把資料儲存到歷史表進行備份,或者備份到離線表中,減少線上大量資料的儲存。透過定期分流資料,可以減少count和一些索引掃描的時間,大大提高查詢的執行效率。

合理使用記憶體

合理使用記憶體需要引入淘汰策略,劃分資料的儲存空間,計過程中需要考慮好成本和查詢效能的平衡。

資料壓縮

目前主流中介軟體本身對於資料提供了壓縮策略,日常最容易接觸磁碟IO的場景是列印日誌,不能夠為了便於排查,列印過多的JSON.toJSONString(Object),同時磁碟很容易被打滿,按照日誌的容量過期策略也很容易被回收。

所以資料壓縮這一節更為重要的是列印日誌,列印日誌的時候思考幾個問題:

  1. 這個日誌有沒有可能會有人看?看了這個日誌能做什麼?
  2. 每個欄位都是必須列印的嗎?
  3. 出現問題能不能提高排查效率?

分庫分表設計

分庫分表是十分考驗業務場景的,這部分內容需要展開單獨的大長文+業務實戰,文章也是說了等於沒說。

避免大量的表 JOIN

阿里編碼規約中超過三個表禁止 JOIN。這一點主要是對於很多業務核心的查詢往往在幾個超級大表上週轉,導致最佳化難度成倍上升。

解決這些JOIN手段無非幾種:

  • 分庫分表,冷熱欄位分離
  • 如果條件允許,部分業務代替SQL,減少表關聯。
  • 冗餘欄位,代價是增加業務複雜度和各種資料同步兜底。

可以看到核心就是空間複雜度換取時間複雜度

高效能小結

總結主要針對三點:架構故障轉移資源隔離效能。防禦措施則分為事前防禦和事後防禦,當然事前防禦是核心,事後防禦直接作為下下策的兜底手段使用。

架構

架構主要考慮的就是冗餘,冗餘很好理解機器越多,可用性會更高。水平的分庫分表也是一種冗餘。

故障轉移

對於DB依賴性高的業務,可以考慮把異常輸出到FO庫或者對於業務場景的上下文寫入到訊息佇列,儲存現場,故障恢復之後進行重新推送處理。

不可抗力的第三方因素需要考慮異地多話,冗餘災備以及定期演練。

資源隔離

對於依賴上游同時會因為上游推送資料壓力巨大的系統,需要做好核心業務和非核心業務資源隔離。對於高併發的業務需要單獨機器部署,比如秒殺的場景。

可用性計算: A 系統可用性 90%,B 系統的可用性 40%,A 系統某服務強依賴 B 系統,那麼 A 系統的可用性為 P(A|B), 可用性大大降低。

事前防禦手段

  • 良好的監控排查機制。比如RocketMq、RabbitMq提供視覺化介面,ELK三件套日誌監控等手段。
  • 限流/熔斷/降級:上游業務流量突增,下游必需做好擋板和措施,解決方式是做好做好完備的壓力測試和熔斷檢測能力。同時多準備幾套方案,當然大部分下能接觸這個難度的業務基本都有成熟的兜底手動,各個公司都有不同思路,這裡不過多擴充套件。
  • 程式碼質量:這個算是最為務實的一項,要對於自己程式碼進行一定的壓力考驗,比如前面提到的突然大量的更新刪除如何從JVM層面防止大物件進入頻繁GC。程式碼質量的最好方式當然是有專業的審查程式碼人員,程式碼質量不過關打回去重寫,但是大部分公司基本是沒有這東西的,所以只能自己平時多思考和多看優秀案例了。

事後防禦

事後防禦都是擦屁股,所以優先考慮恢復關鍵業務,以及故障是否可以兜底回滾或者重試。比如下游呼叫失敗提供手動觸發呼叫機制,上游提供手動回撥的操作等等。這些小操作在關鍵時刻能幫大忙。

最後是原文的一些口水話,比如部署過程中如何做好程式碼的平滑釋出,問題程式碼機器如何快速地摘流量;上下游系統呼叫的釋出,如何保證依賴順序;釋出過程中,正常的業務已經在釋出過的程式碼中執行,逆向操作在未釋出的機器中執行,如何保證業務一致性,都要有充分的考慮。

相關文章