作為後端開發,我想大家一定體會過在監控告警群裡被轟炸的感受,一旦有個接口響應超過門檻值,群裡就開始叮叮咣咣一頓報警,這種時候我們想人間清淨,可以把監控偷偷幹掉…~不過容易被開除,最好的辦法還是趕緊把接口性能優化上去。
前言
接口性能優化對于從事後端開發的同學來說,肯定再熟悉不過了,因為它是一個跟開發語言無關的公共問題。
該問題說簡單也簡單,說複雜也複雜。
有時候,隻需加個索引就能解決問題。
有時候,需要做代碼重構。
有時候,需要增加緩存。
有時候,需要引入一些中間件,比如mq。
有時候,需要需要分庫分表。
有時候,需要拆分服務。
導緻接口性能問題的原因千奇百怪,不同的項目不同的接口,原因可能也不一樣。
本文我總結了一些行之有效的,優化接口性能的辦法,給有需要的朋友一個參考。
1.索引
接口性能優化大家第一個想到的可能是:優化索引。
沒錯,優化索引的成本是最小的。
你通過檢視線上日志或者監控報告,查到某個接口用到的某條sql語句耗時比較長。
這時你可能會有下面這些疑問:
- 該sql語句加索引了沒?
- 加的索引生效了沒?
- mysql選錯索引了沒?
1.1 沒加索引
sql語句中where條件的關鍵字段,或者order by後面的排序字段,忘了加索引,這個問題在項目中很常見。
項目剛開始的時候,由于表中的資料量小,加不加索引sql查詢性能差别不大。
後來,随着業務的發展,表中資料量越來越多,就不得不加索引了。
可以通過指令:
show index from `order`;1.
能單獨檢視某張表的索引情況。
也可以通過指令:
show create table `order`;1.
檢視整張表的建表語句,裡面同樣會顯示索引情況。
通過ALTER TABLE指令可以添加索引:
ALTER TABLE `order` ADD INDEX idx_name (name);1.
也可以通過CREATE INDEX指令添加索引:
CREATE INDEX idx_name ON `order` (name);1.
不過這裡有一個需要注意的地方是:想通過指令修改索引,是不行的。
目前在mysql中如果想要修改索引,隻能先删除索引,再重新添加新的。
删除索引可以用DROP INDEX指令:
ALTER TABLE `order` DROP INDEX idx_name;1.
用DROP INDEX指令也行:
DROP INDEX idx_name ON `order`;1.
1.2 索引沒生效
通過上面的指令我們已經能夠确認索引是有的,但它生效了沒?此時你内心或許會冒出這樣一個疑問。
那麼,如何檢視索引有沒有生效呢?
答:可以使用explain指令,檢視mysql的執行計劃,它會顯示索引的使用情況。
例如:
explain select * from `order` where code='002';1.
結果:
通過這幾列可以判斷索引使用情況,執行計劃包含列的含義如下圖所示:
如果你想進一步了解explain的詳細用法,可以看看我的另一篇文章《explain | 索引優化的這把絕世好劍,你真的會用嗎?》
說實話,sql語句沒有走索引,排除沒有建索引之外,最大的可能性是索引失效了。
下面說說索引失效的常見原因:
如果不是上面的這些原因,則需要再進一步排查一下其他原因。
1.3 選錯索引
此外,你有沒有遇到過這樣一種情況:明明是同一條sql,隻有入參不同而已。有的時候走的索引a,有的時候卻走的索引b?
沒錯,有時候mysql會選錯索引。
必要時可以使用force index來強制查詢sql走某個索引。
至于為什麼mysql會選錯索引,後面有專門的文章介紹的,這裡先留點懸念。
2. sql優化
如果優化了索引之後,也沒啥效果。
接下來試着優化一下sql語句,因為它的改造成本相對于java代碼來說也要小得多。
下面給大家列舉了sql優化的15個小技巧:
由于這些技巧在我之前的文章中已經詳細介紹過了,在這裡我就不深入了。
3. 遠端調用
很多時候,我們需要在某個接口中,調用其他服務的接口。
比如有這樣的業務場景:
在使用者資訊查詢接口中需要傳回:使用者名稱、性别、等級、頭像、積分、成長值等資訊。
而使用者名稱、性别、等級、頭像在使用者服務中,積分在積分服務中,成長值在成長值服務中。為了彙總這些資料統一傳回,需要另外提供一個對外接口服務。
于是,使用者資訊查詢接口需要調用使用者查詢接口、積分查詢接口 和 成長值查詢接口,然後彙總資料統一傳回。
調用過程如下圖所示:
調用遠端接口總耗時 530ms = 200ms + 150ms + 180ms
顯然這種串行調用遠端接口性能是非常不好的,調用遠端接口總的耗時為所有的遠端接口耗時之和。
那麼如何優化遠端接口性能呢?
3.1 并行調用
上面說到,既然串行調用多個遠端接口性能很差,為什麼不改成并行呢?
如下圖所示:
調用遠端接口總耗時 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;
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.
溫馨提醒一下,這兩種方式别忘了使用線程池。示例中我用到了executor,表示自定義的線程池,為了防止高并發場景下,出現線程過多的問題。
3.2 資料異構
上面說到的使用者資訊查詢接口需要調用使用者查詢接口、積分查詢接口 和 成長值查詢接口,然後彙總資料統一傳回。
那麼,我們能不能把資料備援一下,把使用者資訊、積分和成長值的資料統一存儲到一個地方,比如:redis,存的資料結構就是使用者資訊查詢接口所需要的内容。然後通過使用者id,直接從redis中查詢資料出來,不就OK了?
如果在高并發的場景下,為了提升接口性能,遠端接口調用大機率會被去掉,而改成儲存備援資料的資料異構方案。
但需要注意的是,如果使用了資料異構方案,就可能會出現資料一緻性問題。
使用者資訊、積分和成長值有更新的話,大部分情況下,會先更新到資料庫,然後同步到redis。但這種跨庫的操作,可能會導緻兩邊資料不一緻的情況産生。
4. 重複調用
重複調用在我們的日常工作代碼中可以說随處可見,但如果沒有控制好,會非常影響接口的性能。
不信,我們一起看看。
4.1 循環查資料庫
有時候,我們需要從指定的使用者集合中,查詢出有哪些是在資料庫中已經存在的。
實作代碼可以這樣寫:
public List<User> queryUser(List<User> searchList) {
if (CollectionUtils.isEmpty(searchList)) {
return Collections.emptyList();
}
List<User> result = Lists.newArrayList();
searchList.forEach(user -> result.add(userMapper.getUserById(user.getId())));
return result;
}1.2.3.4.5.6.7.8.9.
這裡如果有50個使用者,則需要循環50次,去查詢資料庫。我們都知道,每查詢一次資料庫,就是一次遠端調用。
如果查詢50次資料庫,就有50次遠端調用,這是非常耗時的操作。
那麼,我們如何優化呢?
具體代碼如下:
public List<User> queryUser(List<User> searchList) {
if (CollectionUtils.isEmpty(searchList)) {
return Collections.emptyList();
}
List<Long> ids = searchList.stream().map(User::getId).collect(Collectors.toList());
return userMapper.getUserByIds(ids);
}1.2.3.4.5.6.7.
提供一個根據使用者id集合批量查詢使用者的接口,隻遠端調用一次,就能查詢出所有的資料。
這裡有個需要注意的地方是:id集合的大小要做限制,最好一次不要請求太多的資料。要根據實際情況而定,建議控制每次請求的記錄條數在500以内。
4.2 死循環
有些小夥伴看到這個标題,可能會感到有點意外,死循環也算?
代碼中不是應該避免死循環嗎?為啥還是會産生死循環?
有時候死循環是我們自己寫的,例如下面這段代碼:
while(true) {
if(condition) {
break;
}
System.out.println("do samething");
}1.2.3.4.5.6.
這裡使用了while(true)的循環調用,這種寫法在CAS自旋鎖中使用比較多。
當滿足condition等于true的時候,則自動退出該循環。
如果condition條件非常複雜,一旦出現判斷不正确,或者少寫了一些邏輯判斷,就可能在某些場景下出現死循環的問題。
出現死循環,大機率是開發人員人為的bug導緻的,不過這種情況很容易被測出來。
還有一種隐藏的比較深的死循環,是由于代碼寫的不太嚴謹導緻的。如果用正常資料,可能測不出問題,但一旦出現異常資料,就會立即出現死循環。
4.3 無限遞歸
如果想要列印某個分類的所有父分類,可以用類似這樣的遞歸方法實作:
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);
}1.2.3.4.5.6.7.8.9.
正常情況下,這段代碼是沒有問題的。
但如果某次有人誤操作,把某個分類的parentId指向了它自己,這樣就會出現無限遞歸的情況。導緻接口一直不能傳回資料,最終會發生堆棧溢出。
建議寫遞歸方法時,設定一個遞歸的深度,比如:分類最大等級有4級,則深度可以設定為4。然後在遞歸方法中做判斷,如果深度大于4時,則自動傳回,這樣就能避免無限循環的情況。
5. 異步處理
有時候,我們接口性能優化,需要重新梳理一下業務邏輯,看看是否有設計上不太合理的地方。
比較推薦的方式是自定義TreadPool來實作多線程,在Java 8以及8以上的版本,我們也可以使用CompletableFuture來實作異步程式設計。
比如有個使用者請求接口中,需要做業務操作,發站内通知,和記錄記錄檔。為了實作起來比較友善,通常我們會将這些邏輯放在接口中同步執行,勢必會對接口性能造成一定的影響。
接口内部流程圖如下:
這個接口表面上看起來沒有問題,但如果你仔細梳理一下業務邏輯,會發現隻有業務操作才是核心邏輯,其他的功能都是非核心邏輯。
在這裡有個原則就是:核心邏輯可以同步執行,同步寫庫。非核心邏輯,可以異步執行,異步寫庫。
上面這個例子中,發站内通知和使用者記錄檔功能,對實時性要求不高,即使晚點寫庫,使用者無非是晚點收到站内通知,或者營運晚點看到使用者記錄檔,對業務影響不大,是以完全可以異步處理。
通常異步主要有兩種:多線程 和 mq。
5.1 線程池
使用線程池改造之後,接口邏輯如下:
發站内通知和使用者記錄檔功能,被送出到了兩個單獨的線程池中。
這樣接口中重點關注的是業務操作,把其他的邏輯交給線程異步執行,這樣改造之後,讓接口性能瞬間提升了。
但使用線程池有個小問題就是:如果伺服器重新開機了,或者是需要被執行的功能出現異常了,無法重試,會丢資料。
那麼這個問題該怎麼辦呢?
5.2 mq
使用mq改造之後,接口邏輯如下:
對于發站内通知和使用者記錄檔功能,在接口中并沒真正實作,它隻發送了mq消息到mq伺服器。然後由mq消費者消費消息時,才真正的執行這兩個功能。
這樣改造之後,接口性能同樣提升了,因為發送mq消息速度是很快的,我們隻需關注業務操作的代碼即可。
6. 避免大事務
很多小夥伴在使用spring架構開發項目時,為了友善,喜歡使用@Transactional注解提供事務功能。
沒錯,使用@Transactional注解這種聲明式事務的方式提供事務功能,确實能少寫很多代碼,提升開發效率。
但也容易造成大事務,引發其他的問題。
下面用一張圖看看大事務引發的問題。
從圖中能夠看出,大事務問題可能會造成接口逾時,對接口的性能有直接的影響。
我們該如何優化大事務呢?
- 少用@Transactional注解
- 将查詢(select)方法放到事務外
- 事務中避免遠端調用
- 事務中避免一次性處理太多資料
- 有些功能可以非事務執行
- 有些功能可以異步處理
7. 鎖粒度
在某些業務場景中,為了防止多個線程并發修改某個共享資料,造成資料異常。
為了解決并發場景下,多個線程同時修改資料,造成資料不一緻的情況。通常情況下,我們會:加鎖。
但如果鎖加得不好,導緻鎖的粒度太粗,也會非常影響接口性能。
7.1 synchronized
在java中提供了synchronized關鍵字給我們的代碼加鎖。
通常有兩種寫法:在方法上加鎖 和 在代碼塊上加鎖。
先看看如何在方法上加鎖:
public synchronized doSave(String fileUrl) {
mkdir();
uploadFile(fileUrl);
sendMessage(fileUrl);
}1.2.3.4.5.
這裡加鎖的目的是為了防止并發的情況下,建立了相同的目錄,第二次會建立失敗,影響業務功能。
但這種直接在方法上加鎖,鎖的粒度有點粗。因為doSave方法中的上傳檔案和發消息方法,是不需要加鎖的。隻有建立目錄方法,才需要加鎖。
我們都知道檔案上傳操作是非常耗時的,如果将整個方法加鎖,那麼需要等到整個方法執行完之後才能釋放鎖。顯然,這會導緻該方法的性能很差,變得得不償失。
這時,我們可以改成在代碼塊上加鎖了,具體代碼如下:
public void doSave(String path,String fileUrl) {
synchronized(this) {
if(!exists(path)) {
mkdir(path);
}
}
uploadFile(fileUrl);
sendMessage(fileUrl);
}1.2.3.4.5.6.7.8.9.
這樣改造之後,鎖的粒度一下子變小了,隻有并發建立目錄功能才加了鎖。而建立目錄是一個非常快的操作,即使加鎖對接口的性能影響也不大。
最重要的是,其他的上傳檔案和發送消息功能,任然可以并發執行。
當然,這種做在單機版的服務中,是沒有問題的。但現在部署的生産環境,為了保證服務的穩定性,一般情況下,同一個服務會被部署在多個節點中。如果哪天挂了一個節點,其他的節點服務任然可用。
多節點部署避免了因為某個節點挂了,導緻服務不可用的情況。同時也能分攤整個系統的流量,避免系統壓力過大。
同時它也帶來了新的問題:synchronized隻能保證一個節點加鎖是有效的,但如果有多個節點如何加鎖呢?
答:這就需要使用:分布式鎖了。目前主流的分布式鎖包括:redis分布式鎖、zookeeper分布式鎖 和 資料庫分布式鎖。
由于zookeeper分布式鎖的性能不太好,真實業務場景用的不多,這裡先不講。
下面聊一下redis分布式鎖。
7.2 redis分布式鎖
在分布式系統中,由于redis分布式鎖相對于更簡單和高效,成為了分布式鎖的首先,被我們用到了很多實際業務場景當中。
使用redis分布式鎖的僞代碼如下:
public void doSave(String path,String fileUrl) {
try {
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
if(!exists(path)) {
mkdir(path);
uploadFile(fileUrl);
sendMessage(fileUrl);
}
return true;
}
} finally{
unlock(lockKey,requestId);
}
return false;
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.
跟之前使用synchronized關鍵字加鎖時一樣,這裡鎖的範圍也太大了,換句話說就是鎖的粒度太粗,這樣會導緻整個方法的執行效率很低。
其實隻有建立目錄的時候,才需要加分布式鎖,其餘代碼根本不用加鎖。
于是,我們需要優化一下代碼:
public void doSave(String path,String fileUrl) {
if(this.tryLock()) {
mkdir(path);
}
uploadFile(fileUrl);
sendMessage(fileUrl);
}
private boolean tryLock() {
try {
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
return true;
}
} finally{
unlock(lockKey,requestId);
}
return false;
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.
上面代碼将加鎖的範圍縮小了,隻有建立目錄時才加了鎖。這樣看似簡單的優化之後,接口性能能提升很多。說不定,會有意外的驚喜喔。哈哈哈。
redis分布式鎖雖說好用,但它在使用時,有很多注意的細節,隐藏了很多坑,如果稍不注意很容易踩中。詳細内容可以看看我的另一篇文章《聊聊redis分布式鎖的8大坑》
7.3 資料庫分布式鎖
mysql資料庫中主要有三種鎖:
- 表鎖:加鎖快,不會出現死鎖。但鎖定粒度大,發生鎖沖突的機率最高,并發度最低。
- 行鎖:加鎖慢,會出現死鎖。但鎖定粒度最小,發生鎖沖突的機率最低,并發度也最高。
- 間隙鎖:開銷和加鎖時間界于表鎖和行鎖之間。它會出現死鎖,鎖定粒度界于表鎖和行鎖之間,并發度一般。
并發度越高,意味着接口性能越好。
是以資料庫鎖的優化方向是:
優先使用行鎖,其次使用間隙鎖,再其次使用表鎖。
趕緊看看,你用對了沒?
8.分頁處理
有時候我會調用某個接口批量查詢資料,比如:通過使用者id批量查詢出使用者資訊,然後給這些使用者送積分。
但如果你一次性查詢的使用者數量太多了,比如一次查詢2000個使用者的資料。參數中傳入了2000個使用者的id,遠端調用接口,會發現該使用者查詢接口經常逾時。
調用代碼如下:
List<User> users = remoteCallUser(ids);1.
衆所周知,調用接口從資料庫擷取資料,是需要經過網絡傳輸的。如果資料量太大,無論是擷取資料的速度,還是網絡傳輸受限于帶寬,都會導緻耗時時間比較長。
那麼,這種情況要如何優化呢?
答:分頁處理。
将一次擷取所有的資料的請求,改成分多次擷取,每次隻擷取一部分使用者的資料,最後進行合并和彙總。
其實,處理這個問題,要分為兩種場景:同步調用 和 異步調用。
8.1 同步調用
如果在job中需要擷取2000個使用者的資訊,它要求隻要能正确擷取到資料就好,對擷取資料的總耗時要求不太高。
但對每一次遠端接口調用的耗時有要求,不能大于500ms,不然會有郵件預警。
這時,我們可以同步分頁調用批量查詢使用者資訊接口。
具體示例代碼如下:
List<List<Long>> allIds = Lists.partition(ids,200);
for(List<Long> batchIds:allIds) {
List<User> users = remoteCallUser(batchIds);
}1.2.3.4.5.
代碼中我用的google的guava工具中的Lists.partition方法,用它來做分頁簡直太好用了,不然要巴拉巴拉寫一大堆分頁的代碼。
8.2 異步調用
如果是在某個接口中需要擷取2000個使用者的資訊,它考慮的就需要更多一些。
除了需要考慮遠端調用接口的耗時之外,還需要考慮該接口本身的總耗時,也不能逾時500ms。
這時候用上面的同步分頁請求遠端接口,肯定是行不通的。
那麼,隻能使用異步調用了。
代碼如下:
List<List<Long>> allIds = Lists.partition(ids,200);
final List<User> result = Lists.newArrayList();
allIds.stream().forEach((batchIds) -> {
CompletableFuture.supplyAsync(() -> {
result.addAll(remoteCallUser(batchIds));
return Boolean.TRUE;
}, executor);
})1.2.3.4.5.6.7.8.9.
使用CompletableFuture類,多個線程異步調用遠端接口,最後彙總結果統一傳回。
9.加緩存
解決接口性能問題,加緩存是一個非常高效的方法。
但不能為了緩存而緩存,還是要看具體的業務場景。畢竟加了緩存,會導緻接口的複雜度增加,它會帶來資料不一緻問題。
在有些并發量比較低的場景中,比如使用者下單,可以不用加緩存。
還有些場景,比如在商城首頁顯示商品分類的地方,假設這裡的分類是調用接口擷取到的資料,但頁面暫時沒有做靜态化。
如果查詢分類樹的接口沒有使用緩存,而直接從資料庫查詢資料,性能會非常差。
那麼如何使用緩存呢?
9.1 redis緩存
通常情況下,我們使用最多的緩存可能是:redis和memcached。
但對于java應用來說,絕大多數都是使用的redis,是以接下來我們以redis為例。
由于在關系型資料庫,比如:mysql中,菜單是有上下級關系的。某個四級分類是某個三級分類的子分類,這個三級分類,又是某個二級分類的子分類,而這個二級分類,又是某個一級分類的子分類。
這種存儲結構決定了,想一次性查出這個分類樹,并非是一件非常容易的事情。這就需要使用程式遞歸查詢了,如果分類多的話,這個遞歸是比較耗時的。
是以,如果每次都直接從資料庫中查詢分類樹的資料,是一個非常耗時的操作。
這時我們可以使用緩存,大部分情況,接口都直接從緩存中擷取資料。操作redis可以使用成熟的架構,比如:jedis和redisson等。
用jedis僞代碼如下:
String json = jedis.get(key);
if(StringUtils.isNotEmpty(json)) {
CategoryTree categoryTree = JsonUtil.toObject(json);
return categoryTree;
}
return queryCategoryTreeFromDb();1.2.3.4.5.6.
先從redis中根據某個key查詢是否有菜單資料,如果有則轉換成對象,直接傳回。如果redis中沒有查到菜單資料,則再從資料庫中查詢菜單資料,有則傳回。
此外,我們還需要有個job每隔一段時間,從資料庫中查詢菜單資料,更新到redis當中,這樣以後每次都能直接從redis中擷取菜單的資料,而無需通路資料庫了。
這樣改造之後,能快速的提升性能。
但這樣做性能提升不是最佳的,還有其他的方案,我們一起看看下面的内容。
9.2 二級緩存
上面的方案是基于redis緩存的,雖說redis通路速度很快。但畢竟是一個遠端調用,而且菜單樹的資料很多,在網絡傳輸的過程中,是有些耗時的。
有沒有辦法,不經過請求遠端,就能直接擷取到資料呢?
答:使用二級緩存,即基于記憶體的緩存。
除了自己手寫的記憶體緩存之後,目前使用比較多的記憶體緩存架構有:guava、Ehcache、caffine等。
我們在這裡以caffeine為例,它是spring官方推薦的。
第一步,引入caffeine的相關jar包
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.6.0</version>
</dependency>1.2.3.4.5.6.7.8.9.
第二步,配置CacheManager,開啟EnableCaching
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager(){
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
//Caffeine配置
Caffeine<Object, Object> caffeine = Caffeine.newBuilder()
//最後一次寫入後經過固定時間過期
.expireAfterWrite(10, TimeUnit.SECONDS)
//緩存的最大條數
.maximumSize(1000);
cacheManager.setCaffeine(caffeine);
return cacheManager;
}
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.
第三步,使用Cacheable注解擷取資料
@Service
public class CategoryService {
@Cacheable(value = "category", key = "#categoryKey")
public CategoryModel getCategory(String categoryKey) {
String json = jedis.get(categoryKey);
if(StringUtils.isNotEmpty(json)) {
CategoryTree categoryTree = JsonUtil.toObject(json);
return categoryTree;
}
return queryCategoryTreeFromDb();
}
}1.2.3.4.5.6.7.8.9.10.11.12.13.
調用categoryService.getCategory()方法時,先從caffine緩存中擷取資料,如果能夠擷取到資料,則直接傳回該資料,不進入方法體。
如果不能擷取到資料,則再從redis中查一次資料。如果查詢到了,則傳回資料,并且放入caffine中。
如果還是沒有查到資料,則直接從資料庫中擷取到資料,然後放到caffine緩存中。
具體流程圖如下:
該方案的性能更好,但有個缺點就是,如果資料更新了,不能及時重新整理緩存。此外,如果有多台伺服器節點,可能存在各個節點上資料不一樣的情況。
由此可見,二級緩存給我們帶來性能提升的同時,也帶來了資料不一緻的問題。使用二級緩存一定要結合實際的業務場景,并非所有的業務場景都适用。
但上面我列舉的分類場景,是适合使用二級緩存的。因為它屬于使用者不敏感資料,即使出現了稍微有點資料不一緻也沒有關系,使用者有可能都沒有察覺出來。
10. 分庫分表
有時候,接口性能受限的不是别的,而是資料庫。
當系統發展到一定的階段,使用者并發量大,會有大量的資料庫請求,需要占用大量的資料庫連接配接,同時會帶來磁盤IO的性能瓶頸問題。
此外,随着使用者數量越來越多,産生的資料也越來越多,一張表有可能存不下。由于資料量太大,sql語句查詢資料時,即使走了索引也會非常耗時。
這時該怎麼辦呢?
答:需要做分庫分表。
如下圖所示:
圖中将使用者庫拆分成了三個庫,每個庫都包含了四張使用者表。
如果有使用者請求過來的時候,先根據使用者id路由到其中一個使用者庫,然後再定位到某張表。
路由的算法挺多的:
- 根據id取模,比如:id=7,有4張表,則7%4=3,模為3,路由到使用者表3。
- 給id指定一個區間範圍,比如:id的值是0-10萬,則資料存在使用者表0,id的值是10-20萬,則資料存在使用者表1。
- 一緻性hash算法
分庫分表主要有兩個方向:垂直和水準。
說實話垂直方向(即業務方向)更簡單。
在水準方向(即資料方向)上,分庫和分表的作用,其實是有差別的,不能混為一談。
- 分庫:是為了解決資料庫連接配接資源不足問題,和磁盤IO的性能瓶頸問題。
- 分表:是為了解決單表資料量太大,sql語句查詢資料時,即使走了索引也非常耗時問題。此外還可以解決消耗cpu資源問題。
- 分庫分表:可以解決 資料庫連接配接資源不足、磁盤IO的性能瓶頸、檢索資料耗時 和 消耗cpu資源等問題。
如果在有些業務場景中,使用者并發量很大,但是需要儲存的資料量很少,這時可以隻分庫,不分表。
如果在有些業務場景中,使用者并發量不大,但是需要儲存的數量很多,這時可以隻分表,不分庫。
如果在有些業務場景中,使用者并發量大,并且需要儲存的數量也很多時,可以分庫分表。
11. 輔助功能
優化接口性能問題,除了上面提到的這些常用方法之外,還需要配合使用一些輔助功能,因為它們真的可以幫我們提升查找問題的效率。
11.1 開啟慢查詢日志
通常情況下,為了定位sql的性能瓶頸,我們需要開啟mysql的慢查詢日志。把超過指定時間的sql語句,單獨記錄下來,方面以後分析和定位問題。
開啟慢查詢日志需要重點關注三個參數:
- slow_query_log 慢查詢開關
- slow_query_log_file 慢查詢日志存放的路徑
- long_query_time 超過多少秒才會記錄日志
通過mysql的set指令可以設定:
set global slow_query_log='ON';
set global slow_query_log_file='/usr/local/mysql/data/slow.log';
set global long_query_time=2;1.2.3.
設定完之後,如果某條sql的執行時間超過了2秒,會被自動記錄到slow.log檔案中。
當然也可以直接修改配置檔案my.cnf
[mysqld]
slow_query_log = ON
slow_query_log_file = /usr/local/mysql/data/slow.log
long_query_time = 21.2.3.4.
但這種方式需要重新開機mysql服務。
很多公司每天早上都會發一封慢查詢日志的郵件,開發人員根據這些資訊優化sql。
11.2 加監控
為了出現sql問題時,能夠讓我們及時發現,我們需要對系統做監控。
目前業界使用比較多的開源監控系統是:Prometheus。
它提供了 監控 和 預警 的功能。
架構圖如下:
我們可以用它監控如下資訊:
- 接口響應時間
- 調用第三方服務耗時
- 慢查詢sql耗時
- cpu使用情況
- 記憶體使用情況
- 磁盤使用情況
- 資料庫使用情況
等等。。。
它的界面大概長這樣子:
可以看到mysql目前qps,活躍線程數,連接配接數,緩存池的大小等資訊。
如果發現資料量連接配接池占用太多,對接口的性能肯定會有影響。
這時可能是代碼中開啟了連接配接忘了關,或者并發量太大了導緻的,需要做進一步排查和系統優化。
截圖中隻是它一小部分功能,如果你想了解更多功能,可以通路Prometheus的官網:https://prometheus.io/
11.3 鍊路跟蹤
有時候某個接口涉及的邏輯很多,比如:查資料庫、查redis、遠端調用接口,發mq消息,執行業務代碼等等。
該接口一次請求的鍊路很長,如果逐一排查,需要花費大量的時間,這時候,我們已經沒法用傳統的辦法定位問題了。
有沒有辦法解決這問題呢?
用分布式鍊路跟蹤系統:skywalking。
架構圖如下:
通過skywalking定位性能問題:
在skywalking中可以通過traceId(全局唯一的id),串聯一個接口請求的完整鍊路。可以看到整個接口的耗時,調用的遠端服務的耗時,通路資料庫或者redis的耗時等等,功能非常強大。
之前沒有這個功能的時候,為了定位線上接口性能問題,我們還需要在代碼中加日志,手動列印對外連結路中各個環節的耗時情況,然後再逐一排查。
如果你用過skywalking排查接口性能問題,不自覺的會愛上它的。如果你想了解更多功能,可以通路skywalking的官網:https://skywalking.apache.org/