公司开发的一个门户系统运行几年了,最近因为客户的组织机构调整,要大幅增加用户数,于是开始了一场对系统并发性能进行调优的艰难之旅。
本文记录一个传统Java Web系统性能调优过程的方方面面,希望将来能成为此类工作的指南和索引,从中跳转到各个零碎的知识点。因为涉及点非常多,这里只能做汇总记录,求全不求精,每个知识点的具体细节,需要自行到网上查找,文中也给出了一些不错的参考链接。写作不易,给出的链接地址都是经过筛选的,有些实在找不到满意的就只好自己写,因此这篇文章如果给大家带来了一些帮助,别忘了点个赞哦!
本文未涉及复杂的架构变化(因为不需要啊!),不适用于大型网站。想了解大型网站架构优化的,瞅瞅这个:大型网站架构演化。
系统现状是这样的:
在以上背景下,系统经LoadRunner压力测试,并发访问能力惨不忍睹。客户要求达到1000并发(不是1000在线!),于是开始了艰难的优化之旅。
概括说来,为支持高并发的核心优化点是提高后端处理性能和前后端之间的通信效率。提高前端(浏览器)性能的方法是没有帮助的。总的调优方向可以概括为:
- 增加服务器处理资源
- 提高服务器运行效率
- 减少前后端交互次数
- 减少总通讯流量
因系统运行多年,为降低风险,采用了尽量不修改代码、不增加新的独立架构组件的原则。最终,我们沿着以下的路线来开始这场优化之旅:
一、 应用系统调优
二、 使用集群
三、 网络和部署方式调优
当系统响应缓慢时,如何准确找到瓶颈点在哪?这里隆重介绍JDK自带工具VisuslVM,利用它可以非常容易地找出运行最耗时的地方,精确到函数级别哦!具体用法,可以参考下面这篇文档:性能分析神器VisualVM。在我的实践中,最有用的就是使用其中的Sampler抽样器功能,找出最耗时的函数,做出针对性优化。
另一个分析神器是阿里出品的Druid连接池。是的,你没看错,一个连接池竟然自带了非常完整的系统监控功能,可以在线查看和分析数据库访问、HTTP请求的实时和统计数据,实为居家开发必备之品。参考链接:http://www.cnblogs.com/han-1034683568/p/6730869.html
一个Web系统的典型处理流程是接受HTTP请求、查询数据库、根据数据渲染页面、返回页面给用户。这个过程有两个地方可以运用缓存:
(1)数据库缓存
大多数情况下系统高并发的最大瓶颈是数据库。数据库因为其内部的事务、锁等机制,天生难以达到很高的并发处理能力。我们需要使用应用层缓存来降低应用程序与数据库的交互次数。
可以通过Spring框架中的@Cacheable
和@CacheEvict
来操作缓存(其实不仅仅可以用于数据库方面,Service层的接口都可以)。这方面文章很多,这里不介绍了,只提醒一个坑:Spring默认的缓存Key的生成策略比较简单,如果有两个方法使用的参数一致,可能会产生冲突。所以一定要自定义Key生成策略。这篇文章可以参考一下: https://www.jianshu.com/p/2a584aaafad3。
(2)页面缓存
对企业门户类的系统,页面的变动频率并不高(不要拿互联网门户来比啊!),对用户来说也可以接受一定的延时,因此直接缓存最终渲染后的页面,跳过后台所有获取数据、组装页面的过程,能得到极高的响应速度。配置过程很简单,只要设置好缓存参数,再添加一个Web过滤器,不需要改动代码,是不是很美好?具体可参考ehcache实现页面整体缓存和页面局部缓存。
缓存选型上,我们使用了Ehcache这种嵌入式的轻量级缓存,从而不改变系统总体架构。当然也可以使用其他的,只是要注意,如果使用嵌入式的,最好能支持集群。Spring已经对缓存接口做了抽象,只要写好适配器,就可以通过统一的接口来使用。
根据并发数量的要求,需要调节数据库连接池的参数。我们在门户系统中使用的是Druid连接池,参数可以参考网上文章,根据需求和监测到的结果来调整。可参考: https://www.jianshu.com/p/e75d73129f51
下面列出了我们的部分参数:
<property name="initialSize" value="100" />
<property name="minIdle" value="100" />
<property name="maxActive" value="500" />
<property name="removeAbandoned" value="true" />
<property name="removeAbandonedTimeout" value="10" />
<property name="timeBetweenEvictionRunsMillis" value="10000" />
<property name="minEvictableIdleTimeMillis" value="30000" />
<property name="poolPreparedStatements" value="true" />
<property name="maxPoolPreparedStatementPerConnectionSize" value="20" />
<property name="filters" value="stat" />
当然,上面只是应用程序层的调参,对数据库本身还需要通过命令来修改最大连接数限制,并且这个限制需要与应用层的参数配套。
这里我说一下调优经验:
(1) 务必开启Druid连接池的监控统计功能:通过Druid的监控页面可以在压测时实时查看数据库连接数、最慢SQL、SQL执行次数、HTTP请求次数、最慢HTTP请求等重要信息,从而为优化程序代码、设置最大连接数上限等行动提供参考。
(2) 应用程序连接池和数据库最大连接数之间要配套:如果有多个应用访问同一个数据库,需要注意他们的连接池上限之和,防止出现每个连接池本身未满,加起来压垮了数据库的情况。一般来说,先根据并发用户数来确定数据库的连接上限,再根据该上限来分配各个连接池的额度。不过,对单次用户访问,各个应用通常不会同时连接数据库,所以连接池上限之和是可以大于数据库上限的。测试时监控数据库连接数的变化情况,会帮助你搞清楚你系统的并发用户数与数据库连接数之间的大致关系。
(3) 压测时根据错误日志判断哪里需要调参:简单说,如果报错堆栈的最上面是连接池的Class信息,则说明是连接池自身报错,需要调连接池参数;如果最上面是JDBC Class的错误,则说明已经到达了实际访问数据库的地方,很大可能是数据库出错,需要调整数据库参数,但也有可能是因为连接池参数不当间接造成的数据库错误。分析时可能需要去看数据库运行日志。
我们的应用系统有两种类型日志:
第一种日志在压测时严重影响性能甚至造成系统崩溃。其实不仅仅是日志,因为数据库的锁机制,只要是高并发地往同一张表写入数据,性能都会急剧下降甚至报错。此时需要引入缓存:先把要写入的数据缓存起来,然后用一个后台工作线程定时或定量触发方式把缓存数据通过Batch SQL的方式批量写入数据库。缓存可以起到削峰的作用,缺点是写入滞后、异常情况下丢失数据。对本系统的日志而言,这些问题可以接受。如果需要高实时性、高可靠性,那就考虑使用Redis、ElasticSearch之类的外部组件,优化架构吧。
第二种日志,如果用户一个动作产生2行日志,并发1000就意味着每秒钟要写入2000行文件,说大不大说小不小,毕竟存储设备也是有IOPS上限的。为了榨取最大性能,可以考虑把日志级别调到WARN以上,减少日志输出量。
应用程序代码优化是个细活、工夫活,这里分几个层面来说。
(1)前端优化
先看这个汇总吧:Web前端性能优化。需要提醒的是,这里面大多数需要修改代码,而且很大一部分只是优化在浏览器中的显示速度来提升用户体验,对压力测试成绩不会有什么提高。
不改代码且有明显效果的,是在Tomcat上开启gzip压缩。虽然会提高CPU占用率,但能显著降低网络流量,提高并发访问能力。配置是否成功,看一下浏览器调试窗口中,HTTP Response中的Content-Encoding是不是GZIP就知道了。
我们在本次优化中偷懒只使用gzip压缩这一条,其他需要改代码的方式,以后再说吧,美其名曰保持系统稳定性:-)
值得一提的是,在使用LoadRunner做压力测试时,有一些选项开关是控制是否模仿浏览器行为缓存数据的。这篇文章写的比较仔细,可以参考看一下。如果LoadRunner中不启用缓存,则测试成绩相当于所有用户都是第一次访问系统,需要下载所有数据。而真实高并发场景中,用户的浏览器已经有缓存数据,不需要再次请求。因此测试成绩会比实际要差。
(2)后端优化
Java方面的亲身经历,曾经把一个用String大量循环拼字符串的代码改成用StringBuffer,响应时间从5秒降到了不足1秒。SQL方面,最常见错误是在WHERE 语句中对索引字段进行计算、类型转换等操作,导致索引完全没发挥作用。
Java和SQL的性能优化,网上文章多的很,就不赘述了。使用前面介绍的VisualVM和Druid连接池监控进行分析,从最慢的地方开始,耐心调优吧。
(3)数据架构优化:缓存常用稳定数据
哪些数据是常用稳定数据?组织机构、用户、角色权限、数据字典等。
举个例子,每个用户登录时都需要到数据库去查询账号密码是否匹配。前面说的Spring缓存机制只能提高同一个用户再次登录时的查询性能,不能提高多个不同用户同时第一次登录时的性能。因用户表很少发生变化,数据量也不大,故可以整体放到缓存中,需要时从缓存查询,带来数量级的性能提升。系统在启动时加载数据,在有改动时则更新缓存。
如果系统是单实例部署,出于简单性考虑,缓存可以直接使用JVM内存。如果是集群部署,此类数据最好放在JVM外部独立缓存或支持集群的JVM嵌入式缓存,以确保数据一致性。如果非要用JVM内存(也太懒了吧?),只要对数据的实时性要求不高,比如允许修改的数据在5分钟后才生效,则可以在集群的每个实例中用后台线程定时刷新数据,更新到内存中,不过此时无法保证任一瞬间每台服务器看到的数据是一样的,对业务逻辑是否有影响,要具体情况具体分析。
(4)第三方接口调用优化
原系统逻辑是这样的:用户在加载门户系统首页时,会通过Ajax请求加载第三方系统数据。此时后台会顺序调用若干第三方系统的接口,并返回合并后的数据。同时首页有定时自动刷新机制,确保数据变动能及时反映。
这里的主要问题有:
我们可以采取以下手段进行优化:
如果没有缓存和解耦的理念,上面这些方案全都无法实现,这充分说明了它们在系统架构中的重要性。
架构不变情况下,对表添加查询字段的索引、把最近数据和历史数据分表存储,这些常规方法都有明显效果。还不行的话,考虑读写分离等架构升级方案吧。
默认的Tomcat运行参数不满足当前并发需求,要进行调整。随便搜了几篇供参考:
Tomcat8.0 基本参数调优配置
聊下并发和Tomcat线程数(Updated)
这次优化中,我们主要调的参数是Server.xml中的这两个:
# 最大并发数
maxThreads="2000"
# 指定当所有可以使用的处理请求的线程数都被使用时,可以放到处理队列中的请求数,超过这个数的请求将不予处理
acceptCount="2000"
注意,很多网上文章提到的maxSpareThreads
这个参数,从Tomcat 7之后就没有了,不用再费心调了。
此外,因高并发带来的系统内存资源占用提高,需要根据实际测算结果来调整JVM参数 ,修改Catalina.bat/Catalina.sh中的JAVA_OPTS。比如我们设置的参数是:-Xms8096m -Xmx16192m
。
通过Apache + Tomcat集群的方式,提高系统承载能力。这方面文章很多,可以参考:Apache+Tomcat集群负载均衡的两种session处理方式。
这里说两个要点:
static Map
、static List
等字样来找到这些地雷。讲真,我也不知道这是不是必要的,但很多帖子都说,就这么做了。也找到一篇微软官方文章说这个有用。
Windows系统可能限制了TCP端口连接数,并发高了就无法连接,需要修改下面的注册表项:
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\TCPIP\Parameters
修改端口数MaxUserPort
为十进制的65534,TcpTimedWaitDelay
为5。如果没有的话先创建。
我们用的服务器是Windows,在Apache上设置参数如下:
ThreadLimit 10000
ThreadsPerChild 10000
MaxRequestsPerChild 0
KeepAlive On
KeepAliveTimeout 30
解释如下:
ThreadLimit
,默认值是64。如果设置过大超出硬件处理能力,可能会导致服务器不稳定。在前面集群配置后,Apache负责接受用户请求,并分发给后面的Tomcat服务器。Tomcat是一个Java Web应用服务器,其处理静态资源的效率不高,最好让Apache来接管提供静态资源。这样做也减少了提供资源的过程环节(原来要经过Apache和Tomcat两步,现在只经过Apache),能提高响应速度。步骤如下:
ProxyPass
指令把所有请求转给了Tomcat,所以还要通过ProxyPassMatch
指令屏蔽这个特定路径的转发。下面是我们的配置实例:
#gzip压缩
LoadModule filter_module modules/mod_filter.so
LoadModule deflate_module modules/mod_deflate.so
LoadModule headers_module modules/mod_headers.so
SetOutputFilter DEFLATE
AddOutputFilterByType DEFLATE text/plain
AddOutputFilterByType DEFLATE text/html
#...略
#静态资源URL映射到本地
Alias /portal/static "D:/static"
#排除静态资源的反向代理
ProxyPassMatch ^/portal/static !
总算写完了,累死我了,容我喘口气先…
经过艰苦卓绝的优化、调试、问题分析、再优化,总算是有了回报:在没有对架构和代码做大幅修改,仅使用两台应用服务器的前提下,并发处理能力大概提高了20倍,已经接近客户要求。后面可以通过增加服务器、优化瓶颈代码来继续提高,还在继续努力中。加油!