前言界面性能问题是从事后端开发的同学绕不开的话题。优化一个接口的性能,需要从多方面入手。这篇文章将继续接口性能优化的话题,谈谈我是如何从实际的角度优化一个慢查询接口的。上周优化了在线批量评分查询接口,将接口性能从最初的20s优化到现在的500ms。一般来说,三招就可以搞定。发生了什么?在案发现场,我们每天早上上班前都会收到一封在线慢查询接口汇总邮件,里面会显示接口地址、调用次数、最大耗时、平均耗时、traceId等信息。看到有个批量评分查询接口,最大耗时达到20s,平均耗时也是2s。使用skywalking查看该接口的调用信息,发现大部分情况下,接口响应都比较快。大部分情况下500ms左右可以返回响应,但也有小部分请求超过20s。这种现象很奇怪。会不会跟数据有关?比如:查某个机构的数据,速度非常快。但是如果要查平台,也就是组织的根节点,这种情况下查询的数据量非常大,接口响应可能会很慢。但事实证明这不是原因。很快就有同事给出了答案。他们在结算列表页面批量请求了这个接口,但是他传过来的数据量非常大。这是怎么回事?开头提到的需求是分页列表页调用该接口。每页大小为:10、20、30、50、100,用户可以选择。也就是说,通过调用批量评价查询接口,一次最多可以查询100条记录。但实际情况是:账单列表页也包含了很多订单。基本上,每个结算都有多个订单。调用批量评价查询接口时,需要结合结算单和订单的数据。这样做的结果是:在调用批量评价查询接口时,一次传入了很多参数,传入的参数列表可能包含数百条甚至上千条数据。现在的情况,如果一次性导入成百上千个id,批量查询数据还可以,可以使用主键索引,查询效率也不会太差。但是批量评分查询接口的逻辑并不简单。伪代码如下:publicListquery(Listlist){//ResultListresult=Lists.newArrayList();//获取组织idListorgIds=list.stream().地图(SearchEntity::getOrgId).collect(Collectors.toList());//通过regin调用远程接口获取组织信息ListorgList=feginClient.getOrgByIds(orgIds);for(SearchEntityentity:list){//通过组织id查找组织代码StringorgCode=findOrgCode(orgList,entity.getOrgId());//通过组合条件查询评估ScoreSearchEntityscoreSearchEntity=newScoreSearchEntity();scoreSearchEntity.setOrgCode(orgCode);scoreSearchEntity.setCategoryId(entity.getCategoryId());scoreSearchEntity.setBusinessId(entity.getBusinessId());scoreSearchEntity.setBusinessType(entity.getBusinessType());列表resultList=scoreMapper.queryScore(scoreSearchEntity);如果(CollectionUtils.isNotEmpty(结果列表)){ScoreEntityscoreEntity=resultList.get(0);结果.添加(scoreEntity);}}returnresult;}其实在真实场景中,代码要比这个复杂的多。这里为了给大家演示,我稍微简化了一下。有两个关键点:Point:在接口中,远程调用了另外一个接口,需要在for循环中查询数据。第一点,就是在接口中远程调用另外一个接口,这段代码是必须的。因为如果评估表中的组织代码字段是多余的,万一哪天组织表中的组织代码被修改了,我们就得通过某种机制通知我们同步修改评估表的组织代码,否则就会出现算是数据不一致的问题。很显然,如果要做这个调整,业务流程就需要改动,而且代码改动量会有点大。所以,让我们先把远程调用保留在界面中。从这个角度来看,可以优化的地方只能是:在for循环中查询数据。第一个优化要求在for循环中,每条记录都要根据不同的条件去查询想要的数据。由于业务系统在调用该接口时并没有传递id,所以在where条件中使用idin(...)来批量查询数据是不好的。其实有一种不用循环查询的方法,一个sql就可以解决需求:使用or关键字拼接,例如:(org_code='001'andcategory_id=123andbusiness_id=111andbusiness_type=1)or(org_code='002'andcategory_id=123andbusiness_id=112andbusiness_type=2)or(org_code='003'andcategory_id=124andbusiness_id=117andbusiness_type=1)...这个方法会导致sql语句很长,性能也会很差。其实还有一种写法:where(a,b)in((1,2),(1,3)...)但是这种SQL,如果一次查询的数据量太大much,性能不是很好。不能改为批量查询,只能优化单次查询sql的执行效率。先从索引开始,因为修改成本最低。第一个优化是优化索引。在评估表之前在business_id字段上建立了一个普通的索引,但是从目前来看效率并不理想。因为我果断加了一个联合索引:altertableuser_scoreaddindex`un_org_category_business`(`org_code`,`category_id`,`business_id`,`business_type`)USINGBTREE;该联合索引由四个:org_code、category_id、business_id和business_type字段组成。这样优化之后,效果立竿见影。批量评价查询接口的最大耗时从最初的20s缩短到5s左右。第二种优化要求在for循环中,每条记录都要根据不同的条件去查询想要的数据。只在一个线程中查询数据显然太慢了。那么,为什么不能改成多线程调用呢?第二次优化,查询数据库由单线程改为多线程。但是由于这个接口是返回所有查询到的数据,所以需要获取查询结果。使用多线程调用并获取返回值,这种场景下使用java8中的CompleteFuture是非常适合的。代码调整为:CompletableFuture[]futureArray=dataList.stream().map(data->CompletableFuture.supplyAsync(()->query(data),asyncExecutor).whenComplete((result,th)->{})).toArray(CompletableFuture[]::new);CompletableFuture.allOf(futureArray).join();CompletableFuture的本质是创建线程执行。为了避免产生过多的线程,需要使用线程池。推荐先使用ThreadPoolExecutor类,我们自定义线程池。具体代码如下:ExecutorServicethreadPool=newThreadPoolExecutor(8,//corePoolSize线程池核心线程数为10,//maximumPoolSize线程池最大线程数为60,//最大空闲时间线程池中线程的个数,超过这个时间的空闲线程会被回收//拒绝策略也可以使用ThreadPoolTask??Executor类创建线程池:@ConfigurationpublicclassThreadPoolConfig{/***核心线程数,默认1*/privateintcorePoolSize=8;/***最大线程数,默认Integer.MAX_VALUE;*/privateintmaxPoolSize=10;/***空闲线程的生存时间*/privateintkeepAliveSeconds=60;/***线程阻塞队列容量,默认Integer.MAX_VALUE*/privateintqueueCapacity=1;/***是否允许核心线程超时*/privatebooleanallowCoreThreadTimeOut=false;@Bean("asyncExecutor")publicExecutorasyncExecutor(){ThreadPoolTask??Executorexecutor=newThreadPoolTask??Executor();executor.setCorePoolSize(corePoolSize);executor.setMaxPool大小(最大池大小);executor.setQueueCapacity(queueCapacity);executor.setKeepAliveSeconds(keepAliveSeconds);executor.setAllowCoreThreadTimeOut(allowCoreThreadTimeOut);//设置拒绝策略,直接在执行方法的调用线程中运行被拒绝的任务executor.setRejectedExecutionHandler(newThreadPoolExecutor.CallerRunsPolicy());//执行初始化executor.initialize();回归执行人;}}经过这次优化,界面性能也从5s左右提升到1s左右,提升了5倍。但整体效果并不理想。第三次优化经过前面两次优化,批量查询求值接口的性能有所提升,但耗时仍然大于1s。这个问题的根本原因是:一次查询的数据太多。那么,为什么我们不限制每次查询的记录数呢?第三个优化是限制一次性查询的记录条数。其实之前也有限制过,不过最大是2000条记录,从目前来看效果并不好。接口限制一次只能有200条记录,超过200条记录会报错。如果直接限制接口,可能会导致业务系统异常。为了避免这种情况的发生,需要与业务系统团队商讨优化方案。主要有以下两种解决方案:1、分页在前端完成。在结算列表页面,每个结算默认只显示1个订单,存在冗余分页查询。这种情况下,如果按照每页最大100条记录计算,则结算单和订单一次最多只能查询200条记录。这就需要业务系统前端实现分页功能,同时需要调整后端接口支持分页查询。但是目前的状态是前端没有多余的开发资源。由于人手不足,这项计划只能暂时搁置。2.批量调用接口业务系统后端以前是调用一次评价查询接口,现在是批量调用。例如:在查询500条记录之前,业务系统只调用了一次查询接口。现在改为业务系统一次只查100条记录,分5批调用,一共查询500条记录。这不是更慢吗?答:5批评价查询接口的调用如果在for循环中单线程顺序执行,整体耗时当然可能会慢一些。不过业务系统也可以改成多线程调用,最后只需要汇总结果即可。说到这里,可能有人会问:评价查询接口的服务端多线程调用和其他业务系统的多线程调用一样吗?批量评价查询接口增加服务器线程池的最大线程数如何?显然你忽略了一件事:在线应用一般不会部署成单点的。在大多数情况下,为了避免服务器故障导致的单点故障,至少会部署两个节点。这样即使某个节点宕机了,也能正常访问整个应用。当然,也可能出现这种情况:一个节点挂了,另一个节点可能因为访问流量太大,承受不住压力,也可能因此挂掉。也就是说,通过业务系统中的多线程调用接口,可以将访问接口的流量负载均衡到不同的节点上。他们还使用8个线程将数据分成100条记录的批次,最后汇总结果。经过此次优化,界面性能再次翻倍。从1s左右,缩短到500ms以下。温馨提示,无论是通过批量查询评估接口查询数据库,还是在业务系统中调用批量查询评估接口,使用多线程调用,都只是临时解决方案,并不完美。这样做的原因主要是为了先快速解决问题,因为这种解决方案的变化是最小的。要从根本上解决问题,需要重新设计这套功能,修改表结构,甚至可能需要修改业务流程。但由于涉及多个业务线、多个业务系统,只能按计划慢慢做。