1. 接口性能優(yōu)化的 11 個(gè)技巧

        共 18680字,需瀏覽 38分鐘

         ·

        2022-07-30 11:25

        大家好,我是魚皮。接口性能優(yōu)化對(duì)于從事后端開發(fā)的同學(xué)來說,肯定再熟悉不過了,因?yàn)樗且粋€(gè)跟開發(fā)語言無關(guān)的公共問題。

        該問題說簡(jiǎn)單也簡(jiǎn)單,說復(fù)雜也復(fù)雜。

        有時(shí)候,只需加個(gè)索引就能解決問題。

        有時(shí)候,需要做代碼重構(gòu)。

        有時(shí)候,需要增加緩存。

        有時(shí)候,需要引入一些中間件,比如mq。

        有時(shí)候,需要需要分庫(kù)分表。

        有時(shí)候,需要拆分服務(wù)。

        等等。。。

        導(dǎo)致接口性能問題的原因千奇百怪,不同的項(xiàng)目不同的接口,原因可能也不一樣。

        本文我總結(jié)了一些行之有效的,優(yōu)化接口性能的辦法,給有需要的朋友一個(gè)參考。

        1.索引

        接口性能優(yōu)化大家第一個(gè)想到的可能是:優(yōu)化索引。

        沒錯(cuò),優(yōu)化索引的成本是最小的。

        你通過查看線上日志或者監(jiān)控報(bào)告,查到某個(gè)接口用到的某條sql語句耗時(shí)比較長(zhǎng)。

        這時(shí)你可能會(huì)有下面這些疑問:

        1. 該sql語句加索引了沒?
        2. 加的索引生效了沒?
        3. mysql選錯(cuò)索引了沒?

        1.1 沒加索引

        sql語句中where條件的關(guān)鍵字段,或者order by后面的排序字段,忘了加索引,這個(gè)問題在項(xiàng)目中很常見。

        項(xiàng)目剛開始的時(shí)候,由于表中的數(shù)據(jù)量小,加不加索引sql查詢性能差別不大。

        后來,隨著業(yè)務(wù)的發(fā)展,表中數(shù)據(jù)量越來越多,就不得不加索引了。

        可以通過命令:

        show index from `order`;

        能單獨(dú)查看某張表的索引情況。

        也可以通過命令:

        show create table `order`;

        查看整張表的建表語句,里面同樣會(huì)顯示索引情況。

        通過ALTER TABLE命令可以添加索引:

        ALTER TABLE `order` ADD INDEX idx_name (name);

        也可以通過CREATE INDEX命令添加索引:

        CREATE INDEX idx_name ON `order` (name);

        不過這里有一個(gè)需要注意的地方是:想通過命令修改索引,是不行的。

        目前在mysql中如果想要修改索引,只能先刪除索引,再重新添加新的。

        刪除索引可以用DROP INDEX命令:

        ALTER TABLE `order` DROP INDEX idx_name;

        DROP INDEX命令也行:

        DROP INDEX idx_name ON `order`;

        1.2 索引沒生效

        通過上面的命令我們已經(jīng)能夠確認(rèn)索引是有的,但它生效了沒?此時(shí)你內(nèi)心或許會(huì)冒出這樣一個(gè)疑問。

        那么,如何查看索引有沒有生效呢?

        答:可以使用explain命令,查看mysql的執(zhí)行計(jì)劃,它會(huì)顯示索引的使用情況。

        例如:

        explain select * from `order` where code='002';

        結(jié)果:通過這幾列可以判斷索引使用情況,執(zhí)行計(jì)劃包含列的含義如下圖所示:

        說實(shí)話,sql語句沒有走索引,排除沒有建索引之外,最大的可能性是索引失效了。

        下面說說索引失效的常見原因:如果不是上面的這些原因,則需要再進(jìn)一步排查一下其他原因。

        1.3 選錯(cuò)索引

        此外,你有沒有遇到過這樣一種情況:明明是同一條sql,只有入?yún)⒉煌?。有的時(shí)候走的索引a,有的時(shí)候卻走的索引b?

        沒錯(cuò),有時(shí)候mysql會(huì)選錯(cuò)索引。

        必要時(shí)可以使用force index來強(qiáng)制查詢sql走某個(gè)索引。

        2. sql優(yōu)化

        如果優(yōu)化了索引之后,也沒啥效果。

        接下來試著優(yōu)化一下sql語句,因?yàn)樗母脑斐杀鞠鄬?duì)于java代碼來說也要小得多。

        下面給大家列舉了sql優(yōu)化的15個(gè)小技巧:

        3. 遠(yuǎn)程調(diào)用

        很多時(shí)候,我們需要在某個(gè)接口中,調(diào)用其他服務(wù)的接口。

        比如有這樣的業(yè)務(wù)場(chǎng)景:

        在用戶信息查詢接口中需要返回:用戶名稱、性別、等級(jí)、頭像、積分、成長(zhǎng)值等信息。

        而用戶名稱、性別、等級(jí)、頭像在用戶服務(wù)中,積分在積分服務(wù)中,成長(zhǎng)值在成長(zhǎng)值服務(wù)中。為了匯總這些數(shù)據(jù)統(tǒng)一返回,需要另外提供一個(gè)對(duì)外接口服務(wù)

        于是,用戶信息查詢接口需要調(diào)用用戶查詢接口、積分查詢接口 和 成長(zhǎng)值查詢接口,然后匯總數(shù)據(jù)統(tǒng)一返回。

        調(diào)用過程如下圖所示:調(diào)用遠(yuǎn)程接口總耗時(shí) 530ms = 200ms + 150ms + 180ms

        顯然這種串行調(diào)用遠(yuǎn)程接口性能是非常不好的,調(diào)用遠(yuǎn)程接口總的耗時(shí)為所有的遠(yuǎn)程接口耗時(shí)之和。

        那么如何優(yōu)化遠(yuǎn)程接口性能呢?

        3.1 并行調(diào)用

        上面說到,既然串行調(diào)用多個(gè)遠(yuǎn)程接口性能很差,為什么不改成并行呢?

        如下圖所示:調(diào)用遠(yuǎn)程接口總耗時(shí) 200ms = 200ms(即耗時(shí)最長(zhǎng)的那次遠(yuǎn)程接口調(diào)用)

        在java8之前可以通過實(shí)現(xiàn)Callable接口,獲取線程返回結(jié)果。

        java8以后通過CompleteFuture類實(shí)現(xiàn)該功能。我們這里以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,表示自定義的線程池,為了防止高并發(fā)場(chǎng)景下,出現(xiàn)線程過多的問題。

        3.2 數(shù)據(jù)異構(gòu)

        上面說到的用戶信息查詢接口需要調(diào)用用戶查詢接口、積分查詢接口 和 成長(zhǎng)值查詢接口,然后匯總數(shù)據(jù)統(tǒng)一返回。

        那么,我們能不能把數(shù)據(jù)冗余一下,把用戶信息、積分和成長(zhǎng)值的數(shù)據(jù)統(tǒng)一存儲(chǔ)到一個(gè)地方,比如:redis,存的數(shù)據(jù)結(jié)構(gòu)就是用戶信息查詢接口所需要的內(nèi)容。然后通過用戶id,直接從redis中查詢數(shù)據(jù)出來,不就OK了?

        如果在高并發(fā)的場(chǎng)景下,為了提升接口性能,遠(yuǎn)程接口調(diào)用大概率會(huì)被去掉,而改成保存冗余數(shù)據(jù)的數(shù)據(jù)異構(gòu)方案。

        但需要注意的是,如果使用了數(shù)據(jù)異構(gòu)方案,就可能會(huì)出現(xiàn)數(shù)據(jù)一致性問題。

        用戶信息、積分和成長(zhǎng)值有更新的話,大部分情況下,會(huì)先更新到數(shù)據(jù)庫(kù),然后同步到redis。但這種跨庫(kù)的操作,可能會(huì)導(dǎo)致兩邊數(shù)據(jù)不一致的情況產(chǎn)生。

        4. 重復(fù)調(diào)用

        重復(fù)調(diào)用在我們的日常工作代碼中可以說隨處可見,但如果沒有控制好,會(huì)非常影響接口的性能。

        不信,我們一起看看。

        4.1 循環(huán)查數(shù)據(jù)庫(kù)

        有時(shí)候,我們需要從指定的用戶集合中,查詢出有哪些是在數(shù)據(jù)庫(kù)中已經(jīng)存在的。

        實(shí)現(xiàn)代碼可以這樣寫:

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

        這里如果有50個(gè)用戶,則需要循環(huán)50次,去查詢數(shù)據(jù)庫(kù)。我們都知道,每查詢一次數(shù)據(jù)庫(kù),就是一次遠(yuǎn)程調(diào)用。

        如果查詢50次數(shù)據(jù)庫(kù),就有50次遠(yuǎn)程調(diào)用,這是非常耗時(shí)的操作。

        那么,我們?nèi)绾蝺?yōu)化呢?

        具體代碼如下:

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

        提供一個(gè)根據(jù)用戶id集合批量查詢用戶的接口,只遠(yuǎn)程調(diào)用一次,就能查詢出所有的數(shù)據(jù)。

        這里有個(gè)需要注意的地方是:id集合的大小要做限制,最好一次不要請(qǐng)求太多的數(shù)據(jù)。要根據(jù)實(shí)際情況而定,建議控制每次請(qǐng)求的記錄條數(shù)在500以內(nèi)。

        4.2 死循環(huán)

        有些小伙伴看到這個(gè)標(biāo)題,可能會(huì)感到有點(diǎn)意外,死循環(huán)也算?

        代碼中不是應(yīng)該避免死循環(huán)嗎?為啥還是會(huì)產(chǎn)生死循環(huán)?

        有時(shí)候死循環(huán)是我們自己寫的,例如下面這段代碼:

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

        這里使用了while(true)的循環(huán)調(diào)用,這種寫法在CAS自旋鎖中使用比較多。

        當(dāng)滿足condition等于true的時(shí)候,則自動(dòng)退出該循環(huán)。

        如果condition條件非常復(fù)雜,一旦出現(xiàn)判斷不正確,或者少寫了一些邏輯判斷,就可能在某些場(chǎng)景下出現(xiàn)死循環(huán)的問題。

        出現(xiàn)死循環(huán),大概率是開發(fā)人員人為的bug導(dǎo)致的,不過這種情況很容易被測(cè)出來。

        還有一種隱藏的比較深的死循環(huán),是由于代碼寫的不太嚴(yán)謹(jǐn)導(dǎo)致的。如果用正常數(shù)據(jù),可能測(cè)不出問題,但一旦出現(xiàn)異常數(shù)據(jù),就會(huì)立即出現(xiàn)死循環(huán)。

        4.3 無限遞歸

        如果想要打印某個(gè)分類的所有父分類,可以用類似這樣的遞歸方法實(shí)現(xiàn):

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

        正常情況下,這段代碼是沒有問題的。

        但如果某次有人誤操作,把某個(gè)分類的parentId指向了它自己,這樣就會(huì)出現(xiàn)無限遞歸的情況。導(dǎo)致接口一直不能返回?cái)?shù)據(jù),最終會(huì)發(fā)生堆棧溢出。

        建議寫遞歸方法時(shí),設(shè)定一個(gè)遞歸的深度,比如:分類最大等級(jí)有4級(jí),則深度可以設(shè)置為4。然后在遞歸方法中做判斷,如果深度大于4時(shí),則自動(dòng)返回,這樣就能避免無限循環(huán)的情況。

        5. 異步處理

        有時(shí)候,我們接口性能優(yōu)化,需要重新梳理一下業(yè)務(wù)邏輯,看看是否有設(shè)計(jì)上不太合理的地方。

        比如有個(gè)用戶請(qǐng)求接口中,需要做業(yè)務(wù)操作,發(fā)站內(nèi)通知,和記錄操作日志。為了實(shí)現(xiàn)起來比較方便,通常我們會(huì)將這些邏輯放在接口中同步執(zhí)行,勢(shì)必會(huì)對(duì)接口性能造成一定的影響。

        接口內(nèi)部流程圖如下:這個(gè)接口表面上看起來沒有問題,但如果你仔細(xì)梳理一下業(yè)務(wù)邏輯,會(huì)發(fā)現(xiàn)只有業(yè)務(wù)操作才是核心邏輯,其他的功能都是非核心邏輯。

        在這里有個(gè)原則就是:核心邏輯可以同步執(zhí)行,同步寫庫(kù)。非核心邏輯,可以異步執(zhí)行,異步寫庫(kù)。

        上面這個(gè)例子中,發(fā)站內(nèi)通知和用戶操作日志功能,對(duì)實(shí)時(shí)性要求不高,即使晚點(diǎn)寫庫(kù),用戶無非是晚點(diǎn)收到站內(nèi)通知,或者運(yùn)營(yíng)晚點(diǎn)看到用戶操作日志,對(duì)業(yè)務(wù)影響不大,所以完全可以異步處理。

        通常異步主要有兩種:多線程mq。

        5.1 線程池

        使用線程池改造之后,接口邏輯如下:發(fā)站內(nèi)通知和用戶操作日志功能,被提交到了兩個(gè)單獨(dú)的線程池中。

        這樣接口中重點(diǎn)關(guān)注的是業(yè)務(wù)操作,把其他的邏輯交給線程異步執(zhí)行,這樣改造之后,讓接口性能瞬間提升了。

        但使用線程池有個(gè)小問題就是:如果服務(wù)器重啟了,或者是需要被執(zhí)行的功能出現(xiàn)異常了,無法重試,會(huì)丟數(shù)據(jù)。

        那么這個(gè)問題該怎么辦呢?

        5.2 mq

        使用mq改造之后,接口邏輯如下:對(duì)于發(fā)站內(nèi)通知和用戶操作日志功能,在接口中并沒真正實(shí)現(xiàn),它只發(fā)送了mq消息到mq服務(wù)器。然后由mq消費(fèi)者消費(fèi)消息時(shí),才真正的執(zhí)行這兩個(gè)功能。

        這樣改造之后,接口性能同樣提升了,因?yàn)榘l(fā)送mq消息速度是很快的,我們只需關(guān)注業(yè)務(wù)操作的代碼即可。

        6. 避免大事務(wù)

        很多小伙伴在使用spring框架開發(fā)項(xiàng)目時(shí),為了方便,喜歡使用@Transactional注解提供事務(wù)功能。

        沒錯(cuò),使用@Transactional注解這種聲明式事務(wù)的方式提供事務(wù)功能,確實(shí)能少寫很多代碼,提升開發(fā)效率。

        但也容易造成大事務(wù),引發(fā)其他的問題。

        下面用一張圖看看大事務(wù)引發(fā)的問題。從圖中能夠看出,大事務(wù)問題可能會(huì)造成接口超時(shí),對(duì)接口的性能有直接的影響。

        我們?cè)撊绾蝺?yōu)化大事務(wù)呢?

        1. 少用@Transactional注解
        2. 將查詢(select)方法放到事務(wù)外
        3. 事務(wù)中避免遠(yuǎn)程調(diào)用
        4. 事務(wù)中避免一次性處理太多數(shù)據(jù)
        5. 有些功能可以非事務(wù)執(zhí)行
        6. 有些功能可以異步處理

        7. 鎖粒度

        在某些業(yè)務(wù)場(chǎng)景中,為了防止多個(gè)線程并發(fā)修改某個(gè)共享數(shù)據(jù),造成數(shù)據(jù)異常。

        為了解決并發(fā)場(chǎng)景下,多個(gè)線程同時(shí)修改數(shù)據(jù),造成數(shù)據(jù)不一致的情況。通常情況下,我們會(huì):加鎖。

        但如果鎖加得不好,導(dǎo)致鎖的粒度太粗,也會(huì)非常影響接口性能。

        7.1 synchronized

        在java中提供了synchronized關(guān)鍵字給我們的代碼加鎖。

        通常有兩種寫法:在方法上加鎖在代碼塊上加鎖。

        先看看如何在方法上加鎖:

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

        這里加鎖的目的是為了防止并發(fā)的情況下,創(chuàng)建了相同的目錄,第二次會(huì)創(chuàng)建失敗,影響業(yè)務(wù)功能。

        但這種直接在方法上加鎖,鎖的粒度有點(diǎn)粗。因?yàn)閐oSave方法中的上傳文件和發(fā)消息方法,是不需要加鎖的。只有創(chuàng)建目錄方法,才需要加鎖。

        我們都知道文件上傳操作是非常耗時(shí)的,如果將整個(gè)方法加鎖,那么需要等到整個(gè)方法執(zhí)行完之后才能釋放鎖。顯然,這會(huì)導(dǎo)致該方法的性能很差,變得得不償失。

        這時(shí),我們可以改成在代碼塊上加鎖了,具體代碼如下:

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

        這樣改造之后,鎖的粒度一下子變小了,只有并發(fā)創(chuàng)建目錄功能才加了鎖。而創(chuàng)建目錄是一個(gè)非常快的操作,即使加鎖對(duì)接口的性能影響也不大。

        最重要的是,其他的上傳文件和發(fā)送消息功能,任然可以并發(fā)執(zhí)行。

        當(dāng)然,這種做在單機(jī)版的服務(wù)中,是沒有問題的。但現(xiàn)在部署的生產(chǎn)環(huán)境,為了保證服務(wù)的穩(wěn)定性,一般情況下,同一個(gè)服務(wù)會(huì)被部署在多個(gè)節(jié)點(diǎn)中。如果哪天掛了一個(gè)節(jié)點(diǎn),其他的節(jié)點(diǎn)服務(wù)任然可用。

        多節(jié)點(diǎn)部署避免了因?yàn)槟硞€(gè)節(jié)點(diǎn)掛了,導(dǎo)致服務(wù)不可用的情況。同時(shí)也能分?jǐn)傉麄€(gè)系統(tǒng)的流量,避免系統(tǒng)壓力過大。

        同時(shí)它也帶來了新的問題:synchronized只能保證一個(gè)節(jié)點(diǎn)加鎖是有效的,但如果有多個(gè)節(jié)點(diǎn)如何加鎖呢?

        答:這就需要使用:分布式鎖了。目前主流的分布式鎖包括:redis分布式鎖、zookeeper分布式鎖 和 數(shù)據(jù)庫(kù)分布式鎖。

        由于zookeeper分布式鎖的性能不太好,真實(shí)業(yè)務(wù)場(chǎng)景用的不多,這里先不講。

        下面聊一下redis分布式鎖。

        7.2 redis分布式鎖

        在分布式系統(tǒng)中,由于redis分布式鎖相對(duì)于更簡(jiǎn)單和高效,成為了分布式鎖的首先,被我們用到了很多實(shí)際業(yè)務(wù)場(chǎng)景當(dāng)中。

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

        跟之前使用synchronized關(guān)鍵字加鎖時(shí)一樣,這里鎖的范圍也太大了,換句話說就是鎖的粒度太粗,這樣會(huì)導(dǎo)致整個(gè)方法的執(zhí)行效率很低。

        其實(shí)只有創(chuàng)建目錄的時(shí)候,才需要加分布式鎖,其余代碼根本不用加鎖。

        于是,我們需要優(yōu)化一下代碼:

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

        上面代碼將加鎖的范圍縮小了,只有創(chuàng)建目錄時(shí)才加了鎖。這樣看似簡(jiǎn)單的優(yōu)化之后,接口性能能提升很多。說不定,會(huì)有意外的驚喜喔。哈哈哈。

        redis分布式鎖雖說好用,但它在使用時(shí),有很多注意的細(xì)節(jié),隱藏了很多坑,如果稍不注意很容易踩中。

        7.3 數(shù)據(jù)庫(kù)分布式鎖

        mysql數(shù)據(jù)庫(kù)中主要有三種鎖:

        • 表鎖:加鎖快,不會(huì)出現(xiàn)死鎖。但鎖定粒度大,發(fā)生鎖沖突的概率最高,并發(fā)度最低。
        • 行鎖:加鎖慢,會(huì)出現(xiàn)死鎖。但鎖定粒度最小,發(fā)生鎖沖突的概率最低,并發(fā)度也最高。
        • 間隙鎖:開銷和加鎖時(shí)間界于表鎖和行鎖之間。它會(huì)出現(xiàn)死鎖,鎖定粒度界于表鎖和行鎖之間,并發(fā)度一般。

        并發(fā)度越高,意味著接口性能越好。

        所以數(shù)據(jù)庫(kù)鎖的優(yōu)化方向是:

        優(yōu)先使用行鎖,其次使用間隙鎖,再其次使用表鎖

        趕緊看看,你用對(duì)了沒?

        8.分頁處理

        有時(shí)候我會(huì)調(diào)用某個(gè)接口批量查詢數(shù)據(jù),比如:通過用戶id批量查詢出用戶信息,然后給這些用戶送積分。

        但如果你一次性查詢的用戶數(shù)量太多了,比如一次查詢2000個(gè)用戶的數(shù)據(jù)。參數(shù)中傳入了2000個(gè)用戶的id,遠(yuǎn)程調(diào)用接口,會(huì)發(fā)現(xiàn)該用戶查詢接口經(jīng)常超時(shí)。

        調(diào)用代碼如下:

        List<User> users = remoteCallUser(ids);

        眾所周知,調(diào)用接口從數(shù)據(jù)庫(kù)獲取數(shù)據(jù),是需要經(jīng)過網(wǎng)絡(luò)傳輸?shù)摹H绻麛?shù)據(jù)量太大,無論是獲取數(shù)據(jù)的速度,還是網(wǎng)絡(luò)傳輸受限于帶寬,都會(huì)導(dǎo)致耗時(shí)時(shí)間比較長(zhǎng)。

        那么,這種情況要如何優(yōu)化呢?

        答:分頁處理

        將一次獲取所有的數(shù)據(jù)的請(qǐng)求,改成分多次獲取,每次只獲取一部分用戶的數(shù)據(jù),最后進(jìn)行合并和匯總。

        其實(shí),處理這個(gè)問題,要分為兩種場(chǎng)景:同步調(diào)用異步調(diào)用

        8.1 同步調(diào)用

        如果在job中需要獲取2000個(gè)用戶的信息,它要求只要能正確獲取到數(shù)據(jù)就好,對(duì)獲取數(shù)據(jù)的總耗時(shí)要求不太高。

        但對(duì)每一次遠(yuǎn)程接口調(diào)用的耗時(shí)有要求,不能大于500ms,不然會(huì)有郵件預(yù)警。

        這時(shí),我們可以同步分頁調(diào)用批量查詢用戶信息接口。

        具體示例代碼如下:

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

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

        代碼中我用的googleguava工具中的Lists.partition方法,用它來做分頁簡(jiǎn)直太好用了,不然要巴拉巴拉寫一大堆分頁的代碼。

        8.2 異步調(diào)用

        如果是在某個(gè)接口中需要獲取2000個(gè)用戶的信息,它考慮的就需要更多一些。

        除了需要考慮遠(yuǎn)程調(diào)用接口的耗時(shí)之外,還需要考慮該接口本身的總耗時(shí),也不能超時(shí)500ms。

        這時(shí)候用上面的同步分頁請(qǐng)求遠(yuǎn)程接口,肯定是行不通的。

        那么,只能使用異步調(diào)用了。

        代碼如下:

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

        使用CompletableFuture類,多個(gè)線程異步調(diào)用遠(yuǎn)程接口,最后匯總結(jié)果統(tǒng)一返回。

        9.加緩存

        解決接口性能問題,加緩存是一個(gè)非常高效的方法。

        但不能為了緩存而緩存,還是要看具體的業(yè)務(wù)場(chǎng)景。畢竟加了緩存,會(huì)導(dǎo)致接口的復(fù)雜度增加,它會(huì)帶來數(shù)據(jù)不一致問題。

        在有些并發(fā)量比較低的場(chǎng)景中,比如用戶下單,可以不用加緩存。

        還有些場(chǎng)景,比如在商城首頁顯示商品分類的地方,假設(shè)這里的分類是調(diào)用接口獲取到的數(shù)據(jù),但頁面暫時(shí)沒有做靜態(tài)化。

        如果查詢分類樹的接口沒有使用緩存,而直接從數(shù)據(jù)庫(kù)查詢數(shù)據(jù),性能會(huì)非常差。

        那么如何使用緩存呢?

        9.1 redis緩存

        通常情況下,我們使用最多的緩存可能是:redismemcached。

        但對(duì)于java應(yīng)用來說,絕大多數(shù)都是使用的redis,所以接下來我們以redis為例。

        由于在關(guān)系型數(shù)據(jù)庫(kù),比如:mysql中,菜單是有上下級(jí)關(guān)系的。某個(gè)四級(jí)分類是某個(gè)三級(jí)分類的子分類,這個(gè)三級(jí)分類,又是某個(gè)二級(jí)分類的子分類,而這個(gè)二級(jí)分類,又是某個(gè)一級(jí)分類的子分類。

        這種存儲(chǔ)結(jié)構(gòu)決定了,想一次性查出這個(gè)分類樹,并非是一件非常容易的事情。這就需要使用程序遞歸查詢了,如果分類多的話,這個(gè)遞歸是比較耗時(shí)的。

        所以,如果每次都直接從數(shù)據(jù)庫(kù)中查詢分類樹的數(shù)據(jù),是一個(gè)非常耗時(shí)的操作。

        這時(shí)我們可以使用緩存,大部分情況,接口都直接從緩存中獲取數(shù)據(jù)。操作redis可以使用成熟的框架,比如:jedis和redisson等。

        用jedis偽代碼如下:

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

        先從redis中根據(jù)某個(gè)key查詢是否有菜單數(shù)據(jù),如果有則轉(zhuǎn)換成對(duì)象,直接返回。如果redis中沒有查到菜單數(shù)據(jù),則再?gòu)臄?shù)據(jù)庫(kù)中查詢菜單數(shù)據(jù),有則返回。

        此外,我們還需要有個(gè)job每隔一段時(shí)間,從數(shù)據(jù)庫(kù)中查詢菜單數(shù)據(jù),更新到redis當(dāng)中,這樣以后每次都能直接從redis中獲取菜單的數(shù)據(jù),而無需訪問數(shù)據(jù)庫(kù)了。這樣改造之后,能快速的提升性能。

        但這樣做性能提升不是最佳的,還有其他的方案,我們一起看看下面的內(nèi)容。

        9.2 二級(jí)緩存

        上面的方案是基于redis緩存的,雖說redis訪問速度很快。但畢竟是一個(gè)遠(yuǎn)程調(diào)用,而且菜單樹的數(shù)據(jù)很多,在網(wǎng)絡(luò)傳輸?shù)倪^程中,是有些耗時(shí)的。

        有沒有辦法,不經(jīng)過請(qǐng)求遠(yuǎn)程,就能直接獲取到數(shù)據(jù)呢?

        答:使用二級(jí)緩存,即基于內(nèi)存的緩存。

        除了自己手寫的內(nèi)存緩存之后,目前使用比較多的內(nèi)存緩存框架有:guava、Ehcache、caffine等。

        我們?cè)谶@里以caffeine為例,它是spring官方推薦的。

        第一步,引入caffeine的相關(guān)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>

        第二步,配置CacheManager,開啟EnableCaching

        @Configuration
        @EnableCaching
        public class CacheConfig {
            @Bean
            public CacheManager cacheManager(){
                CaffeineCacheManager cacheManager = new CaffeineCacheManager();
                //Caffeine配置
                Caffeine<Object, Object> caffeine = Caffeine.newBuilder()
                        //最后一次寫入后經(jīng)過固定時(shí)間過期
                        .expireAfterWrite(10, TimeUnit.SECONDS)
                        //緩存的最大條數(shù)
                        .maximumSize(1000);
                cacheManager.setCaffeine(caffeine);
                return cacheManager;
            }
        }

        第三步,使用Cacheable注解獲取數(shù)據(jù)

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

        調(diào)用categoryService.getCategory()方法時(shí),先從caffine緩存中獲取數(shù)據(jù),如果能夠獲取到數(shù)據(jù),則直接返回該數(shù)據(jù),不進(jìn)入方法體。

        如果不能獲取到數(shù)據(jù),則再?gòu)膔edis中查一次數(shù)據(jù)。如果查詢到了,則返回?cái)?shù)據(jù),并且放入caffine中。

        如果還是沒有查到數(shù)據(jù),則直接從數(shù)據(jù)庫(kù)中獲取到數(shù)據(jù),然后放到caffine緩存中。

        具體流程圖如下:該方案的性能更好,但有個(gè)缺點(diǎn)就是,如果數(shù)據(jù)更新了,不能及時(shí)刷新緩存。此外,如果有多臺(tái)服務(wù)器節(jié)點(diǎn),可能存在各個(gè)節(jié)點(diǎn)上數(shù)據(jù)不一樣的情況。

        由此可見,二級(jí)緩存給我們帶來性能提升的同時(shí),也帶來了數(shù)據(jù)不一致的問題。使用二級(jí)緩存一定要結(jié)合實(shí)際的業(yè)務(wù)場(chǎng)景,并非所有的業(yè)務(wù)場(chǎng)景都適用。

        但上面我列舉的分類場(chǎng)景,是適合使用二級(jí)緩存的。因?yàn)樗鼘儆谟脩舨幻舾袛?shù)據(jù),即使出現(xiàn)了稍微有點(diǎn)數(shù)據(jù)不一致也沒有關(guān)系,用戶有可能都沒有察覺出來。

        10. 分庫(kù)分表

        有時(shí)候,接口性能受限的不是別的,而是數(shù)據(jù)庫(kù)。

        當(dāng)系統(tǒng)發(fā)展到一定的階段,用戶并發(fā)量大,會(huì)有大量的數(shù)據(jù)庫(kù)請(qǐng)求,需要占用大量的數(shù)據(jù)庫(kù)連接,同時(shí)會(huì)帶來磁盤IO的性能瓶頸問題。

        此外,隨著用戶數(shù)量越來越多,產(chǎn)生的數(shù)據(jù)也越來越多,一張表有可能存不下。由于數(shù)據(jù)量太大,sql語句查詢數(shù)據(jù)時(shí),即使走了索引也會(huì)非常耗時(shí)。

        這時(shí)該怎么辦呢?

        答:需要做分庫(kù)分表。

        如下圖所示:圖中將用戶庫(kù)拆分成了三個(gè)庫(kù),每個(gè)庫(kù)都包含了四張用戶表。

        如果有用戶請(qǐng)求過來的時(shí)候,先根據(jù)用戶id路由到其中一個(gè)用戶庫(kù),然后再定位到某張表。

        路由的算法挺多的:

        • 根據(jù)id取模,比如:id=7,有4張表,則7%4=3,模為3,路由到用戶表3。
        • 給id指定一個(gè)區(qū)間范圍,比如:id的值是0-10萬,則數(shù)據(jù)存在用戶表0,id的值是10-20萬,則數(shù)據(jù)存在用戶表1。
        • 一致性hash算法

        分庫(kù)分表主要有兩個(gè)方向:垂直水平。

        說實(shí)話垂直方向(即業(yè)務(wù)方向)更簡(jiǎn)單。

        在水平方向(即數(shù)據(jù)方向)上,分庫(kù)和分表的作用,其實(shí)是有區(qū)別的,不能混為一談。

        • 分庫(kù):是為了解決數(shù)據(jù)庫(kù)連接資源不足問題,和磁盤IO的性能瓶頸問題。
        • 分表:是為了解決單表數(shù)據(jù)量太大,sql語句查詢數(shù)據(jù)時(shí),即使走了索引也非常耗時(shí)問題。此外還可以解決消耗cpu資源問題。
        • 分庫(kù)分表:可以解決 數(shù)據(jù)庫(kù)連接資源不足、磁盤IO的性能瓶頸、檢索數(shù)據(jù)耗時(shí) 和 消耗cpu資源等問題。

        如果在有些業(yè)務(wù)場(chǎng)景中,用戶并發(fā)量很大,但是需要保存的數(shù)據(jù)量很少,這時(shí)可以只分庫(kù),不分表。

        如果在有些業(yè)務(wù)場(chǎng)景中,用戶并發(fā)量不大,但是需要保存的數(shù)量很多,這時(shí)可以只分表,不分庫(kù)。

        如果在有些業(yè)務(wù)場(chǎng)景中,用戶并發(fā)量大,并且需要保存的數(shù)量也很多時(shí),可以分庫(kù)分表。

        11. 輔助功能

        優(yōu)化接口性能問題,除了上面提到的這些常用方法之外,還需要配合使用一些輔助功能,因?yàn)樗鼈冋娴目梢詭臀覀兲嵘檎覇栴}的效率。

        11.1 開啟慢查詢?nèi)罩?/span>

        通常情況下,為了定位sql的性能瓶頸,我們需要開啟mysql的慢查詢?nèi)罩?。把超過指定時(shí)間的sql語句,單獨(dú)記錄下來,方面以后分析和定位問題。

        開啟慢查詢?nèi)罩拘枰攸c(diǎn)關(guān)注三個(gè)參數(shù):

        • slow_query_log 慢查詢開關(guān)
        • slow_query_log_file 慢查詢?nèi)罩敬娣诺穆窂?/section>
        • long_query_time 超過多少秒才會(huì)記錄日志

        通過mysql的set命令可以設(shè)置:

        set global slow_query_log='ON'
        set global slow_query_log_file='/usr/local/mysql/data/slow.log';
        set global long_query_time=2;

        設(shè)置完之后,如果某條sql的執(zhí)行時(shí)間超過了2秒,會(huì)被自動(dòng)記錄到slow.log文件中。

        當(dāng)然也可以直接修改配置文件my.cnf

        [mysqld]
        slow_query_log = ON
        slow_query_log_file = /usr/local/mysql/data/slow.log
        long_query_time = 2

        但這種方式需要重啟mysql服務(wù)。

        很多公司每天早上都會(huì)發(fā)一封慢查詢?nèi)罩镜泥]件,開發(fā)人員根據(jù)這些信息優(yōu)化sql。

        11.2 加監(jiān)控

        為了出現(xiàn)sql問題時(shí),能夠讓我們及時(shí)發(fā)現(xiàn),我們需要對(duì)系統(tǒng)做監(jiān)控。

        目前業(yè)界使用比較多的開源監(jiān)控系統(tǒng)是:Prometheus

        它提供了 監(jiān)控預(yù)警 的功能。

        架構(gòu)圖如下:

        我們可以用它監(jiān)控如下信息:

        • 接口響應(yīng)時(shí)間
        • 調(diào)用第三方服務(wù)耗時(shí)
        • 慢查詢sql耗時(shí)
        • cpu使用情況
        • 內(nèi)存使用情況
        • 磁盤使用情況
        • 數(shù)據(jù)庫(kù)使用情況

        等等。。。

        它的界面大概長(zhǎng)這樣子:可以看到mysql當(dāng)前qps,活躍線程數(shù),連接數(shù),緩存池的大小等信息。

        如果發(fā)現(xiàn)數(shù)據(jù)量連接池占用太多,對(duì)接口的性能肯定會(huì)有影響。

        這時(shí)可能是代碼中開啟了連接忘了關(guān),或者并發(fā)量太大了導(dǎo)致的,需要做進(jìn)一步排查和系統(tǒng)優(yōu)化。

        截圖中只是它一小部分功能,如果你想了解更多功能,可以訪問Prometheus的官網(wǎng):https://prometheus.io/

        11.3 鏈路跟蹤

        有時(shí)候某個(gè)接口涉及的邏輯很多,比如:查數(shù)據(jù)庫(kù)、查redis、遠(yuǎn)程調(diào)用接口,發(fā)mq消息,執(zhí)行業(yè)務(wù)代碼等等。

        該接口一次請(qǐng)求的鏈路很長(zhǎng),如果逐一排查,需要花費(fèi)大量的時(shí)間,這時(shí)候,我們已經(jīng)沒法用傳統(tǒng)的辦法定位問題了。

        有沒有辦法解決這問題呢?

        用分布式鏈路跟蹤系統(tǒng):skywalking

        架構(gòu)圖如下:通過skywalking定位性能問題:在skywalking中可以通過traceId(全局唯一的id),串聯(lián)一個(gè)接口請(qǐng)求的完整鏈路??梢钥吹秸麄€(gè)接口的耗時(shí),調(diào)用的遠(yuǎn)程服務(wù)的耗時(shí),訪問數(shù)據(jù)庫(kù)或者redis的耗時(shí)等等,功能非常強(qiáng)大。

        之前沒有這個(gè)功能的時(shí)候,為了定位線上接口性能問題,我們還需要在代碼中加日志,手動(dòng)打印出鏈路中各個(gè)環(huán)節(jié)的耗時(shí)情況,然后再逐一排查。

        如果你用過skywalking排查接口性能問題,不自覺的會(huì)愛上它的。如果你想了解更多功能,可以訪問skywalking的官網(wǎng):https://skywalking.apache.org/



        以上就是本期分享了。

        最后,歡迎加入  魚皮的編程知識(shí)星球(點(diǎn)擊了解詳情),和大家一起交流學(xué)習(xí)編程,向魚皮和大廠同學(xué) 1 對(duì) 1 提問、幫你制定學(xué)習(xí)計(jì)劃不迷茫、跟著魚皮直播做項(xiàng)目(往期項(xiàng)目可無限回看)、領(lǐng)取魚皮原創(chuàng)編程學(xué)習(xí)/求職資料等。最近秋招開始了,星球內(nèi)也會(huì)幫大家規(guī)劃求職進(jìn)度、完善簡(jiǎn)歷和項(xiàng)目。


        往期推薦

        我是怎么自學(xué) Git / GitHub 的?

        轉(zhuǎn)行大數(shù)據(jù) 1 個(gè)月,我麻了。。。

        Web3.0開發(fā)快速入門

        從20s優(yōu)化到500ms,我用了這三招

        時(shí)隔半年,Linux 性能超越 Windows 11 !

        瀏覽 48
        點(diǎn)贊
        評(píng)論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
        評(píng)論
        圖片
        表情
        推薦
        點(diǎn)贊
        評(píng)論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報(bào)
          
          

            1. 调教小荡货h办公室打屁股视频 | 2024日韩无码 | 大陆免费一级性爱视频 | 国模超大尺度 | 酒吧站着做爰视频 |