接手一套“只剩半年寿命”的存量系统,在绝境中完成性能治理与架构自救,是很多后端工程师都经历过的修罗场。本文以一次真实风格的 JVM 与数据库双重性能危机为例,完整拆解从问题发现、根因定位、代码修复到容量回归的全过程。
先说清楚:这篇文章不是什么玄幻故事,而是一套能直接落地的“存量系统续命手册”。它不会教你三个月重构完一套大系统,而是教你在资源有限、时间紧迫的前提下,如何用最小成本识别并清除系统里最要命的“性能杀手”,把系统从死亡线上拉回来。
文章内容覆盖五大典型故障场景、一个完整实战案例、一份高频问题排查清单,以及一线生产系统治理的最佳实践。无论你是刚接触线上问题排查的新人,还是正在为业务系统性能头疼的后端开发,都可以从中找到可直接复用的思路和命令。
1. 背景与核心概念
想象一下:你接手了一套运行多年的老系统,每天高峰期接口超时率超过 30%,CPU 经常被打满,数据库慢查询日志每一分钟刷出好几页,线上告警群从早到晚响个不停。业务方给出的期限是“半年内必须稳定下来,否则系统下线”。
这种局面下,最忌讳的就是上来就推倒重来。存量系统的核心矛盾在于:它还在创造业务价值,不能停;同时它的技术债已经很深,无法短时间修复。正确的姿势不是“重构”,而是“治理”——用最小的改动,换取最大的稳定性。
所谓“系统续命”,本质上是在做三件事:
| 治理方向 | 要解决的问题 | 预期效果 |
|---|---|---|
| 资源治理 | CPU、内存、磁盘、连接池被什么占用 | 资源水位下降,避免频繁告警 |
| 调用链治理 | 慢接口、慢 SQL、外部依赖超时 | 接口耗时下降,成功率回升 |
| 容量治理 | 系统能扛多少 QPS、什么时候会雪崩 | 提前扩容或限流,避免宕机 |
在这个过程里,我们不需要发明新技术,而是要把已有工具用到位:Arthas、JProfile、慢查询日志、Prometheus、Grafana。排查问题的思路比工具本身更重要,它是一条主线,把所有散落的监控数据串联起来。
2. 环境准备与版本说明
为了避免“在我电脑上明明是好的”这种尴尬,先把本次实战的参考环境列出来。版本不需要和你的生产环境完全一致,重点是掌握排查方法。
| 组件 | 版本/说明 |
|---|---|
| 操作系统 | CentOS 7.x / 8.x,Linux x86_64 |
| JDK | OpenJDK 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。很多人第一反应是“加机器”,但如果根因是代码死循环或者锁竞争,加再多机器也只是暂时缓解。
排查顺序应该是:
- 用
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: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 毫秒 |
| 数据库 QPS | 3200 | 450 |
| CPU 使用率 | 95% | 45% |
| 连接池活跃数 | 48/50 | 12/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等高级命令,提升定位效率。
性能问题不会一次性解决干净,但每排查一次,你对系统的理解就更深一层。把这套方法论沉淀成团队的文档和工具,比任何黑科技都管用。你手头那套“只剩半年寿命”的系统,或许就因为你今天多看了一眼线程栈,又稳稳续了五年。