SpringMVC 性能優化(不使用@pathvariable註解)

背景

達達後臺系統目前每天都要支撐數十億的訪問量,這對於服務系統整體架構是個嚴峻的考驗。考慮到越來越複雜的業務以及不斷增加的訪問壓力,我們對數據層進行了一系列的改造(參見達達-高性能服務端優化之路),也對業務層進行了服務化(參見基於Zookeeper的服務註冊與發現)。同時,參照DDD設計,我們引入了一個數據訪問層,即ModelService。

ModelService的職責:

  • 封裝業務層對數據層的調用

  • 實現對數據庫的分庫分表(寫入以及查詢)

  • 實現對部分數據的緩存

ModelService以及我們目前大部分系統提供的對外接口都是RESTful風格。

使用RESTful風格的接口有如下優勢:

  • 語言無關(這點對於我們Python+Java的後臺系統很關鍵)

  • 開發效率高、調試方便

  • 接口的語義明確 <!-- more --> 然而缺點也顯而易見:基於HTTP的RPC在效率上不如傳統的RPC。 在ModelService中,我們使用SpringMVC框架來實現RESTful接口。但是,在最近一次對ModelService的更新中我們發現SpringMVC的RESTful接口性能存在問題。


RESTful:

@RequestMapping(path = "/list/cityId/{cityId}", method = RequestMethod.GET)
@ResponseBody
public String getJsonByCityId(@PathVariable Integer cityId)

客戶端請求: GET /list/cityId/1

非RESTful:

@RequestMapping(path = "/list/cityId", method = RequestMethod.GET)
@ResponseBody
public String getJsonByCityId(@RequestParam Integer cityId)

客戶端請求: GET /list/cityId?cityId=1


我們使用Apache JMeter對SpringMVC RESTful接口與非RESTful接口進行了性能測試:

RESTful接口: 

非RESTful接口: 

*併發量爲200 *測試在同一臺機器上進行,執行業務邏輯相同,僅接口不同。 *爲了證明的確是SpringMVC造成的問題,我們使用了最簡單的業務邏輯,直接返回字符串。

由結果可見,非RESTful接口的性能是RESTful接口的兩倍,且請求的最大響應時間是35毫秒,有99%的請求在20毫秒內完成。相比之下,RESTful接口的最大響應時間是436毫秒。

由於ModelService是一個對併發性能要求極高的系統,且被多個上層業務系統所依賴,所有請求需在50ms內返回,若超時則會引起上層系統的read timeout,進而導致502。所以需要對這一情況進行優化。


方案一:將所有的url修改爲非RESTful風格(不使用@PathVariable)

這是最直接的方式,也是最能保證效果的方式。但是這麼做需要修改的是ModelService中已有的全部100+個接口,同時也要修改客戶端相應的調用。修改量太大,而且極有可能由於寫錯URL導致404。更令人不爽的是這種修改會導致接口沒有了RESTful風格。故該方案只能作爲備選。


方案二:對SpringMVC進行改造

根據實際現象以及測試的結果,幾乎可以確定的是問題出在SpringMVC的RESTful路徑查找中。所以我們對SpringMVC中的相關代碼進行了調查。

SpringMVC的請求處理過程中的路徑匹配過程:

org.springframework.web.servlet.handler.AbstractHandlerMethodMapping#lookupHandlerMethod
(spring-webmvc-4.2.3.RELEASE

路徑匹配的過程中有如下代碼:

List<Match> matches = new ArrayList<Match>();
List<T> directPathMatches = this.mappingRegistry.getMappingsByUrl(lookupPath);
if (directPathMatches != null) {
   addMatchingMappings(directPathMatches, matches, request);
}
if (matches.isEmpty()) {
   // No choice but to go through all mappings...
   addMatchingMappings(this.mappingRegistry.getMappings().keySet(), matches, request);
}

SpringMVC首先對HTTP請求中的path與已註冊的RequestMappingInfo(經解析的@RequestMapping)中的path進行一個完全匹配來查找對應的HandlerMethod,即處理該請求的方法,這個匹配就是一個Map#get方法。若找不到則會遍歷所有的RequestMappingInfo進行查找。這個查找是不會提前停止的,直到遍歷完全部的RequestMappingInfo。

public RequestMappingInfo getMatchingCondition(HttpServletRequest request) {
    RequestMethodsRequestCondition methods = this.methodsCondition.getMatchingCondition(request);
    ParamsRequestCondition params = this.paramsCondition.getMatchingCondition(request);
    HeadersRequestCondition headers = this.headersCondition.getMatchingCondition(request);
    ConsumesRequestCondition consumes = this.consumesCondition.getMatchingCondition(request);
    ProducesRequestCondition produces = this.producesCondition.getMatchingCondition(request);

   if (methods == null || params == null || headers == null || consumes == null || produces == null) {
       if (CorsUtils.isPreFlightRequest(request)) {
           methods = getAccessControlRequestMethodCondition(request);
           if (methods == null || params == null) {
               return null;
           }
       }
       else {
           return null;
       }
   }

   PatternsRequestCondition patterns = this.patternsCondition.getMatchingCondition(request);
   if (patterns == null) {
       return null;
   }

   RequestConditionHolder custom = this.customConditionHolder.getMatchingCondition(request);
   if (custom == null) {
       return null;
   }

   return new RequestMappingInfo(this.name, patterns,
           methods, params, headers, consumes, produces, custom.getCondition());
}

org.springframework.web.servlet.mvc.method.RequestMappingInfo#getMatchingCondition

在遍歷過程中,SpringMVC首先會根據@RequestMapping中的headers, params, produces, consumes, methods與實際的HttpServletRequest中的信息對比,剔除掉一些明顯不合格的RequestMapping。 如果以上信息都能夠匹配上,那麼SpringMVC會對RequestMapping中的path進行正則匹配,剔除不合格的。

Comparator<Match> comparator = new MatchComparator(getMappingComparator(request));
Collections.sort(matches, comparator);

接下來會對所有留下來的候選@RequestMapping進行評分並排序。最後選擇分數最高的那個作爲結果。 評分的優先級爲:

path pattern > params > headers > consumes > produces > methods

所以使用非RESTful風格的URL時,SpringMVC可以立刻找到對應的HandlerMethod來處理請求。但是當在URL中存在變量時,即使用了@PathVariable時,SpringMVC就會進行上述的複雜流程。

值得注意的是SpringMVC在匹配@RequestMapping中的path時是通過AntPathMatcher進行的,這段path匹配邏輯是從Ant中借鑑過來的。

 

Part of this mapping code has been kindly borrowed from Apache Ant.

String[] pattDirs = tokenizePattern(pattern);
String[] pathDirs = tokenizePath(path);

int pattIdxStart = 0;
int pattIdxEnd = pattDirs.length - 1;
int pathIdxStart = 0;
int pathIdxEnd = pathDirs.length - 1;

// Match all elements up to the first
while (pattIdxStart <= pattIdxEnd && pathIdxStart <= pathIdxEnd) {
   String pattDir = pattDirs[pattIdxStart];
   if ("".equals(pattDir)) {
       break;
   }
   if (!matchStrings(pattDir, pathDirs[pathIdxStart], uriTemplateVariables)) {
       return false;
   }
   pattIdxStart++;
   pathIdxStart++;
}

org.springframework.util.AntPathMatcher

path的匹配首先會把url按照“/”分割,然後對於每一部分都會使用到正則表達式,即使該字符串是定長的靜態的。所以該匹配邏輯的性能可能會很差。

在大多數情況下,我們在寫@RequestMapping時不會去寫除了path以外的值,至多會指定一個produces,這會讓SpringMVC難以快速剔除不合格的候選者。我們首先試圖讓SpringMVC在進行path匹配前就可以產生匹配結果,從而不去執行path匹配的邏輯,以提高效率。然而實際情況是我們無法做到讓每個方法都有獨特的params, produces, consumes, methods,所以我們嘗試讓每個方法有一個獨特的headers,然後進行了一次性能測試。性能的確得到了一定的提升(約20%),但這個結果並不令我們滿意,我們需要的是能夠達到與非RESTful接口一樣的性能。

我們對匹配邏輯的性能進行了進一步的測試

RESTful URL數量QPS
1 16116.0
10 13342.2
20 10615.7
40 7800.3
100 4056.8
1000 505.6

從結果可見,這段匹配邏輯對性能的影響很大,URL數量越多,SpringMVC的性能越差,初步驗證了我們從源碼中得出的結論。在最近一次ModelService的更新中,接口數量翻了一倍,導致性能下降了一半,這也符合我們的結論。考慮到未來ModelService的接口必定會持續增加,我們肯定不能容忍在請求壓力不斷增加的情況下ModelService的性能反而不斷下降的情況。所以現在我們要做的就是防止SpringMVC執行這種複雜的匹配邏輯,找到一種方式可以繞過它。

通過繼承

org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping

我們可以實現自己的匹配邏輯。由於ModelService已經服務化,所以每個接口都有一個服務名,通過這個服務名即可直接找到對應的方法,並不需要通過@RequestMapping匹配的方式。而在服務消費端,由於服務消費端是通過服務名進行的方法調用,所以在服務消費端可以很直接地獲取到服務名,把服務名加到HTTP請求的header中並不需要對代碼進行大量的修改。


最終方案:

服務端:

  1. 在每個@RequestMapping中添加接口對應服務名的信息。

  2. 實現自己定義的HandlerMethod查詢邏輯,在HandlerMethod註冊時記錄與之對應的服務名,在查詢時通過HTTP請求頭中的服務名查表獲得HandlerMethod。

客戶端:

  1. 調用服務時將服務名加入到HTTP請求頭中

分析:

  • 這樣的查詢時間複雜度是O(1)的,典型的空間換時間。理論上使用這樣的查找邏輯的效率和非RESTful接口的效率是一樣的。

  • 由於HandlerMethod的註冊是在服務啓動階段完成的,且在運行時不會發生改變,所以不用考慮註冊的效率以及併發問題。

  • SpringMVC提供了一系列的方法可以讓我們替換它的組件,所以該方案的可行性很高。

實現細節:

我們要建立一個HandlerMethod與服務名的映射,保存在一個Map中。注意到在@RequestMapping中有一個name屬性,這個屬性並沒有被SpringMVC用在匹配邏輯中。該屬性是用來在JSP中直接生成接口對應的URL的,但是在AbstractHandlerMethodMapping.MappingRegistry中已經提供了一個name與Handler Method的映射,直接拿來用即可。所以我們只需要在每個接口的@RequestMapping中添加name屬性,值爲接口的服務名。在SpringMVC啓動時會自動幫我們建立起一個服務名與Handler Method的映射。我們只要在匹配時從HTTP請求頭中獲取請求的服務名,然後從該Map中查詢到對應的HandlerMethod返回。如果沒有查詢到則調用父類中的原匹配邏輯,這樣可以保證不會對現有的系統造成問題。

*小細節:

因爲RESTful接口存在@PathVariable,我們還需要調用handleMatch方法來將HTTP請求的path解析成參數。然而這個方法需要的參數是RequestMappingInfo,並不是HandlerMethod,SpringMVC也沒有提供任何映射,所以我們還是要自己實現一個HandlerMethod => RequestMappingInfo的反向查詢表。重寫AbstractHandlerMethodMapping#registerMapping方法即可在@RequestMapping的註冊階段完成映射的建立。

最後我們有兩種方式可以把自己實現的RequestMappingHandlerMapping替換掉SpringMVC中的默認組件。

方法一:配置文件

刪除mvc:annotation-driven/註解,添加如下配置:

<bean name="handlerAdapter"
      class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter">
    <property name="webBindingInitializer">
        <bean class="org.springframework.web.bind.support.ConfigurableWebBindingInitializer">
            <property name="conversionService" ref="conversionService"/>
        </bean>
    </property>
    <property name="messageConverters">
        <list>
            <bean class="org.springframework.http.converter.ByteArrayHttpMessageConverter"/>
            <bean class="org.springframework.http.converter.StringHttpMessageConverter"/>
            <bean class="org.springframework.http.converter.ResourceHttpMessageConverter"/>
            <bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter"/>
        </list>
    </property>
</bean>

<bean name=“conversionService” class=“org.springframework.format.support.DefaultFormattingConversionService”/>
<bean name=“handlerMapping” class=“path.to.your.request.mapping.handler.mapping”/>

這樣做其實就是展開了mvc:annotation-driven/註解,然後替換了其中的handlerMapping組件。

方法二:Java類+註解

繼承

org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport

重寫createRequestMappingHandlerMapping方法,在方法中返回自己實現的RequestMappingHandlerMapping對象。然後在類上加上@Configuration註解。如果配置文件中有context:component-scan/,且該類在base-package中,則到此已完成了全部工作。如果沒有,則需要在配置文件中添加這個類作爲bean(bean的名稱可以不用指定)。

本地性能測試:


 

*該測試與之前的測試在同一臺機器上進行,執行業務邏輯相同。 性能與非RESTful接口相當,比之前提高了一倍。 該結果符合我們的預期以及要求。

線上性能實際效果:

上線前

上線後

高峯期CPU使用率從40%~50%降低至不到20%。

總結

SpringMVC的URL匹配性能問題是由@PathVariable帶來的,可以通過去掉所有@PathVariable的方式解決問題,但是極不優雅。

使用服務名作爲路徑查找的一個關鍵詞,是服務化帶來的一個意外的好處。這樣的方式可能並不適用所有的情況。在其他情況下,該方法也是可用的,總體思路就是在接口中添加獨特的信息(關鍵詞),並建立一個映射關係,然後在客戶端的請求中添加所調用接口的關鍵詞(放在請求頭中即可),服務端通過請求頭中的關鍵詞和之前建立的映射關係進行查找即可。

SpringMVC爲開發人員提供了快速搭建一個HTTP服務器的方法,但是正是由於它對於多種情況的考慮,它有許多可以進行優化的地方。

Spring focuses on the "plumbing" of enterprise applications so that teams can focus on application-level business logic, without unnecessary ties to specific deployment environments. ——摘自spring.io

從Spring框架對自己的定位也可以看出,Spring並沒有把高性能作爲首要的目標。SpringMVC中很多的功能在實際項目中是多餘的,爲了達到極高的性能,在實際項目中要對SpringMVC進行全面的配置和定製。

發佈了54 篇原創文章 · 獲贊 31 · 訪問量 10萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章