前言

很多初学者第一次看到“Tomcat 最大线程数”“连接数”和“等待队列”时,会直接陷入参数细节。其实在讨论容量之前,更重要的是先回答三个问题:浏览器请求怎样到达 Controller?Tomcat 和 Servlet 是什么关系?请求为什么会排队?

本文从一条请求的完整旅程讲起,再用一个具体数字的例子解释 maxThreadsmaxConnectionsacceptCount。文中的实验用于理解机制,不代表所有机器和项目都有相同的性能。


目录


一个请求怎样到达 Controller

假设我们写了一个 Spring Boot 接口:

@RestController
public class DemoController {

    @GetMapping("/hello")
    public String hello() {
        return "hello";
    }
}

浏览器访问 /hello 时,请求并不是直接跳进这个 Java 方法,而是经过下面的链路:

浏览器发送 HTTP 请求
        ↓
Tomcat 在端口上收到请求
        ↓
Tomcat 安排请求处理线程
        ↓
调用 Spring MVC 的 DispatcherServlet
        ↓
DispatcherServlet 找到 /hello 对应的 Controller
        ↓
Controller 执行业务代码
        ↓
结果沿原路返回浏览器

要理解这条链路,首先需要分清 Tomcat、Servlet 和 Spring MVC。

Tomcat、Servlet 和 Spring MVC 到底是什么关系

Servlet 是规范

Servlet 是 Java Web 领域的一套规范。它规定了 Java 程序如何接收请求、处理请求和生成响应。

规范更像一份合同或接口定义,它告诉开发者“应该提供哪些能力”,但规范本身不会监听 8080 端口,也不会独立运行。

Tomcat 是 Servlet 容器

Tomcat 实现了 Servlet 规范,因此被称为 Servlet 容器。同时,Tomcat 也具备 HTTP 服务器能力,可以监听端口、解析 HTTP 请求并发送响应。

可以把二者类比成:

Servlet 规范:餐厅应该遵守的服务标准
Tomcat:按照标准运营的餐厅

JDK 本身不内置 Tomcat。常见的 Spring Boot Web 项目会通过依赖引入内嵌 Tomcat,执行 java -jar app.jar 时,Tomcat 和业务代码运行在同一个 Java 进程中。

Spring MVC 使用了一个核心 Servlet

Spring MVC 的核心入口叫 DispatcherServlet。它是一个 Servlet,由 Tomcat 管理。

我们的 Controller 通常不是 Servlet。Tomcat 先调用 DispatcherServlet,再由 DispatcherServlet 根据请求路径找到对应的 Controller。

Tomcat
└─ 管理 DispatcherServlet
   └─ DispatcherServlet 分发请求
      ├─ /user  → UserController
      ├─ /order → OrderController
      └─ /hello → DemoController

所以可以记住:

Servlet 是规范。
Tomcat 是实现 Servlet 规范的容器。
DispatcherServlet 是 Spring MVC 的统一入口。
Controller 是我们编写的业务入口。

Connector 是 Tomcat 的“HTTP 大门”

Tomcat 可以配置一个或多个 Connector。本文讨论的 HTTP Connector,主要负责:

  • 监听端口,例如 8080;
  • 接受客户端连接;
  • 解析 HTTP 请求;
  • 调度请求处理线程;
  • 把请求交给后面的 Servlet 容器处理。

可以暂时把它理解成 Tomcat 的 HTTP 大门:

客户端
  ↓
HTTP Connector
  ↓
请求处理线程
  ↓
DispatcherServlet
  ↓
Controller

maxThreadsmaxConnectionsacceptCount 都与这扇大门的承接能力有关,但它们限制的不是同一件事。

Nginx 和 Tomcat 有什么区别

生产环境中经常把 Nginx 放在多个 Java 服务前面:

用户
  ↓
Nginx
  ├─ HTTPS
  ├─ 反向代理
  ├─ 限流
  └─ 负载均衡
       ↓
Java 服务 A(内嵌 Tomcat)
Java 服务 B(内嵌 Tomcat)
Java 服务 C(内嵌 Tomcat)
对比项 Nginx Tomcat
常见位置 Java 服务前方 Java 服务内部或独立部署
主要职责 接收入口流量并转发 运行 Servlet Web 应用
是否调用 Controller 通常不调用 通过 Spring MVC 间接调用
负载均衡 可以把请求分给多个实例 主要管理当前实例

最简单的记法是:

Nginx:这次请求转给哪台 Java 服务?
Tomcat:这台 Java 服务怎样接收并处理请求?

Nginx 不是必选组件。本地开发时通常是:

浏览器 → Spring Boot 内嵌 Tomcat → DispatcherServlet → Controller

请求数、QPS 和并发数

理解容量前,还需要分清三个基本概念。

请求数

请求数是一段时间内收到或完成的请求总量,例如一分钟处理了 3000 个请求。

QPS

QPS 表示每秒处理多少个请求。例如一分钟稳定完成 3000 个请求,平均吞吐量约为 50 QPS。

并发数

并发数表示某一时刻系统中还有多少个请求没有完成,包括正在执行业务代码和正在等待资源的请求。

在系统处于稳定状态、统计口径一致时,可以近似使用:

平均并发数 ≈ 平均 QPS × 平均响应时间(秒)

例如平均有 10 个在途请求,平均响应时间为 0.5 秒:

QPS ≈ 10 ÷ 0.5 = 20

这只是帮助理解吞吐、并发和响应时间的关系,不是 Tomcat 的固定容量公式。

TCP 连接、HTTP 请求和工作线程不是一回事

客户端通常先建立 TCP 连接,再通过这条连接发送 HTTP 请求。

TCP 连接:通信通道
HTTP 请求:通道中传输的一次任务
工作线程:执行这次任务的 Java 线程

一条 HTTP Keep-Alive 连接可以先后承载多个请求。HTTP/2 还可以在一条连接上复用多个请求流。因此:

TCP 连接数 ≠ 在途请求数 ≠ Tomcat 活动线程数

这也是理解下面三个参数时最容易踩坑的地方。

三个关键参数分别限制什么

maxThreads:最多有多少个请求处理线程

maxThreads 控制 Connector 最多创建多少个请求处理线程。对于传统的同步 Servlet 请求,一个请求在执行期间通常会占用一个请求处理线程。

假设:

maxThreads = 2

那么同一时刻最多有 2 个请求处理线程工作:

线程 1:处理请求 A
线程 2:处理请求 B
请求 C:暂时没有请求处理线程可用

如果 Connector 绑定了共享 Executor,Connector 自己配置的 maxThreads 会被忽略,实际线程上限由 Executor 的配置决定。

maxConnections:Tomcat 最多维持多少条连接

maxConnections 限制 Tomcat 同时接受和处理的连接数量。

假设:

maxConnections = 5

表示 Tomcat 最多同时维持 5 条已接受的连接。这里限制的是连接,不是正在执行的请求。

即使只有 2 个请求处理线程,Tomcat 也可能已经接受 5 条连接:2 条连接上的请求正在执行,其余连接等待可用线程。

acceptCount:Tomcat 暂时不能接入时,系统还能排多少连接

当已接受的连接数达到 maxConnections 后,Tomcat 暂时不再接受更多连接。新的连接会在操作系统提供的连接队列中等待。

acceptCount 用于设置这个操作系统连接队列的最大长度。例如:

acceptCount = 3

表示达到 maxConnections 后,希望还能有最多 3 条新连接在操作系统队列中等待。

需要注意:这是操作系统层面的队列,操作系统可能根据自身实现和限制采用不同的实际长度。当这个队列也满了,后续连接可能被拒绝或最终超时。

用一个完整例子看懂三个参数

假设配置如下:

maxThreads = 2
maxConnections = 5
acceptCount = 3

为了让数字容易理解,这个例子暂时假设:

每个客户端新建一条连接
每条连接只发送一个慢请求
请求响应后立即关闭连接
所有请求几乎同时到达

现在有 9 个客户端同时连接 Tomcat。

第 1、2 条连接:立即取得线程

Tomcat 有 2 个请求处理线程,因此前两个请求立即执行:

连接 1 → 线程 1 正在执行
连接 2 → 线程 2 正在执行

此时:

活动线程:2 / 2
已接受连接:2 / 5
系统连接队列:0 / 3

第 3~5 条连接:Tomcat 已接受,但等待线程

Tomcat 还能接受 3 条连接,但已经没有空闲请求处理线程:

连接 3 ┐
连接 4 ├─ 已被 Tomcat 接受,等待请求处理线程
连接 5 ┘

此时:

活动线程:2 / 2
已接受连接:5 / 5
系统连接队列:0 / 3

这里达到了 maxConnections,但并不意味着 5 个请求都在同时执行业务代码。真正执行的仍然只有 2 个。

第 6~8 条连接:进入操作系统连接队列

Tomcat 已经达到 maxConnections,暂时不能接受更多连接。接下来的 3 条连接在操作系统提供的队列中等待:

连接 6 ┐
连接 7 ├─ 操作系统连接队列
连接 8 ┘

此时:

活动线程:2 / 2
已接受连接:5 / 5
系统连接队列:3 / 3

第 9 条连接:可能被拒绝或超时

请求处理线程已满、已接受连接达到上限、操作系统连接队列也达到上限。第 9 条连接可能无法继续排队,表现为连接被拒绝或等待后超时。

把整个场景画在一起:

maxThreads = 2
┌───────────────────────────────┐
│ 连接 1 → 线程 1 正在执行       │
│ 连接 2 → 线程 2 正在执行       │
│                               │  maxConnections = 5
│ 连接 3 → 等待可用线程          │
│ 连接 4 → 等待可用线程          │
│ 连接 5 → 等待可用线程          │
└───────────────────────────────┘

┌───────────────────────────────┐
│ 连接 6 → 操作系统队列          │
│ 连接 7 → 操作系统队列          │  acceptCount = 3
│ 连接 8 → 操作系统队列          │
└───────────────────────────────┘

连接 9 → 可能被拒绝或超时

当线程 1 完成请求,并且连接 1 按本例假设关闭后:

  1. 连接 3 可以取得释放出来的线程;
  2. Tomcat 的已接受连接数降到上限以下;
  3. 操作系统队列中的一条连接可以被 Tomcat 接受;
  4. 后面的连接继续依次向前移动。

Keep-Alive 下为什么不能照搬这个人数图

真实项目通常会使用 Keep-Alive。一个请求完成后,TCP 连接可能不会立即关闭,而是保持空闲等待后续请求。

这时即使请求处理线程已经释放,连接仍可能计入连接数。因此不能简单认为“一个请求完成,就一定释放一个 maxConnections 名额”。

上面的 2、5、3 例子是为了解释三个参数处在不同层次,不能把它当作所有协议和配置下完全一致的内部实现图。

慢请求为什么会让用户等待更久

假设有一个同步接口:

/slow?ms=500

它执行 Thread.sleep(500) 模拟慢 SQL 或阻塞式下游调用。sleep 几乎不消耗 CPU,但会占用当前请求处理线程。

10 个线程处理 20 个慢请求

最大工作线程:10
同时到达请求:20
每个请求等待:约 500ms

前 10 个请求可以立即取得线程,后 10 个请求要等待第一批释放线程:

第 1 批:约 500ms 返回
第 2 批:约 1000ms 返回

5 个线程处理 20 个慢请求

如果只保留 5 个工作线程,20 个请求会近似分成 4 批:

第 1 批:约  500ms 返回
第 2 批:约 1000ms 返回
第 3 批:约 1500ms 返回
第 4 批:约 2000ms 返回

因此:

用户响应时间
= 等待时间
+ 业务执行时间
+ 框架和网络开销

业务代码只等待 500ms,不代表用户一定只等待 500ms。

在“请求耗时接近、主要是阻塞等待、线程持续繁忙”的实验条件下,可以用下面的除法粗略理解吞吐上界:

10 个线程 ÷ 0.5 秒 ≈ 20 QPS
5 个线程 ÷ 0.5 秒 ≈ 10 QPS

这不是通用容量公式。真实接口还会受到 CPU、数据库连接池、锁、JVM 和下游服务影响。

为什么不能只增加 maxThreads

假设 Tomcat 有 10 个工作线程,但数据库连接池只有 5 条连接:

10 个请求进入业务代码
├─ 5 个取得数据库连接
└─ 5 个等待数据库连接

此时把 maxThreads 增加到 100,不会让数据库突然拥有更多处理能力,只会让更多线程进入等待。

对于 CPU 密集型任务,过多线程还可能增加线程切换和内存开销。因此调优时要查看整条链路:

Tomcat 线程
    ↓
Java 代码与 JVM
    ↓
数据库连接池
    ↓
Redis、MQ 和其他下游服务

最先达到极限的资源,才是当前系统的主要瓶颈。

第一次压测为什么可能更慢

服务刚启动后的第一批请求可能包含额外开销,例如:

  • 类加载和 JIT 编译;
  • Spring 组件的懒初始化;
  • 数据库或 HTTP 连接池首次建连;
  • 缓存尚未填充;
  • 压测客户端、网络或宿主机抖动。

只看两轮响应时间,不能断定唯一原因。正式压测通常需要:

启动服务
  ↓
小流量预热
  ↓
等待指标稳定
  ↓
逐级增加压力
  ↓
稳态运行并重复验证

冷启动性能也值得测试,但应和稳定运行时的容量分开记录。

容量测试应该看什么

Tomcat 能扛多少请求,不能只看某一个参数。压测时至少要观察:

  • QPS;
  • 平均响应时间;
  • P95 和 P99;
  • 错误率和超时率;
  • CPU、内存和 GC;
  • Tomcat 活动线程与连接数;
  • 数据库连接池使用率和等待时间;
  • Redis、MQ 和其他下游服务;
  • 压测客户端是否先成为瓶颈。

真正的容量边界是:

在响应时间和错误率满足 SLA、资源仍保留合理余量时,系统能够长期稳定维持的最大负载。

最后记住七句话

1. Servlet 是规范,Tomcat 是实现 Servlet 规范的容器。
2. DispatcherServlet 是 Spring MVC 的统一入口,Controller 由它分发调用。
3. Nginx 常负责入口和转发,Tomcat 负责运行 Java Web 应用。
4. TCP 连接、HTTP 请求和请求处理线程不是同一个概念。
5. maxThreads 限制请求处理线程数。
6. maxConnections 限制 Tomcat 同时维持的连接数。
7. acceptCount 限制达到连接上限后,操作系统还能排队的连接数。

参考资料

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐