传统Java应用的高并发优化
一、背景
目前QBus消息中间件每天有1.5~2亿次API接口访问量,其中消息发送和删除的请求有1.2~1.4亿次,由两台4核8G的机器提供服务,消息拉取接口有3~6千万左右的访问量,同样也是由两台4核8G的机器提供服务。
以下是我们在不对基础框架和代码做重大调整的前提下,最大化挖掘现有架构潜力提高服务整体性能上所做出的优化实践。
二、优化思路
在谈优化思路前,首先需要搞清楚一个QBus的API请求是如何从各个业务服务,走到qbus-server服务的。
1 | sdk ==> 域名解析 ==> Http/Https协议 ==> 网关(Nginx/Kong) ==> qbus-server |
以下是针对每个环节的一些优化点:
SDK
TCP的keepalive,复用连接,使用连接池等;域名解析如何优化呢?
CDN,选择就近资源,或者本地域名解析等HTTP协议优化如何优化呢?
现在比较流行的是HTTP/1.1,但是可以的话,用HTTP/2,性能将提高很多网关优化?
操作系统优化和Nginx的优化qbus-server如何优化?
操作系统优化、jvm优化、tomcat优化、代码优化
本文主要关注的是qbus-server的优化,以下将会从这几个方面讲解一下优化的思路:
- 操作系统
- JVM
- Tomcat
- 代码
三、内核及网络优化
以下优化措施主要基于QBus部署的腾讯云上的Centos7的内核、网络参数。大家可以根据各自业务的情况进行相应的调整。
调整步骤:
- 编辑
/etc/sysctl.conf文件 - 执行
sysctl -p使配置生效
特别注意在修改线上文件前,请先备份。
以下是优化的参数项,注释掉的部分为默认值配置:
3.1 关闭无用的资源
关闭
ipv61
2
3
4
5
6
7
8# net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.all.disable_ipv6 = 1
# net.ipv6.conf.default.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
# net.ipv6.conf.lo.disable_ipv6 = 1
net.ipv6.conf.lo.disable_ipv6 = 1数据转发
可以实现数据转发,通常用于将一张网卡的数据转发到另一张网发上1
2# net.ipv4.ip_forward = 0
net.ipv4.ip_forward = 0不处理无源地址的网络包
1
2
3
4
5# net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.all.accept_source_route = 0
# net.ipv4.conf.default.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
3.2 安全方面优化
开启防欺骗攻击1
2# net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1避免ping flood攻击1
2# net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1开启防SYN洪水攻击,当出现SYN等待队列溢出时,启用cookies来处理
1
2# net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_syncookies = 1
3.3 TCP优化
keepalive1
2
3
4
5
6
7
8
9
10
11# TCP发送keepalive探测消息的间隔时间(秒),用于确认TCP连接是否有效,默认是7200s
# net.ipv4.tcp_keepalive_time = 7200
net.ipv4.tcp_keepalive_time = 600
# keepalive探针,当对方不给予回应时,发送的探针的次数
# net.ipv4.tcp_keepalive_probes = 9
net.ipv4.tcp_keepalive_probes = 3
# keepalive探针的间隔,单位秒
# net.ipv4.tcp_keepalive_intvl = 75
net.ipv4.tcp_keepalive_intvl = 15HTTP和TCP的keepalive分别有什么作用?HTTP/1.0协议,每一个HTTP请求开始,都将打开一个TCP连接,请求结束,都将会关闭TCP连接。从HTTP/1.1开始,提供了keepalive功能,指的是同时请求同一个服务的同一台机器时,请求结束后,并不会立即关闭TCP连接,而是保留一段时间,在保留时间段内的请求,会复用TCP连接,过期后,才会真正结束TCP连接。TCP的
keepalive指的是TCP协议层面上的一个检测机制,当某一个TCP连接没有数据传输后,会存在着一个定时器用于确定一个TCP连接是否依然存活,以决定是否关闭当前连接。从上面的解释大家也能看出他们的区别,它们是两个不同层面上的协议,但是它们会共同影响
TCP连接的生命周期。从时间上,TCP的keepalive和HTTP的keepalive会了出现如下几种情况:TCP的keepalive和HTTP的keepalive时间一样TCP的keepalive比HTTP的keepalive时间大TCP的keepalive比HTTP的keepalive时间小
因为
HTTP是比TCP更高层的协议,当HTTP的keepalive和TCP的keepalive的时间一样,或者比TCP的keepalive小,其逻辑会是正常的,因为HTTP先关,再关TCP嘛。但是如果HTTP的keepalive比TCP的keepalive大,则有可能会出现,HTTP的连接依然在复用,但是TCP已经关闭。TIME_WAIT状态的重用及回收1
2
3
4
5
6
7
8
9
10
11# 启TCP时间戳,用来计算往返时间RTT(Round-Trip Time)和防止序列号回绕,用于支持tcp_tw_reuse和tcp_tw_recycle
# net.ipv4.tcp_timestamps = 1
net.ipv4.tcp_timestamps = 1
# TCP中的TIME_WAIT的状态的重用
# net.ipv4.tcp_tw_reuse = 0
net.ipv4.tcp_tw_reuse = 1
# TCP中的TIME_WAIT的快速回收,在对外网提供服务时,需要关闭
# net.ipv4.tcp_tw_recycle = 0
net.ipv4.tcp_tw_recycle = 1TIME-WAIT会保持两个周期(MS),而一个MS是一个IP报文最大的存活时间,而两个周期在于客户端发出ACK到达服务器的过程和数据报来到客户端的过程。只有在两个MS内没有任何数据,才能让客户端确定再没有任何的数据来自服务器,才能关闭Socket。TIME_WAIT的快速回收,是一种基于时间序列的快速回收机制,并不会让TIME_WAIT状态持续两个周期,而是保持一个重传时间,能够快速释放资源。TIME_WAIT的重用,当满足特定条件的Socket,可以用于接受新的连接,而不用先关闭再连接。TCP内存1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20# 内核分配给TCP连接的内存,单位是page:
# 第一个数字表示TCP使用的page少于此值时,内核不进行任何处理(干预),
# 第二个数字表示TCP使用的page超过此值时,内核进入“memory pressure”压力模式,
# 第三个数字表示TCP使用的page超过些值时,报“Out of socket memory”错误,TCP 连接将被拒绝
# net.ipv4.tcp_mem = 765891 1021191 1531782
net.ipv4.tcp_mem = 88557 118079 177114
# 为每个TCP连接分配的读缓冲区内存大小,单位是byte
# 第一个数字表示,为TCP连接分配的最小内存,
# 第二个数字表示,为TCP连接分配的缺省内存,
# 第三个数字表示,为TCP连接分配的最大内存
# net.ipv4.tcp_rmem = 4096 87380 6291456
net.ipv4.tcp_rmem = 4096 87380 6291456
# 为每个TCP连接分配的写缓冲区内存大小,单位是byte
# 第一个数字表示,为TCP连接分配的最小内存,
# 第二个数字表示,为TCP连接分配的缺省内存,
# 第三个数字表示,为TCP连接分配的最大内存
# net.ipv4.tcp_wmem = 4096 16384 4194304
net.ipv4.tcp_wmem = 4096 16384 4194304重试次数
1
2
3
4
5
6
7
8
9
10
11# TCP连接时,SYN 重发的最大次数
# net.ipv4.tcp_syn_retries = 6
net.ipv4.tcp_syn_retries = 1
# TCP连接时重发ACK的最大次数
# net.ipv4.tcp_synack_retries = 5
net.ipv4.tcp_synack_retries = 1
# 孤儿sockets废弃前重试的次数
# net.ipv4.tcp_orphan_retries = 0
net.ipv4.tcp_orphan_retries = 0孤儿sockets指的是已经从进程上下文中删除了,可是还有一些清理工作没有完成的socket。端口范围
1
2
3# 设置端口范围,提高服务能力
# net.ipv4.ip_local_port_range = 32768 60999
net.ipv4.ip_local_port_range = 1024 65000Other
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23# 设置FIN_WAIT2的等待时间,单位秒,默认为60
# net.ipv4.tcp_fin_timeout = 60
net.ipv4.tcp_fin_timeout = 2
# 设置TIME_WAIT的最大个数,大于这个阀值后会被删除
# net.ipv4.tcp_max_tw_buckets = 131072
net.ipv4.tcp_max_tw_buckets = 6000
# 表示SYN队列的长度,默认为1024,可以容纳更多等待连接
# net.ipv4.tcp_max_syn_backlog = 1024
net.ipv4.tcp_max_syn_backlog = 100000
# 用来限制监听(LISTEN)队列最大数据包的数量,超过这个数量就会导致链接超时或者触发重传机制
# net.core.somaxconn = 128
net.core.somaxconn = 100000
# 即不属于任何进程的tcp socket最大数量. 超过这个数量的socket会被reset, 并同时告警
# net.ipv4.tcp_max_orphans = 131072
net.ipv4.tcp_max_orphans = 100000
# 当网卡接受数据包的速率, 比kernel处理来的快时, cache这些数据包的队列长度,默认是1000
# net.core.netdev_max_backlog = 1000
net.core.netdev_max_backlog = 32768
3.4 其它
日志
1
2
3
4
5
6
7# 用于调试内核
# kernel.sysrq = 1
kernel.sysrq = 1
# 用于在core dump文件中,增加进程ID
# kernel.core_uses_pid = 1
kernel.core_uses_pid = 1内核队列
1
2
3
4
5
6
7# 控制一个消息的大小,bytes
# kernel.msgmnb = 65536
kernel.msgmnb = 65536
# 限制一个队列的最大消息个数
# kernel.msgmax = 65536
kernel.msgmax = 65536
四、JVM优化
JVM优化主要针对两个方面,内存和GC,以确保服务的质量和服务所占内存处于一个稳定状态。
首先先介绍一下JVM的内存分布和现有GC算法
4.1 JVM内存
JVM中的内存主要有如下几个部分组成:
程序计数器
用于指明当前线程需要执行的字节码行;线程私有;如果是Java方法,则记录虚拟机字节码指令地址,如果为native方法,则为Undefined虚拟机栈
每一个方法执行时,都将创建一个栈帧,用于记录局部变量、操作栈、动态链接、方法出口等,当方法被调用时,栈帧入栈,执行完成后,出栈。线程私有。本地方法栈
与虚拟机栈类似,唯一的区别就是:虚拟机栈保存Java方法栈帧,而本地方法栈保存native方法栈帧。线程私有。堆区
GC的主要区域,由所有线程共享,在虚拟机启动时创建,用于存储对象实例。方法区
各个线程共享的区域,用于存储已经被虚拟机加载的类信息、final常量、静态变量、编译器即时编译的代码等。直接内存
不受JVM管理的内存。
而我们所做的优化主要针对堆内存,而堆又分成:Eden、Survivor1(From Space)、Survivor2(To Space)、Old,其中Eden、Survivor1(From Space)、Survivor2(To Space)被统称为年轻代,Old是老年代。
4.2 GC算法
GC主要用于回收堆内存,而堆主要包含两个部分:
- 年轻代
- 老年代
针对年轻代的GC称为Minor GC,针对老年代的GC称为Full GC。
由于年轻代具有生命周期短的特点,通常采用 停止-复制的算法,其主要的算法有:
Serial
新生代收集器,使用停止复制算法,使用一个线程进行GC,其它工作线程暂停。ParNew
新生代收集器,使用停止复制算法,Serial收集器的多线程版,用多个线程进行GC,其它工作线程暂停,关注缩短垃圾收集时间。Parallel Scavenge
新生代收集器,使用停止复制算法,关注CPU吞吐量,即运行用户代码的时间/总时间,比如:JVM运行100分钟,其中运行用户代码99分钟,垃圾收集1分钟,则吞吐量是99%,
针对老年代对象具有数量多,对象大的特点,通常采用 标记-整理的算法,其算法有:
Serial Old
老年代收集器,单线程收集器,使用标记整理的策略,单线程GC,暂停其它工作线程,清除废弃的对象,将幸存的对象放在一起,避免内存碎片。Parallel Old
老年代,多线程收集器,暂停其它工作线程,清除废弃的对象,将幸存的对象放在一起,避免内存碎片。CMS
Concurrent Mark Sweep,老年代收集器,致力于获取最短回收停顿时间,使用标记清除算法,多线程,优点是并发收集(用户线程可以和GC线程同时工作),停顿小。- 初始标记, 仅仅标记一下GC Roots能直接关联到的对象
- 并发标记, 进行GC Roots Tracing的过程
- 重新标记, 修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录
- 并发清除, 标记某一个对象不可用
初始标记与重新标记仍然需要停止其它工作线程。其主要的2个过程,都采用并发的操作,能够保证与工作线程一起运行。但是该算法对CPU资源很敏感,CPU越多,越快;无法处理浮动垃圾,即在一次GC过程中,可能又会出现一些垃圾,需要下次GC去处理,因为它没有停止工作线程;会产生大量内存碎片,没有整理。
G1
基于“标记-整理”算法实现的收集器,不会产生空间碎片。G1将整个Java堆划分为多个大小固定的独立区域,并且跟踪这些区域里面的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域。
4.3 JVM优化
为了避免GC带来的停顿影响服务质量,在内存和GC优化过程中会有如下经验:
- 年轻代使用
ParNew算法,老年代使用CMS算法,能够减少系统由于GC带来的停顿,当然也可以直接用G1算法,QBus线上使用的就是ParNew和CMS的组合 - 最大堆和最小堆设置成一样,能够避免扩容引起的
Full GC - 最大堆的大小,不能超过整个物理机内存的50%
- 年轻代的大小,最好占整个堆的
3/8 Eden和Survivor的比例是8-XX:+DisableExplicitGC,这个参数慎用,DirectBuffer的内存释放依赖System.gc(),会导致内存泄露。
除了内存与GC优化外,以下几个方面也是需要优化的:
- 打印
GC日志 - 出现内存问题时,导出内存到文件
如下是线上的一台QBus机器的配置:
1 | export JAVA_OPTS="-server |
五、Tomcat优化
QBus目前生产版本仍然是部署于Tomcat中,还没有对底层通讯层进行全面重构;
Tomcat的优化主要从如下几个方面进行优化:
- 网络模型
- 连接池
- 线程池
- 超时时间
- TCP参数
下面将从这几个方面谈优化。
5.1 网络模型
Tomcat8.5版本中,Connector的protocol支持三个模式:
org.apache.coyote.http11.Http11NioProtocol
同步非阻塞,用Java的NIO实现org.apache.coyote.http11.Http11Nio2Protocol
异步非阻塞,用Java的AIO实现org.apache.coyote.http11.Http11AprProtocol
异步非阻塞,通过JNI调用操作系统的本地库来处理文档读取和网络传输。该协议会用到如下几个库:
- Apache Portable Run-time libraries(Apache可移植运行库,APR)
- JNI wrappers for APR used by Tomcat (libtcnative)
- OpenSSL
APR库,是一个跨平台库,提供与平台无关的API,能够保证同一个API,在不同平台下运行,其结果总是一致的。
libtcnative则会调用APR库进行文档和网络传输的处理。而Tomcat则会通过JNI调用libtcnative库。
org.apache.coyote.http11.Http11AprProtocol是Tomcat上运行高并发的首选,接下来将介绍如何安装APR库。
首先创建tomcat-native-install.sh脚本,并添加执行权限
1 | #!/bin/bash |
然后再用root执行./tomcat-native-install.sh Tomcat的安装目录 APR库的安装位置进行安装,在传参时,需要注意如下两点:
- 传入给shell的参数需要全路径
- 传给shell的路径参数,最后不需要加上
/
例如: ./tomcat-native-install.sh /usr/local/services/tomcat /usr/local/services/tomcat-apr/tomcat-native
安装完成后,还需要修改如下配置:
- 修改
Tomcat目录/conf/server.xml将其中的Connector的protocol改成org.apache.coyote.http11.Http11AprProtocol和org.apache.coyote.ajp.AjpAprProtocol - 修改
Tomcat目录/bin/catalina.sh中添加JAVA_OPTS="$JAVA_OPTS -Djava.library.path=上面APR的安装目录/lib"
重启服务,即可生效。在logs/catalina.out,能看到如下输出,则能确定APR库生效:
1 | 07-Mar-2019 11:46:15.871 INFO [main] org.apache.coyote.AbstractProtocol.start Starting ProtocolHandler ["http-apr-8080"] |
5.2 连接池优化
acceptorThreadCount
在Tomcat中会由专门的accept线程用于接收请求,并将请求转交给工作线程。通常accept线程个数与CPU核数一致,默认值为1acceptCount
当已经没有任何可用的工作线程为新的请求提供服务时,会将请求进行缓存。acceptCount则用于指定缓存队列的大小。当请求无法再放入缓存队列后,请求将会被拒绝。通常用户可能会在nginx层面上看到如下的异常:1
failed (111: Connection refused) while connecting to upstream
maxConnections
用于设置Tomcat的最大连接数,这仅仅是Tomcat层面上的限制,超过最大连接数后,操作系统层面,仍然会接收请求,只是请求将会进入到缓存队列,当缓存队列满后,请求会被阻塞。可以设置为-1,关闭此限制。
5.3 线程池优化
线程池的优化主要有两个方面:
- 线程命名
- 线程个数
线程命名是为在排查问题时,通过jstack命令查询,能够区分出哪些是Tomcat线程。而线程个数能够保证服务的稳定运行。QBus服务线上的配置如下:
1 | <Executor name="tomcatThreadPool" |
name用于指明线程名字前缀minSpareThreads初始化线程个数maxThreads线程池的最大线程个数maxIdleTime当某一个线程多久未使用,将会被回收,单位毫秒
通常可以将线程个数的最大个数设置为500,或者更多,然后上线后,通过jstack tomcat线程ID | grep Tomcat线程前缀名 | grep run | wc -l查看线上稳定运行的线程个数,然后再去调整minSpareThreads。
请注意在Tomcat中的TPS是由maxConnections、acceptCount与maxThreads共同决定的。
5.4 超时优化
Tomcat总体上有如下几个超时设置:
connectionTimeout
当客户端与服务器已经建立连接后,等待客户端传输请求头时,最多等待connectionTimeout毫秒keepAliveTimeout
针对HTTP/1.1协议,用于设置服务器最大保持连接的时间disableUploadTimeout和connectionUploadTimeout
当disableUploadTimeout为false时,connectionUploadTimeout才会生效。connectionUploadTimeout用于设置服务器等待客户传输请求体的最大等待时间。
注意connectionTimeout和connectionUploadTimeout,这两个参数分别设置了传输请求头与请求体的最大等待时间。
QBus的线上配置是:
1 | <Connector port="8080" |
5.5 TCP参数
tcpNoDelay
用于开启或者关闭Nagle算法。Nagle算法,会将小的TCP包合并成一个大的包再进行发送,避免过多的小报文的TCP头的资源消耗。通常在交互性比较强的应用中会关闭Nagle算法,默认是开启的。connectionLinger
与TCP的SO_LINGER的选项一致,用于控制TCP的close行为。SO_LINGER通常有三个取值:- -1,会将
socket发送缓存区中的数据发送完成后,才关闭socket - 0,通过发送RST分组(而不是用正常的FIN|ACK|FIN|ACK四个分组)来关闭该连接,并放弃发送缓存区中的数据。
- 大于0,关闭时,进程将进入睡眠状态,内核通过定时器在超时前尽量发送数据,如果发送完成,则正常关闭,否则超时后,直接通过
RST进行关闭,丢弃发送缓存区中的数据。
- -1,会将
deferAccept
与TCP中的TCP_DEFER_ACCEPT选项一致。在解释TCP_DEFER_ACCEPT参数前,先解释一下TCP的三次握手:1
2
31. Client -> Server:客户端给服务器发送SYN包
2. Client <- Server: 服务器在收到SYN包后,将返给客户SYN + ACK,Server端的连接为SYN_RECV状态
3. Client -> Server: 客户端收到SYN+ACK包后,向服务器发送ACK包,Client和Server端的连接均为ESTABLISHED通常服务器在接受到最后的一个
ACK包后,将进入ESTABLISH状态。此过程会出现一个问题,当服务器已经打开了连接,但是如果客户端一直不发数据的话,会占用服务器的资源。而TCP_DEFER_ACCEPT选项开启后,在接受到最后一个ACK后,并不会进入ESTABLISH状态,也并不会真正建立连接,而是保持在SYN_RECV状态,只是标记当前socket,当客户端的数据真正到来时,才建立连接。通常是需要开启延迟ACK,将ACK与数据一起发送,才能建立连接。useSendfile
用于开启或者关闭零拷贝机制,该机制,通过减少数据在用户态和内核态的拷贝,从而提高性能。
5.6 Tomcat的注意点
Spring项目,尽量不要将项目调整成root目录,否则有可能你会遇到某一个bean会被初始化两次的问题- 一个
Tomcat尽量只有一个项目
六、代码层面上的优化
代码方面,主要引入了内存级别的缓存层,将配置信息放入缓存,避免对数据库的读操作。多个进程间的缓存同步有两个机制进行保证:
- 同步通过
QBus本身进行同步 - 当从缓存中读取不到数据时,再读数据库,此时一定需要注意,避免出现
Dog Pipe Effect现象
加入缓存后,整个消息的发送时延从40ms降低到10~15ms之间。
七、总结
以上就是QBus已经做过的优化,新版本的QBus中,我们将做如下的优化:
- 减少对数据库的依赖,引入分布式二级缓存Redis
- 模块解耦,将
send、publish、ack、pull等API接口与Web页面逻辑进行分离 - 针对
send、publish、ack、pull等API接口的网络通讯,采用Netty来实现,这样可以自定义其TCP参数、连接池、线程池、网络模型等,而且除了能提高性能,同样也能给出详细的日志,比如metric信息、异常信息等,能更好地把控服务 - 优化日志,不直接写kafka,写文件,再同步到kafka,以便快速解决问题
优化是一个长期过程,而要反复迭代,并且会很受到业务功能的影响,比如QBus的拉消息接口,其功能决定了这是一个慢操作,而发送消息、删除消息,就必须要保证尽可能快,所以相对而言,拉消息更消耗内存,而发送消息、删除消息接口更消耗CPU,所以优化时,也需要分开处理。欢迎大家补充指正。
