如果你维护过基于 Zuul 的 API 网关,大概率碰到过这种诡异时刻:机器负载看着不高,后端服务也没报警,客户端却大量报Connection reset;翻网关日志,http-nio-8080-exec线程成片卡住,Tomcat 连接数蹭蹭顶到上限。折腾到最后,多数时候不是代码 Bug,而是连接和线程的配置没有匹配业务流量。
这篇文章我把 Zuul 网关与 Tomcat 连接数配置从头到尾拆一遍,不仅讲参数含义,还讲每个参数为什么会影响并发、调的时候容易在哪个环节翻车。Zuul 1.x 到现在还有大量存量系统在用,Tomcat 是它最常见的 Servlet 容器,这个组合下的连接数配置,基本决定了网关能扛住多大的瞬时流量。适合正在排查线上抖动、或者准备做压测调优的后端和运维同学。
1. 先搞清楚线程和连接分别扛住什么
1.1 Zuul 1.x 的同步请求模型:一个请求占住一个线程
Zuul 1.x 不是像 Zuul 2.x 那样的全异步模型,它基于 Servlet 构建。请求到了 Tomcat 连接器后,连接器负责解析 HTTP 协议,再把请求交给 Zuul 的过滤器链。整个路由转发过程是同步的:从客户端请求进入,到网关把请求转发给下游服务、拿到下游响应、再把响应写回客户端,这个完整生命周期里,一个 Tomcat 工作线程会一直被这个请求占着。
这个模型有一个直接推论:Tomcat 工作线程数就是网关真正能“同时干活”的请求数上限。超过这个数字的请求只能排队,排队也排不进去的,就直接拒绝。所以后文所有连接数的调整,最后都要回到“线程是否够用”这个问题上。很多人优先去调maxConnections,但真正决定并发上限的其实是maxThreads。把这两个概念放在一起理解,后面的配置才不会拧巴。
另一个容易被忽略的点是,网关不像普通 Web 应用那样只做简单的 CRUD,它在同步模型下要等下游服务返回。下游越慢,单个请求占用线程的时间就越长,线程池就越容易被占满。这也是为什么网关场景比普通 Web 应用更敏感于线程数和连接数配置。
1.2 连接数、线程数、队列三者各管一段
一个 TCP 连接到达 Tomcat 时,会经历“建立连接 → 进入 accept 队列 → 分到工作线程 → 处理 → 释放”的过程。三个参数分别管不同阶段:
| 参数 | 默认值 | 控制什么 | 类比 |
|---|---|---|---|
| maxThreads | 200 | 同时处理请求的最大线程数 | 餐厅里能同时上菜的服务员 |
| maxConnections | 8192 (NIO) | 能同时维持的 TCP 连接数上限,包含空闲 keep-alive 连接 | 餐厅能坐下的总客位数 |
| acceptCount | 100 | 工作线程忙时,等待队列里还能排多少请求 | 门口排队的人数上限 |
连接数不是线程数。一个 TCP 连接如果处于 keep-alive 空闲状态,它不占工作线程,但会占连接数。反过来,如果同时过来 50 个请求,每个请求都要占用一个工作线程;即使连接数还很多,线程不够照样排队。这也是为什么排查时要同时看这两类指标,不能只看一个。
有一个接近实际的经验:先让线程数匹配目标并发量,再让连接数比线程数留出余量,最后才考虑队列长度。顺序反了,调参很容易变成碰运气。下面开始逐个参数拆。
2. 内嵌 Tomcat 连接器核心参数逐个拆解
2.1 maxThreads 和 minSpareThreads:先定并发底座
maxThreads默认 200,Spring Boot 内嵌 Tomcat 也一样。一个很常用的估算公式:
需要的工作线程数 ≈ 目标 QPS × 单请求平均 RT(秒)这是 Little's law 的简化。举例:目标 QPS=1000,平均 RT=100ms,那么 1000 × 0.1 = 100,也就是说需要约 100 个工作线程。通常再留 20%~50% 的缓冲,初始值可以取 120~150,而不是一上来写 800。
不建议把maxThreads调得过大。线程不是免费的:每个线程都要分配调用栈,线程多了之后 CPU 上下文切换成本会明显上升。我见过一个网关把maxThreads设到 2000,结果 CPU 先被打满,RT 反而从 120ms 涨到 400ms。线程数的合理上限和机器核数、下游服务的消费能力有关,不能只按 QPS 算。
minSpareThreads代表 Tomcat 启动时预创建的线程数,默认 10。如果流量是突发的,比如每天早上固定一波高并发,这个值设太小会导致 Tomcat 在现场创建线程,虽然最终也能起来,但瞬间会看到 CPU 毛刺和排队。建议设成maxThreads的 20%~30%。比如maxThreads=200时minSpareThreads=50,是一个比较稳的起手配置。
2.2 maxConnections 与 acceptCount:连接容量和排队上限
maxConnections是 Tomcat 能同时维护的 TCP 连接数,NIO 模式下默认 8192。acceptCount默认 100,对应 TCP accept 队列的长度。两者的关系可以这样理解:请求到达时,如果当前连接数还没到maxConnections,连接器会把连接收下;如果已经满了,新连接只能在操作系统的 accept 队列里等,队列长度就是acceptCount。队列也满了,内核会直接拒绝新连接,客户端表现就是Connection refused或Connection reset。
所以出现“连接拒绝”时,要先判断是maxConnections打满,还是acceptCount只排了 100 个就满了。常见错误是只调大maxConnections,忽略acceptCount。反过来,如果maxThreads只有 200,连接数却给到 2 万,意义也不大,因为真正处理请求的线程只有 200,多余连接只会消耗文件描述符和内核内存。
connectionTimeout是 Tomcat 建立 TCP 连接后、等待收到 HTTP 请求头的超时时间,默认 60000ms,单位毫秒。注意它不等于“请求处理超时”。如果客户端建了连但不发请求,这个参数能把连接释放。网关场景下建议调到 2000~5000ms,防止恶意或异常的半开连接占用连接数。
2.3 keepAliveTimeout 与 maxKeepAliveRequests:小心空闲连接占用
HTTP keep-alive 允许同一个 TCP 连接处理多个请求,能省去大量三次握手。代价是一个空闲连接也会占用连接数。keepAliveTimeout控制的是:一条长连接在处理完一个请求之后,等待下一个请求的最长空闲时间。默认会跟随connectionTimeout,实际部署建议显式设置,比如 30 秒。
这里有个很容易踩的坑:如果只把connectionTimeout调到 2000ms,却没有单独设keepAliveTimeout,长连接可能因为“读下一个请求超时”被过早断开。客户端连接池就会频繁重建连接,反而增加握手开销。网关层的经验做法是keepAliveTimeout=30000ms、maxKeepAliveRequests=100,既避免空连占用,也不至于频繁断开。
maxKeepAliveRequests默认 100,意思是同一条 TCP 连接最多处理 100 个请求后主动关闭,避免某个客户端长期霸占连接。如果客户端数量多、连接池大,这个值可以根据真实请求模式调整。一般保持默认或适当减小即可,不需要盲目放大。
2.4 Spring Boot 2.x 配置属性变化与完整示例
Spring Boot 内嵌 Tomcat 时,直接在application.yml里配这些参数。这里请注意版本差异:早期用server.tomcat.max-threads和server.tomcat.min-spare-threads,Spring Boot 2.2 之后推荐改成server.tomcat.threads.max和server.tomcat.threads.min-spare。直接给一套可用的配置:
server: port: 8080 tomcat: threads: max: 200 min-spare: 50 max-connections: 8000 accept-count: 200 connection-timeout: 2000 keep-alive-timeout: 30000 max-keep-alive-requests: 100如果是 war 包部署到独立 Tomcat,就在conf/server.xml的 Connector 上做同样的事:
<Connector port="8080" protocol="HTTP/1.1" maxThreads="200" minSpareThreads="50" maxConnections="8000" acceptCount="200" connectionTimeout="2000" keepAliveTimeout="30000" maxKeepAliveRequests="100" />两种方式的参数含义完全一样,区别只是生效位置。独立 Tomcat 改完要重启,内嵌 Tomcat 则随应用发布。
还有一个容易忽略的点:如果网关前面还有 Nginx,Nginx 到 Tomcat 之间的连接也会占用 Tomcat 连接数。Nginx 的 keepalive 超时如果比 Tomcat 的keepAliveTimeout短,可能出现连接被 Nginx 先关掉、网关还在用的情况,表现为偶发Connection reset。这类问题单看 Tomcat 参数是看不到答案的。
3. 别忽略路由层那个更小的线程池
3.1 为什么 Tomcat 线程调到 400 后吞吐量还是上不去
很多团队把 Tomcat 线程从 200 调到 400,压测发现 QPS 几乎没变,RT 还涨了。这时候问题通常不在 Tomcat,而在 Zuul 转发下游请求时使用的那一层线程池。Zuul 在路由阶段会把下游调用包装成一个命令,放到一个独立的熔断线程池里去执行,用来隔离下游故障。这个池子的默认线程数非常保守,只有 10 个左右。
想象一下:Tomcat 有 400 个服务员,每个服务员接到客人后都要去问后厨,但后厨做菜的位置只有 10 个。客人全在前厅等着,后厨却排不开。Tomcat 线程看起来全忙,实际上都堵在等熔断线程池的机会。所以调完 Tomcat 参数没效果,下一步要看路由线程池的容量配置。
Zuul 的隔离策略大体有三种:线程隔离、信号量隔离、不隔离。线程隔离最重,适合后端调用慢的场景;信号量隔离不额外占线程,但控制不好容易造成请求直接被拒绝;不隔离则失去熔断保护。生产环境里我常用线程隔离,然后把熔断线程池的 coreSize 从默认 10 调大,常见起步值是目标并发的一半左右,再根据压测修正。
3.2 完整链路:Tomcat 线程 → 路由线程 → 转发连接池
一条请求经过的容量节点依次是:
- 客户端 TCP 连接进入 Tomcat 连接器,分配 Tomcat 工作线程
- Zuul 过滤器链执行,路由过滤器把下游调用提交到路由线程池
- 路由线程池里的线程从转发连接池中取连接
- 连接到达下游服务,等待下游响应后逐层返回
任何一个环节满了,都会表现为“网关忙”。最常见的情况是 Tomcat 线程有余量,但路由线程池排队;或者路由线程池有余量,但转发连接池不够,线程都在等连接。排查时一定要一层一层看,不能只盯maxThreads。
路由负载均衡组件自带的 HTTP 连接池也需要显式配置。如果连接池最大连接数小于路由线程数,部分线程拿不到连接,只能等待。反过来,连接池设得太大,下游服务会被压垮。合理做法是让“转发连接池上限 ≈ 路由线程数”,再留 20%~50% 余量,同时结合下游容量上限来定。
3.3 一组压测趋势对比
下面这组数据来自一次模拟压测环境,目的是演示趋势,不代表任何机器的绝对性能。固定 TomcatmaxThreads=200,路由线程池 coreSize 默认 10,客户端并发从 50 逐步加到 200:
| 客户端并发 | 路由池核心 | TPS | RT(ms) | 错误率 |
|---|---|---|---|---|
| 50 | 10 | 460 | 105 | 0% |
| 100 | 10 | 620 | 165 | 0% |
| 200 | 10 | 680 | 290 | 0.4% |
并发到 200 时,RT 从 165ms 涨到 290ms,错误率开始出现,但 Tomcat 线程并没有满。把路由线程池 coreSize 调大到 40 之后再看同样并发:
| 客户端并发 | 路由池核心 | TPS | RT(ms) | 错误率 |
|---|---|---|---|---|
| 200 | 10 | 680 | 290 | 0.4% |
| 200 | 40 | 810 | 115 | 0% |
QPS 和 RT 同时改善。这个案例的核心不是推荐那个数字,而是说明:Tomcat 连接数配置不是终点,路由线程池才是容易隐藏瓶颈的地方。改完参数之后,必须回到压测里去验证,而不是拍脑袋认为线程大就一定快。
4. 连接数告急时的报错特征与逐层排查
4.1 三类典型报错分别指向哪个参数
连接数紧张时,不同报错会把问题指向不同层次。先做一个对应关系,排查时能少走弯路:
| 现象 | 常见原因 | 优先检查项 |
|---|---|---|
| Connection reset | 连接被对端或内核关闭,keep-alive 超时,accept 队列满,fd 耗尽 | ss -s、dmesg、ulimit |
| 503 Service Unavailable | 请求排队超时或线程池拒绝 | maxThreads、acceptCount |
| Too many open files | 文件描述符耗尽 | ulimit -n,maxConnections |
| ZuulException: Forwarding error | 下游转发失败、熔断或超时 | 下游日志、路由线程池、转发连接池 |
注意Connection reset不一定是 Tomcat 连接数的问题。它更常见的是对端主动关闭连接,比如 Nginx 的 keepalive 超时小于 Tomcat,连接被 Nginx 先关掉,网关还在用,就会出现 reset。文件描述符耗尽的报错也会在应用日志里表现为Too many open files,这时候再调 Tomcat 参数也没用,要先解决系统层限制。
4.2 从现象到根因:用 netstat、jstack 和 Actuator 指标一步步定位
排查顺序我一般这样走:
先判断是“线程池满”还是“连接满”。看 Tomcat 线程池占用:如果 busy 线程接近
maxThreads,说明线程是瓶颈;如果连接数接近maxConnections,但 busy 线程不高,说明可能是空闲 keep-alive 连接占满了连接数。用
ss或netstat统计当前连接状态:
ss -ant | grep :8080 | awk '{print $1}' | sort | uniq -c如果TIME_WAIT非常多,说明短连接创建太频繁;CLOSE_WAIT很多,说明对端关闭连接后本地没有及时释放。
- 用
jstack看工作线程都卡在什么地方:
jstack <pid> | grep "http-nio-8080-exec" | wc -l jstack <pid> | grep -A 30 "http-nio-8080-exec"如果大量线程阻塞在“等待获取路由线程池机会”或“等待连接”的调用栈上,基本可以断定瓶颈在路由层,而不是 Tomcat 层。如果线程大多在socketRead,说明请求已经发到了下游,问题出在下游响应慢。
- 如果应用开了 Actuator,看
tomcat.threads.busy、tomcat.threads.config.max这些指标的历史曲线,能比日志更早发现线程池逼近上限。生产环境暴露这类端点要做好访问控制,不要裸奔在公网。
4.3 系统层限制:文件描述符与 TCP backlog
Java 进程每接受一个 TCP 连接,至少消耗一个文件描述符。Linux 默认的ulimit -n经常只有 1024,意味着并发连接数超过 1000 左右就可能开始异常。调大 Tomcat 连接数之前,先确认系统允许的 fd 上限要比maxConnections大不少。临时生效可以用ulimit -n 65535,持久化要改/etc/security/limits.conf:
* soft nofile 65535 * hard nofile 65535如果应用由 systemd 托管,limits.conf可能不够,还要在 service 文件里加上LimitNOFILE=65535。很多“连接数调到 1 万还是报 Too many open files”的案例,就是这里没配。
TCP backlog 也有影响。acceptCount只是 Tomcat 传给内核的 backlog 参数,实际生效值还受net.core.somaxconn限制,内核会取两者中的较小值。调整方法:
sysctl -w net.core.somaxconn=8192想持久化就写入/etc/sysctl.conf。somaxconn不是越大越好,过大会占用更多内核内存,一般跟acceptCount保持一致就够了。类似参数还有tcp_max_syn_backlog等,涉及 TCP 协议栈的全局行为,改之前先确认影响范围。
5. 用一次压测校准网关该配多少连接
5.1 先用公式算出初始线程数
压测之前先用公式给一个“不算离谱”的初始值,避免盲试。核心公式:
并发线程需求 = 目标 QPS × 单请求 RT(秒)比如目标 QPS=1000,网关平均 RT=80ms,那么 1000 × 0.08 = 80,maxThreads取 100 左右,留一点波动余量。
连接数怎么给?如果上游客户端普遍使用长连接,连接数可以接近线程数;如果客户端没有连接复用,每个请求都新建连接,连接数可能需要线程数的 3~5 倍。先设maxConnections在 8000 这个量级,再根据压测调整。
acceptCount建议从 100~200 起步,不要一开始就给几千。队列太长会让请求在网关堆积过久,客户端等待时间失控,最终表现为 RT 升高而非错误率升高,反而不容易发现。
5.2 压测中的参数扫描与结果对比
压测要保证压测机与被测网关不在同一台机器,否则结果会受压测机资源影响。工具用 wrk、ab 或者 JMeter 都可以,关键看四个指标:TPS、平均 RT、P99 RT、错误率。
我常用的扫描方式是固定客户端并发、固定请求体,只改一个 Tomcat 参数。比如maxThreads依次取 100、200、400,其他参数不动,记录表格:
| maxThreads | 并发请求 | TPS | RT(ms) | 错误率 | CPU |
|---|---|---|---|---|---|
| 100 | 500 | 780 | 95 | 0% | 58% |
| 200 | 500 | 810 | 152 | 0.2% | 81% |
| 400 | 500 | 780 | 218 | 1.5% | 95% |
这个结果说明maxThreads从 200 继续往上调已经没有收益,RT 反而变长,错误率上升。继续加线程只会让请求更深地排队、CPU 做更多上下文切换。这时候瓶颈已经移到路由线程池或下游服务,而不是 Tomcat 参数。
压测时如果错误是Connection reset而不是 HTTP 状态码,要回头检查系统层的 backlog 和 fd 限制;如果错误是超时,则要把路由线程池、转发连接池和下游超时时间一起分析。
5.3 参数调整后的验证与回退原则
生产环境不能改一个参数就拍板。比较稳的做法是每次只动一个参数,压测至少 10 分钟,观察 P99 RT 和错误率是否稳定,而不是只看平均值。平均值很容易被长尾掩盖。
如果 P99 RT 明显恶化,先回退最近一次变更,再检查是不是下游服务已经达到容量上限。网关的容量最终取决于下游,而不是网关自己。把压测数据和变更记录留好,直接写成表格,下次调参可以进行对比,避免在两个参数之间反复横跳。
6. 几个容易让人误判的配置死角
6.1 “连接数越多越好”为什么是错的
maxConnections虽然默认有 8192,看起来很大,但每个连接都消耗内核资源。把连接数调到几万,fd 和内存开销会成比例上升,而实际处理能力还是受限于线程池。如果线程不够,多出来的连接只是排得更长,最后被拒绝或超时。
我见过一个很极端的配置:maxConnections=50000,maxThreads=100。压测时连接数确实没打满,看起来“很稳”,但 RT 持续飙升。原因是 100 个线程根本处理不过来,连接都在等线程。把连接数降下来、线程数提高,效果反而更好。对大多数网关来说,把maxConnections设置成maxThreads的 3~5 倍再留余量,已经足够了。
6.2 与下游服务连接池的匹配关系
网关并发能力受下游服务容量限制。如果下游每个实例只能承受 50 个并发,网关就算配 2000 个线程,也会全部阻塞在下游。调优网关前,先和下游对齐容量:下游总并发上限 + 容灾余量,就是网关转发连接池的上限。
相应要配好路由层的连接超时和读取超时。超时时间太长,线程会被慢请求长期占住,连接数很快耗尽;太短又会导致下游抖动时误杀。我常用的是connectTimeout=2000ms、readTimeout=5000ms这样的起步值,再根据 P99 压测结果修正。快速失败不是目的,目的是把线程从注定要失败的请求里释放出来。
6.3 适合多数中小业务的初始配置模板
结合前面的内容,给一套适合多数中小业务起步的模板。强调一次:这是初始值,不是标准答案,必须结合压测调整:
server: port: 8080 tomcat: threads: max: 200 min-spare: 50 max-connections: 8000 accept-count: 200 connection-timeout: 2000 keep-alive-timeout: 30000 max-keep-alive-requests: 100路由线程池按隔离组调大:
hystrix: threadpool: default: coreSize: 40 maximumSize: 40 maxQueueSize: 200 queueSizeRejectionThreshold: 100路由层的 HTTP 连接池也要给足,尤其是小流量突发场景。最后再分享一个我自己的调参习惯:每次变更只动一个参数,把压测曲线保存下来再决定下一步。连接数配置不是算一次就一劳永逸的,它依赖业务流量模型和后端容量;先救活线程池,再放宽连接数,最后补系统层参数,这是我踩过不少坑之后沉淀下来的顺序。