这是"踩坑记录"专栏的第一篇。这个专栏不讲系统性的知识体系,只记开发中真实遇到的问题:现象是什么、我是怎么一步步定位的、根因在哪、怎么修、以及事后复盘能沉淀出哪些通用结论。比起结论,我更想留下排查的思路------因为下次的坑长得肯定不一样,但顺藤摸瓜的方法是通用的。
先说这次的现象:一个 SpringCloud 服务,压测一个单次访问就要 3 秒左右 的接口,把并发设成 200、每秒一轮 ,跑三轮左右(约 600 个请求)服务器就没响应了,Nginx 清一色返回 502 Bad Gateway。诡异的是,把并发从 200 降到 20,结果一模一样 ------同样三轮左右(约 60 个请求)就崩。最后定位到根因:数据库连接池(HikariCP)的最大连接数没配,走默认值 10 ,太小了;把它调到 100、重启服务后,压测就能完整跑完了。这里先把"接口单次 3 秒"这个数记住,它是后面算清整笔账的钥匙。
这个"默认 10"很不起眼,但它引出一个非常值得讲清楚的机制------一个下游资源不足,是怎么反向逐级传导,最终把整个 Tomcat 线程池耗尽、让服务彻底失去响应的。这篇文章就按"现象 → 排查 → 根因 → 为什么调 100 就好了 → SpringCloud 里到底有哪些东西在卡并发"这条线索展开。
目录
- [现象:502 是"果",不是"因"](#现象:502 是"果",不是"因")
- [排查:从 Nginx 往后端一层层扒](#排查:从 Nginx 往后端一层层扒)
- [根因:10 个连接如何拖垮 200 个线程(雪崩链路)](#根因:10 个连接如何拖垮 200 个线程(雪崩链路))
- [为什么并发 20 和 200 结果一样?](#为什么并发 20 和 200 结果一样?)
- [为什么"调到 100"就好了?连接池到底该配多大](#为什么"调到 100"就好了?连接池到底该配多大)
- [全景:SpringCloud 项目里,哪些组件在决定你的并发上限](#全景:SpringCloud 项目里,哪些组件在决定你的并发上限)
- 排查工具箱与预防清单
一、现象: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 线程就跟着被"钉"在原地。
于是雪崩这样发生:
- 前 10 个请求顺利拿到 10 个连接,正常查库、返回、还连接。
- 第 11 个及以后的请求 :连接池已空,只能在
getConnection()上阻塞排队,最长等 30 秒 (connectionTimeout)。而它们此刻并没有释放 Tomcat 线程------线程被死死占着,就杵在那儿等连接。 - 压测是 200 并发/秒持续灌。数据库这边一秒只有 10 个连接在轮转,消化速度远远跟不上进来的速度。每一秒,都有近 200 个新请求涌进来占用 Tomcat 线程、然后加入"等连接"的长队。
- 线程只出不进 :等连接的线程要卡满 30 秒才可能超时退出。而这 30 秒里,新请求还在源源不断占用线程。几轮下来,200 个 Tomcat 线程被"等连接"的请求全部占满。
- Tomcat 线程耗尽后,新来的连接进
accept-count队列(再排 100 个),队列也满后,新连接直接被拒绝 / 无法建立。 - 此时 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 都在这条线之上,所以加压端怎么调都绕不过它。
顺着这条线,你观察到的三个现象就全兜住了:
- 为什么 20 和 200 表现一致:两者都远超 3.3 req/s 的天花板,瓶颈是这条线而非并发数------这恰恰是"问题出在连接池"最有力的反证。
- 为什么三轮左右(60 / 600 个请求)就崩 :每一轮涌入的量都远超每秒只能消化的 3.3 个,在途请求逐轮净增 ,每个在途请求都占着一个卡在
getConnection()上的 Tomcat 线程,累积几轮后线程与队列被占满 → 502。(具体是第 3 还是第 4 轮,取决于压测工具的读超时、keep-alive、Tomcat 线程数等细节,但"每轮净增、迟早爆"的方向是确定的。) - 为什么改完必须重启才好 :崩溃瞬间一大批线程还卡在 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------瓶颈只是从"应用连接池"平移到了"数据库最大连接数",坑还在。
所以正确的姿势是算,不是拍脑袋。给几条实用准则:
- 和数据库
max_connections对账 :所有实例的连接池之和要留足余量地小于max_connections(还要给运维、监控、其他服务留连接)。 - 别被"越大越快"骗了 :HikariCP 官方明确说过,小连接池往往比大连接池吞吐更高 。因为数据库真正能并行干活的能力受限于 CPU 核数和磁盘,连接开太多只会加剧上下文切换和锁竞争。官方给的经验起点公式是
连接数 ≈ (CPU核数 × 2) + 有效磁盘数------常常是个不大的两位数。 - 连接池大小要匹配"下游能力"而非"上游并发" :想扛更高并发,光堆连接池没用,得先看数据库扛不扛得住。必要时该做的是优化慢 SQL、加缓存、缩短事务、读写分离,把单请求的连接持有时间压下来------这比无脑加连接有效得多。
- 务必配
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 时,
maxTotal、defaultMaxPerRoute(每个目标地址的最大连接数,默认往往很小)配小了,跨服务调用就会排队。 - 还有 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_connections、keepalive、proxy_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_connections、proxy_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 连接池 | maxTotal、defaultMaxPerRoute、读超时 |
跨服务调用排队;慢下游拖垮上游 |
| 中间件访问 | 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 拉长了连接持有时间。
预防清单(新服务上线 / 压测前过一遍):
- 所有资源池的大小都显式配置,不吃默认值------数据库连接池(别用默认 10)、Redis 连接池、HTTP 连接池,一个都别漏。
- 所有超时都显式配置且要短 ------连接池
connection-timeout、Feign/HTTP 的连接与读超时。让请求快速失败,是防雪崩的第一道闸。 - 连接池大小与数据库
max_connections对账 :实例数 × 单实例池大小 < max_connections并留余量。 - 配熔断降级(Sentinel / Resilience4j),别让一个慢下游顺着调用链拖垮全局。
- 压测要观测下游,不能只看 QPS :压测时同时盯连接池
pending、数据库连接数、慢 SQL、各组件线程池------瓶颈往往不在你加压的那一端。 - 接入监控告警 :把连接池
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=10、connectionTimeout=30000ms、minimumIdle=同 maxPoolSize、idleTimeout=600000ms、maxLifetime=1800000ms- Tomcat(Spring Boot 内嵌):
threads.max=200、threads.min-spare=10、accept-count=100、max-connections=8192- MySQL 社区版:
max_connections=151