2026/8/31 12:02:08

存量系统性能治理实战:从CPU飙升到慢SQL的绝境续命手册

存量系统性能治理实战:从CPU飙升到慢SQL的绝境续命手册 接手一套“只剩半年寿命”的存量系统在绝境中完成性能治理与架构自救是很多后端工程师都经历过的修罗场。本文以一次真实风格的 JVM 与数据库双重性能危机为例完整拆解从问题发现、根因定位、代码修复到容量回归的全过程。先说清楚这篇文章不是什么玄幻故事而是一套能直接落地的“存量系统续命手册”。它不会教你三个月重构完一套大系统而是教你在资源有限、时间紧迫的前提下如何用最小成本识别并清除系统里最要命的“性能杀手”把系统从死亡线上拉回来。文章内容覆盖五大典型故障场景、一个完整实战案例、一份高频问题排查清单以及一线生产系统治理的最佳实践。无论你是刚接触线上问题排查的新人还是正在为业务系统性能头疼的后端开发都可以从中找到可直接复用的思路和命令。1. 背景与核心概念想象一下你接手了一套运行多年的老系统每天高峰期接口超时率超过 30%CPU 经常被打满数据库慢查询日志每一分钟刷出好几页线上告警群从早到晚响个不停。业务方给出的期限是“半年内必须稳定下来否则系统下线”。这种局面下最忌讳的就是上来就推倒重来。存量系统的核心矛盾在于它还在创造业务价值不能停同时它的技术债已经很深无法短时间修复。正确的姿势不是“重构”而是“治理”——用最小的改动换取最大的稳定性。所谓“系统续命”本质上是在做三件事治理方向要解决的问题预期效果资源治理CPU、内存、磁盘、连接池被什么占用资源水位下降避免频繁告警调用链治理慢接口、慢 SQL、外部依赖超时接口耗时下降成功率回升容量治理系统能扛多少 QPS、什么时候会雪崩提前扩容或限流避免宕机在这个过程里我们不需要发明新技术而是要把已有工具用到位Arthas、JProfile、慢查询日志、Prometheus、Grafana。排查问题的思路比工具本身更重要它是一条主线把所有散落的监控数据串联起来。2. 环境准备与版本说明为了避免“在我电脑上明明是好的”这种尴尬先把本次实战的参考环境列出来。版本不需要和你的生产环境完全一致重点是掌握排查方法。组件版本/说明操作系统CentOS 7.x / 8.xLinux x86_64JDKOpenJDK 8本文示例以 JDK 8 为主部分命令兼容 JDK 11应用框架Spring Boot 2.3.x内嵌 Tomcat 9数据库MySQL 5.7 / 8.0InnoDB 引擎缓存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。很多人第一反应是“加机器”但如果根因是代码死循环或者锁竞争加再多机器也只是暂时缓解。排查顺序应该是用top -Hp pid找出占用 CPU 最高的线程 ID。将线程 ID 转为十六进制printf %x\n tid。用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查看执行计划重点关注type、key、rows三列。如果type是ALL说明走了全表扫描这是最需要警惕的信号。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:formatb,fileheap.hprof pid然后用 MAT 或者 VisualVM 打开快照重点看以下几点Dominator Tree里占用最大的对象。是否有大对象没有释放。是否持有过多静态集合。数据库连接、HTTP 连接等资源是否关闭。排查内存泄漏时我建议先看代码里有没有往 static Map 里放数据的写法这是很多老项目的雷区。4. 完整实战案例给“濒死系统”续命下面用一个模拟场景来演示完整治理流程。假设我们有一个订单查询服务高峰期接口耗时严重超标CPU 经常飙到 95% 以上数据库连接池频繁打满。需求方只给了两周时间要求把 P99 耗时从 3 秒降到 800 毫秒以内。4.1 创建项目结构首先创建一个简化版的 Spring Boot 项目用来复现问题。!-- pom.xml -- parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.3.12.RELEASE/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.1.4/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.23/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency /dependencies4.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); // 问题2N1 查询一条订单查询多次用户表 UserInfo user orderMapper.selectUserByUserId(order.getUserId()); ListOrderItem 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); ListOrderItem selectItemsByOrderId(Param(orderId) Long orderId); }!-- 文件路径src/main/resources/mapper/OrderMapper.xml -- mapper namespacecom.example.perf.mapper.OrderMapper !-- 问题3order_id 是普通索引但没有使用覆盖索引存在回表 -- select idselectByOrderId resultTypecom.example.perf.entity.Order SELECT * FROM t_order WHERE order_id #{orderId} /select !-- 问题4子查询导致全表扫描 -- select idselectItemsByOrderId resultTypecom.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()); ListOrderItem 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 毫秒数据库 QPS3200450CPU 使用率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如果遇到问题无法立刻定位有一个比较稳妥的排查顺序先看监控大屏确认是 CPU、内存、IO 哪个资源先打满。再看错误日志确认有没有大量异常堆栈。然后用 Arthas 的trace命令对慢接口做方法级耗时分析。最后结合数据库慢查询和中间件监控确认瓶颈。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、缓存、内存泄漏。使用top、jstack、jstat、mysqldumpslow、Arthas 等工具定位问题。从 SQL 优化、缓存优化、连接池调优三个层面完成系统续命。建立监控、容量与变更管理机制防止问题复发。下一步可以往这几个方向继续深入学习全链路压测平台的建设比如结合 SkyWalking 和 JMeter。深入研究 JVM 底层原理包括 G1 收集器、对象分配过程、类加载机制。阅读 MySQL 官方文档中关于索引优化和 InnoDB 锁机制的章节。尝试在项目里接入 Arthas 的watch、trace等高级命令提升定位效率。性能问题不会一次性解决干净但每排查一次你对系统的理解就更深一层。把这套方法论沉淀成团队的文档和工具比任何黑科技都管用。你手头那套“只剩半年寿命”的系统或许就因为你今天多看了一眼线程栈又稳稳续了五年。