前言
接口性能問題,對于從事后端開發(fā)的同學(xué)來說闷煤,是一個(gè)繞不開的話題人断。想要優(yōu)化一個(gè)接口的性能披泪,需要從多個(gè)方面著手蜗侈。
本文將會接著接口性能優(yōu)化這個(gè)話題篷牌,從實(shí)戰(zhàn)的角度出發(fā),聊聊我是如何優(yōu)化一個(gè)慢查詢接口的踏幻。
上周我優(yōu)化了一下線上的批量評分查詢接口枷颊,將接口性能從最初的20s,優(yōu)化到目前的500ms以內(nèi)该面。
總體來說夭苗,用三招就搞定了。到底經(jīng)歷了什么隔缀?
1. 案發(fā)現(xiàn)場
我們每天早上上班前题造,都會收到一封線上查詢接口匯總郵件,郵件中會展示接口地址猾瘸、調(diào)用次數(shù)界赔、最大耗時(shí)、平均耗時(shí)和traceId等信息牵触。
我看到其中有一個(gè)批量評分查詢接口淮悼,最大耗時(shí)達(dá)到了20s,平均耗時(shí)也有2s揽思。
用skywalking查看該接口的調(diào)用信息敛惊,發(fā)現(xiàn)絕大數(shù)情況下,該接口響應(yīng)還是比較快的绰更,大部分情況都是500s左右就能返回,但也有少部分超過了20s的請求锡宋。
這個(gè)現(xiàn)象就非常奇怪了儡湾。
莫非跟數(shù)據(jù)有關(guān)?
比如:要查某一個(gè)組織的數(shù)據(jù)执俩,是非承炷疲快的。但如果要查平臺役首,即組織的根節(jié)點(diǎn)尝丐,這種情況下,需要查詢的數(shù)據(jù)量非常大衡奥,接口響應(yīng)就可能會非常慢爹袁。
但事實(shí)證明不是這個(gè)原因。
很快有個(gè)同事給出了答案矮固。
他們在結(jié)算單列表頁面中失息,批量請求了這個(gè)接口,但他傳參的數(shù)據(jù)量非常大。
怎么回事呢盹兢?
當(dāng)初說的需求是這個(gè)接口給分頁的列表頁面調(diào)用邻梆,每頁大小有:10、20绎秒、30浦妄、50、100见芹,用戶可以選擇剂娄。
換句話說,調(diào)用批量評價(jià)查詢接口辆童,一次性最多可以查詢100條記錄宜咒。
但實(shí)際情況是:結(jié)算單列表頁面還包含了很多訂單“鸭基本上每一個(gè)結(jié)算單故黑,都有多個(gè)訂單。調(diào)用批量評價(jià)查詢接口時(shí)庭砍,需要把結(jié)算單和訂單的數(shù)據(jù)合并到一起场晶。
這樣導(dǎo)致的結(jié)果是:調(diào)用批量評價(jià)查詢接口時(shí),一次性傳入的參數(shù)非常多怠缸,入?yún)ist中包含幾百诗轻、甚至幾千條數(shù)據(jù)都有可能。
2. 現(xiàn)狀
如果一次性傳入幾百或者幾千個(gè)id揭北,批量查詢數(shù)據(jù)還好扳炬,可以走主鍵索引,查詢效率也不至于太差搔体。
但那個(gè)批量評分查詢接口恨樟,邏輯不簡單。
偽代碼如下:
public List<ScoreEntity> query(List<SearchEntity> list) {
//結(jié)果
List<ScoreEntity> result = Lists.newArrayList();
//獲取組織id
List<Long> orgIds = list.stream().map(SearchEntity::getOrgId).collect(Collectors.toList());
//通過regin調(diào)用遠(yuǎn)程接口獲取組織信息
List<OrgEntity> orgList = feginClient.getOrgByIds(orgIds);
for(SearchEntity entity : list) {
//通過組織id找組織code
String orgCode = findOrgCode(orgList, entity.getOrgId());
//通過組合條件查詢評價(jià)
ScoreSearchEntity scoreSearchEntity = new ScoreSearchEntity();
scoreSearchEntity.setOrgCode(orgCode);
scoreSearchEntity.setCategoryId(entity.getCategoryId());
scoreSearchEntity.setBusinessId(entity.getBusinessId());
scoreSearchEntity.setBusinessType(entity.getBusinessType());
List<ScoreEntity> resultList = scoreMapper.queryScore(scoreSearchEntity);
if(CollectionUtils.isNotEmpty(resultList)) {
ScoreEntity scoreEntity = resultList.get(0);
result.add(scoreEntity);
}
}
return result;
}
其實(shí)在真實(shí)場景中疚俱,代碼比這個(gè)復(fù)雜很多劝术,這里為了給大家演示,簡化了一下呆奕。
最關(guān)鍵的地方有兩點(diǎn):
在接口中遠(yuǎn)程調(diào)用了另外一個(gè)接口
需要在for循環(huán)中查詢數(shù)據(jù)
其中的第1點(diǎn)养晋,就是:在接口中遠(yuǎn)程調(diào)用了另外一個(gè)接口,這個(gè)代碼是必須的梁钾。
因?yàn)槿绻谠u價(jià)表中冗余一個(gè)組織code字段绳泉,萬一哪天組織表中的組織code有修改,不得不通過某種機(jī)制姆泻,通知我們同步修改評價(jià)表的組織code圈纺,不然就會出現(xiàn)數(shù)據(jù)不一致的問題秦忿。
很顯然,如果要這樣調(diào)整的話蛾娶,業(yè)務(wù)流程上要改了灯谣,代碼改動(dòng)有點(diǎn)大。
所以蛔琅,還是先保持在接口中遠(yuǎn)程調(diào)用吧胎许。
這樣看來,可以優(yōu)化的地方只能在:for循環(huán)中查詢數(shù)據(jù)罗售。
3. 第一次優(yōu)化
由于需要在for循環(huán)中辜窑,每條記錄都要根據(jù)不同的條件,查詢出想要的數(shù)據(jù)寨躁。
由于業(yè)務(wù)系統(tǒng)調(diào)用這個(gè)接口時(shí)穆碎,沒有傳id,不好在where條件中用id in (...)职恳,這方式批量查詢數(shù)據(jù)所禀。
其實(shí),有一種辦法不用循環(huán)查詢放钦,一條sql就能搞定需求:使用or關(guān)鍵字拼接色徘,例如:(org_code='001' and category_id=123 and business_id=111 and business_type=1) or(org_code='002' and category_id=123 and business_id=112 and business_type=2) or(org_code='003' and category_id=124 and business_id=117 and business_type=1)...
這種方式會導(dǎo)致sql語句會非常長,性能也會很差操禀。
其實(shí)還有一種寫法:
where (a,b) in ((1,2),(1,3)...)
不過這種sql褂策,如果一次性查詢的數(shù)據(jù)量太多的話,性能也不太好颓屑。
居然沒法改成批量查詢斤寂,就只能優(yōu)化單條查詢sql的執(zhí)行效率了。
首先從索引入手揪惦,因?yàn)楦脑斐杀咀畹汀?/p>
第一次優(yōu)化是優(yōu)化索引扬蕊。
評價(jià)表之前建立一個(gè)business_id字段的普通索引,但是從目前來看效率不太理想丹擎。
由于我們果斷加了聯(lián)合索引:
alter table user_score add index `un_org_category_business` (`org_code`,`category_id`,`business_id`,`business_type`) USING BTREE;
該聯(lián)合索引由:org_code、category_id歇父、business_id和business_type四個(gè)字段組成蒂培。
經(jīng)過這次優(yōu)化,效果立竿見影榜苫。
批量評價(jià)查詢接口最大耗時(shí)护戳,從最初的20s,縮短到了5s左右垂睬。
4. 第二次優(yōu)化
由于需要在for循環(huán)中媳荒,每條記錄都要根據(jù)不同的條件抗悍,查詢出想要的數(shù)據(jù)。
只在一個(gè)線程中查詢數(shù)據(jù)钳枕,顯然太慢缴渊。
那么,為何不能改成多線程調(diào)用鱼炒?
第二次優(yōu)化衔沼,查詢數(shù)據(jù)庫由單線程改成多線程。
但由于該接口是要將查詢出的所有數(shù)據(jù)昔瞧,都返回回去的指蚁,所以要獲取查詢結(jié)果。
使用多線程調(diào)用自晰,并且要獲取返回值凝化,這種場景使用java8中的CompleteFuture非常合適。
代碼調(diào)整為:
CompletableFuture[] futureArray = dataList.stream()
.map(data -> CompletableFuture
.supplyAsync(() -> query(data), asyncExecutor)
.whenComplete((result, th) -> {
})).toArray(CompletableFuture[]::new);
CompletableFuture.allOf(futureArray).join();
CompleteFuture的本質(zhì)是創(chuàng)建線程執(zhí)行酬荞,為了避免產(chǎn)生太多的線程搓劫,所以使用線程池是非常有必要的。
優(yōu)先推薦使用ThreadPoolExecutor類袜蚕,我們自定義線程池糟把。
具體代碼如下:
ExecutorService threadPool = new ThreadPoolExecutor(
8, //corePoolSize線程池中核心線程數(shù)
10, //maximumPoolSize 線程池中最大線程數(shù)
60, //線程池中線程的最大空閑時(shí)間,超過這個(gè)時(shí)間空閑線程將被回收
TimeUnit.SECONDS,//時(shí)間單位
new ArrayBlockingQueue(500), //隊(duì)列
new ThreadPoolExecutor.CallerRunsPolicy()); //拒絕策略
也可以使用ThreadPoolTaskExecutor類創(chuàng)建線程池:
@Configuration
public class ThreadPoolConfig {
/**
* 核心線程數(shù)量牲剃,默認(rèn)1
*/
private int corePoolSize = 8;
/**
* 最大線程數(shù)量遣疯,默認(rèn)Integer.MAX_VALUE;
*/
private int maxPoolSize = 10;
/**
* 空閑線程存活時(shí)間
*/
private int keepAliveSeconds = 60;
/**
* 線程阻塞隊(duì)列容量,默認(rèn)Integer.MAX_VALUE
*/
private int queueCapacity = 1;
/**
* 是否允許核心線程超時(shí)
*/
private boolean allowCoreThreadTimeOut = false;
@Bean("asyncExecutor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(corePoolSize);
executor.setMaxPoolSize(maxPoolSize);
executor.setQueueCapacity(queueCapacity);
executor.setKeepAliveSeconds(keepAliveSeconds);
executor.setAllowCoreThreadTimeOut(allowCoreThreadTimeOut);
// 設(shè)置拒絕策略,直接在execute方法的調(diào)用線程中運(yùn)行被拒絕的任務(wù)
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 執(zhí)行初始化
executor.initialize();
return executor;
}
}
經(jīng)過這次優(yōu)化凿傅,接口性能也提升了5倍缠犀。
從5s左右,縮短到1s左右聪舒。
但整體效果還不太理想辨液。
5. 第三次優(yōu)化
經(jīng)過前面的兩次優(yōu)化,批量查詢評價(jià)接口性能有一些提升箱残,但耗時(shí)還是大于1s滔迈。
出現(xiàn)這個(gè)問題的根本原因是:一次性查詢的數(shù)據(jù)太多。
那么被辑,我們?yōu)槭裁床幌拗埔幌铝呛罚看尾樵兊挠涗洍l數(shù)呢?
第三次優(yōu)化盼理,限制一次性查詢的記錄條數(shù)谈山。其實(shí)之前也做了限制,不過最大是2000條記錄宏怔,從目前看效果不好奏路。
限制該接口一次只能查200條記錄畴椰,如果超過200條則會報(bào)錯(cuò)提示。
如果直接對該接口做限制鸽粉,則可能會導(dǎo)致業(yè)務(wù)系統(tǒng)出現(xiàn)異常斜脂。
為了避免這種情況的發(fā)生,必須跟業(yè)務(wù)系統(tǒng)團(tuán)隊(duì)一起討論一下優(yōu)化方案潜叛。
主要有下面兩個(gè)方案:
5.1 前端做分頁
在結(jié)算單列表頁中秽褒,每個(gè)結(jié)算單默認(rèn)只展示1個(gè)訂單,多余的分頁查詢威兜。
這樣的話销斟,如果按照每頁最大100條記錄計(jì)算的話,結(jié)算單和訂單最多一次只能查詢200條記錄椒舵。
這就需要業(yè)務(wù)系統(tǒng)的前端做分頁功能蚂踊,同時(shí)后端接口要調(diào)整支持分頁查詢。
但目前現(xiàn)狀是前端沒有多余開發(fā)資源笔宿。
由于人手不足的原因犁钟,這套方案目前只能暫時(shí)擱置。
5.2 分批調(diào)用接口
業(yè)務(wù)系統(tǒng)后端之前是一次性調(diào)用評價(jià)查詢接口泼橘,現(xiàn)在改成分批調(diào)用涝动。
比如:之前查詢500條記錄,業(yè)務(wù)系統(tǒng)只調(diào)用一次查詢接口炬灭。
現(xiàn)在改成業(yè)務(wù)系統(tǒng)每次只查100條記錄醋粟,分5批調(diào)用,總共也是查詢500條記錄重归。
這樣不是變慢了嗎米愿?
答:如果那5批調(diào)用評價(jià)查詢接口的操作,是在for循環(huán)中單線程順序的鼻吮,整體耗時(shí)當(dāng)然可能會變慢育苟。
但業(yè)務(wù)系統(tǒng)也可以改成多線程調(diào)用,只需最終匯總結(jié)果即可椎木。
此時(shí)违柏,有人可能會問題:在評價(jià)查詢接口的服務(wù)器多線程調(diào)用,跟在其他業(yè)務(wù)系統(tǒng)中多線程調(diào)用不是一回事香椎?
還不如把批量評價(jià)查詢接口的服務(wù)器中漱竖,線程池的最大線程數(shù)調(diào)大一點(diǎn)?
顯然你忽略了一件事:線上應(yīng)用一般不會被部署成單點(diǎn)士鸥。絕大多數(shù)情況下,為了避免因?yàn)榉?wù)器掛了谆级,造成單點(diǎn)故障烤礁,基本會部署至少2個(gè)節(jié)點(diǎn)讼积。這樣即使一個(gè)節(jié)點(diǎn)掛了,整個(gè)應(yīng)用也能正常訪問脚仔。
當(dāng)然也可能會出現(xiàn)這種情況:假如掛了一個(gè)節(jié)點(diǎn)勤众,另外一個(gè)節(jié)點(diǎn)可能因?yàn)樵L問的流量太大了,扛不住壓力鲤脏,也可能因此掛掉们颜。
換句話說,通過業(yè)務(wù)系統(tǒng)中的多線程調(diào)用接口猎醇,可以將訪問接口的流量負(fù)載均衡到不同的節(jié)點(diǎn)上窥突。
他們也用8個(gè)線程,將數(shù)據(jù)分批硫嘶,每批100條記錄阻问,最后將結(jié)果匯總。
經(jīng)過這次優(yōu)化沦疾,接口性能再次提升了1倍称近。
從1s左右,縮短到小于500ms哮塞。
溫馨提醒一下刨秆,無論是在批量查詢評價(jià)接口查詢數(shù)據(jù)庫,還是在業(yè)務(wù)系統(tǒng)中調(diào)用批量查詢評價(jià)接口忆畅,使用多線程調(diào)用衡未,都只是一個(gè)臨時(shí)方案,并不完美邻眷。
這樣做的原因主要是為了先快速解決問題眠屎,因?yàn)檫@種方案改動(dòng)是最小的。
要從根本上解決問題肆饶,需要重新設(shè)計(jì)這一套功能改衩,需要修改表結(jié)構(gòu),甚至可能需要修改業(yè)務(wù)流程驯镊。但由于牽涉到多條業(yè)務(wù)線葫督,多個(gè)業(yè)務(wù)系統(tǒng),只能排期慢慢做了板惑。