老项目性能优化实战:从卡顿到丝滑的排查与改造指南
2026/9/8 11:42:42 网站建设 项目流程

看到这个标题,大概率有人以为是水贴。其实不是。我是在清理旧代码时,突然想起一个内部代号叫 Q33 的老项目。当年它跑起来是真的顺滑,现在每次上线都要小心翼翼,稍微加个需求就要跑很久。很多开发朋友应该能体会到这种感觉:不是项目不想改,而是它已经“老了”。

这篇文章不准备写情怀。我想用 Q33 当例子,把一套适合老项目的性能诊断与优化流程分享出来。你可以把 Q33 替换成自己手里那个“又老又卡”的服务,按这个思路去定位问题、做优化、验证效果,最后让它重新变得丝滑。

1. 为什么老项目会从“丝滑”变成“卡顿”

先下结论:老项目变卡,通常不是某一次重构搞坏的,而是长期维护过程中,多个因素叠加导致的。Q33 早期只有几个接口、几万行代码,跑起来当然快。后来需求不断叠加,接口越来越多,依赖越来越重,数据量越来越大,性能问题就逐渐暴露出来。

常见的原因有五个。

第一,技术栈老化。Q33 当时选择的框架和中间件,放到现在可能已经失去维护。老版本 JDK 的垃圾回收器、老版本 Web 容器的线程模型,都会在高并发下成为瓶颈。很多团队为了兼容历史逻辑,不敢升级版本,只能一直扛着。

第二,代码腐化。随着人员流动,每个人在原有代码上不断打补丁。重复查询、循环调用、全表扫描、字符串拼接、大量同步等待,这些问题会像滚雪球一样积累。代码看起来还能跑,但每一层都多了很多无用功。

第三,依赖膨胀。为了快速实现功能,开发时引入了一个又一个 jar 包。有些工具类只用了其中一两个方法,却把整个依赖带进来;有些依赖之间版本冲突,启动时反复加载类。依赖越多,启动越慢,内存占用越高,卡顿自然不可避免。

第四,数据量和流量增长。Q33 当初设计的容量上限,可能只支撑几千用户。现在用户量增长到几十万,数据库里的订单、日志、配置都堆在一起,原来的 SQL 没有索引,查询越来越慢。这种问题不是靠加机器就能解决的,索引和缓存才是关键。

第五,缺乏有效的监控和回归手段。老项目往往没有完善的链路追踪、慢查询日志、性能基线。出问题时只能靠人工猜,改完一个接口,不确定会影响到哪个模块。久而久之,团队不敢动代码,性能只能继续恶化。

所以,在做任何优化之前,一定要先建立问题清单,明确“卡”在哪里。

2. 先从“定义问题”开始,而不是一上来就改代码

很多人拿到一个卡顿项目,第一反应是“把某个循环改掉”,或者“加个 Redis 缓存”。这种做法很容易治标不治本。真正专业的流程是:先把“卡顿”变成一个可量化的指标,再根据指标去定位。

以 Q33 为例,我们需要回答这几类问题:

  • 是接口响应慢,还是页面加载慢?
  • 是偶发性卡顿,还是持续很慢?
  • 是白天高峰期慢,还是任何时间都慢?
  • 是单个用户感知慢,还是所有用户都慢?
  • 是服务端 CPU 高,还是数据库慢?
  • 是请求处理慢,还是网络传输慢?

只有把问题定义清楚,才能决定用什么工具去排查。

我建议先建立一份性能基线文档,记录下来:

场景历史指标当前指标目标指标
用户列表查询100ms800ms200ms
订单详情接口150ms1200ms300ms
应用启动时间10s45s20s
高峰期 CPU 使用率40%90%70%

没有基线,你就不知道优化是否有效。建立基线后,再开始下一步。

3. 环境准备:搭建可复现的性能测试环境

老项目优化最怕一件事:本地不卡,测试环境卡,生产环境更卡。所以环境准备很重要,不能省。

3.1 准备一套独立的压测环境

尽量复制一套和生产环境配置相近的环境,至少数据库版本、中间件版本、JDK 版本要一致。如果条件允许,直接从生产环境导出一份脱敏数据,放到压测库里。这样压测结果才具备参考价值。

3.2 需要的工具清单

Q33 是 Java 技术栈,所以我以 Java 生态为例。如果你手头是 Python、Go 或其他技术栈,原理是一样的。

  • JDK:建议与生产环境保持一致,优化完成后再说升级
  • JVM 监控工具:jstack、jstat、jmap、VisualVM、Arthas
  • 压测工具:wrk、ab、JMeter 都可以
  • 数据库工具:慢查询日志、EXPLAIN、数据库监控面板
  • APM 工具:如果有 SkyWalking、Pinpoint 或 Cat,优先使用;没有的话先用命令行工具顶着

3.3 准备一个可重复执行的压测脚本

这里给出一个 wrk 的压测命令示例,用来给 Q33 的一个核心查询接口建立基准。

# 安装 wrk (macOS 示例) brew install wrk # 压测 Q33 的订单列表接口,4 个线程,100 个连接,持续 30 秒 wrk -t4 -c100 -d30s http://localhost:8080/api/order/list

如果接口需要登录态,可以先用 curl 拿到 Token,再用 wrk 的 Header 参数带入。

wrk -t4 -c100 -d30s -H "Authorization: Bearer <token>" http://localhost:8080/api/order/list

运行完以后,wrk 会输出请求总数、QPS、平均时延、P50/P99 延迟。这些数据就是后续优化的参照。

4. 核心流程:老项目性能问题的分层排查

很多同学拿到压测结果后,看到平均延迟高,就直接去翻代码。我建议还是按照下面五层顺序排查,效率更高。

4.1 第一层:先看系统和 JVM 资源

登录服务器,先用topfree看一眼。

top -Hp <pid> free -g

重点看几个点:

  • CPU 使用率是否接近 100%
  • 内存是否不足,是否频繁 swap
  • Java 进程的线程数是否异常增长

如果 CPU 高,再抓线程栈。老项目最常见的情况是线程阻塞,比如等待数据库连接、等待锁、执行大量同步代码。

# 打印线程快照,连续执行三次,间隔 2 秒 jstack -l <pid> > thread_dump_1.txt sleep 2 jstack -l <pid> > thread_dump_2.txt sleep 2 jstack -l <pid> > thread_dump_3.txt

然后对比三个文件里同一个线程的状态。如果某个业务线程一直处于BLOCKEDWAITING状态,基本可以确定阻塞点。

4.2 第二层:再看 JVM 内存与 GC

老项目变卡,一个典型的隐藏原因是 GC 频繁。你可以用 jstat 查看 GC 情况。

jstat -gcutil <pid> 1000 10

重点看 Full GC 的次数和耗时。如果 Full GC 频繁,说明堆内存分配不合理或存在内存泄漏。这种时候直接调代码可能不如先调 JVM 参数见效快,但长期还是要找回对象不被释放的根因。

如果是老 JDK 8 且未使用 G1,可以尝试切换为 G1 收集器,在小内存场景下更容易控制停顿。但注意,修改 GC 策略后一定要经过压测再上生产。

4.3 第三层:检查慢 SQL 和数据库连接

接口慢,很多时候是数据库慢。开启慢查询日志,找出耗时超过阈值(比如 200ms)的 SQL。

以 MySQL 为例:

SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 0.2;

接着去分析慢 SQL 是否缺少索引、是否查询了大量不需要的数据、是否在循环里执行 SQL。

这里分享一个典型的反例。Q33 早期订单服务查询一批用户评价时,是在循环里逐条查询商品信息:

// 反例:循环查数据库,N+1 问题 List<Order> orders = orderDao.findByUserId(userId); for (Order order : orders) { Product product = productDao.findById(order.getProductId()); order.setProductName(product.getName()); }

优化方式是批量查询,一次性查出所有商品,再在内存中进行映射。

// 正例:批量查询 List<Order> orders = orderDao.findByUserId(userId); List<Long> productIds = orders.stream() .map(Order::getProductId) .distinct() .collect(Collectors.toList()); Map<Long, Product> productMap = productDao.findByIds(productIds) .stream() .collect(Collectors.toMap(Product::getId, p -> p)); orders.forEach(order -> { Product product = productMap.get(order.getProductId()); if (product != null) { order.setProductName(product.getName()); } });

这个改动看起来简单,但是在大数据量场景下,可以把接口时延从几秒降到几百毫秒。

4.4 第四层:分析核心链路的线程执行耗时

如果资源层、SQL 层都没问题,就需要用代码链路分析来找热点。Java 项目推荐使用 Arthas,它不需要在代码里插入埋点,可以直接在生产环境排查。

先启动 Arthas 并 attach 到 Q33 进程:

java -jar arthas-boot.jar <pid>

然后使用 Trace 命令追踪一个接口的方法调用耗时:

trace com.q33.service.OrderService listOrder

运行后会打印出listOrder内部各方法的调用链和耗时分布。通过输出能看到,到底是数据库查询慢,还是某个第三方调用慢,还是本地的序列化逻辑慢。

4.5 第五层:排查外部依赖与网络

如果 Q33 调用了外部 HTTP 服务、Redis、消息队列,那么这些依赖的延迟也会拖慢主流程。建议重点检查超时时间设置和重试策略。

一个常见问题是:外部服务已经变慢,Q33 依然同步等待,导致线程池被打满。优化方向是给 HTTP 客户端、Redis 客户端设置合理的连接超时和读取超时,并考虑使用异步或熔断模式。

5. 完整示例:用 Arthas 定位 Q33 热点方法

下面我用一个更完整的例子演示排查流程。假设 Q33 有一个核心接口/api/order/list压测时 P99 延迟很高。

方法一:先压测拿基线。

wrk -t4 -c100 -d30s http://localhost:8080/api/order/list

方法二:用 Arthas Trace 找到时间消耗最长的调用。

trace com.q33.service.OrderService listOrder '#cost > 200'

输出会显示listOrder内部每个子方法的调用次数、最小耗时、最大耗时和平均耗时。如果里面有一个productService.getProductMap耗时占比超过 80%,那就锁定它。

方法三:继续往下追踪这个方法。

trace com.q33.service.ProductService getProductMap '#cost > 200'

通常追两三层,就能发现问题到底出在 SQL、Redis、HTTP 还是本地算法上。

方法四:如果是 SQL 慢,直接在数据库执行 EXPLAIN。

EXPLAIN SELECT * FROM product WHERE id IN (1,2,3,4,5);

观察typerowsExtra三列。如果出现ALL说明是全表扫描,Using filesort说明排序没有走索引,这些都需要继续优化。

6. 优化策略:从代码、缓存、依赖三个层面恢复“丝滑”

定位到问题后,不要贪多,每次只改一个点。下面梳理三个常见优化方向,并给出可以直接落地的做法。

6.1 代码层:消除重复计算和无效调用

以 Q33 中一个用户维权的状态统计为例,原本每次都要从库里拉全量订单再在内存里统计:

public OrderStatVO getUserOrderStat(Long userId) { List<Order> allOrders = orderDao.findAllByUserId(userId); long totalCount = allOrders.size(); long paidCount = allOrders.stream().filter(o -> "PAID".equals(o.getStatus())).count(); long refundCount = allOrders.stream().filter(o -> "REFUND".equals(o.getStatus())).count(); return new OrderStatVO(totalCount, paidCount, refundCount); }

这里的问题有三个:

  • findAllByUserId会把整行数据全部查出来,内存浪费大
  • 只用到了状态字段,却查了所有列
  • 三次统计消耗在内存里并不大,但每次调用都会触发一次全量查询

优化方式:改成 SQL 分组统计。

public OrderStatVO getUserOrderStat(Long userId) { List<OrderCountResult> results = orderDao.countGroupByStatus(userId); long total = 0, paid = 0, refund = 0; for (OrderCountResult result : results) { total += result.getCount(); if ("PAID".equals(result.getStatus())) paid = result.getCount(); if ("REFUND".equals(result.getStatus())) refund = result.getCount(); } return new OrderStatVO(total, paid, refund); }

对应 Mapper 的 SQL 大致是:

<select id="countGroupByStatus" resultType="com.q33.model.OrderCountResult"> SELECT status, COUNT(*) AS count FROM `order` WHERE user_id = #{userId} GROUP BY status </select>

这类优化在数据量小的时候看不出差别,但 Q33 日常用户量大,一次查询从扫描上千行变成只查一行分组结果,响应时间会有明显改善。

6.2 缓存层:给热点数据加缓存

老项目最值得做的优化之一,就是给热点数据加缓存。比如商品名称、用户昵称、配置项这类读多写少的数据,完全没必要每次都查数据库。

以商品信息为例:

@Service public class ProductService { @Autowired private StringRedisTemplate stringRedisTemplate; @Autowired private ProductDao productDao; private static final String PRODUCT_CACHE_KEY = "q33:product:"; public Product getProductById(Long id) { String key = PRODUCT_CACHE_KEY + id; String json = stringRedisTemplate.opsForValue().get(key); if (json != null) { return JSON.parseObject(json, Product.class); } Product product = productDao.findById(id); if (product != null) { stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(product), 30, TimeUnit.MINUTES); } return product; } public void updateProduct(Product product) { productDao.update(product); // 更新时主动让缓存失效,避免旧数据残留 stringRedisTemplate.delete(PRODUCT_CACHE_KEY + product.getId()); } }

注意,缓存不是加得越多越好。需要评估数据一致性要求。如果业务要求强一致,就不能直接把缓存套上去。老项目做缓存优化时,最好先针对“读多写少、并发高、容忍短时不一致”的数据动手。

6.3 依赖层:清理无用的依赖和重复的轮子

Q33 这类项目,lib 目录里往往躺着很多“历史包袱”。某些 jar 包已经没有人使用了,但构建工具或启动脚本依然会加载它们,导致应用启动慢、内存占用高。

建议做一次依赖梳理:

  • 用 Maven 的话,执行mvn dependency:tree查看依赖树
  • 检查pom.xml中是否有从未引用的依赖
  • 检查是否有多个版本的相同类库,尝试统一版本
  • 如果项目只用到某个大 Jar 的一个小功能,考虑替换成更轻量的实现

依赖清理风险较高,建议分批次进行,每删一个就运行全量测试,并构建一次产物。

7. 优化效果验证与灰度上线

优化做完以后,不要直接上生产。一定要回到压测环境,用与之前相同的参数重新压测,对比基线。

7.1 使用相同的压测命令

wrk -t4 -c100 -d30s http://localhost:8080/api/order/list

对比优化前后的数据:

  • 平均响应时间是否下降
  • P99 响应时间是否下降
  • QPS 是否提升
  • 服务器 CPU 是否下降
  • 数据库慢查询数量是否减少

7.2 小流量灰度

如果压测数据符合预期,可以在生产环境用一个小比例流量验证,例如先把 10% 的流量切到新版本,观察监控指标和错误日志。

老项目最容易出现的回流问题是:接口性能变好了,但部分功能逻辑发生了变化。所以灰度观察期要看:

  • 业务成功率是否下降
  • 超时错误是否增加
  • 是否有内存溢出的新问题
  • 数据库连接数是否异常

如果观察 24 到 48 小时没有异常,再逐步把流量扩大到 50%、100%。整个过程要保留一个快速回滚方案。

8. 常见问题与排查思路

问题现象可能原因排查方式解决方案
接口平时正常,高峰期变卡线程池被打满,外部依赖超时抓线程栈,查看 BLOCKED/WAITING 线程增大线程池合理上限,设置外部调用超时,引入熔断降级
CPU 使用率 100%,响应极慢代码中有大量计算或频繁 GCtop 定位进程,jstack 抓线程,jstat 看 GC优化热点方法,减少对象创建,调整 JVM 参数
数据库 CPU 高,接口查询慢SQL 缺少索引或返回数据过多开启慢查询日志,EXPLAIN 分析增加联合索引,使用覆盖索引,减少 select 列
应用启动从 10 秒变成 45 秒依赖膨胀、初始化逻辑重查看启动日志、打印各 Bean 初始化耗时懒加载非必要 Bean,清理无用依赖,将耗时的预热任务异步化
优化代码后,功能出现异常缓存未失效,或 SQL 改写不正确查看业务日志,和优化前数据对比完善缓存更新逻辑,补充回归测试,必要时短期内去掉缓存
内存持续增长对象被静态集合持有,或缓存没有过期策略jmap 导出堆内存,使用 MAT 分析修复内存泄漏,为缓存设置过期时间
接口时延不高,但用户体验卡顿前端资源未压缩,或接口数据量过大浏览器 DevTools 查看网络耗时开启 Gzip,增加分页,前端组件按需加载

9. 最佳实践:让老项目保持“丝滑”的工程建议

经过 Q33 这个案例,我想分享几个可以直接用于日常维护的工程建议。

9.1 建立性能基线并定期回归

性能问题最怕的不是慢,而是不知道什么时候变慢。团队可以每个季度做一次压测,对比基线数据。一旦指标出现显著恶化,就能及时分析。

9.2 监控要前置

老项目往往没有监控,等到用户投诉才发现性能问题。建议至少做到几件事:

  • 接口耗时监控,按接口维度记录 P50、P95、P99
  • JVM 监控,包含堆内存、GC 耗时、线程数
  • 数据库监控,包含慢查询数量、连接池占用、锁等待
  • 应用日志与链路追踪联调,保证问题可回放

9.3 重构要小步走,不要“大爆炸”

很多团队一看到老项目性能差,就提议“推到重来”。这种方案风险极高,Q33 的例子也说明,重写过程中大概率会丢掉很多隐含的商业逻辑。更推荐的方式:

  • 先解决性能瓶颈,让系统恢复可控状态
  • 再用防腐层隔离老代码
  • 按业务模块逐步替换,而不是一次性重写

9.4 注意安全与权限

生产环境使用 jmap、Arthas、动态开启慢查询等操作时,需要确认有合法授权,并且尽量在低峰期执行。涉及数据更新的操作,一定要先备份,提交前经过测试验证。优化数据库时,最小权限原则同样适用,不要用 root 账号去执行分析命令。

9.5 把性能优化纳入日常开发

不要把性能优化当成一次性的“大扫除”。每次新需求开发时,都要顺手评估:这条 SQL 会不会慢,这个接口会不会被频繁调用,这个缓存是否合理,这次改动是否会影响性能基线。老项目保持丝滑,靠的是持续的小步维护。

10. 总结

回头看 Q33 的“不再丝滑”,其实是许多老项目的缩影。它变卡的原因并不神秘:技术和依赖老化、代码腐化、数据量增长、监控缺失。只要愿意花时间定义问题、分层排查、小步优化,完全可以让一个老项目重新变得可用。

这篇文章的核心思路可以浓缩为三句话:

  • 先量化问题,再定位瓶颈
  • 一次只优化一个点,并用压测结果验证
  • 不停留在“这次改完就好”,而是建立监控和基线,让性能不再退化

如果你手上也有一个像 Q33 这样的老项目,建议先别急着重写。把今天的方法用起来,先追一次慢接口,再跑一次压测,你会发现在“丝滑”这件事上,老项目还有很大的潜力。

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

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

立即咨询