存量系统性能治理实战:从CPU飙升到慢SQL的绝境续命手册
2026/8/31 12:01:30 网站建设 项目流程

接手一套“只剩半年寿命”的存量系统,在绝境中完成性能治理与架构自救,是很多后端工程师都经历过的修罗场。本文以一次真实风格的 JVM 与数据库双重性能危机为例,完整拆解从问题发现、根因定位、代码修复到容量回归的全过程。

先说清楚:这篇文章不是什么玄幻故事,而是一套能直接落地的“存量系统续命手册”。它不会教你三个月重构完一套大系统,而是教你在资源有限、时间紧迫的前提下,如何用最小成本识别并清除系统里最要命的“性能杀手”,把系统从死亡线上拉回来。

文章内容覆盖五大典型故障场景、一个完整实战案例、一份高频问题排查清单,以及一线生产系统治理的最佳实践。无论你是刚接触线上问题排查的新人,还是正在为业务系统性能头疼的后端开发,都可以从中找到可直接复用的思路和命令。

1. 背景与核心概念

想象一下:你接手了一套运行多年的老系统,每天高峰期接口超时率超过 30%,CPU 经常被打满,数据库慢查询日志每一分钟刷出好几页,线上告警群从早到晚响个不停。业务方给出的期限是“半年内必须稳定下来,否则系统下线”。

这种局面下,最忌讳的就是上来就推倒重来。存量系统的核心矛盾在于:它还在创造业务价值,不能停;同时它的技术债已经很深,无法短时间修复。正确的姿势不是“重构”,而是“治理”——用最小的改动,换取最大的稳定性。

所谓“系统续命”,本质上是在做三件事:

治理方向要解决的问题预期效果
资源治理CPU、内存、磁盘、连接池被什么占用资源水位下降,避免频繁告警
调用链治理慢接口、慢 SQL、外部依赖超时接口耗时下降,成功率回升
容量治理系统能扛多少 QPS、什么时候会雪崩提前扩容或限流,避免宕机

在这个过程里,我们不需要发明新技术,而是要把已有工具用到位:Arthas、JProfile、慢查询日志、Prometheus、Grafana。排查问题的思路比工具本身更重要,它是一条主线,把所有散落的监控数据串联起来。

2. 环境准备与版本说明

为了避免“在我电脑上明明是好的”这种尴尬,先把本次实战的参考环境列出来。版本不需要和你的生产环境完全一致,重点是掌握排查方法。

组件版本/说明
操作系统CentOS 7.x / 8.x,Linux x86_64
JDKOpenJDK 8+,本文示例以 JDK 8 为主,部分命令兼容 JDK 11
应用框架Spring Boot 2.3.x,内嵌 Tomcat 9
数据库MySQL 5.7 / 8.0,InnoDB 引擎
缓存Redis 5.x,单节点或哨兵模式
诊断工具Arthas 3.6.x、Jstat、Jstack、MAT、mysqldumpslow
监控组件Prometheus + Grafana(可选,用于容量回归)

如果你的环境是 Spring Cloud 或者其他分布式框架,本文的排查思路同样适用,只是告警来源和调用链追踪工具可能需要换成 SkyWalking、Zipkin 这类组件。

建议准备一台测试环境机器,配置不需要太高,4C8G 即可。重点在于能够模拟流量、复现问题。下面是我推荐的最小验证环境结构:

spring-boot-app ├── src/main/java │ └── com/example/perf │ ├── controller │ │ └── OrderController.java │ ├── service │ │ └── OrderService.java │ ├── mapper │ │ └── OrderMapper.java │ └── PerfDemoApplication.java ├── src/main/resources │ ├── application.yml │ └── mapper/OrderMapper.xml └── pom.xml

如果是老项目改造,不需要刻意新建工程,直接在现有代码上做分支即可。我建议在一个独立的 feature 分支上操作,方便回滚。

3. 核心故障场景与排查原理

在动手实战前,先把存量系统最常遇到的四类“性能杀手”搞清楚。它们就像打不死的小强,反复出现在各类事故报告中。

3.1 CPU 飙升:线程在忙什么

现象是服务器 CPU 使用率长期超过 90%,应用响应缓慢,甚至出现 no response。很多人第一反应是“加机器”,但如果根因是代码死循环或者锁竞争,加再多机器也只是暂时缓解。

排查顺序应该是:

  1. top -Hp <pid>找出占用 CPU 最高的线程 ID。
  2. 将线程 ID 转为十六进制:printf "%x\n" <tid>
  3. jstack <pid> | grep -A 30 <十六进制tid>查看线程栈。

如果线程栈里大量出现Object.wait(),通常是线程池耗尽;如果出现循环调用或者频繁 GC,需要结合 JVM 参数进一步分析。

3.2 慢 SQL:数据库为什么“卡住”

慢 SQL 是存量系统最常见的性能顽疾。一条本应毫秒级返回的 SQL,可能因为少了一个索引、多了一次全表扫描,变成秒级。

获取慢 SQL 的常用命令:

mysql> SHOW VARIABLES LIKE 'slow_query_log'; mysql> SET GLOBAL slow_query_log = ON; mysql> SET GLOBAL long_query_time = 1;

打开慢查询日志后,使用mysqldumpslow分析:

mysqldumpslow -s at -t 10 /var/lib/mysql/slow.log

这条命令会按平均查询时间倒序列出前 10 条慢 SQL,这是定位问题的起点。

拿到慢 SQL 后,用EXPLAIN查看执行计划,重点关注typekeyrows三列。如果typeALL,说明走了全表扫描,这是最需要警惕的信号。

3.3 缓存击穿与穿透:Redis 不是万能药

缓存能挡掉大部分读请求,但如果缓存设计不合理,Redis 反而会成为压垮系统的帮凶。

  • 缓存穿透:查询一个不存在的 key,请求直接打到数据库。
  • 缓存击穿:一个热点 key 过期瞬间,大量请求同时打到数据库。
  • 缓存雪崩:大量 key 同一时间过期,数据库压力瞬间翻倍。

解决思路:

// 缓存穿透:缓存空值,并设置较短过期时间 // 缓存击穿:使用互斥锁或逻辑过期 // 缓存雪崩:过期时间加随机值,避免同时失效

这里不做完整代码展开,先有这个意识,下一节完整案例中会给出实现。

3.4 内存泄漏:Full GC 为什么越来越频繁

JVM 内存问题比 CPU 问题更难察觉,因为它的表现是“温水煮青蛙”。一开始 Minor GC 很正常,慢慢变成频繁 Full GC,最后抛出OutOfMemoryError

排查步骤:

# 查看堆内存使用情况 jmap -heap <pid> # 查看 GC 日志 jstat -gcutil <pid> 1000 10

如果发现老年代占用持续上升且无法回收,基本可以断定存在内存泄漏。此时需要导出堆快照:

jmap -dump:format=b,file=heap.hprof <pid>

然后用 MAT 或者 VisualVM 打开快照,重点看以下几点:

  • Dominator Tree里占用最大的对象。
  • 是否有大对象没有释放。
  • 是否持有过多静态集合。
  • 数据库连接、HTTP 连接等资源是否关闭。

排查内存泄漏时,我建议先看代码里有没有往 static Map 里放数据的写法,这是很多老项目的雷区。

4. 完整实战案例:给“濒死系统”续命

下面用一个模拟场景来演示完整治理流程。假设我们有一个订单查询服务,高峰期接口耗时严重超标,CPU 经常飙到 95% 以上,数据库连接池频繁打满。需求方只给了两周时间,要求把 P99 耗时从 3 秒降到 800 毫秒以内。

4.1 创建项目结构

首先创建一个简化版的 Spring Boot 项目,用来复现问题。

<!-- pom.xml --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.3.12.RELEASE</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.1.4</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.23</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> </dependencies>

4.2 核心代码:问题复现

下面这段代码模拟了一个典型的复杂查询接口。注意它的几个问题:SQL 没有走索引、循环查库、没有缓存。

// 文件路径:src/main/java/com/example/perf/controller/OrderController.java @RestController @RequestMapping("/order") public class OrderController { @Autowired private OrderService orderService; @GetMapping("/detail") public OrderDetailVO getOrderDetail(@RequestParam("orderId") Long orderId) { return orderService.queryOrderDetail(orderId); } }
// 文件路径:src/main/java/com/example/perf/service/OrderService.java @Service public class OrderService { @Autowired private OrderMapper orderMapper; public OrderDetailVO queryOrderDetail(Long orderId) { // 问题1:每次请求都查数据库,没有缓存 Order order = orderMapper.selectByOrderId(orderId); // 问题2:N+1 查询,一条订单查询多次用户表 UserInfo user = orderMapper.selectUserByUserId(order.getUserId()); List<OrderItem> items = orderMapper.selectItemsByOrderId(orderId); return buildVO(order, user, items); } }
// 文件路径:src/main/java/com/example/perf/mapper/OrderMapper.java @Mapper public interface OrderMapper { Order selectByOrderId(@Param("orderId") Long orderId); List<OrderItem> selectItemsByOrderId(@Param("orderId") Long orderId); }
<!-- 文件路径:src/main/resources/mapper/OrderMapper.xml --> <mapper namespace="com.example.perf.mapper.OrderMapper"> <!-- 问题3:order_id 是普通索引,但没有使用覆盖索引,存在回表 --> <select id="selectByOrderId" resultType="com.example.perf.entity.Order"> SELECT * FROM t_order WHERE order_id = #{orderId} </select> <!-- 问题4:子查询导致全表扫描 --> <select id="selectItemsByOrderId" resultType="com.example.perf.entity.OrderItem"> SELECT * FROM t_order_item WHERE order_id IN ( SELECT order_id FROM t_order WHERE user_id = (SELECT user_id FROM t_order WHERE order_id = #{orderId}) ) </select> </mapper>

这段代码初看没什么大问题,但在高并发下会迅速暴露性能瓶颈。

4.3 问题诊断

启动应用后,用压测工具模拟 200 并发、持续 3 分钟的请求,这时候监控数据开始异常。

步骤一:观察全局

top

输出显示 Java 进程 CPU 占用约 350%(4 核机器),Load Average 明显偏高。

步骤二:查看 GC 情况

jstat -gcutil <pid> 1000 5

输出中 YGC 频率很高,但 FGCT 并没有显著增长。说明内存不是首要问题,CPU 消耗主要来自业务代码本身。

步骤三:抓取线程栈

top -Hp <pid>

找到 CPU 最高的线程号,比如 12856。转十六进制是 0x3238。然后:

jstack <pid> | grep -A 30 "0x3238"

线程栈显示大量线程阻塞在 JDBC 的 socketRead 上,说明数据库查询耗时过长。

步骤四:查看慢查询日志

mysqldumpslow -s at -t 10 /var/lib/mysql/slow.log

排名第一的正是selectItemsByOrderId这条子查询 SQL,平均执行时间 2.8 秒。

4.4 修复方案

针对上面定位的问题,给出三层修复。

第一层:SQL 优化

把子查询改写为 JOIN,并利用索引覆盖减少回表。

-- 优化前 SELECT * FROM t_order_item WHERE order_id IN ( SELECT order_id FROM t_order WHERE user_id = (SELECT user_id FROM t_order WHERE order_id = #{orderId}) ); -- 优化后 SELECT i.* FROM t_order_item i JOIN t_order o ON i.order_id = o.order_id WHERE o.user_id = ( SELECT user_id FROM t_order WHERE order_id = #{orderId} ) LIMIT 100;

同时在t_order_item表的order_id列上建立普通索引,在t_order表的user_id列上建立普通索引:

ALTER TABLE t_order_item ADD INDEX idx_order_id (order_id); ALTER TABLE t_order ADD INDEX idx_user_id (user_id);

第二层:缓存优化

引入 Redis 缓存,对热点订单做缓存处理,并设置合理过期时间。

// 文件路径:src/main/java/com/example/perf/service/OrderService.java @Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private StringRedisTemplate redisTemplate; private static final String ORDER_CACHE_KEY = "order:detail:"; public OrderDetailVO queryOrderDetail(Long orderId) { // 先查缓存 String cacheValue = redisTemplate.opsForValue().get(ORDER_CACHE_KEY + orderId); if (cacheValue != null) { return JSON.parseObject(cacheValue, OrderDetailVO.class); } // 缓存未命中,查数据库 OrderDetailVO vo = queryFromDb(orderId); // 回填缓存,设置随机过期时间,避免雪崩 int timeout = 300 + new Random().nextInt(60); redisTemplate.opsForValue().set(ORDER_CACHE_KEY + orderId, JSON.toJSONString(vo), timeout, TimeUnit.SECONDS); return vo; } private OrderDetailVO queryFromDb(Long orderId) { Order order = orderMapper.selectByOrderId(orderId); UserInfo user = orderMapper.selectUserByUserId(order.getUserId()); List<OrderItem> items = orderMapper.selectItemsByOrderId(orderId); return buildVO(order, user, items); } }

这里用 StringRedisTemplate 做演示,生产环境建议使用 Fastjson2 或 Jackson 进行序列化,并封装统一的缓存工具类。

第三层:参数调优

如果数据库连接池经常打满,检查application.yml中的连接池配置:

spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000

一般不要盲目调大maximum-pool-size。连接池过大反而会增加数据库负担,通常 20 到 50 足够单机使用。超出这个范围,应该优先考虑SQL优化和缓存。

4.5 运行与验证

重新启动应用,再次使用同样的压测参数。

指标优化前优化后
P99 耗时3.2 秒420 毫秒
数据库 QPS3200450
CPU 使用率95%45%
连接池活跃数48/5012/50

需要注意的是,优化前与优化后的压测应保证参数一致,包括并发数、持续时间、请求数据分布。如果压测数据太集中,热点 key 会放大缓存的效果,导致结果失真。

5. 常见问题与排查思路

在实际治理过程中,还会遇到很多上面案例没有覆盖的细节问题。下面整理一个高频问题速查表。

问题现象常见原因排查思路解决方案
CPU 飙升但线程栈看不到业务代码JIT 编译或 GC 线程占满jstack多次采样,jstat观察 GC检查 JVM 参数,适当增加堆内存
接口偶发超时外部 HTTP 调用没有设置超时查看调用链,关注第三方接口耗时设置连接超时和读超时,增加熔断
缓存命中率低key 设计不合理,过期时间太短用 Redis 的INFO stats查看命中率优化 key 粒度,延长热点数据过期时间
数据库连接池耗尽慢 SQL 长期占用连接查看连接池活跃数、慢查询日志SQL 优化、增加 read-only 路由
Full GC 频繁大对象分配过多或内存泄漏jmap -dump导出堆快照,MAT 分析代码层面减少大对象,修复泄漏点
压测时性能正常,线上出问题压测数据不真实使用线上脱敏流量回放建设流量回放平台,定期做容量评估
线程池队列堆积核心线程数不足,拒绝策略不合理查看线程池活跃度、队列长度调大核心线程数,或改用 CallerRunsPolicy

如果遇到问题无法立刻定位,有一个比较稳妥的排查顺序:

  1. 先看监控大屏,确认是 CPU、内存、IO 哪个资源先打满。
  2. 再看错误日志,确认有没有大量异常堆栈。
  3. 然后用 Arthas 的trace命令对慢接口做方法级耗时分析。
  4. 最后结合数据库慢查询和中间件监控确认瓶颈。

Arthas 的trace命令示例:

java -jar arthas-boot.jar trace com.example.perf.service.OrderService queryOrderDetail

这条命令会打印queryOrderDetail方法内部每个子调用的耗时分布,非常直观。

6. 最佳实践与工程建议

排完一次故障之后,更重要的是建立长效机制,避免同类问题反复出现。下面这些建议来自一线生产环境治理的经验沉淀。

6.1 代码层面:把性能意识融入开发规范

代码评审时要重点审查以下几类问题:

  • 循环内查数据库、循环内调远程接口。
  • 大列表查询没有分页。
  • 查询条件没有索引支撑。
  • 未使用批量操作,一条条 insert 或 update。
  • 忽略缓存,热点数据每次都查库。

建议在 CI 流程中加入简单的 SQL 检查插件,例如 MyBatis 的拦截器可以打印慢 SQL 并发送告警。至少要做到所有 SQL 变更必须附带 EXPLAIN 执行计划截图。

6.2 监控层面:没有数据就没有治理

很多系统运维难,不是因为问题复杂,而是因为“看不见”。必须确保以下指标有监控:

类型核心指标
应用QPS、RT、错误率、线程池活跃数
JVM堆内存、GC 次数、GC 耗时、FULL GC 频率
数据库慢查询数量、连接数、锁等待、缓冲池命中率
缓存命中率、内存使用、过期 key 数量
中间件队列堆积量、消费延迟、连接数

推荐技术栈组合:Micrometer + Prometheus + Grafana,这套方案在 Spring Boot 2.x 上接入成本很低。

6.3 容量层面:提前发现雪崩风险

容量治理不是等到系统扛不住才做,而是定期去验证系统的极限在哪里。

压测时要注意:

  • 不要用单机压测结果推断集群容量。
  • 压测数据要尽量贴近真实分布。
  • 关注的不只是平均耗时,更要看 P95、P99。
  • 每个压测轮次要记录 JVM、数据库、缓存的关键指标。

建议每季度做一次全链路压测,并在压测前准备好降级预案,比如限流阈值、熔断开关、只读切换等。

6.4 安全与变更管理

对于生产环境的任何变更,都需要遵守最小权限原则:

  • 数据库变更先备份,再在测试环境执行一遍 DDL。
  • 生产环境执行 SQL 前使用EXPLAIN确认执行计划。
  • 发布应用前确认 JVM 参数和连接池配置已评审。
  • 线上排查问题时,不随意 kill 进程,不随意清理日志文件。

一句话总结:性能治理是常态工作,而不是事故后的补救动作。

7. 总结与学习路线

这套“绝境续命”流程背后,其实是一套通用的问题处理框架:发现问题、缩小范围、定位根因、最小修复、回归验证。它不依赖某个特定框架,任何语言、任何业务系统都可以复用。

你读到这里,应该已经掌握:

  • 存量系统性能治理的核心思路是“最小改动、最大稳定”。
  • 四类典型性能杀手的排查路径:CPU、慢 SQL、缓存、内存泄漏。
  • 使用topjstackjstatmysqldumpslow、Arthas 等工具定位问题。
  • 从 SQL 优化、缓存优化、连接池调优三个层面完成系统续命。
  • 建立监控、容量与变更管理机制,防止问题复发。

下一步,可以往这几个方向继续深入:

  • 学习全链路压测平台的建设,比如结合 SkyWalking 和 JMeter。
  • 深入研究 JVM 底层原理,包括 G1 收集器、对象分配过程、类加载机制。
  • 阅读 MySQL 官方文档中关于索引优化和 InnoDB 锁机制的章节。
  • 尝试在项目里接入 Arthas 的watchtrace等高级命令,提升定位效率。

性能问题不会一次性解决干净,但每排查一次,你对系统的理解就更深一层。把这套方法论沉淀成团队的文档和工具,比任何黑科技都管用。你手头那套“只剩半年寿命”的系统,或许就因为你今天多看了一眼线程栈,又稳稳续了五年。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询