从坑里爬出来的经验:一次服务压测优化的技术探索与实践

优秀创造者
2025-06-15 09:11
阅读 601

开篇:那些“看似简单”的任务,其实藏着最深的坑

开篇:那些“看似简单”的任务,其实藏着最深的坑

去年我接手了一个内部服务接口优化的任务。当时的背景是这样的:公司核心业务模块有一个对外提供的 REST API,承载着每天数百万次的请求,随着业务增长,系统开始出现不稳定情况,尤其是在高峰期时,响应时间会突然飙升,甚至偶发超时。

为了确保系统的稳定性,我们决定先对这个接口进行压力测试,看看在高并发下到底会出现什么问题。本来以为这只是一个很常规的性能调优工作,但万万没想到,在接下来两周的时间里,我们团队经历了从基础架构到应用逻辑、再到 JVM 调参等多个层面的“连环踩坑”,最终才把整体吞吐量提升了近 50%,并稳定运行至今。

这篇文章就是想把我在这个过程中的真实经历和思考记录下来,希望对同行们有所帮助——毕竟,踩过的坑,都是成长的阶梯。


项目背景:为什么我们需要做压测优化?

项目背景:为什么我们需要做压测优化?

我们的服务是一个典型的 Spring Boot 微服务,部署在 Kubernetes 集群中,后端连接 MySQL 和 Redis,负责处理用户行为数据的上报、分析与返回结果。这个接口的主要功能是接收客户端上传的行为日志,校验参数,异步写入数据库,并返回一个轻量级的状态码。

表面上看它很简单,没什么复杂逻辑,但由于数据量大、频次高,实际上已经是我们系统中最频繁调用的接口之一。

我们最初是接到运维反馈说:“最近 QPS 上不去,CPU 占用很高,TP99 延迟突破了 SLA”。于是我们就决定先跑一次完整的 JMeter 压测来摸个底,然后再针对性地做优化。

理想是丰满的:压测一压,定位瓶颈,改代码+调配置,上线收工。现实却是骨感的:我们在压测过程中遇到了一系列意料之外的问题,甚至一度怀疑是不是整个架构出了问题……


第一重挑战:压测一开始 CPU 就爆满,吞吐量上不去

第一重挑战:压测一开始 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 写日志上。

解决方案:

  1. 切换为异步日志记录:

    • 修改 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>
      
  2. 精简 Controller 日志级别:

    • 生产环境默认开启 INFO,关闭 DEBUG
    • 使用 MDC 动态打印请求 ID,避免日志混杂又不影响排查问题。

调整完后,再次跑压测,效果明显提升:CPU 占用下降到 70%,QPS 提升到了 5000 左右,延迟也降到了 150ms 内


第二重挑战:MySQL 成了新瓶颈

正当我们以为搞定的时候,新的问题来了。

系统架构设计-2

随着我们继续加大压测线程到 500,TP99 延迟又开始飙升,达到了惊人的 1.5 秒。这时 CPU 使用率虽然没那么紧张了,但是数据库的压力却异常大,出现了大量的慢查询。

我们通过查看监控平台(Prometheus + Grafana)发现,MySQL 的 QPS 被打到了极限,连接池满了,很多请求都在等 Connection。

分析:

我们使用的数据库访问框架是 MyBatis Plus,配置的连接池是 HikariCP,默认最大连接数是 10!

这就成了一个隐藏很深的问题:单个实例只允许 10 个并发数据库连接,而压测线程达到了 500,每条线程可能都会去拿数据库连接,但只有 10 个可用,其他线程只能排队等待,严重拖慢整条链路!

解决方案:

  1. 调整数据库连接池大小:

    spring:
      datasource:
        hikari:
          maximum-pool-size: 50
          minimum-idle: 10
          max-lifetime: 1800000 # 30分钟
          connection-timeout: 3000 # 3秒
    
  2. 优化 SQL 查询:

    • 检查所有涉及该接口的 SQL,添加合适的索引;
    • 避免不必要的全表扫描;
    • 对写操作使用批量插入,减少网络开销。
  3. 启用缓存层:

    • 我们的接口中有部分查询是非实时敏感的,所以加了一层本地 Caffeine 缓存;

    • 对于高频读取的数据,提前加载并设置合理的 TTL 和刷新策略;

    • 示例:

      Cache<String, Object> cache = Caffeine.newBuilder()
          .maximumSize(1000)
          .expireAfterWrite(10, TimeUnit.MINUTES)
          .build();
      

优化后,我们再次测试,TP99 延迟下降到 300ms 以内,QPS 能跑到 7000+,整体指标看起来正常了不少。


第三重挑战:JVM 参数调优带来意想不到的变化

系统架构设计-1

就在我们以为快稳住的时候,有一天压测过程中居然出现了 Full GC!

我们立刻抓取了 JVM 的堆栈和 GC 日志,发现老年代频繁回收,GC 时间占比超过 10%。服务在这种情况下自然无法维持高性能。

这个问题暴露了一个我们前期忽略掉的细节:JVM 默认参数并不适合高并发场景

分析过程:

我们使用了 JDK 自带的 jstat 工具:

jstat -gcutil <pid> 1000

观察到:

  • Eden 区几乎每次都被快速填满;
  • Survivor 区利用率低;
  • Tenuring threshold 设置过低,对象很快晋升到老年代;
  • G1 回收算法表现不佳,CMS 又不推荐使用了……

调优思路:

我们做了以下改动:

  1. 更换垃圾回收器为 ZGC:

    -XX:+UseZGC
    -XX:MaxGCPauseMillis=10
    
  2. 调整堆大小与比例:

    -Xms4g -Xmx4g
    -XX:NewRatio=3 # 新生代占堆总大小的 1/4
    -XX:SurvivorRatio=8 # Eden : Survivor = 8:2
    
  3. 禁用显式 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

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝