天天看點

通俗易懂!接口性能優化技巧幹貨,有點硬

作者:我迷了鹿coding

目錄

  • 背景
  • 哪些問題會引起接口性能問題
  • 問題解決
  • 總結

背景

我負責的系統在去年初就完成了功能上的建設,然後開始進入到推廣階段。随着推廣的逐漸深入,收到了很多好評的同時也收到了很多對性能的吐槽。

剛剛收到吐槽的時候,我們的心情是這樣的:

當越來越多對性能的吐槽回報到我們這裡的時候,我們意識到,接口性能的問題的優先級必須提高了。

然後我們就跟蹤了 1 周的接口性能監控,這個時候我們的心情是這樣的:

通俗易懂!接口性能優化技巧幹貨,有點硬

有 20 多個慢接口,5 個接口響應時間超過 5s,1 個超過 10s,其餘的都在 2s 以上,穩定性不足 99.8%。

作為一個優秀的後端程式員,這個資料肯定是不能忍的,我們馬上就進入了漫長的接口優化之路。本文就是對我們漫長工作曆程的一個總結。

哪些問題會引起接口性能問題

這個問題的答案非常多,需要根據自己的業務場景具體分析。

這裡做一個不完全的總結:

  • 資料庫慢查詢
  • 業務邏輯複雜
  • 線程池設計不合理
  • 鎖設計不合理
  • 機器問題(fullGC,機器重新開機,線程打滿)
  • 萬金油解決方式

問題解決

慢查詢(基于 mysql)

①深度分頁

所謂的深度分頁問題,涉及到 mysql 分頁的原理。通常情況下,mysql 的分頁是這樣寫的:

select name,code from student limit 100,20           

含義當然就是從 student 表裡查 100 到 120 這 20 條資料,mysql 會把前 120 條資料都查出來,抛棄前 100 條,傳回 20 條。

當分頁是以深度不大的時候當然沒問題,随着分頁的深入,sql 可能會變成這樣:

select name,code from student limit 1000000,20           

這個時候,mysql 會查出來 1000020 條資料,抛棄 1000000 條,如此大的資料量,速度一定快不起來。

那如何解決呢?一般情況下,最好的方式是增加一個條件:

select name,code from student where id>1000000 limit 20           

這樣,mysql 會走主鍵索引,直接連接配接到 1000000 處,然後查出來 20 條資料。但是這個方式需要接口的調用方配合改造,把上次查詢出來的最大 id 以參數的方式傳給接口提供方,會有溝通成本(調用方:老子不改!)。

②未加索引

這個是最容易解決的問題,我們可以通過:

show create table xxxx(表名)           

檢視某張表的索引。具體加索引的語句網上太多了,不再贅述。不過順便提一嘴,加索引之前,需要考慮一下這個索引是不是有必要加,如果加索引的字段區分度非常低,那即使加了索引也不會生效。

另外,加索引的 alter 操作,可能引起鎖表,執行 sql 的時候一定要在低峰期(血淚史!!!!)

③索引失效

這個是慢查詢最不好分析的情況,雖然 mysql 提供了 explain 來評估某個 sql 的查詢性能,其中就有使用的索引。

但是為啥索引會失效呢?mysql 卻不會告訴咱,需要咱自己分析。大體上,可能引起索引失效的原因有這幾個(可能不完全):

通俗易懂!接口性能優化技巧幹貨,有點硬

需要特别提出的是,關于字段區分性很差的情況,在加索引的時候就應該進行評估。如果區分性很差,這個索引根本就沒必要加。

區分性很差是什麼意思呢,舉幾個例子,比如:

  • 某個字段隻可能有 3 個值,那這個字段的索引區分度就很低。
  • 再比如,某個字段大量為空,隻有少量有值;
  • 再比如,某個字段值非常集中,90% 都是 1,剩下 10% 可能是 2,3,4....

進一步的,那如果不符合上面所有的索引失效的情況,但是 mysql 還是不使用對應的索引,是為啥呢?

這個跟 mysql 的 sql 優化有關,mysql 會在 sql 優化的時候自己選擇合适的索引,很可能是 mysql 自己的選擇算法算出來使用這個索引不會提升性能,是以就放棄了。

這種情況,可以使用 force index 關鍵字強制使用索引(建議修改前先實驗一下,是不是真的會提升查詢效率):

select name,code from student force index(XXXXXX) where name = '天才'            

其中 xxxx 是索引名。

④join 過多 or 子查詢過多

我把 join 過多和子查詢過多放在一起說了。一般來說,不建議使用子查詢,可以把子查詢改成 join 來優化。同時,join 關聯的表也不宜過多,一般來說 2-3 張表還是合适的。

具體關聯幾張表比較安全是需要具體問題具體分析的,如果各個表的資料量都很少,幾百條幾千條,那麼關聯的表的可以适當多一些,反之則需要少一些。

另外需要提到的是,在大多數情況下 join 是在記憶體裡做的,如果比對的量比較小,或者 join_buffer 設定的比較大,速度也不會很慢。

但是,當 join 的資料量比較大的時候,mysql 會采用在硬碟上建立臨時表的方式進行多張表的關聯比對,這種顯然效率就極低,本來磁盤的 IO 就不快,還要關聯。

一般遇到這種情況的時候就建議從代碼層面進行拆分,在業務層先查詢一張表的資料,然後以關聯字段作為條件查詢關聯表形成 map,然後在業務層進行資料的拼裝。

一般來說,索引建立正确的話,會比 join 快很多,畢竟記憶體裡拼接資料要比網絡傳輸和硬碟 IO 快得多。

⑤in 的元素過多

這種問題,如果隻看代碼的話不太容易排查,最好結合監控和資料庫日志一起分析。如果一個查詢有 in,in 的條件加了合适的索引,這個時候的 sql 還是比較慢就可以高度懷疑是 in 的元素過多。

一旦排查出來是這個問題,解決起來也比較容易,不過是把元素分個組,每組查一次。想再快的話,可以再引入多線程。

進一步的,如果in的元素量大到一定程度還是快不起來,這種最好還是有個限制:

select id from student where id in (1,2,3 ...... 1000) limit 200           

當然了,最好是在代碼層面做個限制:

if (ids.size() > 200) {
throw new Exception("單次查詢資料量不能超過200");
}           

⑥單純的資料量過大

這種問題,單純代碼的修修補補一般就解決不了了,需要變動整個的資料存儲架構。或者是對底層 mysql 分表或分庫+分表;或者就是直接變更底層資料庫,把 mysql 轉換成專門為處理大資料設計的資料庫。

這種工作是個系統工程,需要嚴密的調研、方案設計、方案評審、性能評估、開發、測試、聯調,同時需要設計嚴密的資料遷移方案、復原方案、降級措施、故障處理預案。

除了以上團隊内部的工作,還可能有跨系統溝通的工作,畢竟做了重大變更,下遊系統的調用接口的方式有可能會需要變化。

出于篇幅的考慮,這個不再展開了,筆者有幸完整參與了一次億級别資料量的資料庫分表工作,對整個過程的複雜性深有體會,後續有機會也會分享出來。

業務邏輯複雜

①循環調用

這種情況,一般都循環調用同一段代碼,每次循環的邏輯一緻,前後不關聯。另外,搜尋公衆号Java後端棧背景回複“私活”,擷取一份驚喜禮包。

比如說,我們要初始化一個清單,預置 12 個月的資料給前端:

List<Model> list = new ArrayList<>();
for(int i = 0 ; i < 12 ; i ++) {
Model model = calOneMonthData(i); // 計算某個月的資料,邏輯比較複雜,難以批量計算,效率也無法很高
list.add(model);
}           

這種顯然每個月的資料計算互相都是獨立的,我們完全可以采用多線程方式進行:

// 建立一個線程池,注意要放在外面,不要每次執行代碼就建立一個,具體線程池的使用就不展開了
public static ExecutorService commonThreadPool = new ThreadPoolExecutor(5, 5, 300L,
TimeUnit.SECONDS, new LinkedBlockingQueue<>(10), commonThreadFactory, new ThreadPoolExecutor.DiscardPolicy());

// 開始多線程調用
List<Future<Model>> futures = new ArrayList<>();
for(int i = 0 ; i < 12 ; i ++) {
Future<Model> future = commonThreadPool.submit(() -> calOneMonthData(i););
futures.add(future);
}

// 擷取結果
List<Model> list = new ArrayList<>();
try {
for (int i = 0 ; i < futures.size() ; i ++) {
list.add(futures.get(i).get());
}
} catch (Exception e) {
LOGGER.error("出現錯誤:", e);
}           

②順序調用

如果不是類似上面循環調用,而是一次次的順序調用,而且調用之間沒有結果上的依賴,那麼也可以用多線程的方式進行,例如:

通俗易懂!接口性能優化技巧幹貨,有點硬

代碼上看:

A a = doA();
B b = doB();

C c = doC(a, b);

D d = doD(c);
E e = doE(c);

return doResult(d, e);           

那麼可用 CompletableFuture 解決:

CompletableFuture<A> futureA = CompletableFuture.supplyAsync(() -> doA());
CompletableFuture<B> futureB = CompletableFuture.supplyAsync(() -> doB());
CompletableFuture.allOf(futureA,futureB) // 等a b 兩個任務都執行完成

C c = doC(futureA.join(), futureB.join());

CompletableFuture<D> futureD = CompletableFuture.supplyAsync(() -> doD(c));
CompletableFuture<E> futureE = CompletableFuture.supplyAsync(() -> doE(c));
CompletableFuture.allOf(futureD,futureE) // 等d e兩個任務都執行完成

return doResult(futureD.join(),futureE.join());           

這樣 A B 兩個邏輯可以并行執行,D E 兩個邏輯可以并行執行,最大執行時間取決于哪個邏輯更慢。

線程池設計不合理

有的時候,即使我們使用了線程池讓任務并行處理,接口的執行效率仍然不夠快,這種情況可能是怎麼回事呢?

這種情況首先應該懷疑是不是線程池設計的不合理。我覺得這裡有必要回顧一下線程池的三個重要參數:核心線程數、最大線程數、等待隊列。

這三個參數是怎麼打配合的呢?當線程池建立的時候,如果不預熱線程池,則線程池中線程為 0。當有任務送出到線程池,則開始建立核心線程。

通俗易懂!接口性能優化技巧幹貨,有點硬

當核心線程全部被占滿,如果再有任務到達,則讓任務進入等待隊列開始等待。

通俗易懂!接口性能優化技巧幹貨,有點硬

如果隊列也被占滿,則開始建立非核心線程運作。

通俗易懂!接口性能優化技巧幹貨,有點硬

如果線程總數達到最大線程數,還是有任務到達,則開始根據線程池抛棄規則開始抛棄。

通俗易懂!接口性能優化技巧幹貨,有點硬

那麼這個運作原理與接口運作時間有什麼關系呢?

  • 核心線程設定過小:核心線程設定過小則沒有達到并行的效果
  • 線程池公用,别的業務的任務執行時間太長,占用了核心線程,另一個業務的任務到達就直接進入了等待隊列
  • 任務太多,以至于占滿了線程池,大量任務在隊列中等待

在排查的時候,隻要找到了問題出現的原因,那麼解決方式也就清楚了,無非就是調整線程池參數,按照業務拆分線程池等等。

鎖設計不合理

鎖設計不合理一般有兩種:鎖類型使用不合理 or 鎖過粗。

鎖類型使用不合理的典型場景就是讀寫鎖。也就是說,讀是可以共享的,但是讀的時候不能對共享變量寫;而在寫的時候,讀寫都不能進行。

在可以加讀寫鎖的時候,如果我們加成了互斥鎖,那麼在讀遠遠多于寫的場景下,效率會極大降低。

鎖過粗則是另一種常見的鎖設計不合理的情況,如果我們把鎖包裹的範圍過大,則加鎖時間會過長,例如:

public synchronized void doSome() {
File f = calData();
uploadToS3(f);
sendSuccessMessage();
}           

這塊邏輯一共處理了三部分,計算、上傳結果、發送消息。顯然上傳結果和發送消息是完全可以不加鎖的,因為這個跟共享變量根本不沾邊。

是以完全可以改成:

public void doSome() {
File f = null;
synchronized(this) {
f = calData();
}
uploadToS3(f);
sendSuccessMessage();
}           

機器問題(fullGC,機器重新開機,線程打滿)

造成這個問題的原因非常多,筆者就遇到了定時任務過大引起 fullGC,代碼存線上程洩露引起 RSS 記憶體占用過高進而引起機器重新開機等待諸多原因。

需要結合各種監控和具體場景具體分析,進而進行大事務拆分、重新規劃線程池等等工作。

萬金油解決方式

萬金油這個形容詞是從我們機關某位老師那裡學來的,但是筆者覺得非常貼切。這些萬金油解決方式往往能解決大部分的接口緩慢的問題,而且也往往是我們解決接口效率問題的最終解決方案。

當我們實在是沒有辦法排查出問題,或者實在是沒有優化空間的時候,可以嘗試這種萬金油的方式。

①緩存

緩存是一種空間換取時間的解決方案,是在高性能存儲媒體上(例如:記憶體、SSD 硬碟等)存儲一份資料備份。

當有請求打到伺服器的時候,優先從緩存中讀取資料。如果讀取不到,則再從硬碟或通過網絡擷取資料。

由于記憶體或 SSD 相比硬碟或網絡 IO 的效率高很多,則接口響應速度會變快非常多。緩存适合于應用在資料讀遠遠大于資料寫,且資料變化不頻繁的場景中。

從技術選型上看,有這些:

  • 簡單的 map
  • guava 等本地緩存工具包
  • 緩存中間件:redis、tair 或 memcached

當然,memcached 現在用的很少了,因為相比于 redis 他不占優勢。tair 則是阿裡開發的一個分布式緩存中間件,他的優勢是理論上可以在不停服的情況下,動态擴充存儲容量,适用于大資料量緩存存儲。

相比于單機 redis 緩存當然有優勢,而他與可擴充 Redis 叢集的對比則需要進一步調研。

進一步的,目前緩存的模型一般都是 key-value 模型。如何設計 key 以提高緩存的命中率是個大學問,好的 key 設計和壞的 key 設計所提升的性能差别非常大。

而且,key 設計是沒有一定之規的,需要結合具體的業務場景去分析。各個大公司分享出來的相關文章,緩存設計基本上是最大篇幅。

②回調 or 反查

這種方式往往是業務上的解決方式,在訂單或者付款系統中應用的比較多。

舉個例子:當我們付款的時候,需要調用一個專門的付款系統接口,該系統經過一系列驗證、存儲工作後還要調用銀行接口以執行付款。

由于付款這個動作要求十分嚴謹,銀行側接口執行可能比較緩慢,進而拖累整個付款接口性能。

這個時候我們就可以采用 fast success 的方式:當必要的校驗和存儲完成後,立即傳回 success,同時告訴調用方一個中間态“付款中”。

而後調用銀行接口,當獲得支付結果後再調用上遊系統的回調接口傳回付款的最終結果“成果”or“失敗”。這樣就可以異步執行付款過程,提升付款接口效率。

當然,為了防止多業務方接入的時候回調接口不統一,可以把結果抛進 kafka,讓調用方監聽自己的結果。

通俗易懂!接口性能優化技巧幹貨,有點硬

總結

本文是筆者對工作中遇到的性能優化問題的一個簡單的總結,可能有不完備的地方,歡迎大家讨論交流。

繼續閱讀