图片来自宝途网,即使你有性能指标数据,也很难说服领导从300ms提升到150ms,因为它没有商业价值。这很可悲,但这是可悲的现实。性能优化通常由有技术追求的人发起,是一种基于观察到的指标的正向优化。他们通常是工匠,每毫秒都挑剔,精益求精。当然,前提是你有时间。优化背景和目标我们这次进行性能优化是因为已经到了无法忍受的地步。这是一种事后补救和问题驱动的方法。这通常没有问题。毕竟业务是第一位的,迭代是在填坑中进行的。先说说背景吧。本次要优化的服务,请求响应时间非常不稳定。随着数据量的增加,大部分请求会耗时5-6秒左右!已经超出了普通人能够承受的范围。当然需要优化。为了说明要优化的目标,我粗略地画出了它的拓扑结构。如图所示,这是一组微服务架构的服务。其中,我们优化的目标是在一个相对上游的服务中。它需要通过Feign接口调用很多下游的服务提供者,获取到数据后进行聚合拼接,最后通过Zuul网关和Nginx发送给浏览器客户端。为了观察服务之间的调用关系和监控数据,我们对接Skywalking调用链平台和Prometheus监控平台,收集重要数据,以便我们做出最优决策。在优化之前,我们需要先看看优化需要参考的两个技术指标:吞吐量:单位时间内出现的次数。如QPS、TPS、HPS等。AverageResponseTime:平均每个请求花费的时间。平均响应时间自然是越小越好,越小吞吐量越高。吞吐量的提升也可以合理利用多核,通过并行增加单位时间内的出现次数。我们这次优化的目标是将部分接口的平均响应时间降低到1秒以内;增加吞吐量,也就是提高QPS,让单实例系统可以接受更多的并发请求。通过压缩大幅减少时间消耗我想介绍一下让系统飞起来最重要的优化方法:压缩。通过在chrome的inspect中查看请求的数据,我们发现了一个关键的请求接口,每次传输大约10MB的数据。这得装多少东西。如此大量的数据,下载它需要花费很多时间。如下图,是我对某网站首页的一个请求,里面的内容下载代表了数据在网络上的传输时间。如果用户的带宽很慢,这个请求的耗时会很长。为了减少网络上的数据传输时间,可以启用gzip压缩。gzip压缩是一种时间换空间的方法。对于大多数服务来说,最后一个环节是Nginx,大部分人都会在Nginx层做压缩。其主要配置如下:gzipon;gzip_varyon;gzip_min_length10240;gzip_proxiedexpiredno-cacheno-storeprivateauth;gzip_typestext/plaintext/csstext/xmltext/javascriptapplication/x-javascriptapplication/xml;gzip_disable"MSIE[1-6]\.";惊人的?让我们来看看这个截图。可以看到数据经过压缩后,从8.95MB减少到368KB!可以通过浏览器瞬间下载。但是等等,Nginx只是最外层的链接,还没完,我们还可以让请求更快。请看下面的请求路径。由于使用微服务,请求的流程变得复杂:Nginx不直接调用相关服务,它调用Zuul网关,而Zuul网关实际调用的是目标服务,在目标服务之外,还调用了其他服务。内网带宽也是带宽,网络延迟也会影响调用速度,也必须压缩。nginx->zuul->serviceA->serviceE如果Feign之间的调用都经过压缩通道,则需要额外配置。我们是一个SpringBoot服务,可以用okhttp处理透明压缩。添加依赖:io.github.openfeignfeign-okhttp打开服务器配置:server:port:8888compression:enabled:truemin-response-size:1024mime-types:["text/html","text/xml","application/xml","application/json","application/octet-stream"]启用客户端配置:feign:httpclient:enabled:falseokhttp:enabled:true经过这些压缩后,我们界面的平均响应时间直接从5-6秒减少到2-3秒,优化效果非常显着。当然我们也针对结果集做了一篇文章。在返回给前端的数据中,已经精简了不用的对象和字段。但是一般情况下,这些改动都是创伤性的,需要调整很多代码,所以我们在这上面的精力是有限的,效果自然也是有限的。并行获取数据,响应速度快。接下来就要深入代码逻辑进行分析了。上面我们提到,面向用户的接口其实就是一个数据聚合接口。它的每一个请求,通过Feign调用其他几十个服务接口获取数据,然后拼接结果集。为什么慢?因为这些请求都是串行的!Feign调用是远程调用,即网络I/O密集型调用,大部分时间都在等待。如果数据满足,很适合并行调用。首先,我们需要分析这几十个子接口的依赖关系,看看它们是否有严格的顺序要求。如果大多数人不这样做,那就更好了。分析结果喜忧参半。这一堆接口按照调用逻辑大致可以分为A类和B类。首先,您需要请求A类接口。数据拼接后,数据会被B类使用。但是在A类和B类内部,没有顺序要求。也就是说,我们可以把这个接口拆成两部分依次执行,在某一部分并行获取数据。然后根据这个分析结果尝试修改。使用concurrent包中的CountDownLatch,很容易实现合并功能。CountDownLatchlatch=newCountDownLatch(jobSize);//submitjobexecutor.execute(()->{//jobcodelatch.countDown();});executor.execute(()->{latch.countDown();});...//endsubmitlatch.await(timeout,TimeUnit.MILLISECONDS);结果非常令人满意。我们的界面耗时减少了将近一半!此时接口耗时已经减少到2秒以内。你可能会问,为什么不用Java的并行流呢?关于并行流,不建议大家使用。并发编程一定要慎重,尤其是业务代码中的并发编程。我们构建了一个专门的线程池来支持这个并发获取功能。finalThreadPoolExecutorexecutor=newThreadPoolExecutor(100,200,1,TimeUnit.HOURS,newArrayBlockingQueue<>(100));压缩和并行化是我们这次优化中最有效的手段。他们直接砍掉了大部分耗时的请求,非常有效。但是我们还是不满足,因为每次请求还有1秒多。缓存分类,进一步提速我们发现有些数据的获取是放在一个循环中的,无效请求很多,这是不能容忍的。for(List){client.getData();}如果把这些常用的结果缓存起来,可以大大减少网络IO请求的次数,提高程序的运行效率。缓存在大多数应用程序的优化中起着重要作用。但是由于压缩和并行效果的对比,在我们的场景下缓存的效果不是很明显,但是还是减少了大概30到40毫秒的请求时间。具体做法如下:首先,我们将部分代码逻辑简单、适合CacheAsidePattern模式的数据放在分布式缓存Redis中。具体来说,读的时候先读缓存,读不到缓存的时候再读数据库;更新时,先更新数据库,再删除缓存(延迟双删)。这样可以解决大部分业务逻辑简单的缓存场景,解决数据一致性问题。但是仅仅这样做是不够的,因为有些业务逻辑很复杂,更新的代码也很零散,不适合使用CacheAsidePattern进行改造。我们了解到,有些数据具有以下特点:经过耗时的获取,这些数据会在极端的时候再次被使用。业务数据对它们的一致性要求可以秒级控制。对于这些数据的使用,有多种方式可以跨代码和跨线程使用它们。针对这种情况,我们设计了一个存在时间极短的堆内内存缓存。1秒后,数据将失效,然后再次从数据库中读取。加入一个节点调用服务器接口是每秒1k次,我们直接降为1次。这里使用了Guava的LoadingCache,Feign接口调用减少了一个数量级。LoadingCachelc=CacheBuilder.newBuilder().expireAfterWrite(1,TimeUnit.SECONDS).build(newCacheLoader(){@OverridepublicStringload(Stringkey)throwsException{returnslowMethod(key);}});MySQL索引的优化我们的业务系统使用的是MySQL数据库。由于没有专业的DBA参与,数据表是使用JPA生成的。优化的时候发现大量不合理的指标,当然要优化。由于SQL的敏感性强,这里只讲一些在优化过程中遇到的索引优化规则。相信大家也可以在自己的业务系统中进行类比。索引很有用,但是要小心,如果你对字段做函数操作,那么索引就用不到了。常见的索引失败可能出现在以下两种情况:查询到的索引字段类型与用户传递的数据类型不同,需要进行一层隐式转换。比如在一个varchar类型的字段上,传入一个int参数,查询的两个表之间使用的字符集不同,所以关联的字段不能作为索引。MySQL索引优化最基本的就是遵循最左前缀原则。当有a、b、c三个字段时,如果查询条件使用a,ora,b,ora,b,c,那么我们就可以创建一个索引(a,b,c),其中包含一个和ab。当然,字符串也可以使用前缀进行索引,但这在普通应用中不太常见。有时,MySQL优化器会选择错误的索引,我们需要使用强制索引来指定要使用的索引。在JPA中,需要使用nativeQuery来编写与MySQL数据库绑定的SQL语句。我们尽量避免这种情况。另一个优化是减少后台表。由于InnoDB使用的是B+树,如果不使用非主键索引,会先通过二级索引(secondaryindex)找到聚簇索引,然后定位数据。多一步生成返回表。使用覆盖索引可以在一定程度上避免返表,是一种常用的优化方法。具体方法是将要查询的字段和索引放在一起,形成一个联合索引,这是一种用空间换取时间的方式。JVM优化我一般把JVM优化放在最后一环。而且,除非系统出现严重卡顿或OOM问题,否则不会主动过度优化。不幸的是,由于启用了大内存(8GB+),我们的应用程序经常在JDK1.8的默认并行收集器下冻结。虽然不是很频繁,但是动辄几秒,已经严重影响了一些请求的流畅性。程序一开始在JVM下裸跑,GC信息,OOM,什么都没留下。为了记录GC信息,我们做了如下修改。第一步是添加用于GC故障排除的各种参数。-XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath=/opt/xxx.hprof-DlogPath=/opt/logs/-verbose:gc-XX:+PrintGCDetails-XX:+PrintGCDateStamps-XX:+PrintGCApplicationStoppedTime-XX:+PrintTenuringDistribution-Xloggc:/opt/logs/gc_%p.log-XX:ErrorFile=/opt/logs/hs_error_pid%p.log这样我们就可以把生成的GC文件上传到gceasy等平台进行分析。可以查看JVM的吞吐量,各个阶段的延迟等。第二步,打开SpringBoot的GC信息,接入Promethus监控。给pom添加依赖org.springframework.bootspring-boot-starter-actuator然后配置暴露点。这样我们就有了实时的分析数据和优化的依据。management.endpoints.web.exposure.include=health,info,prometheus观察JVM的性能后,我们切换到G1垃圾收集器。G1有一个max-pause目标来平滑我们的GC时间。主要有以下调优参数:-XX:MaxGCPauseMillis:设置目标停顿时间,G1会尽力去实现。-XX:G1HeapRegionSize:设置小堆区域的大小。这个值应该是2的幂,既不太大也不太小。如果您不知道如何设置它,请保留默认值。-XX:InitiatingHeapOccupancyPercent:当整个堆内存使用率达到一定百分比(默认为45%)时,将开始并发标记阶段。-XX:ConcGCThreads:并发垃圾收集器使用的线程数。默认值因运行JVM的平台而异。不建议修改。切换到G1后,这种不间断的停顿奇迹般的消失了!期间内存溢出问题很多,不过在MAT的加持下,最终轻松解决。如果说其他的优化在工程结构和架构上存在缺陷,那么代码优化的作用其实是有限的,就像我们的案例一样。但是主要代码还是需要整理一下。一些高耗时逻辑中的关键代码,我们都特别注意了。按照开发规范,代码清理了一次。其中,有几点让人印象深刻。有的同学为了能够重复使用地图集,每次用完后都会用clear的方式进行清理。map1.clear();map2.clear();map3.clear();map4.clear();这些map里面的数据很特殊,clear方法有点特殊,它的时间复杂度是O(n),导致耗时更高。publicvoidclear(){Node[]tab;modCount++;if((tab=table)!=null&&size>0){size=0;for(inti=0;ip=first();p!=null;){if(p.item!=null)if(++count==Integer.MAX_VALUE)break;//@seeCollection.size()if(p==(p=p.next))continuerestartFromHead;}??returncount;}}另外,有些网页的响应很慢。这是由于复杂的业务逻辑和前端JavaScript本身执行缓慢造成的。这部分代码优化需要前端同事来处理。如图所示,使用chrome或者firefox的performance选项卡可以轻松找到耗时的前端代码。总结一下性能优化,其实是有套路的,但是一般的团队都是等问题出现了再优化,很少有人提前计划。但它与监控和APM不同。我们可以随时获取数据,反向推动优化过程。有些性能问题可以在业务需求层面或架构层面解决。所有带到代码层,需要程序员介入的优化,已经到了需求方和架构方不能再乱来,或者不想动了的地步。性能优化首先要收集信息,找出瓶颈,权衡CPU、内存、网络、IO等资源,然后最小化平均响应时间,提高吞吐量。缓存、缓冲、池化、减少锁冲突、异步、并行和压缩都是常见的优化方法。在我们的场景中,数据压缩和并行请求发挥了最大的作用。当然,在其他优化手段的辅助下,我们的业务界面直接从5-6秒降到了1秒以内。这个优化效果还是很可观的。估计以后很长一段时间都不会优化了。作者:味姐小姐姐编辑:陶佳龙来源:转载自公众号小姐姐的味道(ID:xjjdog)