编者注:本文为历史博文归档;涉及 JDK、框架与工具链版本请以当前官方文档为准。引用外链图片可能失效,阅读时请注意时效性。

背景与目的

为了确保服务不会被过多的 HTTP 长连接压垮,我们需要对 Tomcat 设定最大连接数。超过该连接数的请求将被拒绝,从而将负载引导至其它机器。这既能达到自我保护的目的,也能起到连接数负载均衡的作用。

核心参数探索与实验

初步尝试:MaxKeepAliveRequests

一开始根据故障 TodoList 提供的参数 MaxKeepAliveRequests 进行验证。我们将 Tomcat 配置 server.xml 修改为:

 title=

同时,启动客户端模拟 30 个长连接。预期应该只有 10 个连接能保持住,但结果与预期不符:30 个连接都连上了,而且正常。

 title=

这让我们怀疑该配置参数是否真正限制了最大连接数。KeepAlive 是在 HTTP/1.1 中定义的,用来保持客户机和服务器的长连接,通过减少建立 TCP Session 的次数来提高性能。常用的配置参数有 {KeepAlive, KeepAliveTimeout, MaxKeepAliveRequests},具体含义如下:

  • KeepAlive:决定开启 KeepAlive 支持。
  • KeepAliveTimeout:决定一个 KeepAlive 的连接能保持多少时间。Timeout 后就尽快 shutdown 链接,若还有数据必须再建立新的连接。
  • MaxKeepAliveRequests:与 KeepAliveTimeout 相似,意思是服务多少个请求就 shutdown 连接。

显然,这些参数与我们想要求的“最大连接数限制”不符。

再次尝试:maxConnections

搜索其它配置参数后,发现 maxConnections。根据字面意思觉得就应该是这个了。去验证吧:

 title=

最大连接数设置为 10,我们启动 30 个长连接。预期应该是只有 10 个长连接,实际结果却是远超过 10 个。这个现象有点不应该。

最终验证:maxThreads 与 acceptCount

原来还有个参数可以决定连接数的大小:

 title=

  • maxThreads:Tomcat 启动的最大线程数,即同时处理的任务个数,默认值为 200。
  • acceptCount:当 Tomcat 启动的线程数达到最大时,接受排队的请求个数,默认值为 100。

这两个值如何起作用,请看下面三种情况:

  1. 情况 1:接受一个请求,此时 Tomcat 启动的线程数没有到达 maxThreads,Tomcat 会启动一个线程来处理此请求。
  2. 情况 2:接受一个请求,此时 Tomcat 启动的线程数已经到达 maxThreads,Tomcat 会把此请求放入等待队列,等待空闲线程。
  3. 情况 3:接受一个请求,此时 Tomcat 启动的线程数已经到达 maxThreads,等待队列中的请求个数也达到了 acceptCount,此时 Tomcat 会直接拒绝此次请求,返回 connection refused

同时加上 maxConnections 配置:

 title=

原来 Tomcat 最大连接数取决于 maxConnections 这个值加上 acceptCount 这个值。在连接数达到了 maxConnections 之后,Tomcat 仍会保持住连接,但是不处理,等待其它请求处理完毕之后才会处理这个请求。

源码原理分析

Tomcat 的最大连接数参数是 maxConnections,这个值表示最多可以有多少个 socket 连接到 Tomcat 上。

  • BIO 模式:默认最大连接数是它的最大线程数(缺省是 200)。
  • NIO 模式:默认是 10000。
  • APR 模式:则是 8192(Windows 上则是低于或等于 maxConnections 的 1024 的倍数)。
  • 不限制:如果设置为 -1 则表示不限制。

在 Tomcat 里通过一个计数器来控制最大连接,比如在 EndpointAcceptor 里大致逻辑如下:

while (running) {
    ...    
    //if we have reached max connections, wait
    countUpOrAwaitConnection(); //计数 +1,达到最大值则等待
 
    ...
    // Accept the next incoming connection from the server socket
    socket = serverSock.accept();
 
    ...
    processSocket(socket);
 
    ...
    countDownConnection(); //计数 -1
    closeSocket(socket);
}

计数器是通过 LimitLatch 锁来实现的,它内部主要通过一个 java.util.concurrent.locks.AbstractQueuedSynchronizer 的实现来控制。

我们将最大连接数设置为 10,同时启动超过 30 个长连接,然后通过 jstack 可以看到 acceptor 线程阻塞在 countUpOrAwaitConnection 方法上:

"http-nio-8080-Acceptor-0" daemon prio=10 tid=0x00007f9cfc191000 nid=0x1e07 waiting on condition [0x00007f9ca9fde000]
   java.lang.Thread.State: WAITING (parking)
        at sun.misc.Unsafe.park(Native Method)
        - parking to wait for  <0x000000076595b688> (a org.apache.tomcat.util.threads.LimitLatch$Sync)
        at java.util.concurrent.locks.LockSupport.park(LockSupport.java:156)
        at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java:811)
        at java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireSharedInterruptibly(AbstractQueuedSynchronizer.java:969)
        at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireSharedInterruptibly(AbstractQueuedSynchronizer.java:1281)
        at org.apache.tomcat.util.threads.LimitLatch.countUpOrAwait(LimitLatch.java:115)
        at org.apache.tomcat.util.net.AbstractEndpoint.countUpOrAwaitConnection(AbstractEndpoint.java:755)
        at org.apache.tomcat.util.net.NioEndpoint$Acceptor.run(NioEndpoint.java:787)
        at java.lang.Thread.run(Thread.java:662)

代码层面也解释了这种现象。

调优策略建议

Tomcat 能支持的最大连接数由 maxConnections 加上 acceptCount 来决定。同时 maxThreads 如何设定?以下部分结论引用自:http://duanfei.iteye.com/blog/1894387

一般的服务器操作都包括两方面:1. 计算(主要消耗 CPU),2. 等待(IO、数据库等)。

  • 计算密集型:如果我们的操作是纯粹的计算,那么系统响应时间的主要限制就是 CPU 的运算能力。此时 maxThreads 应该尽量设的小,降低同一时间内争抢 CPU 的线程个数,可以提高计算效率,提高系统的整体处理能力。
  • IO 密集型:如果我们的操作纯粹是 IO 或者数据库,那么响应时间的主要限制就变为等待外部资源。此时 maxThreads 应该尽量设的大,这样才能提高同时处理请求的个数,从而提高系统整体的处理能力。此情况下因为 Tomcat 同时处理的请求量会比较大,所以需要关注一下 Tomcat 的虚拟机内存设置和 Linux 的 open file 限制。

我在测试时遇到一个问题,maxThreads 我设置的比较大比如 3000,当服务的线程数大到一定程度时(一般是 2000 出头),单次请求的响应时间就会急剧的增加。百思不得其解这是为什么,四处寻求答案无果,最后我总结的原因可能是 CPU 在线程切换时消耗的时间随着线程数量的增加越来越大。CPU 把大多数时间都用来在这 2000 多个线程直接切换上了,当然 CPU 就没有时间来处理我们的程序了。

以前一直简单的认为多线程=高效率,其实多线程本身并不能提高 CPU 效率,线程过多反而会降低 CPU 效率。当 CPU 核心数 < 线程数时,CPU 就需要在多个线程直接来回切换,以保证每个线程都会获得 CPU 时间,即通常我们说的并发执行。所以 maxThreads 的配置绝对不是越大越好。

现实应用中,我们的操作都会包含以上两种类型(计算、等待),所以 maxThreads 的配置并没有一个最优值,一定要根据具体情况来配置。最好的做法是:在不断测试的基础上,不断调整、优化,才能得到最合理的配置。

acceptCount 的配置,我一般是设置的跟 maxThreads 一样大,这个值应该是主要根据应用的访问峰值与平均值来权衡配置的。

  • 如果设的较小,可以保证接受的请求较快响应,但是超出的请求可能就直接被拒绝。
  • 如果设的较大,可能就会出现大量的请求超时的情况,因为我们系统的处理能力是一定的。

Tomcat 6 的 Connector 配置示例如下:

<Connector port="8080" protocol="HTTP/1.1"
           connectionTimeout="20000"
           redirectPort="8443"
           maxThreads="800" acceptCount="1000"/>

附:Linux 系统连接数查看

以下命令可用于查看系统当前的连接状态及并发数。

1. 查看当前并发访问数

netstat -an | grep ESTABLISHED | wc -l

可对比 httpd.confMaxClients 的数字差距多少。

2. 查看进程数

ps aux | grep httpd | wc -l

3. 统计特定进程数

ps -ef | grep httpd | wc -l

统计 httpd 进程数,每个请求会启动一个进程,适用于 Apache 服务器。表示 Apache 能够处理 1388 个并发请求,这个值 Apache 可根据负载情况自动调整。

4. 查看特定端口连接数

netstat -nat | grep -i "80" | wc -l

netstat -an 会打印系统当前网络链接状态,而 grep -i "80" 是用来提取与 80 端口有关的连接的,wc -l 进行连接数统计。最终返回的数字就是当前所有 80 端口的请求总数。

5. 查看已建立连接数

netstat -an | grep ESTABLISHED | wc -l

netstat -an 会打印系统当前网络链接状态,而 grep ESTABLISHED 提取出已建立连接的信息,然后 wc -l 统计。最终返回的数字就是当前所有 80 端口的已建立连接的总数。

查看所有建立连接的详细记录:

netstat -nat | grep ESTABLISHED | wc

6. 查看 TCP 连接状态

查看 Apache 的并发请求数及其 TCP 连接状态:

netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'

返回结果示例:

LAST_ACK 5
SYN_RECV 30
ESTABLISHED 1597
FIN_WAIT1 5
FIN_WAIT2 504
TIME_WAIT 1057

其中的状态含义:

  • SYN_RECV:表示正在等待处理的请求数。
  • ESTABLISHED:表示正常数据传输状态。
  • TIME_WAIT:表示处理完毕,等待超时结束的请求数。
说明:本文基于 Tomcat 6 及早期版本测试撰写。不同 Tomcat 版本(如 8.5/9/10)在连接器(Connector)实现及默认参数上可能存在差异,生产环境配置请以官方文档及实际压测结果为准。