前段時間我們的服務遇到了性能瓶頸,由於前期需求太急沒有註意這方面的優化,到了要還技術債的時候就非常痛苦了。 在很低的 QPS 壓力下伺服器 load 就能達到 10-20,CPU 使用率 60% 以上,而且在每次流量峰值時介面都會大量報錯,雖然使用了服務熔斷框架 Hystrix,但熔斷後服務卻... ...
背景
前段時間我們的服務遇到了性能瓶頸,由於前期需求太急沒有註意這方面的優化,到了要還技術債的時候就非常痛苦了。
在很低的 QPS 壓力下伺服器 load 就能達到 10-20,CPU 使用率 60% 以上,而且在每次流量峰值時介面都會大量報錯,雖然使用了服務熔斷框架 Hystrix,但熔斷後服務卻遲遲不能恢復。每次變更上線更是提心吊膽,擔心會成為壓死駱駝的最後一根稻草,導致服務雪崩。
在需求終於緩下來後,leader 給我們定下目標,限我們在兩周內把服務性能問題徹底解決。近兩周的排查和梳理中,發現並解決了多個性能瓶頸,修改了系統熔斷方案,最終實現了服務能處理的 QPS 翻倍,能實現在極高 QPS(3-4倍)壓力下服務正常熔斷,且能在壓力降低後迅速恢復正常,以下是部分問題的排查和解決過程。
伺服器高CPU、高負載
首先要解決的問題就是服務導致伺服器整體負載高、CPU 高的問題。
我們的服務整體可以歸納為從某個存儲或遠程調用獲取到一批數據,然後就對這批數據進行各種花式變換,最後返回。由於數據變換的流程長、操作多,系統 CPU 高一些會正常,但平常情況下就 CPU us 50% 以上,還是有些誇張了。
我們都知道,可以使用 top 命令在伺服器上查詢系統內各個進程的 CPU 和記憶體占用情況。可是 JVM 是 Java 應用的領地,想查看 JVM 里各個線程的資源占用情況該用什麼工具呢?
jmc 是可以的,但使用它比較麻煩,要進行一系列設置。我們還有另一種選擇,就是使用 jtop
,jtop 只是一個 jar 包,它的項目地址在 yujikiriki/jtop , 我們可以很方便地把它複製到伺服器上,獲取到 java 應用的 pid 後,使用 java -jar jtop.jar [options] <pid>
即可輸出 JVM 內部統計信息。
jtop 會使用預設參數 -stack n
列印出最耗 CPU 的 5 種線程棧。
形如:
Heap Memory: INIT=134217728 USED=230791968 COMMITED=450363392 MAX=1908932608 NonHeap Memory: INIT=2555904 USED=24834632 COMMITED=26411008 MAX=-1 GC PS Scavenge VALID [PS Eden Space, PS Survivor Space] GC=161 GCT=440 GC PS MarkSweep VALID [PS Eden Space, PS Survivor Space, PS Old Gen] GC=2 GCT=532 ClassLoading LOADED=3118 TOTAL_LOADED=3118 UNLOADED=0 Total threads: 608 CPU=2454 (106.88%) USER=2142 (93.30%) NEW=0 RUNNABLE=6 BLOCKED=0 WAITING=2 TIMED_WAITING=600 TERMINATED=0 main TID=1 STATE=RUNNABLE CPU_TIME=2039 (88.79%) USER_TIME=1970 (85.79%) Allocted: 640318696 com.google.common.util.concurrent.RateLimiter.tryAcquire(RateLimiter.java:337) io.zhenbianshu.TestFuturePool.main(TestFuturePool.java:23) RMI TCP Connection(2)-127.0.0.1 TID=2555 STATE=RUNNABLE CPU_TIME=89 (3.89%) USER_TIME=85 (3.70%) Allocted: 7943616 sun.management.ThreadImpl.dumpThreads0(Native Method) sun.management.ThreadImpl.dumpAllThreads(ThreadImpl.java:454) me.hatter.tools.jtop.rmi.RmiServer.listThreadInfos(RmiServer.java:59) me.hatter.tools.jtop.management.JTopImpl.listThreadInfos(JTopImpl.java:48) sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ... ...
通過觀察線程棧,我們可以找到要優化的代碼點。
在我們的代碼里,發現了很多 json 序列化和反序列化和 Bean 複製耗 CPU 的點,之後通過代碼優化,通過提升 Bean 的復用率,使用 PB 替代 json 等方式,大大降低了 CPU 壓力。
熔斷框架優化
服務熔斷框架上,我們選用了 Hystrix,雖然它已經宣佈不再維護,更推薦使用 resilience4j
和阿裡開源的 sentinel,但由於部門內技術棧是 Hystrix,而且它也沒有明顯的短板,就接著用下去了。
先介紹一下基本情況,我們在控制器介面最外層和內層 RPC 調用處添加了 Hystrix 註解,隔離方式都是線程池模式,介面處超時時間設置為 1000ms,最大線程數是 2000,內部 RPC 調用的超時時間設置為 200ms,最大線程數是 500。
響應時間不正常
要解決的第一個問題是介面的響應時間不正常。在觀察介面的 access 日誌時,可以發現介面有耗時為 1200ms 的請求,有些甚至達到了 2000ms 以上。服務正常時,這種情況對於線程池隔離方式是不可能發生的,因為線程池模式下,Hystrix 會創建一個新的線程去執行真正的業務邏輯,而主線程則一直在等待,一旦等待超時,主線程是可以立刻返回的。所以介面耗時超過超時時間,問題很可能發生在 Hystrix 框架層、Spring 框架層或系統層。
這時候可以對運行時線程棧來分析,我使用 jstack 列印出線程棧,並將多次列印的結果製作成火焰圖(參見應用調試工具-火焰圖)來觀察。
如上圖,可以看到很多線程都停在 LockSupport.park(LockSupport.java:175)
處,這些線程都被鎖住了,向下看來源發現是 HystrixTimer.addTimerListener(HystrixTimer.java:106)
, 而再向下就是我們的業務代碼了。
Hystrix 註釋里解釋這些 TimerListener 是 HystrixCommand 用來處理非同步線程超時的,這些 TimerListener 會在調用超時時執行,將超時結果返回。而在調用量大時,進入線程池時這些 TimerListener 的設置就會因為鎖而阻塞,而這些 TimerListener 的設置被阻塞後,就會導致介面設置的超時時間不生效。
要解決這個問題,只能修改服務的隔離策略了,將 Hystrix 的隔離策略改為信號量模式。信號量模式下,Hystrix 會在每次執行 HystrixCommand 時獲取一次信號量,在執行結束後還回。由於信號量的操作效率非常高,而且沒有其他附加操作,所以在使用信號量隔離模式時不會有其他性能損耗。
但使用信號量隔離模式也要註意一個問題:信號量只能限制方法是否能夠進入,如果可以進入執行,則在原來的主線程內執行,執行的過程中 Hystrix 是無法干預的,只能在方法返回後再判斷介面是否超時並對超時進行處理,這可能會導致有部分請求耗時超長時,一直占用一個信號量,但框架卻無法處理。
在修改了 Hystrix 的隔離模式後,介面的最大耗時就穩定了,而且由於方法都在主線程執行,少了 Hystrix 線程池維護和主線程與 Hystrix 線程的上下文切換,系統 CPU 使用率又有進一步下降。
服務隔離和降級
另一個問題是服務不能按照預期的方式進行服務隔離和降級,我們認為流量在非常大的情況下應該會持續熔斷時,而 Hystrix 總表現為半熔斷半執行,我們認為多餘的請求不會進入方法內部時,它們偏偏還能被執行。
開始時,我們對日誌進行觀察,由於日誌被設置成非同步,看不到實時日誌,而且有大量的報錯信息干擾,過程痛苦而低效。後來得知 Hystrix 還有可視化界面後,才算找到正確的調優方式。
Hystrix 可視化模式分為服務端和客戶端,服務端就是我們要觀察的服務,需要在服務內引入 hystrix-metrics-event-stream
包並添加一個介面來輸出 Metrics 信息。要將這些信息展示出來,只需要啟動 hystrix-dashboard
客戶端並填入服務端地址即可。
通過可視化界面,Hystrix 的整體狀態就展示得非常清楚了,我們就可以根據這些狀態信息對它的熔斷配置進行調整了。由於上文的優化,介面的最大響應時間完全可控,可以通過嚴格限制介面方法的併發量來修改服務的拒絕策略了。
假設介面平均響應時間為 50ms,而服務能容納的最大 QPS 為 2000,那麼可以通過 2000*50/1000=100
得到適合的信號量限制,如果被拒絕的錯誤數過多,可以再添加一些冗餘。
這樣,在流量突變時,就可以通過拒絕一部分連接來控制進入服務的總請求數,而在進入服務的總請求里,又嚴格限制了平均耗時,如果錯誤數過多,還可以通過熔斷來進行降級。多種策略同時進行,就能保證介面的平均響應時長了。
熔斷時高負載導致無法恢復
接下來就要解決服務熔斷時,服務負載持續升高,而在 QPS 壓力降低後服務遲遲無法恢復的問題。
在伺服器負載特別高時,使用各種工具來觀測服務內部狀態,結果都是不靠譜的,因為觀測一般都採用打點收集的方式,在觀察服務的同時已經改變了服務。例如使用 jtop 在高負載時查看占用 CPU 最高的線程時,獲取到的結果總是 JVM TI(Java 動態位元組碼技術) 相關的棧。
不過,觀察服務外部可以發現,這個時候會有大量的錯誤日誌輸出,往往在服務已經穩定好久了,還有之前的錯誤日誌在列印,延時的單位甚至以分鐘計。大量的錯誤日誌不僅造成 I/O 壓力,而且線程棧的獲取、日誌存儲記憶體的分配都很有可能會增加伺服器壓力。而且我們的服務早因為日誌量大而改為了非同步日誌,這使得通過 I/O 阻塞線程的屏障也消失了。
要驗證這項猜測也很簡單,修改服務內的日誌記錄點,在列印日誌時不再列印異常棧,再重寫 Spring 框架的 ExceptionHandler,徹底減少日誌量的輸出。
結果非常符合預期,在錯誤量極大時,日誌輸出也被控制在正常範圍,這樣熔斷後,就不會再因為日誌給服務增加壓力,一旦 QPS 壓力下降,熔斷開關被關閉,服務很快就能恢復正常狀態。
Spring 數據綁定異常
另外,在查看 jstack 輸出的線程棧時,還偶然發現了一種奇怪的棧。
at java.lang.Throwable.fillInStackTrace(Native Method) at java.lang.Throwable.fillInStackTrace(Throwable.java:783) - locked <0x00000006a697a0b8> (a org.springframework.beans.NotWritablePropertyException) at java.lang.Throwable.<init>(Throwable.java:287) at java.lang.Exception.<init>(Exception.java:84) at java.lang.RuntimeException.<init>(RuntimeException.java:80) at org.springframework.core.NestedRuntimeException.<init>(NestedRuntimeException.java:66) at org.springframework.beans.BeansException.<init>(BeansException.java:50) at org.springframework.beans.FatalBeanException.<init>(FatalBeanException.java:45) at org.springframework.beans.InvalidPropertyException.<init>(InvalidPropertyException.java:54) at org.springframework.beans.InvalidPropertyException.<init>(InvalidPropertyException.java:43) at org.springframework.beans.NotWritablePropertyException.<init>(NotWritablePropertyException.java:77) at org.springframework.beans.BeanWrapperImpl.createNotWritablePropertyException(BeanWrapperImpl.java:243) at org.springframework.beans.AbstractNestablePropertyAccessor.processLocalProperty(AbstractNestablePropertyAccessor.java:426) at org.springframework.beans.AbstractNestablePropertyAccessor.setPropertyValue(AbstractNestablePropertyAccessor.java:278) at org.springframework.beans.AbstractNestablePropertyAccessor.setPropertyValue(AbstractNestablePropertyAccessor.java:266) at org.springframework.beans.AbstractPropertyAccessor.setPropertyValues(AbstractPropertyAccessor.java:97) at org.springframework.validation.DataBinder.applyPropertyValues(DataBinder.java:839) at org.springframework.validation.DataBinder.doBind(DataBinder.java:735) at org.springframework.web.bind.WebDataBinder.doBind(WebDataBinder.java:197) at org.springframework.web.bind.ServletRequestDataBinder.bind(ServletRequestDataBinder.java:107) at org.springframework.web.servlet.mvc.method.annotation.ServletModelAttributeMethodProcessor.bindRequestParameters(ServletModelAttributeMethodProcessor.java:157) at org.springframework.web.method.annotation.ModelAttributeMethodProcessor.resolveArgument(ModelAttributeMethodProcessor.java:153) at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:124) at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:161) at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:131) at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:102) at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:877) at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:783) at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:991)
jstack 的一次輸出中,可以看到多個線程的棧頂都停留在 Spring 的異常處理,但這時候也沒有日誌輸出,業務也沒有異常,跟進代碼看了一下,Spring 竟然偷偷捕獲了異常且不做任務處理。
List<PropertyAccessException> propertyAccessExceptions = null; List<PropertyValue> propertyValues = (pvs instanceof MutablePropertyValues ? ((MutablePropertyValues) pvs).getPropertyValueList() : Arrays.asList(pvs.getPropertyValues())); for (PropertyValue pv : propertyValues) { try { // This method may throw any BeansException, which won't be caught // here, if there is a critical failure such as no matching field. // We can attempt to deal only with less serious exceptions. setPropertyValue(pv); } catch (NotWritablePropertyException ex) { if (!ignoreUnknown) { throw ex; } // Otherwise, just ignore it and continue... } ... ... }
結合代碼上下文再看,原來 Spring 在處理我們的控制器數據綁定,要處理的數據是我們的一個上下文類 ApiContext,它是由多個欄位組成的參數傳輸 Bean。
控制器代碼類似於:
@RequestMapping("test.json") public Map testApi(@RequestParam(name = "id") String id, ApiContext apiContext) {}
按照正常的套路,我們應該為這個 ApiContext 類添加一個參數解析器(HandlerMethodArgumentResolver),這樣 Spring 會在解析這個參數時會調用這個參數解析器為方法生成一個對應類型的參數。可是如果沒有這麼一個參數解析器,Spring 會怎麼處理呢?
答案就是會使用上面的那段”奇怪”代碼,先創建一個空的 ApiContext 類,並將所有的傳入參數依次嘗試 set 進這個類,如果 set 失敗了,就 catch 住異常繼續執行,而 set 成功後,就完成了 ApiContext 類內一個屬性的參數綁定。
而不幸的是,我們的介面上層會為我們統一傳過來三四十個參數,所以每次都會進行大量的”嘗試綁定”,造成的異常和異常處理就會導致大量的性能損失,在使用參數解析器解決這個問題後,介面性能竟然有近十分之一的提升。
小結
性能優化不是一朝一夕的事,把技術債都堆到最後一塊解決絕不是什麼好的選擇。平時多註意一些代碼寫法,在使用黑科技時註意一下其實現有沒有什麼隱藏的坑才是正解,還可以進行定期的性能測試,及時發現並解決代碼里近期引入的不安定因素。
關於本文有什麼疑問可以在下麵留言交流,如果您覺得本文對您有幫助,歡迎關註我的公眾號【Java技術zhai】,有新文章發佈會第一時間通知您。