實戰總結!18 種接口優化方案的總結

前言

大家好,我是撿田螺的小男孩

之前工作中,遇到一個504超時問題。原因是因爲接口耗時過長,超過nginx配置的10秒。然後 真槍實彈搞了一次接口性能優化,最後接口從11.3s降爲170ms。本文將跟小夥伴們分享接口優化的一些通用方案。

1. 批量思想:批量操作數據庫

優化前:

//for循環單筆入庫
for(TransDetail detail:transDetailList){
  insert(detail);  
}

優化後:

batchInsert(transDetailList);

打個比喻:

打個比喻: 假如你需要搬一萬塊磚到樓頂, 你有一個電梯, 電梯一次可以放適量的磚(最多放500), 你可以選擇一次運送一塊磚, 也可以一次運送500, 你覺得哪種方式更方便,時間消耗更少?

2. 異步思想:耗時操作,考慮放到異步執行

耗時操作,考慮用異步處理,這樣可以降低接口耗時。

假設一個轉賬接口,匹配聯行號,是同步執行的,但是它的操作耗時有點長,優化前的流程:

爲了降低接口耗時,更快返回,你可以把匹配聯行號移到異步處理,優化後:

3. 空間換時間思想:恰當使用緩存。

在適當的業務場景,恰當地使用緩存,是可以大大提高接口性能的。緩存其實就是一種空間換時間的思想,就是你把要查的數據,提前放好到緩存裏面,需要時,直接查緩存,而避免去查數據庫或者計算的過程

這裏的緩存包括:Redis緩存,JVM本地緩存,memcached,或者Map等等。我舉個我工作中,一次使用緩存優化的設計吧,比較簡單,但是思路很有借鑑的意義。

那是一次轉賬接口的優化,老代碼,每次轉賬,都會根據客戶賬號,查詢數據庫,計算匹配聯行號。

因爲每次都查數據庫,都計算匹配,比較耗時,所以使用緩存,優化後流程如下:

4. 預取思想:提前初始化到緩存

預取思想很容易理解,就是提前把要計算查詢的數據,初始化到緩存。如果你在未來某個時間需要用到某個經過複雜計算的數據,才實時去計算的話,可能耗時比較大。這時候,我們可以採取預取思想,提前把將來可能需要的數據計算好,放到緩存中,等需要的時候,去緩存取就行。這將大幅度提高接口性能。

我記得以前在第一個公司做視頻直播的時候,看到我們的直播列表就是用到這種優化方案。就是啓動個任務,提前把直播用戶、積分等相關信息,初始化到緩存

5. 池化思想:預分配與循環使用

大家應該都記得,我們爲什麼需要使用線程池

線程池可以幫我們管理線程,避免增加創建線程和銷燬線程的資源損耗。

如果你每次需要用到線程,都去創建,就會有增加一定的耗時,而線程池可以重複利用線程,避免不必要的耗時。 池化技術不僅僅指線程池,很多場景都有池化思想的體現,它的本質就是預分配與循環使用

比如TCP三次握手,大家都很熟悉吧,它爲了減少性能損耗,引入了Keep-Alive長連接,避免頻繁的創建和銷燬連接。當然,類似的例子還有很多,如數據庫連接池、HttpClient連接池。

我們寫代碼的過程中,學會池化思想,最直接相關的就是使用線程池而不是去new一個線程。

6. 事件回調思想:拒絕阻塞等待。

如果你調用一個系統B的接口,但是它處理業務邏輯,耗時需要10s甚至更多。然後你是一直阻塞等待,直到系統 B 的下游接口返回,再繼續你的下一步操作嗎?這樣顯然不合理

我們參考 IO 多路複用模型。即我們不用阻塞等待系統B的接口,而是先去做別的操作。等系統B的接口處理完,通過事件回調通知,我們接口收到通知再進行對應的業務操作即可。

如果大家忘記了 IO 模型,可以複習一下我的文章:看一遍就理解:IO 模型詳解

7. 遠程調用由串行改爲並行

假設我們設計一個 APP 首頁的接口,它需要查用戶信息、需要查 banner 信息、需要查彈窗信息等等。如果是串行一個一個查,比如查用戶信息200ms,查 banner 信息100ms、查彈窗信息50ms,那一共就耗時350ms了,如果還查其他信息,那耗時就更大了。

其實我們可以改爲並行調用,即查用戶信息、查 banner 信息、查彈窗信息,可以同時並行發起

最後接口耗時將大大降低。有些小夥伴說,不知道如何使用並行優化接口?

我之前寫過一篇文章並行優化接口的文章,保姆級別的!大家可以看一下,看完會有用的:後端思維篇,手把手教你寫一個並行調用模板

8. 鎖粒度避免過粗

在高併發場景,爲了防止超賣等情況,我們經常需要加鎖來保護共享資源。但是,如果加鎖的粒度過粗,是很影響接口性能的。

什麼是加鎖粒度呢?

其實就是就是你要鎖住的範圍是多大。比如你在家上衛生間,你只要鎖住衛生間就可以了吧,不需要將整個家都鎖起來不讓家人進門吧,衛生間就是你的加鎖粒度。

不管你是synchronized加鎖還是redis分佈式鎖,只需要在共享臨界資源加鎖即可,不涉及共享資源的,就不必要加鎖。這就好像你上衛生間,不用把整個家都鎖住,鎖住衛生間門就可以了。

比如,在業務代碼中,有一個ArrayList因爲涉及到多線程操作,所以需要加鎖操作,假設剛好又有一段比較耗時的操作(代碼中的slowNotShare方法)不涉及線程安全問題。反例加鎖,就是一鍋端,全鎖住:

//不涉及共享資源的慢方法
private void slowNotShare() {
    try {
        TimeUnit.MILLISECONDS.sleep(100);
    } catch (InterruptedException e) {
    }
}

//錯誤的加鎖方法
public int wrong() {
    long beginTime = System.currentTimeMillis();
    IntStream.rangeClosed(1, 10000).parallel().forEach(i -> {
        //加鎖粒度太粗了,slowNotShare其實不涉及共享資源
        synchronized (this) {
            slowNotShare();
            data.add(i);
        }
    });
    log.info("cosume time:{}", System.currentTimeMillis() - beginTime);
    return data.size();
}

正例:

public int right() {
    long beginTime = System.currentTimeMillis();
    IntStream.rangeClosed(1, 10000).parallel().forEach(i -> {
        slowNotShare();//可以不加鎖
        //只對List這部分加鎖
        synchronized (data) {
            data.add(i);
        }
    });
    log.info("cosume time:{}", System.currentTimeMillis() - beginTime);
    return data.size();
}

9. 切換存儲方式:文件中轉暫存數據

如果數據太大,落地數據庫實在是慢的話,就可以考慮先用文件的方式暫存。先保存文件,再異步下載文件,慢慢保存到數據庫

這裏可能會有點抽象,給大家分享一個,我之前的一個真實的優化案例吧。

之前開發了一個轉賬接口。如果是併發開啓,10 個併發度,每個批次1000筆轉賬明細數據,數據庫插入會特別耗時,大概 6 秒左右;這個跟我們公司的數據庫同步機制有關,併發情況下,因爲優先保證同步,所以並行的插入變成串行啦,就很耗時。

優化前1000筆明細轉賬數據,先落地DB數據庫,返回處理中給用戶,再異步轉賬。如圖:

記得當時壓測的時候,高併發情況,這1000筆明細入庫,耗時都比較大。所以我轉換了一下思路,把批量的明細轉賬記錄保存的文件服務器,然後記錄一筆轉賬總記錄到數據庫即可。接着異步再把明細下載下來,進行轉賬和明細入庫。最後優化後,性能提升了十幾倍

優化後,流程圖如下:

如果你的接口耗時瓶頸就在數據庫插入操作這裏,用來批量操作等,還是效果還不理想,就可以考慮用文件或者MQ等暫存。有時候批量數據放到文件,會比插入數據庫效率更高。

10. 索引

提到接口優化,很多小夥伴都會想到添加索引。沒錯,添加索引是成本最小的優化,而且一般優化效果都很不錯。

索引優化這塊的話,一般從這幾個維度去思考:

10.1 SQL 沒加索引

我們開發的時候,容易疏忽而忘記給 SQL 添加索引。所以我們在寫完SQL的時候,就順手查看一下 explain執行計劃。

explain select * from user_info where userId like '%123';

你也可以通過命令show create table ,整張表的索引情況。

show create table user_info;

如果某個表忘記添加某個索引,可以通過alter table add index命令添加索引

alter table user_info add index idx_name (name);

一般就是:SQLwhere條件的字段,或者是order by 、group by後面的字段需需要添加索引。

10.2 索引不生效

有時候,即使你添加了索引,但是索引會失效的。田螺哥整理了索引失效的常見原因

10.3 索引設計不合理

我們的索引不是越多越好,需要合理設計。比如:

11. 優化 SQL

處了索引優化,其實 SQL 還有很多其他有優化的空間。比如這些:

更詳細的內容,大家可以看我之前的這兩篇文章哈:

12. 避免大事務問題

爲了保證數據庫數據的一致性,在涉及到多個數據庫修改操作時,我們經常需要用到事務。而使用spring聲明式事務,又非常簡單,只需要用一個註解就行@Transactional,如下面的例子:

@Transactional
public int createUser(User user){
    //保存用戶信息
    userDao.save(user);
    passCertDao.updateFlag(user.getPassId());
    return user.getUserId();
}

這塊代碼主要邏輯就是創建個用戶,然後更新一個通行證pass的標記。如果現在新增一個需求,創建完用戶,調用遠程接口發送一個email消息通知,很多小夥伴會這麼寫:

@Transactional
public int createUser(User user){
    //保存用戶信息
    userDao.save(user);
    passCertDao.updateFlag(user.getPassId());
    sendEmailRpc(user.getEmail());
    return user.getUserId();
}

這樣實現可能會有坑,事務中嵌套RPC遠程調用,即事務嵌套了一些非DB操作。如果這些非DB操作耗時比較大的話,可能會出現大事務問題

所謂大事務問題就是,就是運行時間長的事務。由於事務一致不提交,就會導致數據庫連接被佔用,即併發場景下,數據庫連接池被佔滿,影響到別的請求訪問數據庫,影響別的接口性能

大事務引發的問題主要有:接口超時、死鎖、主從延遲等等。因此,爲了優化接口,我們要規避大事務問題。我們可以通過這些方案來規避大事務:

13. 深分頁問題

在以前公司分析過幾個接口耗時長的問題,最終結論都是因爲深分頁問題

深分頁問題,爲什麼會慢?我們看下這個 SQL

select id,name,balance from account where create_time> '2020-09-19' limit 100000,10;

limit 100000,10意味着會掃描100010行,丟棄掉前100000行,最後返回10行。即使create_time,也會回表很多次。

我們可以通過標籤記錄法和延遲關聯法來優化深分頁問題。

13.1 標籤記錄法

就是標記一下上次查詢到哪一條了,下次再來查的時候,從該條開始往下掃描。就好像看書一樣,上次看到哪裏了,你就摺疊一下或者夾個書籤,下次來看的時候,直接就翻到啦。

假設上一次記錄到100000,則 SQL 可以修改爲:

select  id,name,balance FROM account where id > 100000 limit 10;

這樣的話,後面無論翻多少頁,性能都會不錯的,因爲命中了id主鍵索引。但是這種方式有侷限性:需要一種類似連續自增的字段。

13.2 延遲關聯法

延遲關聯法,就是把條件轉移到主鍵索引樹,然後減少回表。優化後的 SQL 如下:

select  acct1.id,acct1.name,acct1.balance FROM account acct1 INNER JOIN (SELECT a.id FROM account a WHERE a.create_time > '2020-09-19' limit 100000, 10) AS acct2 on acct1.id= acct2.id;

優化思路就是,先通過idx_create_time二級索引樹查詢到滿足條件的主鍵 ID,再與原表通過主鍵 ID 內連接,這樣後面直接走了主鍵索引了,同時也減少了回表。

14. 優化程序結構

優化程序邏輯、程序代碼,是可以節省耗時的。比如,你的程序創建多不必要的對象、或者程序邏輯混亂,多次重複查數據庫、又或者你的實現邏輯算法不是最高效的,等等。

我舉個簡單的例子:複雜的邏輯條件,有時候調整一下順序,就能讓你的程序更加高效。

假設業務需求是這樣:如果用戶是會員,第一次登陸時,需要發一條感謝短信。如果沒有經過思考,代碼直接這樣寫了

if(isUserVip && isFirstLogin){
    sendSmsMsg();
}

假設有5個請求過來,isUserVip判斷通過的有3個請求,isFirstLogin通過的只有1個請求。那麼以上代碼,isUserVip執行的次數爲5次,isFirstLogin執行的次數也是3次,如下:

如果調整一下isUserVipisFirstLogin的順序:

if(isFirstLogin && isUserVip ){
    sendMsg();
}

isFirstLogin執行的次數是5次,isUserVip執行的次數是1次:

醬紫程序是不是變得更高效了呢?

15. 壓縮傳輸內容

壓縮傳輸內容,傳輸報文變得更小,因此傳輸會更快啦。10M帶寬,傳輸10k的報文,一般比傳輸1M的會快呀。

打個比喻,一匹千里馬,它馱着 100 斤的貨跑得快,還是馱着 10 斤的貨物跑得快呢?

再舉個視頻網站的例子:

如果不對視頻做任何壓縮編碼,因爲帶寬又是有限的。巨大的數據量在網絡傳輸的耗時會比編碼壓縮後,慢好多倍

16. 海量數據處理,考慮 NoSQL

之前看過幾個慢SQL,都是跟深分頁問題有關的。發現用來標籤記錄法和延遲關聯法,效果不是很明顯,原因是要統計和模糊搜索,並且統計的數據是真的大。最後跟組長對齊方案,就把數據同步到Elasticsearch,然後這些模糊搜索需求,都走Elasticsearch去查詢了。

我想表達的就是,如果數據量過大,一定要用關係型數據庫存儲的話,就可以分庫分表。但是有時候,我們也可以使用NoSQL,如Elasticsearch、Hbase等。

17. 線程池設計要合理

我們使用線程池,就是讓任務並行處理,更高效地完成任務。但是有時候,如果線程池設計不合理,接口執行效率則不太理想。

一般我們需要關注線程池的這幾個參數:核心線程、最大線程數量、阻塞隊列

大家可以看下我之前兩篇有關於線程池的文章:

18. 機器問題 (fullGC、線程打滿、太多 IO 資源沒關閉等等)。

有時候,我們的接口慢,就是機器處理問題。主要有fullGC、線程打滿、太多 IO 資源沒關閉等等。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/DGP1frbIlirZ_C8Vd0OmEA