1. 程式人生 > 其它 >如何優化提升介面的效能?

如何優化提升介面的效能?

  如何優化提升介面的效能問題?這個問題雖然問的很廣,沒有一個標準答案,但回答者需要根據以往的工作經驗或學習經驗來進行回答,根據作答的深淺情況可以反映一個程式設計師的大致水平。

導致介面效能問題的原因千奇百怪,不同的專案不同的介面,原因可能也不一樣。

本文我總結了一些行之有效的,優化介面效能的辦法:

一、優化索引

首先大家可能第一想到就是優化索引,沒錯,優化索引的成本是最小的。可以通過檢視日誌或監控平臺報告,檢視某隻介面用到的sql語句耗時比較長的,這是你可能會有以下疑問:

1、這條sql加了索引沒?
2、加的索引生效沒?
3、mysql選錯索引沒?

從幾個緯度去驗證索引的問題

1.1、沒加索引

sql語句中where條件的關鍵欄位,或者order by後面的排序欄位,忘了加索引,這個問題在專案中很常見。

專案剛開始的時候,由於表中的資料量小,加不加索引sql查詢效能差別不大。

後來,隨著業務的發展,表中資料量越來越多,就不得不加索引了。

//查看錶的索引
show index from `tb_order`;
//檢視整張表的建表語句,也可以檢視索引情況
show create table `tb_order`;

可以通過上面的語句可以看出表的索引情況,通常若沒有加索引,需要建立索引。

//alter table新增索引
ALTER TABLE `tb_order` ADD INDEX idx_name (name);
//create index 新增索引
CREATE INDEX idx_name ON `tb_order` (name);

通過上面方式可以新增索引,值得注意的是:想通過命令修改索引是不行的,在mysql中需要刪除索引,在重新新增新索引。

//刪除索引方式1
ALTER TABLE `tb_order` DROP INDEX idx_name;
//刪除索引方式2
DROP INDEX idx_name ON `tb_order`;

1.2、索引沒生效

通過上面的方式可以查詢出是否建立了索引,但它生效了沒?如何判定索引是否生效呢?可以使用explain命令,檢視mysql的執行計劃,它會顯示索引的使用情況。

//explain檢查索引使用情況
explain select * from `tb_order` where code='002';

表字段代表的含義:

經驗總結:sql語句沒有走索引,排除沒有建索引之外,最大的可能性是索引失效。

靈魂拷問:那索引失效的原因有哪些呢?

1.2、選錯索引

有沒有遇到過這樣一種情況:明明是同一條sql,只有入參不同而已。有的時候走的索引a,有的時候卻走的索引b?這就是mysql會選錯索引,必要時可以使用force index來強制查詢sql走某個索引。

二、優化sql語句

優化索引後沒啥效果,那我們咋辦呢?接下來可以優化sql語句,相對於改造程式碼,優化sql的成本是最小的。

三、遠端呼叫

舉個例子,有這樣的業務場景:在使用者資訊查詢介面中需要返回使用者名稱稱、性別、等級、頭像、積分、成長值等資訊。而使用者名稱稱、性別、等級、頭像在使用者服務中,積分在積分服務中,成長值在成長值服務中。為了彙總這些資料統一返回,需要另外提供一個對外介面服務。於是,使用者資訊查詢介面需要呼叫使用者查詢介面、積分查詢介面 和 成長值查詢介面,然後彙總資料統一返回。

呼叫過程如下圖:

顯然這種序列呼叫遠端介面效能是非常不好的,呼叫遠端介面總的耗時為所有的遠端介面耗時之和。那如何優化遠端介面效能呢?

3.1、並行呼叫

既然序列呼叫多個遠端介面效能很差,為什麼不改成並行呢?

在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,表示自定義的執行緒池,為了防止高併發場景下,出現執行緒過多的問題。

3.2、資料快取

上面說到的使用者資訊查詢介面需要呼叫使用者查詢介面、積分查詢介面和成長值查詢介面,然後彙總資料統一返回。那麼,可以把使用者資訊、積分和成長值的資料統一儲存到一個地方,比如:redis,存的資料結構就是使用者資訊查詢介面所需要的內容。然後通過使用者id,直接從redis中查詢資料出來,不就OK。

如果使用了資料快取方案,就可能會出現資料一致性問題。

大部分情況下,會先更新到資料庫,然後同步到redis。但這種跨庫的操作,可能會導致兩邊資料不一致的情況產生。

四、重複呼叫

重複呼叫在程式碼中隨處可見,但如果沒有控制好,會非常影響介面的效能。

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;
}

上面的案例就是在forEach迴圈中每次去呼叫查詢資料庫,將使用者資訊新增到集合中。這是不合理的,那怎麼優化呢?將使用者id集合批量查詢使用者的介面,只遠端呼叫一次,就能查詢出所有的資料。

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);
}

id集合的大小要做限制,最好一次不要請求太多的資料。要根據實際情況而定,建議控制每次請求的記錄條數在500以內。

4.2 死迴圈

有時候我們不注意就會造成程式碼的死迴圈。

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

這裡使用了while(true)的迴圈呼叫,這種寫法在CAS自旋鎖中使用比較多。當滿足condition等於true的時候,則自動退出該迴圈。但如果condition條件比較複雜,一旦出現判斷不正確,或者少寫了一些邏輯判斷,就可能在某些場景下出現死迴圈的問題。

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);
}

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

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

五、非同步處理

介面效能優化,需要重新梳理一下業務邏輯,看看是否有設計上不太合理的地方。

比如有個使用者請求介面中,需要做業務操作,發站內通知,和記錄操作日誌。為了實現起來比較方便,如果將這些邏輯放在介面中同步執行,勢必會對介面效能造成一定的影響。

遵循一個原則:核心邏輯可以同步執行,同步寫庫。非核心邏輯,可以非同步執行,非同步寫庫。

上面這個例子中,發站內通知和使用者操作日誌功能,對實時性要求不高,即使晚點寫庫,使用者無非是晚點收到站內通知,或者運營晚點看到使用者操作日誌,對業務影響不大,所以完全可以非同步處理。

通常非同步主要有兩種:多執行緒mq

5.1 執行緒池

發站內通知和使用者操作日誌功能,被提交到了兩個單獨的執行緒池中去執行,介面中重點關注的是業務操作,把其他的邏輯交給執行緒非同步執行,讓介面效能瞬間提升。

使用執行緒池有個問題是:如果伺服器重啟了,或者是需要被執行的功能出現異常了,無法重試,會丟資料。那怎麼處理呢?可以使用中介軟體mq。

5.1 mq

對於發站內通知和使用者操作日誌功能,在介面中並沒真正實現,它只發送了mq訊息到mq伺服器。然後由mq消費者消費訊息時,才真正的執行這兩個功能。

六、避免大事務

在使用spring框架開發專案時,為了方便,喜歡使用@Transactional註解提供事務功能。雖然這種方式能少寫很多程式碼,提升開發效率,但也容易造成大事務,引發其他的問題。

大事務問題可能會造成介面超時,對介面的效能有直接的影響。

該如何優化大事務呢?
少用@Transactional註解
將查詢(select)方法放到事務外
事務中避免遠端呼叫
事務中避免一次性處理太多資料
有些功能可以非事務執行
有些功能可以非同步處理

七、鎖粒度

某些業務場景中,為了防止多個執行緒併發修改某個共享資料,造成資料異常,通常情況下選擇加鎖處理,但如果鎖加得不好,導致鎖的粒度太粗,也會非常影響介面效能。

7.1 synchronized

public synchronized doSave(String fileUrl) {
    mkdir();
    uploadFile(fileUrl);
    sendMessage(fileUrl);
}

這種直接在方法上加鎖,鎖的粒度有點粗。因為doSave方法中的上傳檔案和發訊息方法,是不需要加鎖的。只有建立目錄方法,才需要加鎖。

檔案上傳操作是非常耗時的,如果將整個方法加鎖,那麼需要等到整個方法執行完之後才能釋放鎖。顯然,這會導致該方法的效能很差。

public void doSave(String path,String fileUrl) {
    synchronized(this) {
      if(!exists(path)) {
          mkdir(path);
       }
    }
    uploadFile(fileUrl);
    sendMessage(fileUrl);
}

改造之後,鎖的粒度一下子變小了,只有併發建立目錄功能才加了鎖。而建立目錄是一個非常快的操作,即使加鎖對介面的效能影響也不大。

當然,這種做在單機版的服務中,是沒有問題的。但現在部署的生產環境,為了保證服務的穩定性,一般情況下,同一個服務會被部署在多個節點。多節點部署避免了因為某個節點掛了,導致服務不可用的情況。同時也能分攤整個系統的流量,避免系統壓力過大。但帶來了個新問題:synchronized只能保證一個節點加鎖是有效的,但如果有多個節點如何加鎖呢?

使用分散式鎖。目前主流的分散式鎖包括:redis分散式鎖、zookeeper分散式鎖 和 資料庫分散式鎖。

7.2 redis分散式鎖

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;
}

7.3資料庫分散式鎖

mysql資料庫中主要有三種鎖:

  • 表鎖:加鎖快,不會出現死鎖。但鎖定粒度大,發生鎖衝突的概率最高,併發度最低。
  • 行鎖:加鎖慢,會出現死鎖。但鎖定粒度最小,發生鎖衝突的概率最低,併發度也最高。
  • 間隙鎖:開銷和加鎖時間界於表鎖和行鎖之間。它會出現死鎖,鎖定粒度界於表鎖和行鎖之間,併發度一般。

併發度越高,意味著介面效能越好。所以資料庫鎖的優化方向是:優先使用行鎖,其次使用間隙鎖,再其次使用表鎖。

八、分頁處理

有時候呼叫某個介面批量查詢資料,比如:通過使用者id批量查詢出使用者資訊。若一次查詢的使用者數量太多,遠端呼叫介面,會發現該使用者查詢介面經常超時。

List<User> users = remoteCallUser(ids);

那這種情況下如何優化呢?分頁處理

將一次獲取所有的資料的請求,改成分多次獲取,每次只獲取一部分使用者的資料,最後進行合併和彙總。

8.1 同步呼叫

List<List<Long>> allIds = Lists.partition(ids,200);

for(List<Long> batchIds:allIds) {
   List<User> users = remoteCallUser(batchIds);
}

8.2 非同步呼叫

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);
})

九、加快取

解決介面效能問題,加快取是一個非常高效的方法。但不能為了快取而快取,還是要看具體的業務場景。畢竟加了快取,會導致介面的複雜度增加,它會帶來資料不一致問題。

在有些併發量比較低的場景中,比如使用者下單,可以不用加快取。還有些場景,比如在商城首頁顯示商品分類的地方,假設這裡的分類是呼叫介面獲取到的資料,但頁面暫時沒有做靜態化。如果查詢分類樹的介面沒有使用快取,而直接從資料庫查詢資料,效能會非常差。

如何使用快取呢?

9.1 redis快取

在關係型資料庫,比如:mysql中,級聯選單的查詢是一個非常耗時的操作。這時候想要用快取,可以用jedis和redisson框架直接從快取中獲取資料。

String json = jedis.get(key);
if(StringUtils.isNotEmpty(json)) {
   CategoryTree categoryTree = JsonUtil.toObject(json);
   return categoryTree;
}
return queryCategoryTreeFromDb();

先從redis中根據某個key查詢是否有選單資料,如果有則轉換成物件,直接返回。如果redis中沒有查到選單資料,則再從資料庫中查詢選單資料,有則返回。

此外,我們還需要有個job每隔一段時間,從資料庫中查詢選單資料,更新到redis當中,這樣以後每次都能直接從redis中獲取選單的資料,而無需訪問資料庫了。

9.2 二級快取

上面的方案是基於redis快取的,雖說redis訪問速度很快。但畢竟是一個遠端呼叫,而且選單樹的資料很多,在網路傳輸的過程中,是有些耗時的。有沒有辦法,不經過請求遠端,就能直接獲取到資料呢?使用二級快取,即基於記憶體的快取。除了自己手寫的記憶體快取之後,目前使用比較多的記憶體快取框架有:guava、Ehcache、caffine等。

這裡以caffeine為例,它是spring官方推薦的。

<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>

第二步,配置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;
    }
}

第三步,使用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();
   }
}

呼叫categoryService.getCategory()方法時,先從caffine快取中獲取資料,如果能夠獲取到資料,則直接返回該資料,不進入方法體。如果不能獲取到資料,則再從redis中查一次資料。如果查詢到了,則返回資料,並且放入caffine中。如果還是沒有查到資料,則直接從資料庫中獲取到資料,然後放到caffine快取中。

該方案的效能更好,但有個缺點就是,如果資料更新了,不能及時重新整理快取。此外,如果有多臺伺服器節點,可能存在各個節點上資料不一樣的情況。

二級快取給我們帶來效能提升的同時,也帶來了資料不一致的問題。使用二級快取一定要結合實際的業務場景,並非所有的業務場景都適用。

十、分庫分表

有時候,介面效能受限的不是別的,而是資料庫。當系統發展到一定的階段,使用者併發量大,會有大量的資料庫請求,需要佔用大量的資料庫連線,同時會帶來磁碟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資源等問題。

如果在有些業務場景中,使用者併發量很大,但是需要儲存的資料量很少,這時可以只分庫,不分表。

如果在有些業務場景中,使用者併發量不大,但是需要儲存的數量很多,這時可以只分表,不分庫。如果在有些業務場景中,使用者併發量大,並且需要儲存的數量也很多時,可以分庫分表。