SpringBoot内嵌Tomcat能处理多少请求?线程池与连接数全解析 去年有次面试候选人简历上写着“熟练使用 SpringBoot”项目也上线跑了大半年。我随口问了一句你们那个 SpringBoot 项目能处理多少请求他愣了一下然后说出一个数字200。这个数字在网络上流传太广以至于很多人把它当成标准答案可你要是追问一句“200 是哪来的”“200 是并发连接还是 QPS”“撑爆之后会先报什么错”基本就露馅了。这篇我不打算讲面试八股就沿着一次请求从进入到返回的完整路径把 SpringBoot 内嵌 Tomcat 的线程池、连接数、排队队列和那些真正拖垮项目的隐形瓶颈挨个揉碎了讲清楚。看完你不仅知道怎么回答案还能真的拿这套思路去排查线上问题。1. 面试官真正想考的请求进来之后的完整链路1.1 别急着回答数字先看请求到了 Tomcat 哪一层SpringBoot 的 Web 项目默认内嵌 Tomcat所以“一个 SpringBoot 项目能处理多少请求”这个问题本质上是在问 Tomcat 的连接器和线程池能扛住什么量级。很多人的理解卡在“请求进来Tomcat 开个线程去执行”这一步这个理解不能说错但它把一整条链路压成了一句话自然会漏掉很多关键点。一次 HTTP 请求真正经过的路径是操作系统内核接收 TCP 连接交给 Tomcat 的 Acceptor 线程Acceptor 只负责把新连接接进来丢给 PollerPoller 是个事件轮询器它等这个连接上有数据可读时才把任务交给 Worker 线程池Worker 线程池里的线程才是真正执行 Servlet 业务逻辑的人。整个过程你可以类比成一个繁忙的餐厅Acceptor 是门口负责领位的服务员Poller 是盯着哪桌客人已经点好菜的巡场Worker 才是厨房里真正颠勺的厨师。这就有意思了。连接能建立不代表请求能立刻执行请求能被轮询到也不代表有人在厨房里炒菜。这三个角色各管一段各自有各自的容量上限你只盯住“厨师”的数量自然解释不了为什么有时候连接一堆、后端却空转。1.2 真正干活的是线程池不是连接数Tomcat 的 Worker 线程池是核心。SpringBoot 2.x 默认server.tomcat.max-threads200SpringBoot 3.x 对应改成server.tomcat.threads.max200意思都是同一个同一时刻最多只有 200 个请求在 Servlet 代码里执行。注意这里说的是“同一时刻执行”不是“一秒钟能处理”。一个线程处理完一个请求后马上处理下一个所以只要请求够快200 个线程一秒能吞吐几千个请求非常正常但如果是 200 个慢接口同时卡在数据库查询上这 200 个线程就全部占住后续请求哪怕连接都建立好了也只能在连接上干等。这也是很多性能调优误区开始的地方。看到 Tomcat 线程数是 200就以为瓶颈固定在这然后盲目往上调结果要么内存吃紧要么上下文切换开销大反而更慢。线程数是执行能力的天花板但它不是承载能力的天花板。1.3 拒绝不是发生在线程满的时候而是发生在排队也排不下的时候Tomcat 对外还有一个“接纳能力”的概念用两个参数共同决定maxConnections和acceptCount。maxConnections是 Tomcat 愿意保持的最大 TCP 连接数NIO 模式下默认 8192acceptCount是操作系统内核里 listen 队列的长度默认 100。当一个新连接到达时如果当前连接数还没到 8192Acceptor 会直接收下如果已经到了 8192新连接就进内核的 accept 队列排队这个队最多排 100 个如果队列也满了操作系统就会直接拒绝新连接客户端看到的是 Connection refused 或者 connect timeout。换句话说真正让客户端“进不来”的不是线程池满了而是两个队列都满了。这个模型解释了一个非常常见的现象压测刚开始客户端哗啦啦报Connection refused但后端日志里 Tomcat 线程池根本没满CPU 也很闲。这时候你去调max-threads是没用的问题出在maxConnections acceptCount这个接纳容量上要么调大acceptCount要么把连接尽快释放掉。2. SpringBoot 默认参数和它们背后的数学2.1 先记住这张默认参数表我把最常见的几个参数和默认值摆出来你拿这个当底子去记比死记一个“200”靠谱得多。参数SpringBoot 2.x 配置SpringBoot 3.x 配置默认值作用最大工作线程数server.tomcat.max-threadsserver.tomcat.threads.max200同一时刻能执行 Servlet 逻辑的线程数最小空闲线程数server.tomcat.min-spare-threadsserver.tomcat.threads.min-spare10线程池保留的兜底线程最大连接数server.tomcat.max-connectionsserver.tomcat.max-connections8192连接器最多保持的 TCP 连接数等待队列长度server.tomcat.accept-countserver.tomcat.accept-count100连接数打满后内核 accept 队列的排队上限连接超时server.tomcat.connection-timeoutserver.tomcat.connection-timeout不设置则走 Tomcat 默认socket 读超时这张表有两点特别容易踩坑。第一SpringBoot 版本一变配置键名就变了老文章里的max-threads照抄到 SpringBoot 3 会直接不生效。第二min-spare-threads不是“最少线程数”它是线程池低于这个水位时会去补线程不要理解成常驻线程。2.2 10000 个请求同时来的话会发生什么我面试时喜欢拿具体数字考候选人比如10000 个连接同时打过来会发生什么按默认值算一遍就清楚了。前 8192 个连接被 Acceptor 正常接收剩下的 1808 个连接进入内核 accept 队列排队但队伍只有 100 个坑位于是大概有 1708 个连接直接连不上客户端那边表现为连接被拒绝或超时。注意这 8192 个连接里只有 200 个能在某一瞬间真正执行 Servlet。剩下的 7992 个连接都处于“已建立但还没轮到处理”的状态。它们靠 NIO 事件机制挂在那里不占用工作线程只占用文件描述符和一点内存。这就是为什么连接数是 8192、线程数是 200系统却不会立刻崩掉的原因。但这里还得多说一句连接状态是动态的。200 个线程处理完一批请求后同一个 TCP 连接如果开了 Keep-Alive还能继续复用这个连接不会被关掉只有请求不带 Keep-Alive 或者达到 Keep-Alive 上限连接才会被回收。并发压测时连接数曲线怎么走很大程度上取决于 Keep-Alive 的复用效率。2.3 QPS 的朴素估算公式面试官如果接着问“那到底能处理多少请求”你需要把一个数字拆成两个维度来说承载并发量和吞吐量。吞吐量最朴素的估算公式是单机 QPS ≈ 工作线程数 × (1000 / 平均响应时间 ms)假设每个接口平均响应 100ms200 个线程那理论吞吐是200 × 1000 / 100 2000 QPS。如果某个接口平均要 1 秒那同样 200 个线程理论吞吐直接掉到 200 QPS。公式本身不复杂但它有两个致命的隐含前提第一200 个线程全都在执行你的代码没有在等下游第二每次执行都能在平均响应时间内完成没有极端慢的尾巴请求。真实项目里这两个前提极少同时成立。汤姆猫线程池哪怕一个线程通过异步化撬动了几千并发最终能完成的请求量还是由最慢的那个环节决定。所以“一个 SpringBoot 项目能处理多少请求”这个题真正的高分回答是先讲连接器模型再讲线程池然后用响应时间和下游依赖做一把估算最后补一句“具体数字得靠压测定”。3. 真正影响处理量的隐形瓶颈3.1 线程忙等Tomcat 参数再好也扛不住慢接口Tomcat 的 200 个线程一旦全部阻塞在某个环节请求就会排队。我在一个生产项目里见过最典型的场景接口内部要查 MySQL而连接池的 HikariCP 默认maximum-pool-size只有 10。Tomcat 有 200 个线程同时进来其中 190 个拿不到数据库连接全部卡在“等待连接”的状态。从外部看这个接口响应时间一路飙升线程池跑满但 MySQL 自己其实很闲连接池就是瓶颈。所以排查“能处理多少请求”的时候一定不要只看 Tomcat。要用jstack看一眼线程到底阻塞在哪里卡在读取 Socket、卡在数据库连接池、卡在 Redis 调用对策完全不同。数据库连接池偏小把maximum-pool-size调大可能立刻见效如果是慢 SQL调大连接池反而会让数据库负载更高问题更严重。3.2 Keep-Alive 占连接不占线程但会偷偷挤爆 maxConnectionsHTTP/1.1 默认开启 Keep-Alive这个机制本身没问题问题是很多人在计算容量时完全忘了它。一次请求处理完TCP 连接不会立刻关闭而是保留一段时间等待下一个请求复用。这意味着即使没有任何业务在跑几千个空闲连接也能把 8192 的maxConnections占得满满的新连接进不来而线程池其实一个都没忙。高并发压测时这种现象尤其明显。压测工具用并发连接数去压每个连接跑完一个请求后还挂着连接数曲线一路上涨很快就摸到maxConnections的天花板。处理思路通常是两个方向调大maxConnections或者调小 Keep-Alive 的超时时间让空闲连接早点被回收。SpringBoot 里对应配置是server.tomcat.keep-alive-timeout和server.tomcat.max-keep-alive-requests后者默认 100意思是同一个连接最多复用 100 次请求后就强制关闭。3.3 同步模型与异步模型200 线程的上限能撬动多少请求200 个线程是不是硬上限并不是。问题是这 200 个线程是在“等别人”还是在“干活”。如果接口需要通过远程调用拿数据同步写法下线程要傻等远程响应改成异步写法线程先把请求丢出去立刻回去接其他请求等远程结果回来再继续执行。同样 200 个线程能做到的并发量会差好几倍。SpringBoot 里常用的异步手段有Async、DeferredResult、CompletableFuture再激进一点的直接上个响应式栈WebFlux。这里有个坑要提醒你SpringBoot WebFlux 默认用 Netty 而不是 Tomcat 作为 Web 容器所以“SpringBoot 项目用 Tomcat”这句话只在传统的 Servlet 栈里成立。如果你玩的是 WebFlux前面聊的所有 Tomcat 线程池参数都和你无关你要聊的是 Netty 的 EventLoop 线程数也就是spring.webflux.netty.worker之类的配置。3.4 JVM 与系统层的隐藏约束就算你把 Tomcat 参数调得再合理操作系统层还有一把隐形的锁。Linux 默认的进程文件描述符上限是 1024也就是一个进程最多能打开 1024 个文件句柄。每一个 TCP 连接至少占一个文件描述符你的maxConnections设成 8192但ulimit -n没放开实际连接数到 1024 左右就会开始报Too many open files表现同样是新连接进不来。JVM 层也得算进来。200 个线程默认栈大小是 1MB200 个线程差不多就是 200MB 的虚拟内存开销再叠加堆内存、GC 停顿线程数调太大会让 Full GC 更频繁响应时间反而恶化。处理量从来不是只看一个参数就能算出来的Tomcat 之外的每一个环节都可能成为那块短板。4. 具体配置与实操把参数调到合理范围4.1 先在 application.yml 里改这几个值如果是 SpringBoot 2.x 项目比较稳妥的起步配置如下server: tomcat: max-threads: 300 min-spare-threads: 20 max-connections: 10000 accept-count: 200 connection-timeout: 5000 keep-alive-timeout: 15000 max-keep-alive-requests: 100换成 SpringBoot 3.x注意键名变化server: tomcat: threads: max: 300 min-spare: 20 max-connections: 10000 accept-count: 200 connection-timeout: 5000 keep-alive-timeout: 15000 max-keep-alive-requests: 100这些值不是越高越好。max-threads调到 1000 容易让上下文切换成本变大max-connections盲目调大可能撑爆文件描述符和内存。我一般建议按这个节奏来先用默认值压测看线程池使用率和响应时间确认瓶颈在连接器再逐项调大同时把 Linux 的文件描述符上限配合调高否则改完配置照样崩。4.2 想更细致用代码自定义连接器有些参数没法直接通过application.yml暴露比如要动态设置协议处理器的细节这时候可以注册一个自定义的WebServerFactoryCustomizer。代码如下Bean public WebServerFactoryCustomizerTomcatServletWebServerFactory tomcatCustomizer() { return factory - { factory.addConnectorCustomizers(connector - { connector.setProperty(acceptCount, 256); ProtocolHandler handler connector.getProtocolHandler(); if (handler instanceof AbstractHttp11Protocol? protocol) { protocol.setMaxConnections(12000); protocol.setConnectionTimeout(5000); protocol.setKeepAliveTimeout(15000); protocol.setMaxKeepAliveRequests(200); } }); }; }这段代码在 SpringBoot 2 和 3 里都能用毕竟底层都是 Tomcat 的连接器。setProperty(acceptCount, ...)要放在 connector 层面其他参数走ProtocolHandler来设置。如果你在压测时发现某些参数改了没生效多半是配置键写到了错误的对象上。4.3 别靠感觉调参压测才是唯一裁判参数调得对不对唯一靠谱的判断方式是压测。轻量一点的场景我习惯用wrkwrk -t16 -c1000 -d30s http://localhost:8080/api/test-t16表示开 16 个线程-c1000表示保持 1000 个并发连接-d30s表示压 30 秒。压测完看三个指标QPS、平均响应时间、错误率。如果 QPS 上不去但响应时间一直平稳说明承载能力还有余量可以加大并发继续探如果错误率突然飙升就要停下来看是连接被拒还是超时对应的问题方向完全不同。想要更复杂的场景就上 JMeter可以配置线程组、设置 Ramp-Up 时间、加断言还能输出聚合报告。不过 JMeter 跑在高并发时本身也吃资源压测机性能不够你会把压测工具的瓶颈当成服务端的瓶颈。稳妥的做法是多准备一台独立的压测机器或者用分布式压测。压测过程中别忘了同时看服务端状态光看压测报告不够。连接数推荐用ss -ant | grep :8080 | wc -l直接数线程状态用jstack pid | grep java.lang.Thread.State | sort | uniq -c | sort -nr看分布GC 用jstat -gcutil pid 1000持续观察。这几个命令配合起来才能定位真正的瓶颈在连接器、线程池、数据库还是 JVM。5. 常见问题排查实录5.1 压测一开始就 Connection refused现象原因处理方向客户端报Connection refusedaccept 队列满了内核拒绝新连接调大accept-count或max-connections客户端报Connection resetTomcat 主动关闭了连接可能连接数超限检查max-connections和文件描述符上限大量Too many open files进程文件描述符耗尽ulimit -n调大注意对守护进程要改 limits.confConnection refused 是很多人第一次压测时最容易撞见的问题而且特别容易误判成“Tomcat 崩了”。实际上后端还活得好好的只是没地方放新连接了。先按上面的表排查不要一上来就调线程池。5.2 CPU 没跑满请求却大量超时这是比连接被拒更隐蔽的坑。表面看 Tomcat 线程池满不满你都不知道一查才发现 200 个线程几乎全部处于WAITING或TIMED_WAITING状态。拿jstack的线程栈一看一大片卡在数据库连接池的等待队列上或者卡在远程调用的 Socket 读取上。这种情况 CPU 占用往往不高因为线程都没在计算都在等别人。瓶颈在后端依赖上调 Tomcat 参数没用要把数据库连接池、Redis 连接池、下游接口的耗时逐个排查一遍。我遇到过最离谱的一次线程栈里几百行都指向同一个第三方接口那个接口平时 20ms 返回压测时响应时间涨到 3 秒Tomcat 线程池瞬间被拖垮。后来给这个远程调用加了超时和熔断整体 QPS 立刻恢复了。线程不是越多越好而是越少阻塞越好。5.3 线程数加了一倍QPS 反而降了这个现象我在真实项目里复现过。原配置 200 线程压测稳定在 3000 QPS改成 400 线程后QPS 掉到 2400响应时间还长了。原因主要是两点一是 400 个线程的上下文切换开销明显增大CPU 大量时间在切线程而不是跑业务二是更深的 GC 问题线程变多后内存压力变大Full GC 变频繁响应时间被 GC 停顿拉长。所以调参要克制。一般业务系统里 Tomcat 工作线程在 200 到 500 之间是个常见区间超过 500 你需要先回答一个问题到底是接口太慢还是流量真的需要这么多并发线程如果是接口太慢该做的是优化接口、加缓存、异步化而不是无脑加线程。5.4 连接数明明很多后端却闲得很压测时用ss -ant | grep :8080 | wc -l看到连接数几千但jstack显示 Tomcat 线程大多空闲业务耗时也很短这时候要怀疑 Keep-Alive 在占连接。连接都挂着但每秒钟只有少数连接上有新请求进来大量连接属于“占着茅坑不拉屎”。去确认压测工具是否真的在并发发送请求以及max-connections是不是被无效连接吃光了然后把 Keep-Alive 超时调小一点通常就能看到有效并发涨上去。我自己压测下来最稳的路子是先保证接口响应时间再谈调线程池宁可让max-threads小一点也别让它全部卡在数据库上。第一次压测如果发现线程池没满但响应时间涨得飞快别急着动 Tomcat先顺着线程栈把阻塞点找出来。能回答到这个层面比单纯背出一个“200”要有说服力得多。