Java Web应用高并发性能优化方案汇总

文章目录

  • 背景
  • 系统现状
  • 优化过程
    • 一、应用系统调优
      • 准备:调优分析工具
      • 1. 使用缓存
      • 2. 优化数据库连接
      • 3. 优化日志输出
      • 4. 程序代码优化
      • 5. 数据库设计优化
      • 6. Tomcat运行参数优化
    • 二、Tomcat集群
    • 三、网络和部署方式调优
      • 1. 操作系统TCP连接数调优(仅针对Windows服务器)
      • 2. Apache参数调优
      • 3. 静态资源代理(又称动静分离)
  • 总结

背景


公司开发的一个门户系统运行几年了,最近因为客户的组织机构调整,要大幅增加用户数,于是开始了一场对系统并发性能进行调优的艰难之旅。

本文记录一个传统Java Web系统性能调优过程的方方面面,希望将来能成为此类工作的指南和索引,从中跳转到各个零碎的知识点。因为涉及点非常多,这里只能做汇总记录,求全不求精,每个知识点的具体细节,需要自行到网上查找,文中也给出了一些不错的参考链接。写作不易,给出的链接地址都是经过筛选的,有些实在找不到满意的就只好自己写,因此这篇文章如果给大家带来了一些帮助,别忘了点个赞哦!
本文未涉及复杂的架构变化(因为不需要啊!),不适用于大型网站。想了解大型网站架构优化的,瞅瞅这个:大型网站架构演化。

系统现状


系统现状是这样的:

  • 服务器:1台应用服务器,1台Oracle数据库。都是Windows系统。
  • 应用:有两个,分别是提供单点登录功能的CAS服务和门户系统,各自运行在各自的Tomcat中,但都在一台应用服务器上。系统代码老旧,没考虑过优化。
  • 在线用户数:100左右
  • 与外部系统接口:作为门户系统,与多个第三方系统有后台接口,抓取这些系统的数据,展现在门户系统首页上。这些接口需要在用户登录后定时刷新显示,

在以上背景下,系统经LoadRunner压力测试,并发访问能力惨不忍睹。客户要求达到1000并发(不是1000在线!),于是开始了艰难的优化之旅。

优化过程


概括说来,为支持高并发的核心优化点是提高后端处理性能和前后端之间的通信效率。提高前端(浏览器)性能的方法是没有帮助的。总的调优方向可以概括为:

  • 增加服务器处理资源
  • 提高服务器运行效率
  • 减少前后端交互次数
  • 减少总通讯流量

因系统运行多年,为降低风险,采用了尽量不修改代码、不增加新的独立架构组件的原则。最终,我们沿着以下的路线来开始这场优化之旅:
一、 应用系统调优
二、 使用集群
三、 网络和部署方式调优

一、应用系统调优


准备:调优分析工具

当系统响应缓慢时,如何准确找到瓶颈点在哪?这里隆重介绍JDK自带工具VisuslVM,利用它可以非常容易地找出运行最耗时的地方,精确到函数级别哦!具体用法,可以参考下面这篇文档:性能分析神器VisualVM。在我的实践中,最有用的就是使用其中的Sampler抽样器功能,找出最耗时的函数,做出针对性优化。

另一个分析神器是阿里出品的Druid连接池。是的,你没看错,一个连接池竟然自带了非常完整的系统监控功能,可以在线查看和分析数据库访问、HTTP请求的实时和统计数据,实为居家开发必备之品。参考链接:http://www.cnblogs.com/han-1034683568/p/6730869.html

1. 使用缓存

一个Web系统的典型处理流程是接受HTTP请求、查询数据库、根据数据渲染页面、返回页面给用户。这个过程有两个地方可以运用缓存:

  • 数据库缓存:缓存查询数据库的结果;
  • 页面缓存:缓存渲染的页面;

(1)数据库缓存
大多数情况下系统高并发的最大瓶颈是数据库。数据库因为其内部的事务、锁等机制,天生难以达到很高的并发处理能力。我们需要使用应用层缓存来降低应用程序与数据库的交互次数。

可以通过Spring框架中的@Cacheable@CacheEvict来操作缓存(其实不仅仅可以用于数据库方面,Service层的接口都可以)。这方面文章很多,这里不介绍了,只提醒一个坑:Spring默认的缓存Key的生成策略比较简单,如果有两个方法使用的参数一致,可能会产生冲突。所以一定要自定义Key生成策略。这篇文章可以参考一下: https://www.jianshu.com/p/2a584aaafad3。

(2)页面缓存
对企业门户类的系统,页面的变动频率并不高(不要拿互联网门户来比啊!),对用户来说也可以接受一定的延时,因此直接缓存最终渲染后的页面,跳过后台所有获取数据、组装页面的过程,能得到极高的响应速度。配置过程很简单,只要设置好缓存参数,再添加一个Web过滤器,不需要改动代码,是不是很美好?具体可参考ehcache实现页面整体缓存和页面局部缓存。


缓存选型上,我们使用了Ehcache这种嵌入式的轻量级缓存,从而不改变系统总体架构。当然也可以使用其他的,只是要注意,如果使用嵌入式的,最好能支持集群。Spring已经对缓存接口做了抽象,只要写好适配器,就可以通过统一的接口来使用。

2. 优化数据库连接

根据并发数量的要求,需要调节数据库连接池的参数。我们在门户系统中使用的是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的错误,则说明已经到达了实际访问数据库的地方,很大可能是数据库出错,需要调整数据库参数,但也有可能是因为连接池参数不当间接造成的数据库错误。分析时可能需要去看数据库运行日志。

3. 优化日志输出

我们的应用系统有两种类型日志:

  • 用户登录等重要事件的操作日志,存在数据库操作日志表中
  • 系统运行时通过Logger打印到文件的运行日志

第一种日志在压测时严重影响性能甚至造成系统崩溃。其实不仅仅是日志,因为数据库的锁机制,只要是高并发地往同一张表写入数据,性能都会急剧下降甚至报错。此时需要引入缓存:先把要写入的数据缓存起来,然后用一个后台工作线程定时或定量触发方式把缓存数据通过Batch SQL的方式批量写入数据库。缓存可以起到削峰的作用,缺点是写入滞后、异常情况下丢失数据。对本系统的日志而言,这些问题可以接受。如果需要高实时性、高可靠性,那就考虑使用Redis、ElasticSearch之类的外部组件,优化架构吧。

第二种日志,如果用户一个动作产生2行日志,并发1000就意味着每秒钟要写入2000行文件,说大不大说小不小,毕竟存储设备也是有IOPS上限的。为了榨取最大性能,可以考虑把日志级别调到WARN以上,减少日志输出量。

4. 程序代码优化

应用程序代码优化是个细活、工夫活,这里分几个层面来说。
(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请求加载第三方系统数据。此时后台会顺序调用若干第三方系统的接口,并返回合并后的数据。同时首页有定时自动刷新机制,确保数据变动能及时反映。
这里的主要问题有:

  • 一次查询多个第三方接口本身是个较慢的过程,页面请求要等待后台查询完成,响应慢,用户体验不佳,压力测试成绩差;
  • 如果过程任何一个环节出错,页面上就缺失数据甚至为空;
  • 第三方系统无法承受高并发的查询压力;

我们可以采取以下手段进行优化:

  • 引入缓存,解耦客户端请求和第三方接口调用过程
    建立一个缓存区,后台调用第三方接口并把结果放在缓存中,客户端请求直接从缓存获取数据。
    这样一来,用户请求不用等待后台调用,响应时间大大缩短。后台通过定时器和任务队列机制,把在线用户的数据从各个第三方系统拉过来放入缓存,这个过程慢就慢一点,反正用户感觉不到,如果一个周期没执行完任务,那就跳过下一个定时任务就好了,避免雪崩效应压垮第三方系统。
    聪明的读者可能立刻会想到,如果用户刚登录,缓存里还没数据,那不是什么都看不到吗?的确存在这个问题。后台定时拉数据是采用队列方式的,我们可以在用户登录时产生一个优先级高于后台定时任务的任务,插队优先获取数据,从而让用户及时看到。不过这种方式对用户高并发同时登录的场景不适用,还需要后面的方法解决。
    缓存的内存占用取决于用户数和第三方数据量。通常门户系统展示的是一些待办通知类的数据,1万人*每人100条数据=100万条数据,对现在的硬件能力是小菜一碟。
  • 化零为整,降低频度
    以用户为单位获取第三方数据,在高并发下必然导致调用过于频繁、第三方系统压力过高,很容易崩溃。可以考虑化零为整,把以用户为单位的查询合并起来,变为批量用户查询,把查询结果以用户为单位放到缓存中。当然,如果用户数太多,每次查询所有用户的数据,这个过程会非常缓慢甚至不可行。可以通过分组,把重点用户(例如领导)放在一起且优先查询,其他的分成另一组或几组,频率降低,慢就慢一点也无妨。要彻底解决,还需要考虑其他方法。
  • 减少每次的数据量
    每次获取全量数据,大部分都没有变化,太浪费地球资源了。如果每次调用接口都带上上一次调用的时间戳参数,就可以让第三方系统只提供增量数据,从而减少传输量。不过这需要第三方系统接口支持才行。对数据的修改、删除,还需要有约定标志便于更新缓存。
  • 拉数据改为推数据
    最高效的办法是让第三方在数据变动时主动推送数据给我们的接口,接口负责更新到缓存,这样理论上把数据流量降到了最低。不过这还是需要第三方系统支持,可遇不可求。此外,还需要去解决系统刚启动时的数据从何而来,要增加接口,这里不展开说了。

如果没有缓存和解耦的理念,上面这些方案全都无法实现,这充分说明了它们在系统架构中的重要性。

5. 数据库设计优化

架构不变情况下,对表添加查询字段的索引、把最近数据和历史数据分表存储,这些常规方法都有明显效果。还不行的话,考虑读写分离等架构升级方案吧。

6. Tomcat运行参数优化

默认的Tomcat运行参数不满足当前并发需求,要进行调整。随便搜了几篇供参考:
Tomcat8.0 基本参数调优配置
聊下并发和Tomcat线程数(Updated)
这次优化中,我们主要调的参数是Server.xml中的这两个:

# 最大并发数 
maxThreads="2000"
# 指定当所有可以使用的处理请求的线程数都被使用时,可以放到处理队列中的请求数,超过这个数的请求将不予处理
acceptCount="2000"

注意,很多网上文章提到的maxSpareThreads这个参数,从Tomcat 7之后就没有了,不用再费心调了。

此外,因高并发带来的系统内存资源占用提高,需要根据实际测算结果来调整JVM参数 ,修改Catalina.bat/Catalina.sh中的JAVA_OPTS。比如我们设置的参数是:-Xms8096m -Xmx16192m

二、Tomcat集群


通过Apache + Tomcat集群的方式,提高系统承载能力。这方面文章很多,可以参考:Apache+Tomcat集群负载均衡的两种session处理方式。

这里说两个要点:

  1. 统一Session:集群可以实现负载均衡,但不一定能实现HA高可用(即一台服务器宕机不影响用户继续使用)。要实现高可用,就必须要通过Session复制、分布式Session等方法实现Session的高可用。上面给出的链接中有使用Ehcache实现的例子。
  2. 统一静态数据容器:放在JVM内存中的静态容器(例如用一个全局HashMap存储当前在线用户列表),在集群后因为应用进程分成了多个,会导致每个应用服务器上的该容器数据不一致。因Session粘滞,A服务器上的用户看到的列表是A服务器上的用户列表,B上的用户看到的则是B上的用户列表,连在线用户总数都无法统计。因此做集群前,必须对系统代码中这些全局数据容器进行摸排分析,必要的话把这些数据放到可保证唯一性的容器中,如数据库、支持集群的嵌入式缓存(例如Ehcache)、外部独立缓存服务器中。也就是说,从单机迁移到集群,可能是需要修改程序的!!!。可以在代码中搜索 static Mapstatic List等字样来找到这些地雷。

三、网络和部署方式调优


1. 操作系统TCP连接数调优(仅针对Windows服务器)

讲真,我也不知道这是不是必要的,但很多帖子都说,就这么做了。也找到一篇微软官方文章说这个有用。

Windows系统可能限制了TCP端口连接数,并发高了就无法连接,需要修改下面的注册表项:

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\TCPIP\Parameters

修改端口数MaxUserPort为十进制的65534,TcpTimedWaitDelay为5。如果没有的话先创建。

2. Apache参数调优

我们用的服务器是Windows,在Apache上设置参数如下:


    ThreadLimit 10000
    ThreadsPerChild 10000
    MaxRequestsPerChild 0

KeepAlive  On 
KeepAliveTimeout 30

解释如下:

  • 使用Windows服务器要启用mpm_winnt_module模块来控制并发。该模块的参数含义最好看Apache官方文档,清晰明了。
  • ThreadLimit:Apache支持多少客户端同时连接的全局上限。Windows下默认值是1920,不满足就按需调大吧。但Apache硬编码了上限,在Windows上不能超过15000。
  • ThreadsPerChild:每个子进程的最大线程数。在Windows上Apache只有一个子进程来处理HTTP请求,所以这个数字设置成最大可能数就行了,不能超过ThreadLimit,默认值是64。如果设置过大超出硬件处理能力,可能会导致服务器不稳定。
  • MaxRequestsPerChild:每个子进程处理的请求数上限,如果达到了,则子进程会被Apache杀掉重新生成。设置成0(默认值)表示没有上限,进程永远不会被杀死重建。在Apache 2.3.9之后,这个指令的名字改成了MaxConnectionsPerChild,但原名也可以用。为什么要设置这个参数呢?官方解释是为了防止进程出现偶发性内存泄露,所以搞了个定时重生。然而Windows上Apache只有一个子进程,杀掉重启的这段时间应该是会影响用户访问体验的(所有线程都没了啊啊啊,要重新创建啊啊啊),所以我设置成了0,阿弥陀佛上帝保佑不要出问题。
  • KeepAlive:设置HTTP连接用完后不立刻关闭,也就是常说的HTTP长连接。该指令对性能影响极大,一定要设置成On。
  • KeepAliveTimeout:持久连接保持的时间,默认5。太短则影响性能,太长则浪费资源。

3. 静态资源代理(又称动静分离)

在前面集群配置后,Apache负责接受用户请求,并分发给后面的Tomcat服务器。Tomcat是一个Java Web应用服务器,其处理静态资源的效率不高,最好让Apache来接管提供静态资源。这样做也减少了提供资源的过程环节(原来要经过Apache和Tomcat两步,现在只经过Apache),能提高响应速度。步骤如下:

  1. 在系统源码中,尽可能把html、js、css、图片等静态资源,集中放在一个请求路径下,比如http://…/static。这也是一个良好的开发习惯;
  2. 在Apache服务器上建一个本地目录,用来存放上面的静态资源文件。每次在Tomcat中发布新版本时,也同时往该目录拷贝一份;
  3. 在Apache的配置文件中,通过Alias指令,把/static路径映射到第2步建立的本地目录。因为配置集群时已经通过ProxyPass指令把所有请求转给了Tomcat,所以还要通过ProxyPassMatch指令屏蔽这个特定路径的转发。
  4. Apache直接提供的静态资源也需要进行gzip压缩以提高性能,参考Apache启用mod_deflate的gzip压缩。JPG图片就没必要压缩了哈!
  5. 打完收工。

下面是我们的配置实例:

#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倍,已经接近客户要求。后面可以通过增加服务器、优化瓶颈代码来继续提高,还在继续努力中。加油!

你可能感兴趣的:(开发技术)