性能文章>面试官:我们来拉扯一下Spring Boot 最大连接数吧?>

面试官:我们来拉扯一下Spring Boot 最大连接数吧?原创

https://a.perfma.net/img/2382850
1年前
5537912

你好呀,我是歪歪。

上个月的时候,我写过这样一篇文章:《面试官:一个 SpringBoot 项目能处理多少请求?(小心有坑)》

在文章的最后,我抛出了这样的一个问题:

这个问题关乎于这两个配置参数在 Tomcat 里面的作用:

server.tomcat.accept-count
server.tomcat.max-connections

如果说我们来玩个文字游戏,当面试官问“一个 SpringBoot 项目能处理多少请求”的时候,我们可以从线程池的角度去答。

但是当面试官问“一个 SpringBoot 项目能接受多少个连接”的时候,我们就不得不聊到刚刚提到的这两个参数了。

我写文章的时候对于这两个参数避而不谈就是怕一篇文章,知识点太多给大家搞混了。

同时,在评论区里面也有“老江湖”一眼就看穿了我的小把戏,特意追问了我基本上就是几句带过的 web 连接相关的部分,

因为我写文章的时候我就是把自己置为一个面试者的角度,让我来回答这个题,我优先会考虑把面试官往我更为熟悉的考点上引。在面试官没有明确要求往连接数的方向回答的前提下,我肯定是往更熟悉的线程池上说了。

也许你是面试者,遇到这个题的时候,你更熟悉 Tomcat 的 socket 链接部分,能横向对比 BIO、NIO、AIO 在 Tomcat 里面的应用,你当然可以把问题中的请求理解为“连接数”,从这个角度去答题。

那么肯定又有读者要说了:这么多角度,我怎么知道面试官想要的是什么回答。万一我答了“连接”而他更想要“线程”呢,把我挂了怎么办?

放心,正常来说面试官都会引导你。别老是想着套路面试官,毕竟主动权大多数时候在他那边。

如果没有提示,而且因为这个把你挂了,你出门之后可以大骂一声:**

这个也是我前面的文章最后说的:不管是对于面试者还是面试官,一个好的面试体验,一定不是没有互动的一问一答,而是一个相互拉锯的过程。

拉扯,你明白吧?

本周原计划是写一篇文章来填上前面关于“连接角度”的回答的坑的。

然而计划赶不上变化,没来得及写。

但是,我在这个过程中发现了一个文章,也能很好的回答这个问题,关键部分的源码给到了,操作案例也有,行文精炼,突出一个点到为止。借花献佛,分享给大家:

原文连接:https://laker.blog.csdn.net/article/details/130957301 《图解Spring Boot 最大连接数及最大并发数》

前言

每个 Spring Boot 版本和内置容器不同,结果也不同,这里以 Spring Boot 2.7.10 版本 + 内置 Tomcat 容器举例。

在 SpringBoot2.7.10 版本中内置 Tomcat 版本是 9.0.73,SpringBoot 内置 Tomcat 的默认设置如下:

  • Tomcat的连接等待队列长度,默认是100
  • Tomcat的最大连接数,默认是8192
  • Tomcat的最小工作线程数,默认是10
  • Tomcat的最大线程数,默认是200
  • Tomcat的连接超时时间,默认是20s

相关配置及默认值如下

server:
  tomcat:
    # 当所有可能的请求处理线程都在使用中时,传入连接请求的最大队列长度
    accept-count: 100
    # 服务器在任何给定时间接受和处理的最大连接数。一旦达到限制,操作系统仍然可以接受基于“acceptCount”属性的连接。
    max-connections: 8192
    threads:
      # 工作线程的最小数量,初始化时创建的线程数
      min-spare: 10
      # 工作线程的最大数量 io密集型建议10倍的cpu数,cpu密集型建议cpu数+1,绝大部分应用都是io密集型
      max: 200
    # 连接器在接受连接后等待显示请求 URI 行的时间。
    connection-timeout: 20000
    # 在关闭连接之前等待另一个 HTTP 请求的时间。如果未设置,则使用 connectionTimeout。设置为 -1 时不会超时。
    keep-alive-timeout: 20000
    # 在连接关闭之前可以进行流水线处理的最大HTTP请求数量。当设置为0或1时,禁用keep-alive和流水线处理。当设置为-1时,允许无限数量的流水线处理或keep-alive请求。 
    max-keep-alive-requests: 100

架构图

当连接数大于 maxConnections+acceptCount + 1 时,新来的请求不会收到服务器拒绝连接响应,而是不会和新的请求进行 3 次握手建立连接,一段时间后(客户端的超时时间或者 Tomcat 的 20s 后)会出现请求连接超时。

在这里,我们回顾一下 TCP 的 3 次握手 4 次挥手:

这个图是 Tomcat 中请求处理的工作原理:

具体描述可以参考这个链接:

https://www.eginnovations.com/blog/tomcat-monitoring-metrics/

核心参数

AcceptCount

全连接队列容量,等同于 backlog 参数,与 Linux 中的系统参数 somaxconn 取较小值,Windows 中没有系统参数。

对应源码:NioEndpoint.java

serverSock = ServerSocketChannel.open();
socketProperties.setProperties(serverSock.socket());
InetSocketAddress addr = new InetSocketAddress(getAddress(), getPortWithOffset());
// 这里
serverSock.socket().bind(addr,getAcceptCount());

MaxConnections

对应源码:Acccptor.java

// 线程的run方法。
public void run() {    
  while (!stopCalled) { 
      // 如果我们已达到最大连接数,等待
         connectionLimitLatch.countUpOrAwait();
            // 接受来自服务器套接字的下一个传入连接
            socket = endpoint.serverSocketAccept()
            // socket.close 释放的时候 调用 connectionLimitLatch.countDown();    

MinSpareThread/MaxThread

对应源码:AbstractEndpoint.java

// tomcat 启动时
public void createExecutor() {
        internalExecutor = true;
     // 容量为Integer.MAX_VALUE
        TaskQueue taskqueue = new TaskQueue();
        TaskThreadFactory tf = new TaskThreadFactory(getName() + "-exec-", daemon, getThreadPriority());
     // Tomcat扩展的线程池
        executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), 60, TimeUnit.SECONDS,taskqueue, tf);
        taskqueue.setParent( (ThreadPoolExecutor) executor);
}

MaxKeepAliveRequests

长连接,在发送了 maxKeepAliveRequests 个请求后就会被服务器端主动断开连接。

在连接关闭之前可以进行流水线处理的最大 HTTP 请求数量。

当设置为 0 或 1 时,禁用 keep-alive 和流水线处理。

当设置为 -1 时,允许无限数量的流水线处理或 keep-alive 请求。

较大的 MaxKeepAliveRequests 值可能会导致服务器上的连接资源被长时间占用。根据您的具体需求,您可以根据服务器的负载和资源配置来调整 MaxKeepAliveRequests 的值,以平衡并发连接和服务器资源的利用率。

NioEndpoint.setSocketOptions 
 socketWrapper.setKeepAliveLeft(NioEndpoint.this.getMaxKeepAliveRequests());

Http11Processor.service(SocketWrapperBase<?> socketWrapper)
  keepAlive = true;
  while(!getErrorState().isError() && keepAlive && !isAsync() && upgradeToken == null &&
                sendfileState == SendfileState.DONE && !protocol.isPaused()) {
    // 默认100  
 int maxKeepAliveRequests = protocol.getMaxKeepAliveRequests();
 if (maxKeepAliveRequests == 1) {
     keepAlive = false;
 } else if (maxKeepAliveRequests > 0 &&
            //    
         socketWrapper.decrementKeepAlive() <= 0) {
     keepAlive = false;
 }

ConnectionTimeout

连接的生存周期,当已经建立的连接,在 connectionTimeout 时间内,如果没有请求到来,服务端程序将会主动关闭该连接。

  • 在 Tomcat 9 中,ConnectionTimeout 的默认值是 20000 毫秒,也就是 20 秒。
  • 如果该时间过长,服务器将要等待很长时间才会收到客户端的请求结果,从而导致服务效率低下。如果该时间过短,则可能会出现客户端在请求过程中网络慢等问题,而被服务器取消连接的情况。
  • 由于某个交换机或者路由器出现了问题,导致某些 post 大文件的请求堆积在交换机或者路由器上,Tomcat 的工作线程一直拿不到完整的文件数据。

对应源码:NioEndpoint.Poller#run()

 // Check for read timeout
 if ((socketWrapper.interestOps() & SelectionKey.OP_READ) == SelectionKey.OP_READ) {
     long delta = now - socketWrapper.getLastRead();
     long timeout = socketWrapper.getReadTimeout();
     if (timeout > 0 && delta > timeout) {
         readTimeout = true;
     }
 }
 // Check for write timeout
 if (!readTimeout && (socketWrapper.interestOps() & SelectionKey.OP_WRITE) == SelectionKey.OP_WRITE) {
     long delta = now - socketWrapper.getLastWrite();
     long timeout = socketWrapper.getWriteTimeout();
     if (timeout > 0 && delta > timeout) {
         writeTimeout = true;
     }
 }

KeepAliveTimeout

等待另一个 HTTP 请求的时间,然后关闭连接。当未设置时,将使用 connectionTimeout。当设置为 -1 时,将没有超时。

对应源码:

Http11InputBuffer.parseRequestLine

// Read new bytes if needed
if (byteBuffer.position() >= byteBuffer.limit()) {
    if (keptAlive) {
        // 还没有读取任何请求数据,所以使用保持活动超时
        wrapper.setReadTimeout(keepAliveTimeout);
    }
    if (!fill(false)) {
        // A read is pending, so no longer in initial state
        parsingRequestLinePhase = 1;
        return false;
    }
    //  至少已收到请求的一个字节 切换到套接字超时。
     wrapper.setReadTimeout(connectionTimeout);
}

Acceptor

Acceptor: 接收器,作用是接受 scoket 网络请求,并调用 setSocketOptions() 封装成为 NioSocketWrapper,并注册到 Poller 的 events 中。

注意查看 run 方法 org.apache.tomcat.util.net.Acceptor#run:

public void run() {
       while (!stopCalled) {
           // 等待下一个请求进来
           socket = endpoint.serverSocketAccept();
            // 注册socket到Poller,生成PollerEvent事件
           endpoint.setSocketOptions(socket);
              // 向轮询器注册新创建的套接字
                    - poller.register(socketWrapper);
                        - (SynchronizedQueue(128))events.add(new PollerEvent(socketWrapper))    

Poller

Poller:轮询器,轮询是否有事件达到,有请求事件到达后,以 NIO 的处理方式,查询 Selector 取出所有请求,遍历每个请求的需求,分配给 Executor 线程池执行。

查看 org.apache.tomcat.util.net.NioEndpoint.Poller#run()

public void run() {
       while (true) {
               //查询selector取出所有请求事件
               Iterator<SelectionKey> iterator =
                   keyCount > 0 ? selector.selectedKeys().iterator() : null;
               // 遍历就绪键的集合并调度任何活动事件。
               while (iterator != null && iterator.hasNext()) {
                   SelectionKey sk = iterator.next();
                   iterator.remove();
                   NioSocketWrapper socketWrapper = (NioSocketWrapper) sk.attachment();
                   // 分配给Executor线程池执行处理请求key
                   if (socketWrapper != null) {
                       processKey(sk, socketWrapper);
                       - processSocket(socketWrapper, SocketEvent.OPEN_READ/SocketEvent.OPEN_WRITE)
                           - executor.execute((Runnable)new SocketProcessor(socketWrapper,SocketEvent))
                   }
               }

TomcatThreadPoolExecutor

真正执行连接读写操作的线程池,在 JDK 线程池的基础上进行了扩展优化。

对应源码:AbstractEndpoint.java

public void createExecutor() {
        internalExecutor = true;
        TaskQueue taskqueue = new TaskQueue();
        TaskThreadFactory tf = new TaskThreadFactory(getName() + "-exec-", daemon, getThreadPriority());
     // tomcat自定义线程池
        executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), 60, TimeUnit.SECONDS,taskqueue, tf);
        taskqueue.setParent( (ThreadPoolExecutor) executor);
    }

对应源码:TomcatThreadPoolExecutor.java

// 与 java.util.concurrent.ThreadPoolExecutor 相同,但实现了更高效的getSubmittedCount()方法,用于正确处理工作队列。
// 如果未指定 RejectedExecutionHandler,将配置一个默认的,并且该处理程序将始终抛出 RejectedExecutionException
public class ThreadPoolExecutor extends java.util.concurrent.ThreadPoolExecutor {
 // 已提交但尚未完成的任务数。这包括队列中的任务和已交给工作线程但后者尚未开始执行任务的任务。
    // 这个数字总是大于或等于getActiveCount() 。
    private final AtomicInteger submittedCount = new AtomicInteger(0);
    
    @Override
    protected void afterExecute(Runnable r, Throwable t) {
        if (!(t instanceof StopPooledThreadException)) {
            submittedCount.decrementAndGet();
        }
    @Override
    public void execute(Runnable command){
        // 提交任务的数量+1
        submittedCount.incrementAndGet();
        try {
            //  线程池内部方法,真正执行的方法。就是JDK线程池原生的方法。
            super.execute(command);
        } catch (RejectedExecutionException rx) {
            // 再次把被拒绝的任务放入到队列中。
            if (super.getQueue() instanceof TaskQueue) {
                final TaskQueue queue = (TaskQueue)super.getQueue();
                try {
                      //强制的将任务放入到阻塞队列中
                    if (!queue.force(command, timeout, unit)) {
                        //放入失败,则继续抛出异常
                        submittedCount.decrementAndGet();
                        throw new RejectedExecutionException(sm.getString("threadPoolExecutor.queueFull"));
                    }
                } catch (InterruptedException x) {
                     //被中断也抛出异常
                    submittedCount.decrementAndGet();
                    throw new RejectedExecutionException(x);
                }
            } else {
                 //不是这种队列,那么当任务满了之后,直接抛出去。
                submittedCount.decrementAndGet();
                throw rx;
            }

        }
    }

Tomcat 的自定义队列

/**
 * 实现Tomcat特有逻辑的自定义队列
 */
public class TaskQueue extends LinkedBlockingQueue<Runnable> {

    ...

    /**
     * put:向阻塞队列填充元素,当阻塞队列满了之后,put时会被阻塞。
     * offer:向阻塞队列填充元素,当阻塞队列满了之后,offer会返回false
     *
     * @param o 当任务被拒绝后,继续强制的放入到线程池中
     * @return 向阻塞队列塞任务,当阻塞队列满了之后,offer会返回false
     */
    public boolean force(Runnable o) {
        if (parent == null || parent.isShutdown()) {
            throw new RejectedExecutionException("taskQueue.notRunning");
        }
        return super.offer(o);
    }

    /**
     * 带有阻塞时间的塞任务
     */
    @Deprecated
    public boolean force(Runnable o, long timeout, TimeUnit unit) throws InterruptedException {
        if (parent == null || parent.isShutdown()) {
            throw new RejectedExecutionException("taskQueue.notRunning");
        }
        return super.offer(o, timeout, unit); //forces the item onto the queue, to be used if the task is rejected
    }

    /**
     * 当线程真正不够用时,优先是开启线程(直至最大线程),其次才是向队列填充任务。
     *
     * @param runnable 任务
     * @return false 表示向队列中添加任务失败,
     */
    @Override
    public boolean offer(Runnable runnable) {
        if (parent == null) {
            return super.offer(runnable);
        }
        //若是达到最大线程数,进队列。
        if (parent.getPoolSize() == parent.getMaximumPoolSize()) {
            return super.offer(runnable);
        }
        //当前活跃线程为10个,但是只有8个任务在执行,于是,直接进队列。
        if (parent.getSubmittedCount() < (parent.getPoolSize())) {
            return super.offer(runnable);
        }
        //当前线程数小于最大线程数,那么直接返回false,去创建最大线程
        if (parent.getPoolSize() < parent.getMaximumPoolSize()) {
            return false;
        }
        //否则的话,将任务放入到队列中
        return super.offer(runnable);
    }
    
    ...

重点重点重点

Tomcat 扩展了线程池增强了功能。

JDK 线程池流程:minThreads --> queue --> maxThreads --> Exception
Tomcat 增强后: minThreads --> maxThreads --> queue --> Exception

JDK 线程池架构图:

Tomcat 线程架构

测试案例

如下配置举例:

server:
  port: 8080
  tomcat:
    accept-count: 3
    max-connections: 6
    threads:
      min-spare: 2
      max: 3

使用 ss -nlt 查看全连接队列容量。

ss -nlt|grep 8080
- Recv-Q表示(acceptCount)全连接队列目前长度
- Send-Q表示(acceptCount)全连接队列的容量。

静默状态

6 个并发连接

结果和静默状态一致:

9 个并发连接

10 个并发连接

11 个并发连接

结果和 10 个并发连接一样:

使用 ss -nt 查看连接状态。

ss -nt|grep 8080
- Recv-Q表示客户端有多少个字节发送但还没有被服务端接收
- Send-Q就表示为有多少个字节未被客户端接收。

静默状态

6 个并发连接

9 个并发连接

针对 9 个并发连接的场景,补充个 netstat,显示一下网络相关信息:

10 个并发连接

和上面的结果比起来,只是队列中多加了个,由 3 个变成了 4 个。

11 个并发连接

前面说过,当连接数大于 maxConnections+acceptCount+1 时,新来的请求不会收到服务器拒绝连接响应,而是不会和新的请求进行 3 次握手建立连接,一段时间后(客户端的超时时间或者 Tomcat 的 20s 后)会出现请求连接超时。

所以我们可以看到,当有 11 个并发连接,即超出最大的 10 个连接后,会有个连接一直停留在 SYN_RECV 状态,不会完成 3 次握手了。

超出连接后客户端一直就停留在 SYN-SENT 状态,服务端不会再发送 SYN+ACK,直到客户端超时(20s 内核控制)断开,客户端请求超时。

这里如果客户端设置了超时时间,要和服务端 3 次握手超时时间对比,以时间短的为准。

到这里就算是接近尾声了,但是歪师傅看原作者的文章是以这个图结尾的:

我感觉应该是一处笔误吧,我理解这里应该是有 15 个并发连接。

6 个被 accept, 4 个在队列中,5 个请求没有进行三次握手才对。

那么到底是不是呢?

抽空按照他的测试案例跑一波就行了,实践是检验真理的唯一标准。

好了,技术部分就到这里了。

··············  END  ··············

你好呀,我是歪歪。我没进过一线大厂,没创过业,也没写过书,更不是技术专家,所以也没有什么亮眼的title

当年高考,随缘调剂到了某二本院校计算机专业。纯属误打误撞,进入程序员的行列,之后开始了运气爆棚的程序员之路。

说起程序员之路还是有点意思,可以点击,查看我的程序员之路

 

点赞收藏
why技术
请先登录,查看9条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

随机一门技术分享之Netty

随机一门技术分享之Netty

Netty源码解析:writeAndFlush

Netty源码解析:writeAndFlush

12
9