踩坑 - 压测三轮后 502:一个默认 10 的连接池如何拖垮整个服务

这是"踩坑记录"专栏的第一篇。这个专栏不讲系统性的知识体系,只记开发中真实遇到的问题:现象是什么、我是怎么一步步定位的、根因在哪、怎么修、以及事后复盘能沉淀出哪些通用结论。比起结论,我更想留下排查的思路------因为下次的坑长得肯定不一样,但顺藤摸瓜的方法是通用的。

先说这次的现象:一个 SpringCloud 服务,压测一个单次访问就要 3 秒左右 的接口,把并发设成 200、每秒一轮 ,跑三轮左右(约 600 个请求)服务器就没响应了,Nginx 清一色返回 502 Bad Gateway。诡异的是,把并发从 200 降到 20,结果一模一样 ------同样三轮左右(约 60 个请求)就崩。最后定位到根因:数据库连接池(HikariCP)的最大连接数没配,走默认值 10 ,太小了;把它调到 100、重启服务后,压测就能完整跑完了。这里先把"接口单次 3 秒"这个数记住,它是后面算清整笔账的钥匙。

这个"默认 10"很不起眼,但它引出一个非常值得讲清楚的机制------一个下游资源不足,是怎么反向逐级传导,最终把整个 Tomcat 线程池耗尽、让服务彻底失去响应的。这篇文章就按"现象 → 排查 → 根因 → 为什么调 100 就好了 → SpringCloud 里到底有哪些东西在卡并发"这条线索展开。

目录

  1. [现象:502 是"果",不是"因"](#现象:502 是"果",不是"因")
  2. [排查:从 Nginx 往后端一层层扒](#排查:从 Nginx 往后端一层层扒)
  3. [根因:10 个连接如何拖垮 200 个线程(雪崩链路)](#根因:10 个连接如何拖垮 200 个线程(雪崩链路))
  4. [为什么并发 20 和 200 结果一样?](#为什么并发 20 和 200 结果一样?)
  5. [为什么"调到 100"就好了?连接池到底该配多大](#为什么"调到 100"就好了?连接池到底该配多大)
  6. [全景:SpringCloud 项目里,哪些组件在决定你的并发上限](#全景:SpringCloud 项目里,哪些组件在决定你的并发上限)
  7. 排查工具箱与预防清单

一、现象:502 是"果",不是"因"

先把现象说准确,这是排查的起点。

  • 压测工具设置并发 200、每秒一轮;
  • 前三四轮请求还能正常返回,响应时间逐轮变长;
  • 第四轮左右开始,请求大面积超时,Nginx 返回 502 Bad Gateway
  • 此后服务"卡死",即便停止加压,也要过好一会儿或重启才能恢复。

这里第一个要澄清的概念:502 是 Nginx 报出来的,但错不在 Nginx

Nginx 在这套架构里是反向代理,它自己不处理业务,只负责把请求转发给后端(Tomcat)。它抛出的 5xx 状态码,本质是在替后端"喊话":

  • 502 Bad Gateway :我(Nginx)去连后端、或者从后端读响应时,连接建立失败 / 被后端重置 / 后端进程无法提供有效响应。翻译成大白话就是"后端不干活了"。
  • 504 Gateway Timeout :我连上后端了,但等它返回等到超时(proxy_read_timeout,默认 60s)也没等到。翻译过来是"后端在磨蹭"。

所以看到 502,正确的第一反应不是去查 Nginx 配置,而是掉头去看后端 Tomcat 的状态。这是排查的方向性判断,方向错了后面全白费。

小提醒:502 和 504 的区分能帮你快速定位。502 偏向"后端连不上/被拒绝/挂了",504 偏向"后端还在跑但太慢"。这次是 502,说明 Tomcat 已经到了新连接都接不进来的程度------比单纯的慢更严重。

二、排查:从 Nginx 往后端一层层扒

顺着"502 = 后端不干活"这个方向,一层层往下扒。SpringCloud 的一次请求,链路大致是:

复制代码
客户端 → Nginx → 网关/Tomcat(业务线程池)→ 业务代码 → 数据库连接池 → MySQL
                                          ↘ Redis / ES / RPC 调用 ...

排查就沿着这条链路,从外往里逐段确认"卡在哪一段":

第 1 步,看后端进程还活着吗。 服务进程没挂、端口还在监听------排除了"服务崩溃/OOM 退出"这种最粗暴的原因。进程活着但不响应,那问题就在"它在忙什么、被什么卡住了"。

第 2 步,看线程在干嘛(关键一步)。 对 Java 服务,jstack <pid> 打印线程栈是这类"假死"问题的照妖镜。这次一打出来,现象非常典型:大量 http-nio-8080-exec-*(Tomcat 的 worker 线程)处于 WAITING / TIMED_WAITING 状态,栈顶都停在同一个地方------

复制代码
"http-nio-8080-exec-137" ... TIMED_WAITING
   at sun.misc.Unsafe.park(...)
   at java.util.concurrent.locks.LockSupport.parkNanos(...)
   at com.zaxxer.hikari.pool.HikariPool.getConnection(...)   ← 卡在这
   at com.zaxxer.hikari.HikariDataSource.getConnection(...)
   at ...你的业务 DAO/Mapper

满屏的 Tomcat 线程都卡在 HikariPool.getConnection() 上等着拿数据库连接------这就把矛头精准指向了数据库连接池。

第 3 步,坐实连接池配置。 翻配置文件,果然没配 spring.datasource.hikari.maximum-pool-size。SpringBoot 2.x 起默认数据源就是 HikariCP,而 HikariCP 的 maximumPoolSize 默认值就是 10 (同时 connectionTimeout 默认 30 秒------记住这个数,下一节要用)。

到这里,链路上的堵点已经清清楚楚:Tomcat 有一大堆线程在处理请求,但它们全卡在"排队等那 10 个数据库连接"上。根因锁定,接下来把这条雪崩链路完整讲透。

三、根因:10 个连接如何拖垮 200 个线程(雪崩链路)

这是本文最核心的一节。把几个默认值摆出来,一切就都通了:

组件 参数 默认值 含义
Tomcat server.tomcat.threads.max 200 最多同时处理 200 个请求
Tomcat server.tomcat.accept-count 100 线程满了后,等待队列最多再排 100 个
Tomcat server.tomcat.max-connections 8192 最多接受 8192 个连接
HikariCP maximumPoolSize 10 数据库连接最多 10 个
HikariCP connectionTimeout 30000ms 拿不到连接时最多等 30 秒,超时抛异常

一次业务请求的资源占用顺序是:先占一个 Tomcat 线程 → 再向连接池借一个数据库连接 → 用完还连接 → 还线程 。注意关键点:从借到数据库连接、到把它还回去,这整段时间,那个 Tomcat 线程是一直被占着的。数据库这一环慢一点、或者干脆借不到,Tomcat 线程就跟着被"钉"在原地。

于是雪崩这样发生:

  1. 前 10 个请求顺利拿到 10 个连接,正常查库、返回、还连接。
  2. 第 11 个及以后的请求 :连接池已空,只能在 getConnection() 上阻塞排队,最长等 30 秒connectionTimeout)。而它们此刻并没有释放 Tomcat 线程------线程被死死占着,就杵在那儿等连接。
  3. 压测是 200 并发/秒持续灌。数据库这边一秒只有 10 个连接在轮转,消化速度远远跟不上进来的速度。每一秒,都有近 200 个新请求涌进来占用 Tomcat 线程、然后加入"等连接"的长队。
  4. 线程只出不进 :等连接的线程要卡满 30 秒才可能超时退出。而这 30 秒里,新请求还在源源不断占用线程。几轮下来,200 个 Tomcat 线程被"等连接"的请求全部占满
  5. Tomcat 线程耗尽后,新来的连接进 accept-count 队列(再排 100 个),队列也满后,新连接直接被拒绝 / 无法建立
  6. 此时 Nginx 再转发请求过来,连后端 TCP 连接都建不上(或被重置) ------于是它对客户端返回 502 Bad Gateway

一句话总结这条链路:下游(数据库连接池)的容量太小 → 上游(Tomcat 线程)因等待下游而被长时间占用无法释放 → 上游资源被耗尽 → 对更上游(Nginx)表现为服务不可用 。10 是瓶颈,但真正"死"的是被它拖住的 200 个线程。这是典型的级联失败(cascading failure) ,也叫线程池饥饿(thread pool starvation)

面试角度:这道题非常爱考------"连接池太小为什么会耗尽 Tomcat 线程?" 标准答法就是上面这条链路:Tomcat 线程在等待数据库连接期间不会被释放,connectionTimeout 默认 30s 又把等待时间拉得很长,高并发下线程被迅速占满而无法回收,最终服务假死。把"占着线程等下游 + 30s 超时 + 只出不进"这三点讲出来就满分。

四、为什么并发 20 和 200 结果一样?用 3s 把这笔账算平

这次事故最反直觉、也最有价值的线索是:压 20(约 60 个请求)和压 200(约 600 个请求),都是三轮左右就崩,表现一模一样 。很多人到这一步会去怀疑压测工具或网络,其实它在明明白白地告诉你------瓶颈根本不在你设的那个并发数上

把被压接口的关键数字放进来就全通了:这个接口单次访问约 3 秒 ,意味着每个请求要把一条数据库连接攥住大约 3 秒才还回去。于是有一个决定性的公式(本质是排队论里的 Little's Law):

连接池能支撑的最大吞吐 ≈ 连接数 ÷ 单请求持有连接的时间

代进去算一算:

连接池大小 吞吐天花板 对比压测负载
10(默认,出事时) 10 ÷ 3s ≈ 3.3 请求/秒 20/s、200/s 分别是它的 6 倍 / 60 倍 → 必崩
100(调整后) 100 ÷ 3s ≈ 33 请求/秒 足够把这几轮共 60 / 600 个请求排空跑完

默认池的天花板只有约 3.3 请求/秒 。你压 20 也好、200 也好,都远远高过这条线------区别只是"多快撑爆",而不是"会不会撑爆"。这就是"20 和 200 一样崩"的真相:门槛是那 10 个连接(3.3 req/s),20 和 200 都在这条线之上,所以加压端怎么调都绕不过它

顺着这条线,你观察到的三个现象就全兜住了:

  1. 为什么 20 和 200 表现一致:两者都远超 3.3 req/s 的天花板,瓶颈是这条线而非并发数------这恰恰是"问题出在连接池"最有力的反证。
  2. 为什么三轮左右(60 / 600 个请求)就崩 :每一轮涌入的量都远超每秒只能消化的 3.3 个,在途请求逐轮净增 ,每个在途请求都占着一个卡在 getConnection() 上的 Tomcat 线程,累积几轮后线程与队列被占满 → 502。(具体是第 3 还是第 4 轮,取决于压测工具的读超时、keep-alive、Tomcat 线程数等细节,但"每轮净增、迟早爆"的方向是确定的。)
  3. 为什么改完必须重启才好 :崩溃瞬间一大批线程还卡在 30 秒的 connectionTimeout 等待里,这些存量阻塞不重启清不掉------所以"停了压测也没立刻恢复,重启后才正常"。

一个能顺带确认根因的推断 :有意思的是,"调大连接池就治好了"这件事本身,反过来证明了那 3 秒主要花在数据库上(或至少连接被攥了大半程)。因为如果 3 秒主要耗在别处(比如一个慢的外部调用),连接只是顺手用一下很快就还,那加大连接池根本不会有效。既然加到 100 就好了,说明连接确实被持有了接近整个 3 秒 ------这个接口八成藏着一条约 3 秒的慢 SQL。这就引出下一节要强调的:调大连接池只是止血,接口为什么要 3 秒才是更上游的问题

五、为什么"调到 100"就好了?连接池到底该配多大

把连接池从 10 调到 100,消化能力直接提了 10 倍,队列不再堆积、线程能正常回收,问题当然就没了。但别就此以为"连接池越大越好"------这是另一个更隐蔽的坑。

连接池不是免费的,它的大小受下游数据库的承载能力约束。 MySQL 有个 max_connections,社区版默认才 151 。假设你的服务部署了 4 个实例,每个实例连接池都配 100,那理论上要向 MySQL 请求 400 个连接,远超 151 ,结果就是应用侧改报 Too many connections------瓶颈只是从"应用连接池"平移到了"数据库最大连接数",坑还在。

所以正确的姿势是算,不是拍脑袋。给几条实用准则:

  1. 和数据库 max_connections 对账所有实例的连接池之和 要留足余量地小于 max_connections(还要给运维、监控、其他服务留连接)。
  2. 别被"越大越快"骗了 :HikariCP 官方明确说过,小连接池往往比大连接池吞吐更高 。因为数据库真正能并行干活的能力受限于 CPU 核数和磁盘,连接开太多只会加剧上下文切换和锁竞争。官方给的经验起点公式是 连接数 ≈ (CPU核数 × 2) + 有效磁盘数------常常是个不大的两位数。
  3. 连接池大小要匹配"下游能力"而非"上游并发" :想扛更高并发,光堆连接池没用,得先看数据库扛不扛得住。必要时该做的是优化慢 SQL、加缓存、缩短事务、读写分离,把单请求的连接持有时间压下来------这比无脑加连接有效得多。
  4. 务必配 connectionTimeout :保持一个合理超时(如 3~5s,而非默认 30s),让拿不到连接的请求快速失败 ,而不是把线程一钉就是 30 秒------快速失败本身就是一种保护,能避免线程被长时间占用而雪崩。

所以"调到 100 解决了"是对的,但更完整的结论是:100 是否合适,取决于实例数 × 数据库 max_connections 以及压测实测,而不是一个拍出来的数字。落地时给一份参考配置:

yaml 复制代码
spring:
  datasource:
    hikari:
      maximum-pool-size: 50        # 按 实例数 × 该值 < MySQL max_connections 反推
      minimum-idle: 10
      connection-timeout: 3000     # 拿不到连接 3s 快速失败,别用默认 30s
      max-lifetime: 1800000        # 30min,要小于 MySQL wait_timeout
      idle-timeout: 600000         # 10min

最后回到本例:接口单次 3 秒、连接被攥 3 秒,是 10 个连接扛不住的直接原因 。反过来看这个杠杆------假如把接口从 3s 优化到 300ms,同样 10 个连接的天花板就会从 3.3 req/s 直接跳到 33 req/s(10 ÷ 0.3 ,一分钱连接都不用加。可见"加大连接池 "和"缩短单请求持有连接的时间 "是完全等效的两个杠杆,而后者往往更治本,还顺带把 3 秒的糟糕体验也治了。所以修完连接池,务必回头问一句:这个接口凭什么要跑 3 秒? 大概率是一条缺索引/扫大表的慢 SQL,查一下慢查询日志就现形了。

六、全景:SpringCloud 项目里,哪些组件在决定你的并发上限

这次栽在数据库连接池上,但这只是"资源池"家族里的一个。SpringCloud 项目的一次请求要穿过一长串组件,任何一环的池子/线程/连接数配小了,都会成为整条链路的瓶颈 ,症状还都长得差不多(线程阻塞、请求堆积、超时)。结合当前项目栈(SpringCloud + MySQL + Redis + Nacos + ES),把该盯的地方列全,供以后排查对照。下面按组件逐个过,最后用一张分层总表(入口 → 应用 → 中间件访问 → 中间件/存储服务端 → 操作系统)收口。先给一张全景鸟瞰,红框就是本次事故的两处堵点:

6.1 Web 容器线程池(Tomcat)------请求的总闸门

  • 关键参数:server.tomcat.threads.max(默认 200)、accept-count(默认 100)、max-connections(默认 8192)。
  • 它是并发的总入口 。但正如这次的教训:光把它调大没用 ------如果下游(连接池、Redis、RPC)跟不上,线程再多也只是更多线程一起卡死。Tomcat 线程数要和下游承载力匹配

6.2 数据库连接池(HikariCP)------本次主角

  • 关键参数:maximum-pool-size(默认 10,本次元凶)、connection-timeout(默认 30s)。
  • 前面讲透了,不赘述。核心记忆点:它太小会反向耗尽 Tomcat 线程

6.3 Redis 连接池(Lettuce / Jedis)

  • SpringBoot 2.x 默认客户端是 Lettuce (基于 Netty,单连接可多路复用,通常不需要大连接池);如果显式换成 Jedis ,则是"一个操作占一个连接"的模型,必须配连接池spring.redis.jedis.pool.max-active 等),配小了同样会让线程排队等 Redis 连接。
  • 排查要点:如果 jstack 看到线程卡在 Redis 客户端获取连接上,就是这里的池子小了,或者 Redis 有慢命令(KEYS *、大 key 操作)把连接占住了。

6.4 服务间调用:Feign / OkHttp / 连接池 + 负载均衡

  • SpringCloud 服务互调(Feign / RestTemplate / WebClient)底层也走 HTTP 连接池。用 Apache HttpClient / OkHttp 时,maxTotaldefaultMaxPerRoute(每个目标地址的最大连接数,默认往往很小)配小了,跨服务调用就会排队
  • 还有 Feign / RestTemplate 自身的读超时/连接超时:如果不设或设得过大,被调方一慢,调用方线程就被长时间占用------又是一次"占着线程等下游"的雪崩,只不过下游从数据库换成了另一个微服务。
  • 超时与降级要成对出现:配合 Sentinel / Resilience4j 做熔断降级,避免一个慢服务顺着调用链把上游全拖垮。

6.5 Elasticsearch 客户端

  • ES 的 RestHighLevelClient / 新版 ElasticsearchClient 底层是 RestClient,基于异步 HTTP 连接池。默认每个 route 的连接数也不大。查询慢、聚合重、连接池小,都会让请求在 ES 这一环堆积。
  • ES 服务端还有自己的 search/write 线程池和队列(thread_pool.search.queue_size),队列满会直接拒绝请求(EsRejectedExecutionException)------这是 ES 侧的并发闸门。

6.6 Nacos(注册中心 / 配置中心)

  • Nacos 一般不在每次业务请求的热路径上(服务列表有本地缓存,配置也是监听推送),所以它通常不是并发瓶颈。
  • 但它是可用性依赖:Nacos 抖动或连不上,可能触发服务发现异常、配置拉取失败。压测时如果 Nacos 与业务服务混部、抢占资源,也可能间接影响。排查并发问题时它优先级靠后,但要知道它在链路里。

6.7 MySQL 服务端本身

  • max_connections(默认 151):应用连接池之和的天花板,第五节讲过。
  • 慢 SQL、缺索引、行锁/间隙锁、长事务 :这些会拉长"单请求持有连接的时间",等价于变相缩小了连接池的有效吞吐------很多"连接池不够用"的表象,根子其实是慢 SQL。先治慢 SQL,再谈加连接

6.8 Nginx(入口反代)

  • worker_connectionskeepaliveproxy_read_timeout(默认 60s)等决定入口承载与超时行为。
  • 它一般不是瓶颈,但它是症状的显示器------502/504 都从它嘴里说出来。看懂它的报错,是排查的起点(见第一节)。

6.9 入口网关(Spring Cloud Gateway)

  • 微服务架构里请求往往先过网关再分发。Spring Cloud Gateway 基于 Reactor Netty(异步非阻塞),并发闸门在两处:① event loop 线程数 (默认等于 CPU 核数)------千万别在 filter 里写阻塞代码 ,一旦阻塞就拖垮整个事件循环,影响所有路由;② 网关到下游服务的 HttpClient 连接池spring.cloud.gateway.httpclient.pool.max-connections)。
  • 网关是所有流量的公共咽喉:某个下游变慢、把网关到它的连接占满,或全局限流阈值配低,会波及经过网关的所有接口,而不只是一个。

6.10 应用内的业务线程池(@Async / CompletableFuture / 自定义线程池)

  • 除了 Tomcat 那个"请求线程池",业务代码常自己开线程池做并行查询、异步任务。这些池子同样有 core/max/queueCapacity/拒绝策略,配不好一样堆积。
  • 两个高频坑:① @Async 不指定线程池 时,早期默认走 SimpleAsyncTaskExecutor------它不复用线程、来一个建一个 ,高并发下疯狂创建线程直到 OOM;② CompletableFuture 不传线程池 时走 ForkJoinPool.commonPool(),这是全 JVM 共享的池,被一个慢任务占满会连累所有用到它的地方。
  • 准则:核心业务用独立、命名清晰的线程池做资源隔离,别共享、别吃默认。

6.11 JVM 与 GC(外加日志)

  • 高并发 = 高对象分配速率 = GC 更频繁。GC 的 STW(Stop-The-World)停顿期间所有业务线程暂停,宏观上就是周期性的延迟毛刺,严重时请求集体超时------不少"莫名其妙的偶发卡顿"根子在 GC。堆太小→频繁 Full GC;内存泄漏/大对象→最终 OOM 直接把服务干趴。生产用 G1/ZGC、设合理堆大小、盯 GC 日志与停顿时间。
  • 顺带一提日志 :高并发下同步日志(同步 appender + 磁盘 IO + 内部锁)也会成隐形瓶颈,尤其误开 DEBUG 刷海量日志。用异步 appender、管好日志级别。

6.12 分布式锁(Redis / Redisson)------ 主动给自己设的并发上限

  • 前面讲的都是"资源不够"导致的被动 瓶颈;分布式锁是你主动引入的串行化。为保证一致性(扣库存、防重复提交),拿锁的请求必须排队,再多线程也只能一个个来------这本身就是一道并发闸门。
  • 常见性能杀手是锁粒度过粗 :本该按"商品 ID"加锁,却上了一把全局锁,把本可并行的请求全串成一条队。锁要尽量细化到最小临界区,能用行级/键级就别用全局。

6.13 操作系统与网络层

  • 文件描述符(ulimit -n :每条 TCP 连接、每个 socket、每个文件都占一个 fd,Linux 默认常是 1024,高并发下爆 Too many open files,新连接直接建不了。生产要调到 65535+。
  • 本地端口 / TIME_WAIT :服务作为客户端大量连下游(DB、Redis、其他服务)时,本地临时端口(约 2.8 万个)可能耗尽、TIME_WAIT 堆积------这也正是为什么要用连接池复用长连接,而不是频繁新建短连接。
  • TCP 全连接队列(net.core.somaxconn :和 Tomcat 的 accept-count 呼应,队列满了新连接直接被丢弃。
  • 再往下就是 CPU、内存、网络带宽、磁盘 IO 这些物理天花板------它们是一切并发的最终上限。

6.14 分层总表(排查时按层对照)

组件 关键参数 / 默认值 配小 / 出问题的典型症状
入口 Nginx worker_connectionsproxy_read_timeout(60s) 报 502/504,是症状显示器
入口 Spring Cloud Gateway httpclient.pool.max-connections、event-loop 线程 某下游拖垮网关 → 全路由变慢
应用 Tomcat 线程池 threads.max(200)、accept-count(100) 线程耗尽 → 拒连 → 502(本次
应用 业务线程池 / @Async core/max/queue、拒绝策略 任务堆积;默认执行器疯建线程 → OOM
应用 JVM / GC 堆大小、收集器(G1/ZGC) STW 停顿 → 周期性超时;OOM
中间件访问 DB 连接池 HikariCP maximum-pool-size(10)、connection-timeout(30s) 本次元凶:等连接耗尽 Tomcat 线程
中间件访问 Redis 连接池 Lettuce/Jedis jedis.pool.max-active 线程排队等 Redis 连接
中间件访问 Feign / HTTP 连接池 maxTotaldefaultMaxPerRoute、读超时 跨服务调用排队;慢下游拖垮上游
中间件访问 ES 客户端 RestClient 每 route 最大连接数 请求在 ES 这一环堆积
服务端 MySQL max_connections(151)、慢 SQL / 锁 / 长事务 Too many connections;连接持有被拉长
服务端 Redis 单线程、慢命令、maxclients(10000) 一条慢命令(KEYS/大 key)阻塞全局
服务端 Elasticsearch thread_pool.search.queue_size 队列满 → 拒绝(EsRejectedExecution
服务端 分布式锁 Redisson 锁粒度 请求被主动串行化排队
注册/配置 Nacos (在热路径外) 通常非瓶颈,属可用性依赖
操作系统 fd / 端口 / TCP / 带宽 ulimit -n(1024)、somaxconn Too many open files;端口耗尽

一句话收敛这一节 :从入口的 Nginx / 网关,到应用的 Tomcat 线程与 JVM,到各类客户端连接池,再到 MySQL/Redis/ES 服务端,直到操作系统的 fd 与端口------整条链路是层层串联的"水管",最细的那一节决定整体流量 。这次最细的是数据库连接池,下次可能是 Redis 慢命令、Feign 连接数、一把过粗的分布式锁,甚至 ulimit。排查的通法不变:沿着这张表从上到下,找那根最细的管子

七、排查工具箱与预防清单

把这次能复用的方法沉淀下来。

排查工具箱(服务"假死"类问题):

  • jstack <pid> :第一利器。线程栈能直接告诉你"线程卡在哪一行"------这次就是它把矛头指向 HikariPool.getConnection()。看到大量线程 WAITING/TIMED_WAITING 且栈顶集中在同一处,基本就是在等某个资源池。
  • jstack 连打几次做对比:如果多次采样线程都卡在同一个地方,说明是稳定阻塞而非偶发。
  • 监控指标 :HikariCP 会暴露 hikaricp.connections.active / pending / idle(配合 Micrometer + Prometheus)。pending(等待连接的线程数)持续大于 0,就是连接池不够用的铁证 。同理关注 Tomcat 的 busy threads、Redis/ES 客户端的连接数。
  • SHOW PROCESSLIST / SHOW STATUS LIKE 'Threads_connected' :从 MySQL 侧看连接是否被打满、有没有长时间 Sleep 或卡住的查询。
  • 慢查询日志:定位是不是慢 SQL 拉长了连接持有时间。

预防清单(新服务上线 / 压测前过一遍):

  1. 所有资源池的大小都显式配置,不吃默认值------数据库连接池(别用默认 10)、Redis 连接池、HTTP 连接池,一个都别漏。
  2. 所有超时都显式配置且要短 ------连接池 connection-timeout、Feign/HTTP 的连接与读超时。让请求快速失败,是防雪崩的第一道闸。
  3. 连接池大小与数据库 max_connections 对账实例数 × 单实例池大小 < max_connections 并留余量。
  4. 配熔断降级(Sentinel / Resilience4j),别让一个慢下游顺着调用链拖垮全局。
  5. 压测要观测下游,不能只看 QPS :压测时同时盯连接池 pending、数据库连接数、慢 SQL、各组件线程池------瓶颈往往不在你加压的那一端
  6. 接入监控告警 :把连接池 pending、Tomcat 忙碌线程数等做成看板 + 告警,别等 502 了才 jstack

小结

复盘这次"压测三轮后 502",值得记住的就几条:

  • 502 是果不是因:它是 Nginx 在替后端喊"我不干活了",看到它要掉头查后端 Tomcat,而不是查 Nginx。
  • 一个默认 10 的连接池,能拖垮 200 个 Tomcat 线程 :因为线程在等数据库连接期间不释放,connectionTimeout 默认 30s 又把等待拉得极长,高并发下线程被迅速占满而无法回收------这就是级联失败 / 线程池饥饿
  • "降并发也没用"反而是好线索 :本例接口单次 3 秒,10 个连接的吞吐天花板只有 10 ÷ 3s ≈ 3.3 req/s,20 和 200 都远超这条线,所以都崩、且表现一致。瓶颈在下游那 10 个连接,不在加压端------判断会不会崩,看的是"连接数 ÷ 单请求持有时间"这条天花板,不是瞬时并发。
  • 调大连接池能救急,但不是越大越好 :受 MySQL max_connections(默认 151)约束,也受数据库真实并行能力约束。治本靠优化慢 SQL、缩短事务、加缓存、配短超时
  • 排查通法jstack 看线程卡在哪 + 盯连接池 pending 指标,沿"Nginx → Tomcat → 各连接池 → MySQL/Redis/ES"这根水管,找最细的那一节。

坑虽小,但把它背后这条"资源级联"的机制吃透,以后再遇到 Redis 池、Feign 连接、ES 队列打满这些"同一类不同长相"的问题,就能一眼看穿了。


配置默认值速查(本文核对来源:HikariCP 官方文档、Spring Boot 官方 application properties 文档)

  • HikariCP:maximumPoolSize=10connectionTimeout=30000msminimumIdle=同 maxPoolSizeidleTimeout=600000msmaxLifetime=1800000ms
  • Tomcat(Spring Boot 内嵌):threads.max=200threads.min-spare=10accept-count=100max-connections=8192
  • MySQL 社区版:max_connections=151
相关推荐
Escalating_xu1 小时前
【Linux线程】线程控制全解析:终止、join/detach、cancel、线程栈与 NPTL(下篇)
java·linux·运维
2602_959960921 小时前
电商大厂Java面试实录:Spring Boot/JVM/Redis/Kafka/微服务/安全/测试全解析
java·jvm·spring boot·redis·面试题
吃饱了得干活2 小时前
限界上下文之后:微服务怎么拆、上下文怎么聊?
java·后端·架构
yngsqq2 小时前
窗体快速取消
java·开发语言
岁月如歌77862 小时前
一次讲透 Redis 缓存击穿、穿透、雪崩,从原理到实战解决方案
java·后端·架构
AI多Agent协作实战派3 小时前
AI多Agent协作系统实战(四十六):改了十次规则,AI员工还是老样子——会话缓存的坑
java·spring·缓存
一知半解仙3 小时前
当机器人学会泛化,而我在写Java:一名后端开发者的2026年8月21日观察手记
java·开发语言·机器人
葡萄成熟时 !11 小时前
JAVA 常用API学习笔记
java·笔记·学习
Rain的Java大神之路12 小时前
介绍一下分布式事务
java·分布式·后端·spring·spring cloud·架构·springcloud