达达后台系统目前每天都要支撑数十亿的访问量,这对于服务系统整体架构是个严峻的考验。考虑到越来越复杂的业务以及不断增加的访问压力,我们对数据层进行了一系列的改造(参见 达达-高性能服务端优化之路 ),也对业务层进行了服务化(参见 基于Zookeeper的服务注册与发现 )。同时,参照DDD设计,我们引入了一个数据访问层,即ModelService。
ModelService以及我们目前大部分系统提供的对外接口都是RESTful风格。
@RequestMapping(path = "/list/cityId/{cityId}", method = RequestMethod.GET) @ResponseBody public String getJsonByCityId(@PathVariable Integer cityId)
客户端请求: GET /list/cityId/1
@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。所以需要对这一情况进行优化。
这是最直接的方式,也是最能保证效果的方式。但是这么做需要修改的是ModelService中已有的全部100+个接口,同时也要修改客户端相应的调用。修改量太大,而且极有可能由于写错URL导致404。更令人不爽的是这种修改会导致接口没有了RESTful风格。故该方案只能作为备选。
根据实际现象以及测试的结果,几乎可以确定的是问题出在SpringMVC的RESTful路径查找中。所以我们对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中并不需要对代码进行大量的修改。
我们要建立一个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中的默认组件。
删除
<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"/>
这样做其实就是展开了
继承
org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport
重写createRequestMappingHandlerMapping方法,在方法中返回自己实现的RequestMappingHandlerMapping对象。然后在类上加上@Configuration注解。如果配置文件中有
*该测试与之前的测试在同一台机器上进行,执行业务逻辑相同。
性能与非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进行全面的配置和定制。