V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
yanhomlin
V2EX  ›  Java

Tomcat 调优之从 Linux 内核源码层面看 Tcp backlog

  •  1
     
  •   yanhomlin ·
    yanhom1314 · 2022-11-03 10:22:49 +08:00 · 1362 次点击
    这是一个创建于 512 天前的主题,其中的信息可能已经有所发展或是发生改变。

    前两天看到一群里在讨论 Tomcat 参数调优,看到不止一个人说通过 accept-count 来配置线程池大小,我笑了笑,看来其实很多人并不太了解我们用的最多的 WebServer Tomcat ,这篇文章就来聊下 Tomcat 调优,重点介绍下线程池调优及 TCP 半连接、全连接队列调优

    Tomcat 线程池

    先来说下线程池调优,就拿 SpringBoot 内置的 Tomcat 来说,确实是支持线程池参数配置的,但不是 accept-count 参数,可以通过 threads.max 和 threads.minSpare 来配置线程池最大线程数和核心线程数。

    如果没有设置,则会使用默认值

    threads.max: 200
    threads.minSpare: 10
    

    Tomcat 底层用到的 ThreadPoolExecutor 也不是 JUC 原生的线程池,而是自定义的,做了一些调整来支持 IO 密集型场景使用,具体介绍可以看之前写的两篇文章。

    动态线程池( DynamicTp ),动态调整 Tomcat 、Jetty 、Undertow 线程池参数篇

    以面试官视角万字解读线程池 10 大经典面试题!

    通过这两篇文章能了解到 Tomcat 自定义线程池的执行流程及原理,然后可以接入动态线程池框架 DynamicTp ,将 Tomcat 线程池交由 DynamicTp 管理,使之能享受到动态调参、监控告警的功能。

    在配置中心配置 tomcat 线程池核心参数

    spring:
      dynamic:
        tp:
          tomcatTp:
            corePoolSize: 100
            maximumPoolSize: 400
            keepAliveTime: 60
    

    Tomcat 线程池调优主要思想就是动态化线程池参数,上线前通过压测初步确定一套较优的参数值,上线后通过监控、告警实时感知线程池负载情况,动态调整参数适应流量的变化。

    线程池调优就说这些吧,下面主要介绍下 Tcp backlog 及半连接、全连接队列相关内容。

    划重点

    1. threads.max 和 threads.minSpare 是用来配置 Tomcat 的工作线程池大小的,是线程池维度的参数

    2. accept-count 和 max-connections 是 TCP 维度的配置参数

    TCP 状态机

    Client 端和 Server 端基于 TCP 协议进行通信时,首先需要经过三次握手建连的,通信结束时需要通过四次挥手断连的。注意所谓的连接其实是个逻辑上的概念,并不存在真实连接的,那 TCP 是怎么面向连接传输的呢?

    TCP 定义了个复杂的有限状态机模型,通信双方通过维护一个连接状态,来达到看起来像有一条连接的效果。如下是 TCP 状态机状态流转图,这个图非常重要,建议大家一定要掌握。图片来自 TCP 状态机

    1. 图上半部分描述了三次握手建立连接过程中状态的变化

    2. 图下半部分描述了四次挥手断开连接过程中状态的变化

    1.TCP 协议的状态机

    图 2 是通过三次握手建立连接的过程,老八股文了,建议结合图 1 状态机变化图看,图片来源三次握手

    2.TCP 建立连接过程

    图 3 是通过四次挥手断开连接的过程,建议结合图 1 状态机变化图看,图片来源四次挥手

    3.TCP 断开连接过程

    服务端程序调用 listen() 函数后,TCP 状态机从 CLOSED 转变为 LISTEN ,并且 linux 内核会创建维护两个队列。一个是半连接队列( Syn queue ),另一个是全连接队列( Accept queue )。

    建连主要流程如下:

    1. 客户端向服务端发送 SYN 包请求建立连接,发送后客户端进入 SYN_SENT 状态

    2. 服务端收到客户端的 SYN 请求,将该连接存放到半连接队列( Syn queue )中,并向客户端回复 SYN + ACK ,随后服务端进入 SYN_RECV 状态

    3. 客户端收到服务端的 SYN + ACK 后,回复服务端 ACK 并进入 ESTABLISHED 状态

    4. 服务端收到客户端的 ACK 后,从半连接队列中取出连接放到全连接队列( Accept queue )中,服务端进入 ESTABLISHED 状态

    5. 服务端程序调用 accept() 方法,从全连接队列中取出连接进行处理请求

    连接队列大小

    上述提到了半连接队列、全连接队列,这两队列都有大小限制的,超过的连接会被丢掉或者返回 RST 包。

    半连接队列大小主要受:listen backlog 、somaxconn 、tcp_max_syn_backlog 这三参数影响

    全连接队列大小主要受:listen backlog 和 somaxconn 这两参数影响

    tcp_max_syn_backlog 和 somaxconn 都是 linux 内核参数,在 /proc/sys/net/ipv4/ 和 /proc/sys/net/core/ 下,可以通过 /etc/sysctl.conf 文件来修改,默认值为 128 。

    listen backlog 参数其实就是我们调用 listen 函数时传入的第二个参数。回到主题,Tomcat 的 accept-count 其实最后就会传给 listen 函数做 backlog 用。

    int listen(int sockfd, int backlog);
    

    可以在配置文件中配置 tomcat accept-count 大小,默认为 100

    以下代码注释中也注明了 acceptCount 就是 backlog

    以 Nio2Endpoint 为例看下代码,bind 方法首先会根据配置的核心线程数、最大线程数创建 worker 线程池。然后调用 jdk nio2 中的 AsynchronousServerSocketChannelImpl 的 bind 方法,该方法内会调用 Net.listen() 进行 socket 监听。通过这几段代码,我们可以清晰的看到 Tomcat accept-count = Tcp backlog ,默认值为 100 。

    上面说到了半全两个连接队列,至于这两个连接队列大小怎么确定,其实不同 linux 内核版本算法也都不太一样,我们就以 v3.10 来看。

    以下是 linux 内核 socket.c 中的源码,也就是我们调用 listen() 函数会执行的代码

    /*
     * Perform a listen. Basically, we allow the protocol to do anything
     * necessary for a listen, and if that works, we mark the socket as
     * ready for listening.
     */
    SYSCALL_DEFINE2(listen, int, fd, int, backlog)
    {
        struct socket *sock;
        int err, fput_needed;
        int somaxconn;
    
        sock = sockfd_lookup_light(fd, &err, &fput_needed);
        if (sock) {
                somaxconn = sock_net(sock->sk)->core.sysctl_somaxconn;
                if ((unsigned int)backlog > somaxconn)
                        backlog = somaxconn;
    
                err = security_socket_listen(sock, backlog);
                if (!err)
                        err = sock->ops->listen(sock, backlog);
    
                fput_light(sock->file, fput_needed);
        }
        return err;
    }
    

    可以看到,此处会拿内核参数 somaxconn 和 传入的 backlog 做比较,取二者中的较小者作为全连接队列大小。

    全连接队列大小 = min(backlog, somaxconn)。

    接下来 backlog 会依次传递给如下函数,格式约定(源代码文件名#函数名)

    af_inet.c#inet_listen() -> inet_connection_sock.c#inet_csk_listen_start() -> request_sock.c#reqsk_queue_alloc()

    reqsk_queue_alloc() 函数代码如下,主要就是用来计算半连接队列大小的。

    计算逻辑可以简化为下述公式,简单描述 roundup_pow_of_two 算法就是向上取最接近的最大 2 的指数次幂,注意此处 backlog 已经是 min(backlog, somaxconn)

    半连接队列大小 = roundup_pow_of_two(max(8, min(backlog, tcp_max_syn_backlog))+1)

    代码里 max_qlen_log 在一个 for 循环里计算,比如算出的半连接队列大小 nr_table_entries = 16 = 2^4 ,那么 max_qlen_log = 4 ,该值在判断半连接队列是否溢出时会用到。

    举个例子,如果 listen backlog = 10 、somaxconn = 128 、tcp_max_syn_backlog = 128 ,那么半连接队列大小 = 16 ,全连接队列大小 = 10 。

    所以要知道,在做连接队列大小调优的时候,一定要综合上述三个参数,只修改某一个起不到想要的效果。

    连接队列大小查看

    全连接队列大小

    可以通过 linux 提供的 ss 命令来查看全连接队列的大小

    参数说明,参数很多,其他参数可以自己 help 查看说明

    l:表示显示 listening 状态的 socket

    n:不解析服务名称

    t:只显示 tcp sockets

    这个命令结果怎么解读呢?

    主要看前三个字段,Recv-Q 和 Send-Q 在 State 为 LISTEN 和非 LISTEN 状态时代表不同的含义。

    State: LISTEN

    Recv-Q: 全连接队列的当前长度,也就是已经完成三次握手等待服务端调用 accept() 方法获取的连接数量

    Send-Q: 全连接队列的最大长度,也就是我们上述分析的 backlog 和 somaxconn 的最小值

    State: 非 LISTEN

    Recv-Q: 已接受但未被应用进程读取的字节数

    Send-Q: 已发送但未收到确认的字节数

    以上区别从如下内核代码也可以看出,ss 命令就是从 tcp_diag 模块获取的数据

    半连接队列大小

    半连接队列没有像 ss 这种命令直接查看,但服务端处于 SYN_RECV 状态的连接都在半连接队列里,所以可以通过如下命令间接统计

    netstat -natp | grep SYN_RECV | wc -l
    

    半连接队列最大长度可以使用我们上述分析得到的公式计算得到

    半全连接队列溢出

    全连接队列溢出

    当请求量很大,全连接队列比较小时,就有可能发生全连接队列溢出的情况。

    此代码是 linux 内核用来判断全连接队列是否已满的函数,可以看到判断用的是大于号,这也就是我们用 ss 命令可能会看到 Recv-Q > Send-Q 的原因

    1. sk_ack_backlog 是当前全连接队列的大小

    2. sk_max_ack_backlog 是全连接队列的最大长度,也就是 min(listen_backlog, somaxconn)

    当全连接队列满了发生溢出时,会根据 /proc/sys/net/ipv4/tcp_abort_on_overflow 内核参数来决定怎么处理后续的 ack 请求,tcp_abort_on_overflow 默认值为 0 。

    1. 当 tcp_abort_on_overflow = 0 时,如果全连接队列已满,服务端会直接扔掉客户端发送的 ACK ,此时服务端处于 SYN_RECV 状态,客户端处于 ESTABLISHED 状态,服务端的超时重传定时器会重传 SYN + ACK 包给客户端(重传次数由 /proc/sys/net/ipv4/tcp_synack_retries 指定,默认值为 5 ,重试间隔为 1s 、2s 、4s 、8s 、16s ,共 31s ,第 5 次发出后还要等 32s 才知道第 5 次也超时了,所以总共需要 63s )。超过 tcp_synack_retries 后,服务端不会在重传,这时如果客户端发送数据过来,服务端会返回 RST 包,客户端会报 connection reset by peer 异常

    2. 当 tcp_abort_on_overflow = 1 时,如果全连接队列已满,服务端收到客户端的 ACK 后,会发送一个 RST 包给客户端,表示结束掉这个握手过程和这个连接,客户端会报 connection reset by peer 异常

    一般情况下 tcp_abort_on_overflow 保持默认值 0 就行,能提高建立连接的成功率

    半连接队列溢出

    我们知道,服务端收到客户端发送的 SYN 包后会将该连接放入半连接队列中,然后回复 SYN+ACK ,如果客户端一直不回复 ACK 做第三次握手,这样就会使得服务端有大量处于 SYN_RECV 状态的 TCP 连接存在半连接队列里,超过设置的队列长度后就会发生溢出。

    下述代码是 linux 内核判断是否发生半连接队列溢出的函数

    // 代码在 include/net/inet_connection_sock.h 中
    static inline int inet_csk_reqsk_queue_is_full(const struct sock *sk)
    {
        return reqsk_queue_is_full(&inet_csk(sk)->icsk_accept_queue);
    }
    
    // 代码在 include/net/request_sock.h 中
    static inline int reqsk_queue_is_full(const struct request_sock_queue *queue)
    {
       /*
        * qlen 是当前半连接队列大小
        * max_qlen_log 上述解释过,如果半连接队列大小 = 16 = 2^4 ,那么该值就是 4
        * 非常巧妙的用了移位运行来判断半连接队列是否溢出,底层满满的都是细节
        */
        return queue->listen_opt->qlen >> queue->listen_opt->max_qlen_log;
    }
    

    我们常说的 SYN Flood 洪水攻击 是一种典型的 DDOS 攻击,就是利用了这个点,给服务端发送一个 SYN 包后客户端就下线了,服务端会超时重传 SYN+ACK 包,上述也说了总共需要 63s 才停止重传,也就是说服务端需要经过 63s 后才断开该连接,这样就会导致半连接队列快速被耗尽,不能处理正常的请求。

    那是怎么防止攻击的呢?

    linux 提供个一个内核参数 /proc/sys/net/ipv4/tcp_syncookies 来应对该攻击,当半连接队列满了且开启 tcp_syncookies = 1 配置时,服务端在收到 SYN 并返回 SYN+ACK 后,不将该连接放入半连接队列,而是根据这个 SYN 包 TCP 头信息计算出一个 cookie 值。将这个 cookie 作为第二次握手 SYN+ACK 包的初始序列号 seq 发过去,如果是攻击者,就不会有响应,如果是正常连接,客户端回复 ACK 包后,服务端根据头信息计算 cookie ,与返回的确认序列号进行比对,如果相同,则是一个正常建立连接。

    下述代码是计算 cookie 的函数,可以看到跟这些字段有关(源 ip 、源端口、目标 ip 、目标端口、客户端 syn 包序列号、时间戳、mssind )

    下面看下第一次握手,收到 SYN 包后服务端的处理代码,代码太多,简化提出跟半连接队列溢出相关代码

    int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb)
    {
       /*
        * 如果半连接队列已满,且 tcp_syncookies 未开启,则直接丢弃该连接
        */
        if (inet_csk_reqsk_queue_is_full(sk) && !isn) {
            want_cookie = tcp_syn_flood_action(sk, skb, "TCP");
            if (!want_cookie)
                    goto drop;
        }
    
       /*
        * 如果全连接队列已满,并且没有重传 SYN+ACk 包的连接数量大于 1 ,则直接丢弃该连接
        * inet_csk_reqsk_queue_young 获取没有重传 SYN+ACk 包的连接数量
        */
        if (sk_acceptq_is_full(sk) && inet_csk_reqsk_queue_young(sk) > 1) {
            NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
            goto drop;
        }
    
        // 分配 request sock 内核对象
        req = inet_reqsk_alloc(&tcp_request_sock_ops);
        if (!req)
            goto drop;
    
        if (want_cookie) {
            // 如果开启了 tcp_syncookies 且半连接队列已满,则计算 cookie
            isn = cookie_v4_init_sequence(sk, skb, &req->mss);
            req->cookie_ts = tmp_opt.tstamp_ok;
        } else if (!isn) {
             /* 如果没有开启 tcp_syncookies 并且 max_syn_backlog - 半连接队列当前大小 < max_syn_backlog >> 2 ,则丢弃该连接 */
            else if (!sysctl_tcp_syncookies &&
                     (sysctl_max_syn_backlog - inet_csk_reqsk_queue_len(sk) <
                      (sysctl_max_syn_backlog >> 2)) &&
                     !tcp_peer_is_proven(req, dst, false)) {
                LIMIT_NETDEBUG(KERN_DEBUG pr_fmt("drop open request from %pI4/%u\n"),
                               &saddr, ntohs(tcp_hdr(skb)->source));
                goto drop_and_release;
            }
            isn = tcp_v4_init_sequence(skb);
        }
        tcp_rsk(req)->snt_isn = isn;
        // 构造 syn+ack 响应包
        skb_synack = tcp_make_synack(sk, dst, req,
            fastopen_cookie_present(&valid_foc) ? &valid_foc : NULL);
        if (likely(!do_fastopen)) {
            int err;
            // 发送 syn+ack 响应包
            err = ip_build_and_send_pkt(skb_synack, sk, ireq->loc_addr,
                 ireq->rmt_addr, ireq->opt);
            err = net_xmit_eval(err);
            if (err || want_cookie)
                    goto drop_and_free;
    
            tcp_rsk(req)->snt_synack = tcp_time_stamp;
            tcp_rsk(req)->listener = NULL;
            // 添加到半连接队列,并且开启超时重传定时器
            inet_csk_reqsk_queue_hash_add(sk, req, TCP_TIMEOUT_INIT);
        } else if (tcp_v4_conn_req_fastopen(sk, skb, skb_synack, req))
            goto drop_and_free;
    }
    

    查看溢出命令

    当连接队列溢出时,可以通过 netstart -s 命令查询

      # 表示全连接队列溢出的次数,累计值
      119005 times the listen queue of a socket overflowed
    
      # 表示半连接队列溢出的次数,累计值
      119085 SYNs to LISTEN sockets dropped
    

    如果发现这两个值一直在增加,就说明发生了队列溢出,需要看情况调大队列大小

    常用组件 backlog 大小

    1. Redis 默认 backlog = 511

    2. Nginx 默认 backlog = 511

    3. Mysql 默认 backlog = 50

    4. Undertow 默认 backlog = 1000

    5. Tomcat 默认 backlog = 100

    总结

    这篇文章以 Tomcat 性能调优为切入点,首先简单讲了下 Tomcat 线程池调优。然后借 Tomcat 配置参数 accept-count 引出了 Tcp backlog ,从 linux 内核源码层面详细讲解了下 TCP backlog 参数以及半连接、全连接队列的相关知识,包括连接队列大小设置,以及队列溢出怎么排查,这些东西也是我们服务端开发需要掌握的,在性能调优,问题排查时会有一定的帮助。

    个人开源项目

    DynamicTp 是一个基于配置中心实现的轻量级动态线程池管理工具,主要功能可以总结为动态调参、通知报警、运行监控、三方包线程池管理等几大类。

    目前累计 2k star ,欢迎大家试用,感谢你的 star ,欢迎 pr ,业务之余一起给开源贡献一份力量

    官网https://dynamictp.cn

    gitee 地址https://gitee.com/dromara/dynamic-tp

    github 地址https://github.com/dromara/dynamic-tp

    4 条回复    2022-11-29 00:13:34 +08:00
    chendy
        1
    chendy  
       2022-11-03 10:45:04 +08:00
    https://www.v2ex.com/t/892271

    虽然但是这动态线程池是个蓝海么?……
    yanhomlin
        2
    yanhomlin  
    OP
       2022-11-03 11:13:22 +08:00
    @chendy 不知道啊,自己项目需要就做了,顺便开源了
    hankli
        3
    hankli  
       2022-11-03 11:16:27 +08:00
    ..................嗯
    holyliao
        4
    holyliao  
       2022-11-29 00:13:34 +08:00 via iPhone
    👍
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   2762 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 30ms · UTC 12:23 · PVG 20:23 · LAX 05:23 · JFK 08:23
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.