介面突然超時10宗罪。。。

蘇三說技術發表於2023-01-19

前言

不知道你有沒有遇到過這樣的場景:我們提供的某個API介面響應時間原本一直都很快,但在某個不經意的時間點,卻出現了介面超時問題。

也許你會有點懵,到底是為什麼呢?

今天跟大家一起聊聊介面突然超時的10個原因,希望對你會有所幫助。

1.網路異常

介面原本好好的,突然出現超時,最常見的原因,可能是網路出現異常了。比如:偶然的網路抖動,或者是頻寬被佔滿了。

1.1 網路抖動

經常上網的我們,肯定遇到過這樣的場景:大多數情況下我們訪問某個網站很快,但偶爾會出現網頁一直轉圈,載入不出來的情況。

有可能是你的網路出現了抖動,丟包了。

網頁請求API介面,或者介面返回資料給網頁,都有可能會出現網路丟包的情況。

網路丟包可能會導致介面超時。

2.1 頻寬被佔滿

有時候,由於頁面或者介面設計不合理,使用者請求量突增的時候,可能會導致伺服器的網路頻寬被佔滿的情況。

伺服器頻寬指的是在一定時間內傳輸資料的大小,比如:1秒傳輸了10M的資料。

如果使用者請求量突然增多,超出了1秒10M的上限,比如:1秒100M,而伺服器頻寬本身1秒就只能傳輸10M,這樣會導致在這1秒內,90M資料就會延遲傳輸的情況,從而導致介面超時的發生。

所以對於有些高併發請求場景,需要評估一下是否需要增加伺服器頻寬。

2.執行緒池滿了

我們呼叫的API介面,有時候為了效能考慮,可能會使用執行緒池非同步查詢資料,最後把查詢結果進行彙總,然後返回。

如下圖所示:

呼叫遠端介面總耗時 200ms = 200ms(即耗時最長的那次遠端介面呼叫)

在java8之前可以透過實現Callable介面,獲取執行緒返回結果。

java8以後透過CompleteFuture類實現該功能。我們這裡以CompleteFuture為例:

public UserInfo getUserInfo(Long id) throws InterruptedException, ExecutionException {
    final UserInfo userInfo = new UserInfo();
    CompletableFuture userFuture = CompletableFuture.supplyAsync(() -> {
        getRemoteUserAndFill(id, userInfo);
        return Boolean.TRUE;
    }, executor);

    CompletableFuture bonusFuture = CompletableFuture.supplyAsync(() -> {
        getRemoteBonusAndFill(id, userInfo);
        return Boolean.TRUE;
    }, executor);

    CompletableFuture growthFuture = CompletableFuture.supplyAsync(() -> {
        getRemoteGrowthAndFill(id, userInfo);
        return Boolean.TRUE;
    }, executor);
    CompletableFuture.allOf(userFuture, bonusFuture, growthFuture).join();

    userFuture.get();
    bonusFuture.get();
    growthFuture.get();

    return userInfo;
}

這裡我用到了executor,表示自定義的執行緒池,為了防止高併發場景下,出現執行緒過多的問題。

但如果使用者請求太多,執行緒池中已有的執行緒處理不過來,執行緒池會把多餘的請求,放到佇列排隊,等待空閒執行緒的去處理。

如果佇列中排隊的任務非常多,某次API請求一直在等待,沒辦法得到及時處理,就會出現介面超時問題。

這時候,我們可以考慮是否核心執行緒數設定太小了,或者有多種業務場景共用了同一個執行緒池。

如果是因為核心執行緒池設定太小,可以將其調大一些。

如果是因為多種業務場景共用了同一個執行緒池,可以拆分成多個執行緒池

3.資料庫死鎖

有時候介面超時得有點莫名其妙,特別是遇到資料庫出現死鎖的時候。

你提供的API介面中透過某個id更新某條資料,此時,正好線上在手動執行一個批次更新資料的sql語句。

該sql語句在一個事務當中,並且剛好也在更新那條資料,可能會出現死鎖的情況。

由於該sql語句執行時間很長,會導致API介面的那次更新資料操作,長時間被資料庫鎖住,沒法即使返回資料,而出現介面超時問題。

你說坑不坑?

所以建議在執行資料庫批次操作前,一定要評估資料的影響範圍,不要一次性更新太多的資料,不然可能會導致很多意想不到的問題。

此外,批次更新操作建議在使用者訪問少的時段執行,比如:凌晨。

4.傳入引數太多

有時候,偶爾的一次介面超時,是由於引數傳入太多導致的。

例如:根據id集合批次查詢分類介面,如果傳入的id集合資料量不多,傳入幾十個或上百個id,不會出現效能問題。畢竟id是分類表的主鍵,可以走主鍵索引,資料庫的查詢速度是非常快的。

但如果介面呼叫方,一次性傳入幾千個,甚至幾萬個id,批次查詢分類,也可能會出現介面超時問題。

因為資料庫在執行sql語句之前,會評估一下耗時情況,查詢條件太多,有可能走全表掃描更快。

所以這種情況下sql語句可能會丟失索引,讓執行時間變慢,出現介面超時問題。

因此我們在設計批次介面的時候,建議要限制傳入的集合的大小,比如:500。

如果超過我們設定最大的集合大小,則介面直接返回失敗,並提示給使用者:一次性傳入引數過多

該限制一定要寫到介面文件中,避免介面呼叫方,在生產環境呼叫介面失敗而踩坑。要在介面開發階段通知到位。

此外,如果介面呼叫方要傳入的引數就是很多怎麼辦?

答:可能是需求不合理,或者系統設計有問題,我們要儘量在系統設計階段就規避這個問題。

如果我們重新進行系統設計改動比較大的話,有個臨時的解決方案:在介面呼叫方中多執行緒分批呼叫該介面,最後將結果進行彙總。

5.超時時間設定過短

通常情況下,建議我們在呼叫遠端API介面時,要設定連線超時時間讀超時時間這兩個引數,並且可以動態配置。

這樣做的好處是,可以防止呼叫遠端API介面萬一出現了效能問題,響應時間很長,把我們自己的服務拖掛的情況發生。

比如:你呼叫的遠端API介面,要100秒才返回資料,而你設定的超時時間是100秒。這時1000個請求過來,去請求該API介面,這樣會導致tomcat執行緒池很快被佔滿,導致整個服務暫時不可用,至少新的請求過來,是沒法即使響應的。

所以我們需要設定超時時間,並且超時時間還不能設定太長。

併發量不大的業務場景,可以將這兩個超時時間設定稍微長一點,比如:連線超時時間為10秒,讀超時時間為20秒。

併發量大的業務場景,可以設定成秒級或者毫秒級。

有些小夥伴為了開發方便,在多種業務場景共用這兩個超時時間。

某一天,在併發量大的業務場景中,你將該超時時間改短了。

但直接導致併發量不大的業務場景中,出現呼叫API介面超時的問題。

因此,不建議多種業務場景共用同一個超時時間,最好根據併發量的不同,單獨設定不同的超時時間。

6.一次性返回資料太多

不知道你有沒有遇到過這樣的需求:我們有個job,每天定時呼叫第三方API查詢介面,獲取昨天更新的資料,然後更新到我們自己的資料庫表中。

由於第三方每天更新的資料不多,所以該API介面響應時間還是比較快的。

但突然有一天,該API介面卻出現了介面超時問題。

檢視日誌發現,該API介面一次性返回的資料太多,而且該資料的更新時間相同。

這就可以斷定,該API介面提供方進行了批次更新操作,修改了大量的資料,導致該問題的發生。

即使我們在job中加了失敗重試機制,但由於該API一次性返回資料實在太多太多,重試也很有可能會介面超時,這樣會導致一直獲取不到第三方前一天最新的資料。

所以第三方這種根據日期查詢增量資料的介面,建議做成分頁查詢的,不然後面沒準哪一天,遇到批次更新的操作,就可能出現介面超時的問題。

7. 死迴圈

死迴圈也會導致介面超時?

死迴圈不應該在介面測試階段就發現了,為什麼要到生產環境才發現?

確實,絕大部分死迴圈問題,在測試階段可以發現。

但有些無限遞迴隱藏的比較深,比如下面的情況。

死迴圈其實有兩種:

  1. 普通死迴圈
  2. 無限遞迴

7.1 普通死迴圈

有時候死迴圈是我們自己寫的,例如下面這段程式碼:

while(true) {
    if(condition) {
        break;
    }
    System.out.println("do samething");
}

這裡使用了while(true)的迴圈呼叫,這種寫法在CAS自旋鎖中使用比較多。

當滿足condition等於true的時候,則自動退出該迴圈。

如果condition條件非常複雜,一旦出現判斷不正確,或者少寫了一些邏輯判斷,就可能在某些場景下出現死迴圈的問題。

出現死迴圈,大機率是開發人員人為的bug導致的,不過這種情況很容易被測出來。

還有一種隱藏的比較深的死迴圈,是由於程式碼寫的不太嚴謹導致的。如果用正常資料,可能測不出問題,但一旦出現異常資料,就會立即出現死迴圈。

7.2 無限遞迴

如果想要列印某個分類的所有父分類,可以用類似這樣的遞迴方法實現:

public void printCategory(Category category) {
  if(category == null 
      || category.getParentId() == null) {
     return;
  } 
  System.out.println("父分類名稱:"+ category.getName());
  Category parent = categoryMapper.getCategoryById(category.getParentId());
  printCategory(parent);
}

正常情況下,這段程式碼是沒有問題的。

但如果某次有人誤操作,把某個分類的parentId指向了它自己,這樣就會出現無限遞迴的情況。導致介面一直不能返回資料,最終會發生堆疊溢位

建議寫遞迴方法時,設定一個遞迴的深度,比如:分類最大等級有4級,則深度可以設定為4。然後在遞迴方法中做判斷,如果深度大於4時,則自動返回,這樣就能避免無限遞迴的情況。

8.sql語句沒走索引

你有沒有遇到過這樣一種情況:明明是同一條sql,只有入參不同而已。有的時候走的索引a,有的時候卻走的索引b?

沒錯,有時候mysql會選錯索引,甚至有時會不走索引。

mysql在執行某條sql語句之前,會透過抽樣統計來估算掃描行數,根據影響行數、區分度、基數、資料頁等資訊,最後綜合評估走哪個索引。

有時候傳入引數1,sql語句走了索引a,執行時間很快。但有時候傳入引數2,sql語句走了索引b,執行時間明顯慢了很多。

這樣有可能會導致API介面出現超時問題。

必要時可以使用force index來強制查詢sql走某個索引。

9.服務OOM

我之前遇到過這樣一種場景:一個根據id查詢分類的介面,該id是主鍵,sql語句可以走主鍵索引,竟然也出現了介面超時問題。

我當時覺得有點不可思議,因為這個介面平均耗時只有十幾毫秒,怎麼可能會出現超時呢?

但從當時的日誌看,介面響應時間有5秒,的確出現了介面超時問題。

最後從Prometheus的服務記憶體監控中,查到了OOM問題。

其實該API介面部署的服務當時由於OOM記憶體溢位,其實掛了一段時間。

當時所有的介面都出現了請求超時問題。

但由於K8S叢集有監控,它自動會將掛掉的服務節點kill掉,並且在容器中重新部署了一個新的服務節點,幸好對使用者沒造成太大的影響。

如果你對OOM問題比較感興趣,可以看看我的另一篇文章《糟了,線上服務出現OOM了》。

10.在debug

我們有時候需要在本地開發工具,比如:idea中,直接連線測試環境的資料庫,除錯某個API介面的業務邏輯。

因為在開發環境,某些問題不太好復現。

為了排查某個bug,你在請求某個本地介面時,開啟了debug模式,一行行的跟蹤程式碼,排查問題。

走到某一行程式碼的時候,停留了很長一段時間,該行程式碼主要是更新某條資料。

此時,測試同學在相關的業務頁面中,操作更新了相同的資料。

這種也可能會出現資料庫死鎖的問題。

由於你在idea的debug模式中,一直都沒有提交事務,會導致死鎖的時間變得很長,從而導致業務頁面請求的API介面出現超時問題。

當然如果你對常規的介面超時問題比較感興趣,可以看看我的另一篇文章《聊聊介面效能最佳化的11個小技巧》,裡面有非常詳細的介紹。

最後說一句(求關注,別白嫖我)

如果這篇文章對您有所幫助,或者有所啟發的話,幫忙掃描下發二維碼關注一下,您的支援是我堅持寫作最大的動力。
求一鍵三連:點贊、轉發、在看。
關注公眾號:【蘇三說技術】,在公眾號中回覆:面試、程式碼神器、開發手冊、時間管理有超讚的粉絲福利,另外回覆:加群,可以跟很多BAT大廠的前輩交流和學習。

相關文章