java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > SpringBoot内嵌Tomcat性能调优

SpringBoot内嵌Tomcat在高并发场景下的性能瓶颈与调优实践

作者:(farerboy)

本文聚焦Spring Boot内嵌Tomcat在高并发场景下的性能瓶颈与调优实践,深入解析maxThreads、maxConnections、acceptCount、keepAliveTimeout 等六大核心参数的作用机制,需要的朋友可以参考下

Spring Boot 内嵌 Tomcat 默认值是能跑的,但“能跑”和“扛得住”是两回事。有次我们压测一个商品详情接口,8C16G 的机器,CPU 才 25%,内存也没压力,QPS愣是卡在 4000 左右上不去。请求一多,响应时间往上涨,然后开始超时。第一反应是谁写的烂代码?结果查了半天,接口内部就是查一次 Redis 一次 MySQL,平均耗时 50ms,业务逻辑没毛病。后来把内嵌 Tomcat 的线程池和连接参数调了一轮,QPS 才过了 10000。

这篇文章就聊聊当时排查和调优的思路。

1. 机器配置不差,吞吐为什么上不去?

先还原一下场景:

压测出来的结果很尴尬:

问题就出在 Tomcat 默认的 maxThreads=200。这个参数的意思是:Servlet 容器里最多同时只有 200 个请求在执行。

按每个请求 50ms 算,200 个线程在一秒内最多能处理的请求数是:

200 / 0.05 = 4000

你让系统跑到 4100 QPS 已经到天花板了,剩下的请求全在排队。CPU 当然不忙,因为业务代码根本没机会执行,大家都在等线程。

很多人以为“服务器资源没用满就说明代码没问题”,其实 Java Web 应用的并发上限,有时候是 Tomcat 内部的线程池和连接队列决定的,跟 CPU 和内存关系不大。

2. 从 Tomcat 网络模型说起

Spring Boot 2.x 默认用 NIO。NIO 不会给每个连接配一个线程,而是靠少量 I/O 线程处理网络事件,业务再丢给工作线程池。

大致路径是这样的:

TCP 连接进来之后,Acceptor 负责 accept,然后把网络事件注册给 Poller。Poller 用 Selector 轮询哪些 Socket 能读、能写。一旦一个 HTTP 请求被完整解析,Poller 就会把它包装成一个任务,丢给后面 Worker 线程池去执行业务逻辑。

你可以理解为:Connector 管“能接多少连接”,Executor 管“能同时跑多少请求”。这两个是完全独立的东西。

所以,光有连接是不够的。就算几千个连接建立好了,如果 Worker 线程只有 200 个,那同一时刻还是只有 200 个请求在处理,别的都在等。

3. 六个关键参数

在 Spring Boot 的 application.yml 里,你需要关注这六个配置项。我直接说结论:

maxThreads
这是最重要的一个。控制最大工作线程数。默认 200。这个值太小,再好的 CPU 也白搭。但也不是越大越好,后面会提到。一般做法是根据业务 RT 和目标 QPS,用 Little’s Law 来粗算。

minSpareThreads
初始化时预创建的线程数。默认 10。如果流量是瞬时突发的,10 个线程根本不够用,Tomcat 要靠 new 线程来应付,会有一定延迟。线上一般会调到 100 左右,省得流量进来时临时拉线程。

maxConnections
同一时刻允许的最大 TCP 连接数。默认 8192。注意,连接多不代表并发请求多。很多连接可能是 Keep-Alive 空挂着,不占 Worker 线程,但会占连接数。如果这个值设得太小,高并发下新连接就会进内核队列等待。

acceptCount
连接数满了之后,操作系统内核排队队列的长度。默认 100。相当于系统在最外层设了一个缓冲。如果队列也满了,后面的连接就会被直接拒绝。客户端表现为连接超时或者 reset。这个值需要结合突发的连接数量来设。

connectionTimeout
等待客户端发完整 HTTP 请求的超时时间。默认 20000ms。对于大多数 API 服务来说,20 秒太长了。很多恶意连接或者半开连接会一直占着,不传数据也不断开。生产环境我一般改成 5000ms,超过 5 秒没把请求头发过来就直接断掉。

keepAliveTimeout
Keep-Alive 长连接的空闲超时。默认如果没设置,会跟随 connectionTimeout。连接空闲超过这段时间,Tomcat 就会主动关闭连接。这个参数需要看客户端复用情况。如果客户端用连接池且请求频率稳定,可以设 30 秒左右;如果担心大量连接空闲占资源,就调小到 5~10 秒。

各参数作用范围要理清楚:

4. 怎么估算线程数和连接数

最常用的公式就是 Little’s Law:

并发线程数 = QPS × 平均响应时间 RT

要支撑 10000 QPS,平均 RT 是 50ms,那么:

10000 × 0.05 = 500

500 个线程就够了。但这是在理论完美情况下。实际肯定会有些抖动,GC、网络波动、排队都会影响。所以一般还要多留一些余量。我当时把 maxThreads 设成了 800。

连接数的估算稍微复杂一点,因为 Keep-Alive 连接不是处理完就断。假设一个请求处理完 50ms,然后连接空闲着等下一个请求,客户端可能 100ms 后才发下一个请求。那么一个连接大约在 150ms 内被复用一次。支撑 10000 QPS 需要的连接数大约是:

10000 × (0.05 + 0.1) = 1500

当然,如果客户端池里连接更多、请求更稀疏,那用到的连接数会更多,甚至可能出现“连接数很多但 QPS 不高”的情况。所以 keepAliveTimeout 很重要,避免一堆闲连接赖着不走。

5. 首先搞一个靠谱的压测基线

调优不是瞎调,得有对比。我习惯先用默认配置跑一遍,记录下指标。

压测工具用 ab 或 JMeter 都行。ab 简单,直接看结果:

ab -n 200000 -c 1000 -k http://localhost:8080/api/product/detail

注意 -k 参数必须加。-k 表示启用 HTTP Keep-Alive。不开启的话每次请求都要重新建立 TCP 连接,压出来的数据很不真实,测的其实是连接建立能力,不是应用处理能力。

JMeter 里也一样,勾选 HTTP 请求里的 Use KeepAlive 选项,不然默认也是短连接。

如果你的目标 QPS 比较高,建议从 500 并发开始,测一轮看指标,再逐步加并发,找到系统的拐点。

6. 一次实际的调优过程

还是回到开头那个 50ms 的接口。

用默认配置跑一遍,结果是 QPS 稳定在 4100,平均响应 245ms,P99 到 800ms。CPU 25%。很明显问题就是线程数不够。

把配置改成这样:

server:
  tomcat:
    threads:
      max: 500
      min-spare: 100
    accept-count: 500

再压一次,QPS 上升到 8500 左右,平均 RT 降到了 118ms。但是 P99 还是偏高,约 240ms,错误率降到了 0.01%。理论上 500 线程按 50ms RT 能跑到 10000 QPS,但离目标还差一点。

那会儿分析是连接层还有瓶颈。因为压测并发是 1000,而 maxConnections 默认 8192,并不是不够。可能的问题是 acceptCount 太小,在高并发下内核 backlog 排队空间不足,导致有些连接被拒绝或者处理延迟。另外 keepAliveTimeout 没有设置,有些空闲连接一直占着。于是继续调整:

server:
  tomcat:
    threads:
      max: 800
      min-spare: 100
    accept-count: 1000
    max-connections: 20000
    connection-timeout: 5000
    keep-alive-timeout: 30000

这次压测结果终于理想了:QPS 稳定在 10300,平均 RT 97ms,P99 145ms,错误率 0%。CPU 到 70% 左右,虽然不低,但至少资源在用。

后来也试过把 maxThreads 调到 2000,QPS 没上去多少,P99 反而变差了。因为 8 核的机器上 2000 个线程频繁切换,CPU 全耗在上下文切换上。所以不要一看到吞吐不行就无脑加线程,调到一个合理范围就够了。

7. 光调参数还不够,得监控线

参数调完,生产上没有监控等于白调。Spring Boot Actuator 配合 Micrometer 可以直接拿到 Tomcat 的指标。

引入依赖后暴露 Prometheus 端点:

management:
  endpoints:
    web:
      exposure:
        include: health,metrics,prometheus

然后重点看这几个指标:

在 Prometheus 里面,这些指标名会被转换成下划线格式,比如 tomcat_threads_busy。常用查询:

# 线程池使用率
tomcat_threads_busy / tomcat_threads_config_max
# 当前连接数(如果 maxConnections 是 20000)
tomcat_connections_current

如果 tomcat_threads_busy / tomcat_threads_config_max > 0.8 持续五分钟,基本说明线程池要满了。这时候排查一下是业务 RT 变长,还是 QPS 真的涨了。如果是业务RT变长,得先优化接口逻辑;如果是流量涨了,可以考虑扩容。

不想上 Prometheus 的话,用 JConsole 连到 JMX 也能看到这些值:

线上至少要有线程池使用率和连接数监控,不然出问题你只能靠猜。

8. 几个深坑

a. 业务里同步阻塞 IO 太多

线程池调大不是万能的。如果代码里同步调用各种 IO,比如 JDBC、HTTP、Redis,线程其实都在等着 IO 返回,这种情况没本质区别。线程从 200 加到 2000,撑不了多久。而且 2000 个线程光栈内存就吃掉 2GB,CPU 浪费在上下文切换上,下游连接池也会被打爆。

我之前遇到一个项目,就是代码里到处同步调第三方接口,单个请求 RT 动不动 300ms。Tomcat 线程调到 1000 也没用,QPS 还是几十。最后解决办法是把第三方调用改成异步,或者用批量接口合并,才把 RT 降下来。遇到这种情况别先调 Tomcat,要把业务 RT 尽量压缩。

b. Keep-Alive 连接可能拖垮连接数

HTTP/1.1 默认长连接,这个设计是为了复用连接,但有风险。有的客户端会建立很多连接,然后不用也不关,一直空着占 maxConnections。这会导致新用户连不进来,即使业务线程很闲。

那次调优后,我们特意看了 tomcat.connections.current,发现经常有一两千个空闲连接。后来把 keepAliveTimeout 调成 10 秒,情况好很多。如果后面有 Nginx 或负载均衡,尽量让这一层去管连接复用,后端 Tomcat 的压力会小很多。

不过要注意,keepAliveTimeout 设太短也有副作用。如果客户端每次请求间隙本来就长,你设 3 秒,那服务端每 3 秒就关一次连接,客户端反而需要不断重建连接,消耗性能。这个值要和客户端的调用频率匹配。

c. 文件描述符不够

压测中经常遇到“Too many open files”错误。这不是 Tomcat 参数问题,而是 Linux 文件句柄限制。连接数一高,文件描述符不够用了。

线上记得检查 ulimit -n,一般调到 100000 以上比较稳妥。同时用 ss -s 看看 TCP 连接状态。如果 CLOSE_WAIT 非常多,多半是应用没有正确关闭 Socket。如果 TIME_WAIT 很多,可能是客户端一直在建短连接,没有用 Keep-Alive。这个得从客户端解决,服务端改参数没用。

d. 参数不是越大越好

我见过有人为了压测数据好看,把 maxThreads 调到 5000,结果服务直接假死。因为线程越多,上下文切换越严重,内存栈占用也大,GC 压力跟着上来了。acceptCount 设太大也不好,连接虽然在排队,但业务处理不过来时只会让用户一直等,最后等超时,效果更差。

调优的过程应该是:

先根据 RT 和目标 QPS 估算一个 maxThreads,压测看结果。如果 QPS 没到预期且线程池已满,再慢慢加。如果连接层有异常,再看 maxConnections 和 acceptCount。最后根据连接空闲情况设置 keepAliveTimeout。每次只动一个变量,对照压测报告看趋势,别一次全改。

写完这些可能有人觉得“还是不知道最优值是多少”。老实说,没有最优值。不同业务 RT,不同客户端行为,甚至不同操作系统都会影响最终参数。我只提供一个可复用的思路:先估线程数,再通过压测找拐点,最后用监控持续验证。这套流程走下来,你的 Tomcat 参数就不会成为瓶颈。

以上就是SpringBoot内嵌Tomcat在高并发场景下的性能瓶颈与调优实践的详细内容,更多关于SpringBoot内嵌Tomcat性能调优的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文