
Spring Boot 3.4.5 后端服务调用链路的 P99 延迟治理从基准测试到线程池参数调优上周压测环境跑了一组基准数据结论很直接在单机 QPS 从 200 升到 800 的过程中P99 延迟不是线性增长而是在 450 QPS 附近出现了一个陡峭拐点——从 120ms 跳到了 680ms。这个拐点不在业务逻辑里而在 Tomcat 线程池和 HikariCP 连接池的交互层。基准测试数据压测环境配置Spring Boot 3.4.5、JDK 21.0.7、Tomcat 10.1.30内嵌、HikariCP 5.1.0、MySQL 8.0.39。硬件为 8C16G 虚拟机JVM 参数-Xms4g -Xmx4g -XX:UseZGC。使用 wrk 2.6.0 进行阶梯式压测每档持续 60 秒并发连接数 100| QPS 档位 | P50 延迟 | P95 延迟 | P99 延迟 | GC 暂停ms | HikariCP 等待线程数 ||----------|----------|----------|----------|---------------|---------------------|| 200 | 18ms | 32ms | 45ms | 0.8 | 0 || 400 | 22ms | 41ms | 58ms | 1.2 | 1 || 450 | 28ms | 95ms | 120ms | 2.1 | 8 || 500 | 35ms | 210ms | 680ms | 3.5 | 47 || 600 | 52ms | 480ms | 1450ms | 5.8 | 89 || 800 | 89ms | 1200ms | 3200ms | 8.2 | 127 |拐点出现在 450→500 QPS 之间。P99 从 120ms 直接跳到 680ms翻了 5.7 倍。但 P50 只从 28ms 升到 35ms。这说明问题不是整体变慢而是尾部请求被严重阻塞。瓶颈定位用 async-profiler 3.0 在 500 QPS 档位抓取了 30 秒的 CPU 火焰图和 15 秒的 wall-clock 火焰图。CPU 火焰图显示线程主要在跑业务逻辑没有明显的热点。但 wall-clock 火焰图暴露了真正的问题Tomcat 默认配置server.tomcat.threads.max200、server.tomcat.threads.min-spare10。HikariCP 默认maximum-pool-size10。当 500 QPS 打进来时Tomcat 线程数迅速爬升到 180但数据库连接池只有 10 个连接。大量 Tomcat 线程在com.zaxxer.hikari.pool.HikariPool.getConnection()处阻塞等待连接释放。具体表现180 个 Tomcat 线程争抢 10 个 DB 连接平均每个连接被 18 个线程排队。一个连接持有时长 25ms简单查询排队 18 个请求意味着等待时间约 450ms——这就是 P99 延迟 680ms 的来源。另一个被忽略的因素server.tomcat.accept-count100默认值。当 200 个工作线程全部忙时多余的连接进入 OS 的 accept 队列。队列满了之后新连接直接被拒绝或超时。在 500 QPS 档位accept 队列经常打满部分请求在 TCP 层就产生了额外延迟。优化方案与前后对比第一轮调大 HikariCP 连接池yamlspring:datasource:hikari:maximum-pool-size: 40minimum-idle: 10connection-timeout: 3000idle-timeout: 600000max-lifetime: 1800000leak-detection-threshold: 5000将maximum-pool-size从默认 10 调到 40。逻辑是 Tomcat 200 线程、DB 连接 40 个平均每 5 个线程争一个连接排队时间从 450ms 降到约 125ms。实测效果500 QPS 下 P99 从 680ms 降到 245ms。改善了但还不够。第二轮调整 Tomcat 线程模型yamlserver:tomcat:threads:max: 120min-spare: 20accept-count: 300connection-timeout: 20000把threads.max从 200 降到 120。反直觉但有效。原因线程数从 200 降到 120每个 DB 连接的竞争线程从 18 降到 3排队等待时间大幅缩短。同时accept-count从 100 提到 300给突发流量更多缓冲。第三轮异步化非关键路径javaBeanpublic ExecutorService asyncExecutor() {return new ThreadPoolTaskExecutor().setCorePoolSize(8).setMaxPoolSize(16).setQueueCapacity(200).setThreadNamePrefix(biz-async-).setRejectedExecutionHandler(new CallerRunsPolicy()).initialize();}Servicepublic class OrderService {Async(asyncExecutor)public CompletableFuture sendNotification(OrderEvent event) {// 推送消息到 MQ不阻塞主线程messageTemplate.convertAndSend(order.exchange, event);return CompletableFuture.completedFuture(null);}Transactionalpublic OrderResponse createOrder(OrderRequest request) {Order order orderRepository.save(toEntity(request));// 不等待通知完成sendNotification(new OrderEvent(order.getId()));return toResponse(order);}}把订单创建中的通知推送、积分计算等非关键路径异步化。主线程只做事务写入耗时从 85ms 降到 42ms。优化后基准数据同样环境同样 500 QPS 压测| 指标 | 优化前 | 优化后 | 变化 ||------|--------|--------|------|| P50 延迟 | 35ms | 28ms | -20% || P95 延迟 | 210ms | 72ms | -66% || P99 延迟 | 680ms | 115ms | -83% || HikariCP 等待线程数 | 47 | 2 | -96% || Tomcat 活跃线程峰值 | 198 | 95 | -52% || 单机最大稳定 QPS | 450 | 1200 | 167% |800 QPS 档位优化后 P99 为 198ms优化前是 3200ms。一个反方观点这个方案有一个明显代价Tomcat 线程从 200 降到 120意味着同步请求的并发处理能力下降了 40%。如果业务场景以同步短请求为主比如纯 CRUD平均耗时 15ms这个调整反而会让系统在低负载时响应变慢——因为线程池缩小后突发流量更容易打满。在我们的场景里能接受是因为 80% 的请求是写操作事务持有时长 40-80ms线程利用率本身就低。但如果你的接口平均耗时在 10ms 以下线程数应该反过来调大连接池保持小一些就够了。适用场景与结论这套调优思路适用于请求处理链路包含数据库写操作、存在非关键路径可以异步化、单机 QPS 目标在 500-2000 之间的后端服务。Spring Boot 3.4.5 JDK 21 ZGC 的组合在这个负载区间表现稳定。核心原则先跑基准数据找到拐点再用 wall-clock 火焰图定位阻塞点最后针对性调参。不要凭感觉改线程数。P99 延迟的优化90% 的情况不是代码慢而是线程和连接在排队。#后端 #Java #SpringBoot #性能优化 #HikariCP你在实际项目中有遇到类似问题吗欢迎在评论区分享你的经验和解决方案。