Tomcat优化以达到高吞吐快速失败系统

问题

奈飞有许多高吞吐低延迟的中间层服务(mid tier services,估计翻译中间件)。在这些服务中,他们发现有些服务,在在很短的时间剧增的流量,会导致这些机子变成CPU饥饿(cpu-starved),并且服务会没响应。会导致这些服务的客户端体验很差,如读超时和连接超时。特别如果将读超时设置得非常高,会导致特别差的体验,客户端会等到好久好久。在SOA架构中,客户端的客户端也会请求超时,从而导致雪崩效应(ripple effect),以至于整个应用的其他服务也一起都变慢或不可用,最后全部服务变慢或不可用。在正常情况,机子都是有大量的CPU资源,服务也不是CPU密集型(cpu intensive)。所以,为什么会导致上述那些异常情况发发生?为了搞懂这些,熟悉我们要从最高层看看这些服务层次,请求流程如下:

--> Apache --> Tomcat

在测试环境模拟流量激增时,发现CPU不足(cpu starvation)的原因是Apache和tomcat配置不当。当突然增加流量,Apache的worker就会变得繁忙和非常大量tomcat线程也在繁忙。系统CPU有个巨大的跳跃,是当大部分CPU时间都在处理上下文切换,就没有线程可以做任何有意义的工作时。

解决方法

由于这是个中间层服务,所以apache使用不多(apache压力不大)。所以与其优化两个系统(apache和tomcat),不如简单化这层次,直接关注tomcat优化。为了了解为什么tomcat的线程会繁忙,我们需要了解tomcat的线程模型。

高层次的描述Tomcat Http Connectotr的线程模型

Tomcat有一个acceptor线程来接收连接(这里就涉及到对网络的熟悉,熟悉就知道连接不仅仅是socket的表面,更是tcp的三次握手过程)。另外还有线程池来做实际的工作。于是一个请求的过程是:

  1. OS和客户端建立连接的TCP握手。这取决于OS的实现,可能是有一个队列来保存这些连接或多个队列来保存。在多队列的情况,一条队列保存未完成的连接,这些连接都是还没完成三次握手的。一旦完成握手,连接就会被移到保存完成连接的队列,应用就会消费这队列里的连接。“acceptCount”这tomcat参数就用于控制这些队列的长度。(acceptCount是指完成握手的连接数,acceptCount会被映射成backlog)
    //org.apache.catalina.connector.Connector
    protected static HashMap<String,String> replacements =
                new HashMap<String,String>();
        static {
            replacements.put("acceptCount", "backlog");
            replacements.put("connectionLinger", "soLinger");
            replacements.put("connectionTimeout", "soTimeout");
            replacements.put("rootFile", "rootfile");
        }
    backlog在linux2.2就变为指定完成握手的队列长度,而未完成的握手的半连接队列长度则通过操作系统/proc/sys/net/ipv4/tcp_max_syn_backlog来设置.

    int listen(int sockfd, int backlog);
    DESCRIPTION listen() marks the socket referred to by sockfd as a passive socket, that is, as a socket that will be used to accept incoming connection requests using accept(2).
    The backlog argument defines the maximum length to which the queue of pending connections for sockfd may grow. If a connection request arrives when the queue is full, the client may receive an error with an indication of ECONNREFUSED or, if the underlying protocol supports retransmission, the request may be ignored so that a later reattempt at connection succeeds.
    The behavior of the backlog argument on TCP sockets changed with Linux 2.2. Now it specifies the queue length for completely established sockets waiting to be accepted, instead of the number of incomplete connection requests. The maximum length of the queue for incomplete sockets can be set using /proc/sys/net/ipv4/tcp_max_syn_backlog. When syncookies are enabled there is no logical maximum length and this setting is ignored. See tcp(7) for more information.

  1. tomcat的acceptor线程接收连接,这些队列都是来自于已完成握手的队列。

  2. 检查工作线程池是否有空闲的线程,如果没有且活动线程数小于maxThreads,则会创建工作线程,否则等待空闲线程。

  3. 一旦有空闲工作线程,acceptor线程就会将连接交给工作线程后,然后继续监听新的连接。

  4. 工作线程做的就是实际的工作,如从连接读取输入,处理请求,然后发送响应给客户端。如果连接不是keep alive则会关闭连接,然后将自己放回线程池。如果是keep aliave连接,继续等待该连接读取输入。如果数据一直没到,那么keep alive情况,会有个keepAliveTimeout,超过该时间,则会关闭连接,然后将自己放回线程池。

考虑这种情况,tomcat的maxThreads和acceptCount设置都很大,突增的流量会填满OS的队列和让tomcat的所有线程都变得繁忙。当更多的请求发送到这台机子,从而超过系统所能处理的数量时,这种请求的“排队”是不可避免的,并会导致繁忙线程的增加,最终导致CPU不足(cpu starvation)。因此,解决方法的关键是避免多个点(OS和tomcat线程)上有太多排队的请求,并在应用程序达到最大容量时快速失败(返回http状态503)。以下是实际操作的一个推荐:

当达到系统容量,应快速失败

预估在峰值负载时繁忙的线程数。如果服务器平均5ms内对请求作出响应,那么单个线程每秒则可处理200个请求(rps)。如果机子是4核CPU,则可以达到800rps。假设4个请求并行发送到机子(假设机子有4核),这会让4个线程繁忙5ms,所以下个5ms,4个或更多的请求让4个线程繁忙。随后的请求会选取一个空闲线程。所以理论上,在800rps时,平均不应该有超过8个线程处理繁忙状态。但实际当中,会有些不同,是因为系统所有资源都是共享的。因此,应该对系统能够维持的总吞吐量进行实验,并计算繁忙线程的期望数量。这将为维持峰值负载所需的线程数量提供一个基线。为了提供一些缓冲区,需要将线程数增加三倍以上,达到30个。这个缓冲区是任意的,如果需要还可以进一步调优。在我们的实验中,我们使用了略多于3倍的缓冲区,效果很好。
跟踪内存中运行中并发请求的数量,并将其用于快速失败。例如,当并发请求的数量接近刚刚预估的繁忙线程数据(8个),则返回一个http状态码503。这将防止太多的工作线程变得繁忙,因为一旦达到峰值吞吐了,任何变得活跃的额外线程都将执行非常轻量级的工作,即返回503。

设置操作系统参数

acceptCount参数用于tomcat表示最长队列,这队列是操作系统级别,用于处理还未完成tcp握手的操作(具体取决于OS)。这是一个很重要的调优参数,否则在建立连接时会出现连接不上,活着导致OS队列中的连接过度排队,从而导致读超时。当然每个OS处理正在握手和完成握手的连接细节是不同,可能会时只有一个连接队列,或多个连接队列用于区分存放未完成握手和完成握手的连接(请阅读相关的文档来获取这些细节)。所以,有一个很好的方法调优这个acceptCount参数,那就是从很小的值开始测试,逐步增大,增达到没有连接错误即可。
太大的acceptCount值意味OS层面可以接收更多的请求,但是,如果rps大于该机子能够处理的能力,所有工作线程会变得繁忙,于是aceeptor线程会等待,直至有worker线程空闲。更多的请求将继续堆积在OS队列中,因为只有当工作线程可用时,acceptor线程才可以使用它们。在最糟糕的情况,这些请求还在OS队列时就已经超时了,但是tomcat的acceptor线程依然会去获取它来交给工作线程处理。这种是完全浪费资源,而客户端也没收到任何响应。
如果acceptCount设置太小,则会在很高的rps时无法有足够的OS空间来接收连接,这样客户端就会手连接超时的错误(connect time out error),实际吞吐会低于服务器能够承载的。

因此可以实验从很小的值如10开始,然后逐步增加acceptCount的值,直至没有连接错误出现。

当完成上面两个改变后,就算最差的情况,所有工作线程都很繁忙,但机子不会cpu不足,依然有能力做更多的工作(最大吞吐)。

其他考虑

如上所述,每个连接最终都被tomcat的一个工作线程处理。假如keep alive被打开,工作线程就会继续监听该连接,从而不会变成空闲而返回到线程池中。所以,如果客户端不够智能从而不会关闭连接,那么服务端很快就会用完它的线程。如果keep alive被打开,那么必须通过记住这种情况,来调节服务器群的大小。
或者,如果keep alive是关闭的,那么就不必担心使用工作线程处理非活动连接的问题。但是,在这种情况,每个调用就要付出打开和关闭连接的代价。此外,这还会创建很多TIME_WAIT状态的socket,这样会给服务器造成压力。

最好根据应用程序的用力进行选择,并通过运行实验来测试性能。

备注:
“连接”这两个字做于名词时,实际指的是socket。而当用于动词时,其实就是TCP三次握手。
线程池里的线程在本文,有时称为空闲线程或工作线程,或空闲工作线程。

参考:
https://netflixtechblog.com/tuning-tomcat-for-a-high-throughput-fail-fast-system-e4d7b2fc163f
https://zhuanlan.zhihu.com/p/92924737


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!