5秒到1秒,記一次效果“非?!憋@著的性能優(yōu)化
性能優(yōu)化,有時(shí)候看起來(lái)是一個(gè)比較虛的技術(shù)需求。除非代碼慢的已經(jīng)讓人無(wú)法忍受,否則,很少有公司會(huì)有覺(jué)悟投入資源去做這些工作。即使你有了性能指標(biāo)數(shù)據(jù),也很難說(shuō)服領(lǐng)導(dǎo)做一個(gè)由耗時(shí)300ms降低到150ms的改進(jìn),因?yàn)樗鼪](méi)有業(yè)務(wù)價(jià)值。
這很讓人傷心,但這是悲催的現(xiàn)實(shí)。
性能優(yōu)化,通常由有技術(shù)追求的人發(fā)起,根據(jù)觀測(cè)指標(biāo)進(jìn)行的正向優(yōu)化。他們通常具有工匠精神,對(duì)每一毫秒的耗時(shí)都吹毛求疵,力求完美。當(dāng)然,前提是你得有時(shí)間。
1. 優(yōu)化背景和目標(biāo)
我們本次的性能優(yōu)化,就是由于達(dá)到了無(wú)法忍受的程度,才進(jìn)行的優(yōu)化工作,屬于事后補(bǔ)救,問(wèn)題驅(qū)動(dòng)的方式。這通常沒(méi)什么問(wèn)題,畢竟業(yè)務(wù)第一嘛,迭代在填坑中進(jìn)行。
先說(shuō)背景。本次要優(yōu)化的服務(wù),請(qǐng)求響應(yīng)時(shí)間十分的不穩(wěn)定。隨著數(shù)據(jù)量的增加,大部分請(qǐng)求,要耗時(shí)5-6秒左右!超出了常人能忍受的范圍。
當(dāng)然需要優(yōu)化。
為了說(shuō)明要優(yōu)化的目標(biāo),我大體畫(huà)了一下它的拓?fù)浣Y(jié)構(gòu)。如圖所示,這是一套微服務(wù)架構(gòu)的服務(wù)。
其中,我們優(yōu)化的目標(biāo),就處于一個(gè)比較靠上游的服務(wù)。它需要通過(guò)Feign接口,調(diào)用下游非常多的服務(wù)提供者,獲取數(shù)據(jù)后進(jìn)行聚合拼接,最終通過(guò)zuul網(wǎng)關(guān)和nginx,來(lái)發(fā)送到瀏覽器客戶端。
為了觀測(cè)服務(wù)之間的調(diào)用關(guān)系和監(jiān)控?cái)?shù)據(jù),我們接入了Skywalking調(diào)用鏈平臺(tái)和Prometheus監(jiān)控平臺(tái),收集重要的數(shù)據(jù)以便能夠進(jìn)行優(yōu)化決策。要進(jìn)行優(yōu)化之前,我們需要首先看一下優(yōu)化需要參考的兩個(gè)技術(shù)指標(biāo)。
- 吞吐量:?jiǎn)挝粫r(shí)間內(nèi)發(fā)生的次數(shù)。比如QPS、TPS、HPS等。
- 平均響應(yīng)時(shí)間:每個(gè)請(qǐng)求的平均耗時(shí)。
平均響應(yīng)時(shí)間自然是越小越好,它越小,吞吐量越高。吞吐量的增加還可以合理利用多核,通過(guò)并行度增加單位時(shí)間內(nèi)的發(fā)生次數(shù)。
我們本次優(yōu)化的目標(biāo),就是減少某些接口的平均響應(yīng)時(shí)間,降低到1秒以內(nèi);增加吞吐量,也就是提高QPS,讓單實(shí)例系統(tǒng)能夠承接更多的并發(fā)請(qǐng)求。
2. 通過(guò)壓縮讓耗時(shí)急劇減少
我想要先介紹讓系統(tǒng)飛起來(lái)最重要的一個(gè)優(yōu)化手段:壓縮。
通過(guò)在chrome的inspect中查看請(qǐng)求的數(shù)據(jù),我們發(fā)現(xiàn)一個(gè)關(guān)鍵的請(qǐng)求接口,每次要傳輸大約10MB的數(shù)據(jù)。這得塞了多少東西。
這么大的數(shù)據(jù),光下載就需要耗費(fèi)大量時(shí)間。如下圖所示,是我請(qǐng)求juejin主頁(yè)的某一個(gè)請(qǐng)求,其中的content download,就代表了數(shù)據(jù)在網(wǎng)絡(luò)上的傳輸時(shí)間。如果用戶的帶寬非常慢,那么這個(gè)請(qǐng)求的耗時(shí),將會(huì)是非常長(zhǎng)的。
為了減少數(shù)據(jù)在網(wǎng)絡(luò)上的傳輸時(shí)間,可以啟用gzip壓縮。gzip壓縮是屬于時(shí)間換空間的做法。對(duì)于大多數(shù)服務(wù)來(lái)說(shuō),最后一環(huán)是nginx,大多數(shù)人都會(huì)在nginx這一層去做壓縮。它的主要配置如下:
- gzip on;
- gzip_vary on;
- gzip_min_length 10240;
- gzip_proxied expired no-cache no-store private auth;
- gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml;
- gzip_disable "MSIE [1-6]\.";
壓縮率有多驚人呢?我們可以看一下這張截圖??梢钥吹剑瑪?shù)據(jù)壓縮后,由8.95MB縮減到了368KB!瞬間就能夠被瀏覽器下載下來(lái)。
但是等等,nginx只是最外面的一環(huán),還沒(méi)完,我們還可以讓請(qǐng)求更快一些。
請(qǐng)看下面的請(qǐng)求路徑,由于采用了微服務(wù),請(qǐng)求的流轉(zhuǎn)就變得復(fù)雜起來(lái):nginx并不是直接調(diào)用了相關(guān)得服務(wù),它調(diào)用的是zuul網(wǎng)關(guān),zuul網(wǎng)關(guān)才真正調(diào)用的目標(biāo)服務(wù),目標(biāo)服務(wù)又另外調(diào)用了其他服務(wù)。內(nèi)網(wǎng)帶寬也是帶寬,網(wǎng)絡(luò)延遲也會(huì)影響調(diào)用速度,同樣也要壓縮起來(lái)。
- nginx->zuul->服務(wù)A->服務(wù)E
要想Feign之間的調(diào)用全部都走壓縮通道,還需要額外的配置。我們是springboot服務(wù),可以通過(guò)okhttp的透明壓縮進(jìn)行處理。
加入它的依賴:
- <dependency>
- <groupId>io.github.openfeign</groupId>
- <artifactId>feign-okhttp</artifactId>
- </dependency>
開(kāi)啟服務(wù)端配置:
- server:
- port:8888
- compression:
- enabled:true
- min-response-size:1024
- mime-types:["text/html","text/xml","application/xml","application/json","application/octet-stream"]
開(kāi)啟客戶端配置:
- feign:
- httpclient:
- enabled:false
- okhttp:
- enabled:true
經(jīng)過(guò)這些壓縮之后,我們的接口平均響應(yīng)時(shí)間,直接從5-6秒降低到了2-3秒,優(yōu)化效果非常顯著。
當(dāng)然,我們也在結(jié)果集上做了文章,在返回給前端的數(shù)據(jù)中,不被使用的對(duì)象和字段,都進(jìn)行了精簡(jiǎn)。但一般情況下,這些改動(dòng)都是傷筋動(dòng)骨的,需要調(diào)整大量代碼,所以我們?cè)谶@上面用的精力有限,效果自然也有限。
3. 并行獲取數(shù)據(jù),響應(yīng)飛快
接下來(lái),就要深入到代碼邏輯內(nèi)部進(jìn)行分析了。上面我們提到,面向用戶的接口,其實(shí)是一個(gè)數(shù)據(jù)聚合接口。它的每次請(qǐng)求,通過(guò)Feign,調(diào)用了幾十個(gè)其他服務(wù)的接口,進(jìn)行數(shù)據(jù)獲取,然后拼接結(jié)果集合。
為什么慢?因?yàn)檫@些請(qǐng)求全部是串行的!Feign調(diào)用屬于遠(yuǎn)程調(diào)用,也就是網(wǎng)絡(luò)I/O密集型調(diào)用,多數(shù)時(shí)間都在等待,如果數(shù)據(jù)滿足的話,是非常適合并行調(diào)用的。
首先,我們需要分析這幾十個(gè)子接口的依賴關(guān)系,看一下它們是否具有嚴(yán)格的順序性要求。如果大多數(shù)沒(méi)有,那就再好不過(guò)了。
分析結(jié)果喜憂參半,這堆接口,按照調(diào)用邏輯,大體上可以分為A,B類。首先,需要請(qǐng)求A類接口,拼接數(shù)據(jù)后,這些數(shù)據(jù)再供B類使用。但在A,B類內(nèi)部,是沒(méi)有順序性要求的。
也就是說(shuō),我們可以把這個(gè)接口,拆分成順序執(zhí)行的兩部分,在某個(gè)部分都可以并行的獲取數(shù)據(jù)。
那就按照這種分析結(jié)果改造試試吧,使用concurrent包里的CountDownLatch,很容易的就實(shí)現(xiàn)了并取功能。
- CountDownLatch latch = new CountDownLatch(jobSize);
- //submit job
- executor.execute(() -> {
- //job code
- latch.countDown();
- });
- executor.execute(() -> {
- latch.countDown();
- });
- ...
- //end submit
- latch.await(timeout, TimeUnit.MILLISECONDS);
結(jié)果非常讓人滿意,我們的接口耗時(shí),又減少了接近一半!此時(shí),接口耗時(shí)已經(jīng)降低到2秒以下。
你可能會(huì)問(wèn),為什么不用Java的并行流呢?關(guān)于并行流的坑,可以參考這篇文章。非常不建議你使用它。
并發(fā)編程一定要小心,尤其是在業(yè)務(wù)代碼中的并發(fā)編程。我們構(gòu)造了專用的線程池,來(lái)支撐這個(gè)并發(fā)獲取的功能。
- final ThreadPoolExecutor executor = new ThreadPoolExecutor(100, 200, 1,
- TimeUnit.HOURS, new ArrayBlockingQueue<>(100));
壓縮和并行化,是我們本次優(yōu)化中,最有效的手段。它們直接砍掉了請(qǐng)求大半部分的耗時(shí),非常的有效。但我們還是不滿足,因?yàn)槊看握?qǐng)求,依然有1秒鐘以上呢。
4. 緩存分類,進(jìn)一步加速
我們發(fā)現(xiàn),有些數(shù)據(jù)的獲取,是放在循環(huán)中的,有很多無(wú)效請(qǐng)求,這不能忍。
- for(List){
- client.getData();
- }
如果將這些常用的結(jié)果緩存起來(lái),那么就可以大大減少網(wǎng)絡(luò)IO請(qǐng)求的次數(shù),增加程序的運(yùn)行效率。
緩存在大多數(shù)應(yīng)用程序的優(yōu)化中,作用非常大。但由于壓縮和并行效果的對(duì)比,緩存在我們這個(gè)場(chǎng)景中,效果不是非常的明顯,但依然減少了大約三四十毫秒的請(qǐng)求時(shí)間。
我們是這么做的。
首先,我們將一部分代碼邏輯簡(jiǎn)單,適合Cache Aside Pattern模式的數(shù)據(jù),放在了分布式緩存Redis中。具體來(lái)說(shuō),就是讀取的時(shí)候,先讀緩存,緩存讀不到的時(shí)候,再讀數(shù)據(jù)庫(kù);更新的時(shí)候,先更新數(shù)據(jù)庫(kù),再刪除緩存(延時(shí)雙刪)。使用這種方式,能夠解決大部分業(yè)務(wù)邏輯簡(jiǎn)單的緩存場(chǎng)景,并能解決數(shù)據(jù)的一致性問(wèn)題。
但是,僅僅這么做是不夠的,因?yàn)橛行I(yè)務(wù)邏輯非常的復(fù)雜,更新的代碼發(fā)非常的分散,不適合使用Cache Aside Pattern進(jìn)行改造。我們了解到,有部分?jǐn)?shù)據(jù),具有以下特點(diǎn):
- 這些數(shù)據(jù),通過(guò)耗時(shí)的獲取之后,在極端的時(shí)間內(nèi),會(huì)被再次用到
- 業(yè)務(wù)數(shù)據(jù)對(duì)它們的一致性要求,可以控制在秒級(jí)別以內(nèi)
- 對(duì)于這些數(shù)據(jù)的使用,跨代碼、跨線程,使用方式多樣
針對(duì)于這種情況,我們?cè)O(shè)計(jì)了存在時(shí)間極短的堆內(nèi)內(nèi)存緩存,數(shù)據(jù)在1秒之后,就會(huì)失效,然后重新從數(shù)據(jù)庫(kù)中讀取。加入某個(gè)節(jié)點(diǎn)調(diào)用服務(wù)端接口是1秒鐘1k次,我們直接給降低到了1次。
在這里,使用了Guava的LoadingCache,減少的Feign接口調(diào)用,是數(shù)量級(jí)的。
- LoadingCache<String, String> lc = CacheBuilder
- .newBuilder()
- .expireAfterWrite(1,TimeUnit.SECONDS)
- .build(new CacheLoader<String, String>() {
- @Override
- public String load(String key) throws Exception {
- return slowMethod(key);
- }});
5. MySQL索引的優(yōu)化
我們的業(yè)務(wù)系統(tǒng),使用的是MySQL數(shù)據(jù)庫(kù),由于沒(méi)有專業(yè)DBA介入,而且數(shù)據(jù)表是使用JPA生成的。在優(yōu)化的時(shí)候,發(fā)現(xiàn)了大量不合理的索引,當(dāng)然是要優(yōu)化掉。
由于SQL具有很強(qiáng)的敏感性,我這里只談一些在優(yōu)化過(guò)程中碰到的索引優(yōu)化規(guī)則問(wèn)題,相信你一樣能夠在自己的業(yè)務(wù)系統(tǒng)中進(jìn)行類比。
索引非常有用,但是要注意,如果你對(duì)字段做了函數(shù)運(yùn)算,那索引就用不上了。常見(jiàn)的索引失效,還有下面兩種情況:
- 查詢的索引字段類型,與用戶傳遞的數(shù)據(jù)類型不同,要做一層隱式轉(zhuǎn)換。比如varchar類型的字段上,傳入了int參數(shù)
- 查詢的兩張表之間,使用的字符集不同,也就無(wú)法使用關(guān)聯(lián)字段作為索引
MySQL的索引優(yōu)化,最基本的是遵循最左前綴原則,當(dāng)有a、b、c三個(gè)字段的時(shí)候,如果查詢條件用到了a,或者a、b,或者a、b、c,那么我們就可以創(chuàng)建(a,b,c)一個(gè)索引即可,它包含了a和ab。當(dāng)然,字符串也是可以加前綴索引的,但在平常應(yīng)用中較少。
有時(shí)候,MySQL的優(yōu)化器,會(huì)選擇了錯(cuò)誤的索引,我們需要使用force index指定所使用的索引。在JPA中,就要使用nativeQuery,來(lái)書(shū)寫(xiě)綁定到MySQL數(shù)據(jù)庫(kù)的SQL語(yǔ)句,我們盡量的去避免這種情況。
另外一個(gè)優(yōu)化是減少回表。由于InnoDB采用了B+樹(shù),但是如果不使用非主鍵索引,會(huì)通過(guò)二級(jí)索引(secondary index)先查到聚簇索引(clustered index),然后再定位到數(shù)據(jù)。多了一步,產(chǎn)生回表。使用覆蓋索引,可以一定程度上避免回表,是常用的優(yōu)化手段。具體做法,就是把要查詢的字段,與索引放在一起做聯(lián)合索引,是一種空間換時(shí)間的做法。
6. JVM優(yōu)化
我通常將JVM的優(yōu)化放在最后一環(huán)。而且,除非系統(tǒng)發(fā)生了嚴(yán)重的卡頓,或者OOM問(wèn)題,都不會(huì)主動(dòng)對(duì)其進(jìn)行過(guò)度優(yōu)化。
很不幸的是,我們的應(yīng)用,由于開(kāi)啟了大內(nèi)存(8GB+),在JDK1.8默認(rèn)的并行收集器下,經(jīng)常發(fā)生卡頓。雖然不是很頻繁,但動(dòng)輒幾秒鐘,已經(jīng)嚴(yán)重影響到部分請(qǐng)求的平滑性。
程序剛開(kāi)始,是光禿禿跑在JVM下的,GC信息,還有OOM,什么都沒(méi)留下。為了記錄GC信息,我們做了如下的改造。
第一步,加入GC問(wèn)題排查的各種參數(shù)。
- -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/xxx.hprof -DlogPath=/
這樣,我們就可以拿著生成的GC文件,上傳到gceasy等平臺(tái)進(jìn)行分析??梢圆榭碕VM的吞吐量和每個(gè)階段的延時(shí)等。
第二步,開(kāi)啟SpringBoot的GC信息,接入Promethus監(jiān)控。
在pom中加入依賴。
- <dependency>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-starter-actuator</artifactId>
- </dependency>
然后配置暴露點(diǎn)就可以了。這樣,我們就擁有了實(shí)時(shí)的分析數(shù)據(jù),有了優(yōu)化的依據(jù)。
- management.endpoints.web.exposure.include=health,info,prometheus
在觀測(cè)了JVM的表現(xiàn)之后,我們切換成了G1垃圾回收器。G1有最大停頓目標(biāo),可以讓我們的GC時(shí)間更加的平滑。它主要有以下幾個(gè)調(diào)優(yōu)參數(shù):
- -XX:MaxGCPauseMillis 設(shè)置目標(biāo)停頓時(shí)間,G1會(huì)盡力達(dá)成。
- -XX:G1HeapRegionSize 設(shè)置小堆區(qū)大小。這個(gè)值為2的次冪,不要太大,也不要太小。如果是在不知道如何設(shè)置,保持默認(rèn)。
- -XX:InitiatingHeapOccupancyPercent 當(dāng)整個(gè)堆內(nèi)存使用達(dá)到一定比例(默認(rèn)是45%),并發(fā)標(biāo)記階段就會(huì)被啟動(dòng)。
- -XX:ConcGCThreads 并發(fā)垃圾收集器使用的線程數(shù)量。默認(rèn)值隨JVM運(yùn)行的平臺(tái)不同而不同。不建議修改。
切換成G1之后,這種不間斷的停頓,竟然神奇的消失了!期間,還發(fā)生過(guò)很多次內(nèi)存溢出的問(wèn)題,不過(guò)有MAT這種神器的加持,最終都很easy的被解決了。
7. 其他優(yōu)化
在工程結(jié)構(gòu)和架構(gòu)方面,如果有硬傷的話,那么代碼優(yōu)化方面,起到的作用其實(shí)是有限的,就比如我們這種情況。
但主要代碼還是要整一下容得。有些處于高耗時(shí)邏輯中的關(guān)鍵的代碼,我們對(duì)其進(jìn)行了格外的關(guān)照。按照開(kāi)發(fā)規(guī)范,對(duì)代碼進(jìn)行了一次統(tǒng)一的清理。其中,有幾個(gè)印象比較深深刻的點(diǎn)。
有同學(xué)為了能夠復(fù)用map集合,每次用完之后,都使用clear方法進(jìn)行清理。
- map1.clear();
- map2.clear();
- map3.clear();
- map4.clear();
這些map中的數(shù)據(jù),特別的多,而clear方法有點(diǎn)特殊,它的時(shí)間復(fù)雜度事O(n)的,造成了較高的耗時(shí)。
- public void clear() {
- Node<K,V>[] tab;
- modCount++;
- if ((tab = table) != null && size > 0) {
- size = 0;
- for (int i = 0; i < tab.length; ++i)
- tab[i] = null;
- }
- }
同樣的線程安全的隊(duì)列,有ConcurrentLinkedQueue,它的size()方法,時(shí)間復(fù)雜度非常高,不知怎么就被同事給用上了,這都是些性能殺手。
- public int size() {
- restartFromHead: for (;;) {
- int count = 0;
- for (Node<E> p = first(); p != null;) {
- if (p.item != null)
- if (++count == Integer.MAX_VALUE)
- break; // @see Collection.size()
- if (p == (p = p.next))
- continue restartFromHead;
- }
- return count;
- }
- }
另外,有些服務(wù)的web頁(yè)面,本身響應(yīng)就非常的慢,這是由于業(yè)務(wù)邏輯復(fù)雜,前端JavaScript本身就執(zhí)行緩慢。這部分代碼優(yōu)化,就需要前端的同事去處理了,如圖,使用chrome或者firefox的performance選項(xiàng)卡,可以很容易發(fā)現(xiàn)耗時(shí)的前端 代碼。
8. 總結(jié)
性能優(yōu)化,其實(shí)也是有套路的,但一般團(tuán)隊(duì)都是等發(fā)生了問(wèn)題才去優(yōu)化,鮮有未雨綢繆的。但有了監(jiān)控和APM就不一樣,我們能夠隨時(shí)拿到數(shù)據(jù),反向推動(dòng)優(yōu)化過(guò)程。
有些性能問(wèn)題,能夠在業(yè)務(wù)需求層面,或者架構(gòu)層面去解決。凡是已經(jīng)帶到代碼層,需要程序員介入的優(yōu)化,都已經(jīng)到了需求方和架構(gòu)方不能再亂動(dòng),或者不想再動(dòng)的境地。
性能優(yōu)化首先要收集信息,找出瓶頸點(diǎn),權(quán)衡CPU、內(nèi)存、網(wǎng)絡(luò)、、IO等資源,然后盡量的減少平均響應(yīng)時(shí)間,提高吞吐量。
緩存、緩沖、池化、減少鎖沖突、異步、并行、壓縮,都是常見(jiàn)的優(yōu)化方式。在我們的這個(gè)場(chǎng)景中,起到最大作用的,就是數(shù)據(jù)壓縮和并行請(qǐng)求。當(dāng)然,加上其他優(yōu)化方法的協(xié)助,我們的業(yè)務(wù)接口,由5-6秒的耗時(shí),直接降低到了1秒之內(nèi),這個(gè)優(yōu)化效果還是非常可觀的。估計(jì)在未來(lái)很長(zhǎng)一段時(shí)間內(nèi),都不會(huì)再對(duì)它進(jìn)行優(yōu)化了。
作者簡(jiǎn)介:小姐姐味道 (xjjdog),一個(gè)不允許程序員走彎路的公眾號(hào)。聚焦基礎(chǔ)架構(gòu)和Linux。十年架構(gòu),日百億流量,與你探討高并發(fā)世界,給你不一樣的味道。