實戰總結!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. 索引
提到接口優化,很多小夥伴都會想到添加索引。沒錯,添加索引是成本最小的優化,而且一般優化效果都很不錯。
索引優化這塊的話,一般從這幾個維度去思考:
-
你的 SQL 加索引了沒?
-
你的索引是否真的生效?
-
你的索引建立是否合理?
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);
一般就是:SQL
的where
條件的字段,或者是order by 、group by
後面的字段需需要添加索引。
10.2 索引不生效
有時候,即使你添加了索引,但是索引會失效的。田螺哥整理了索引失效的常見原因:
10.3 索引設計不合理
我們的索引不是越多越好,需要合理設計。比如:
-
刪除冗餘和重複索引。
-
索引一般不能超過
5
個 -
索引不適合建在有大量重複數據的字段上、如性別字段
-
適當使用覆蓋索引
-
如果需要使用
force index
強制走某個索引,那就需要思考你的索引設計是否真的合理了
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
操作耗時比較大的話,可能會出現大事務問題。
所謂大事務問題就是,就是運行時間長的事務。由於事務一致不提交,就會導致數據庫連接被佔用,即併發場景下,數據庫連接池被佔滿,影響到別的請求訪問數據庫,影響別的接口性能。
大事務引發的問題主要有:接口超時、死鎖、主從延遲等等。因此,爲了優化接口,我們要規避大事務問題。我們可以通過這些方案來規避大事務:
-
RPC 遠程調用不要放到事務裏面
-
一些查詢相關的操作,儘量放到事務之外
-
事務中避免處理太多數據
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
次,如下:
如果調整一下isUserVip
和isFirstLogin
的順序:
if(isFirstLogin && isUserVip ){
sendMsg();
}
isFirstLogin
執行的次數是5
次,isUserVip
執行的次數是1
次:
醬紫程序是不是變得更高效了呢?
15. 壓縮傳輸內容
壓縮傳輸內容,傳輸報文變得更小,因此傳輸會更快啦。10M
帶寬,傳輸10k
的報文,一般比傳輸1M
的會快呀。
打個比喻,一匹千里馬,它馱着 100 斤的貨跑得快,還是馱着 10 斤的貨物跑得快呢?
再舉個視頻網站的例子:
如果不對視頻做任何壓縮編碼,因爲帶寬又是有限的。巨大的數據量在網絡傳輸的耗時會比編碼壓縮後,慢好多倍。
16. 海量數據處理,考慮 NoSQL
之前看過幾個慢SQL
,都是跟深分頁問題有關的。發現用來標籤記錄法和延遲關聯法,效果不是很明顯,原因是要統計和模糊搜索,並且統計的數據是真的大。最後跟組長對齊方案,就把數據同步到Elasticsearch
,然後這些模糊搜索需求,都走Elasticsearch
去查詢了。
我想表達的就是,如果數據量過大,一定要用關係型數據庫存儲的話,就可以分庫分表。但是有時候,我們也可以使用NoSQL,如Elasticsearch、Hbase
等。
17. 線程池設計要合理
我們使用線程池,就是讓任務並行處理,更高效地完成任務。但是有時候,如果線程池設計不合理,接口執行效率則不太理想。
一般我們需要關注線程池的這幾個參數:核心線程、最大線程數量、阻塞隊列。
-
如果核心線程過小,則達不到很好的並行效果。
-
如果阻塞隊列不合理,不僅僅是阻塞的問題,甚至可能會
OOM
-
如果線程池不區分業務隔離,有可能核心業務被邊緣業務拖垮。
大家可以看下我之前兩篇有關於線程池的文章:
18. 機器問題 (fullGC、線程打滿、太多 IO 資源沒關閉等等)。
有時候,我們的接口慢,就是機器處理問題。主要有fullGC
、線程打滿、太多 IO 資源沒關閉等等。
-
之前排查過一個
fullGC
問題:運營小姐姐導出60多萬
的excel
的時候,說卡死了,接着我們就收到監控告警。後面排查得出,我們老代碼是Apache POI
生成的excel
,導出excel
數據量很大時,當時 JVM 內存喫緊會直接Full GC
了。 -
如果線程打滿了,也會導致接口都在等待了。所以。如果是高併發場景,我們需要接入限流,把多餘的請求拒絕掉。
-
如果 IO 資源沒關閉,也會導致耗時增加。這個大家可以看下,平時你的電腦一直打開很多很多文件,是不是會覺得很卡。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/DGP1frbIlirZ_C8Vd0OmEA