从坑里爬出来的经验:一次服务压测优化的技术探索与实践
开篇:那些“看似简单”的任务,其实藏着最深的坑

去年我接手了一个内部服务接口优化的任务。当时的背景是这样的:公司核心业务模块有一个对外提供的 REST API,承载着每天数百万次的请求,随着业务增长,系统开始出现不稳定情况,尤其是在高峰期时,响应时间会突然飙升,甚至偶发超时。
为了确保系统的稳定性,我们决定先对这个接口进行压力测试,看看在高并发下到底会出现什么问题。本来以为这只是一个很常规的性能调优工作,但万万没想到,在接下来两周的时间里,我们团队经历了从基础架构到应用逻辑、再到 JVM 调参等多个层面的“连环踩坑”,最终才把整体吞吐量提升了近 50%,并稳定运行至今。
这篇文章就是想把我在这个过程中的真实经历和思考记录下来,希望对同行们有所帮助——毕竟,踩过的坑,都是成长的阶梯。
项目背景:为什么我们需要做压测优化?

我们的服务是一个典型的 Spring Boot 微服务,部署在 Kubernetes 集群中,后端连接 MySQL 和 Redis,负责处理用户行为数据的上报、分析与返回结果。这个接口的主要功能是接收客户端上传的行为日志,校验参数,异步写入数据库,并返回一个轻量级的状态码。
表面上看它很简单,没什么复杂逻辑,但由于数据量大、频次高,实际上已经是我们系统中最频繁调用的接口之一。
我们最初是接到运维反馈说:“最近 QPS 上不去,CPU 占用很高,TP99 延迟突破了 SLA”。于是我们就决定先跑一次完整的 JMeter 压测来摸个底,然后再针对性地做优化。
理想是丰满的:压测一压,定位瓶颈,改代码+调配置,上线收工。现实却是骨感的:我们在压测过程中遇到了一系列意料之外的问题,甚至一度怀疑是不是整个架构出了问题……
第一重挑战:压测一开始 CPU 就爆满,吞吐量上不去

初始设定:
- JMeter 线程数:200
- 平均请求体:1KB 左右 JSON
- 使用 HTTP 连接池(Keep Alive)
- 测试环境为 K8s 集群中专门切出的服务副本,隔离良好
第一次压测跑起来后,我们发现一个问题:QPS 刚刚突破 3000 就开始大幅波动,TP99 延迟飙到了 800ms,而服务的 CPU 使用率直接冲到了 90%以上。
看起来像是服务本身的计算瓶颈导致的。
当时第一反应是看线程栈:
jstack <pid> > thread.dump
打开一看,一大半的线程都处于 RUNNABLE 状态,而且多数集中在日志输出相关的线程中,比如 Logback 或者 Slf4j 的部分。
原来我们用了 Logback 的同步日志记录,并且在 Controller 层打印了大量调试信息。一旦并发上来,线程就被阻塞在 IO 写日志上。
解决方案:
切换为异步日志记录:
修改 Logback 配置,使用
AsyncAppender来包裹原来的日志 Appender。设置队列大小和丢弃策略,防止内存溢出。
示例配置如下:
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <!-- 原始的同步输出 --> <encoder> <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> </encoder> </appender> <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender"> <appender-ref ref="STDOUT" /> <!-- 可选:设置缓冲区大小 --> <queueSize>2048</queueSize> <!-- 可选:丢失策略 --> <discardingThreshold>0</discardingThreshold> </appender> <root level="info"> <appender-ref ref="ASYNC" /> </root>
精简 Controller 日志级别:
- 生产环境默认开启
INFO,关闭DEBUG; - 使用 MDC 动态打印请求 ID,避免日志混杂又不影响排查问题。
- 生产环境默认开启
调整完后,再次跑压测,效果明显提升:CPU 占用下降到 70%,QPS 提升到了 5000 左右,延迟也降到了 150ms 内。
第二重挑战:MySQL 成了新瓶颈
正当我们以为搞定的时候,新的问题来了。

随着我们继续加大压测线程到 500,TP99 延迟又开始飙升,达到了惊人的 1.5 秒。这时 CPU 使用率虽然没那么紧张了,但是数据库的压力却异常大,出现了大量的慢查询。
我们通过查看监控平台(Prometheus + Grafana)发现,MySQL 的 QPS 被打到了极限,连接池满了,很多请求都在等 Connection。
分析:
我们使用的数据库访问框架是 MyBatis Plus,配置的连接池是 HikariCP,默认最大连接数是 10!
这就成了一个隐藏很深的问题:单个实例只允许 10 个并发数据库连接,而压测线程达到了 500,每条线程可能都会去拿数据库连接,但只有 10 个可用,其他线程只能排队等待,严重拖慢整条链路!
解决方案:
调整数据库连接池大小:
spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10 max-lifetime: 1800000 # 30分钟 connection-timeout: 3000 # 3秒优化 SQL 查询:
- 检查所有涉及该接口的 SQL,添加合适的索引;
- 避免不必要的全表扫描;
- 对写操作使用批量插入,减少网络开销。
启用缓存层:
我们的接口中有部分查询是非实时敏感的,所以加了一层本地 Caffeine 缓存;
对于高频读取的数据,提前加载并设置合理的 TTL 和刷新策略;
示例:
Cache<String, Object> cache = Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(10, TimeUnit.MINUTES) .build();
优化后,我们再次测试,TP99 延迟下降到 300ms 以内,QPS 能跑到 7000+,整体指标看起来正常了不少。
第三重挑战:JVM 参数调优带来意想不到的变化

就在我们以为快稳住的时候,有一天压测过程中居然出现了 Full GC!
我们立刻抓取了 JVM 的堆栈和 GC 日志,发现老年代频繁回收,GC 时间占比超过 10%。服务在这种情况下自然无法维持高性能。
这个问题暴露了一个我们前期忽略掉的细节:JVM 默认参数并不适合高并发场景。
分析过程:
我们使用了 JDK 自带的 jstat 工具:
jstat -gcutil <pid> 1000
观察到:
- Eden 区几乎每次都被快速填满;
- Survivor 区利用率低;
- Tenuring threshold 设置过低,对象很快晋升到老年代;
- G1 回收算法表现不佳,CMS 又不推荐使用了……
调优思路:
我们做了以下改动:
更换垃圾回收器为 ZGC:
-XX:+UseZGC -XX:MaxGCPauseMillis=10调整堆大小与比例:
-Xms4g -Xmx4g -XX:NewRatio=3 # 新生代占堆总大小的 1/4 -XX:SurvivorRatio=8 # Eden : Survivor = 8:2禁用显式 Full GC(System.gc()):
-XX:+DisableExplicitGC
调整之后,JVM 表现稳定多了,Full GC 几乎不再出现,GC 时间控制在 1ms 以内,服务的响应延迟进一步降低。
最终成果与收益
经过这次全面优化,我们达成了以下几个目标:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 吞吐量(QPS) | 3000 | 7500 |
| TP99 延迟 | 800ms | 150ms |
| 数据库连接压力 | 高 | 中等 |
| JVM GC 频繁 | 是 | 否 |
| 整体 CPU 占用 | 高 | 中等偏上 |
更关键的是,线上业务的抖动大大减少,SLA 完成率从之前的 92% 提升到了 99.8%。
经验分享:那些你一定会遇到的坑和我的建议
如果你也在做类似的工作,或者正在面对性能优化,这里是我的一些实践经验总结:
1. 日志系统是隐形杀手
- 强烈建议将日志系统统一改为异步输出;
- 控制日志级别,在生产环境关闭
DEBUG; - 避免在循环体内打印日志,哪怕是
INFO;
2. 不要迷信默认参数
- Spring Boot 的默认配置适合开发阶段,但不代表生产可用;
- 特别是数据库连接池、线程池、JVM 参数这类,一定要根据业务负载调整;
- 可以使用 Micrometer 接入 Prometheus,监控这些指标的变化;
3. GC 是性能优化的重要组成部分
- 高性能服务必须关注 GC 表现;
- 如果你能接受低延迟(<10ms),ZGC 是首选;
- 否则也可以考虑 G1,注意调整 RegionSize 和 MaxGCPauseMillis;
4. 压测要模拟真实流量
- 多变的请求体比单一 Payload 更考验系统;
- 请求分布也要合理,不能只测“最轻请求”;
- 压测工具建议使用 JMeter、k6、locust,配合分布式部署可以更好地模拟真实场景;
5. 业务代码中的“小聪明”可能是大坑
- 比如有人喜欢用反射动态构造对象、或者在方法体内 new Thread;
- 这类操作在并发下很容易引发资源争用或 OOM;
- 所以一定要定期做 Code Review,尤其是底层工具类和通用组件;
6. 做好故障回滚预案
- 性能优化不是一次性的;
- 每次改动都要有 AB Test 对比;
- 必须准备 fallback plan,万一某个配置出错,能快速回退;
结语:坑,是用来跳过去的
回顾整个过程,从最初的自信满满到最后的战战兢兢,再到现在从容应对,真的是一步步在实战中成长起来的。
技术没有银弹,也没有捷径。唯一靠谱的,是你愿意花时间去理解每一行代码背后的原理,每一个配置项背后的作用。
如果你现在正处在优化的瓶颈期,不要着急。静下心来看看日志、看看监控、看看 JVM,很多时候答案就藏在这些最不起眼的地方。
愿我们都能在踩坑的路上,走出一条属于自己的“康庄大道”。

评论 0