
一个老项目一旦上了年纪最先让人察觉的往往不是某个具体报错而是整体“不丝滑”了。最近同事报怨代号为 Q33 的老服务已经失去往日的流畅接口响应变慢、偶发抖动、超时开始增多。Q33 在本文中只是一个假设的旧项目代号不指代任何真实软件。这类性能问题最忌讳一开始就凭感觉猜原因是不是服务器资源不够、是不是并发太高、是不是缓存没设置。猜中的概率低而且容易被表面现象带偏。正确路径是先量化、再分层、再定位、再优化、最后验证。这篇博客会以 Q33 项目为线索拆解一套从现象到根因的性能排查方法包含常用命令、配置、代码示例和常见坑适合刚接手老旧系统的开发、运维和测试同学参考。1. 先确定问题的边界不然优化会变成猜谜接手一个“不丝滑”的系统第一件事不是翻日志而是把模糊的抱怨翻译成可以对比、可以追踪、可以验收的指标。没有指标就没有讨论基础优化完也无法判断是否真的有效。1.1 把“不丝滑”转成可量化的性能指标用户说的“卡”“慢”“转圈圈”都是表象。技术侧需要把这些词翻译成四类核心指标响应时间、吞吐量、错误率和资源利用率。响应时间不能只看平均值平均值很容易被长尾掩盖。一个接口平均耗时 300ms可能 TP99 已经到了 1.5 秒。平时看 P50、P95、P99性能劣化时还要看 P999。吞吐量看每秒请求数QPS或每秒事务数TPS用来判断系统目前到底扛了多少压力。错误率包括 HTTP 5xx、业务返回失败、超时以及框架层出现的连接异常。资源利用率则看 CPU、内存、磁盘 IO、网络带宽等有时候瓶颈根本不在应用代码而在基础设施。下表是 Q33 项目排查时建议优先采集的指标指标分类指标名称获取方式关注点响应时间P50、P95、P99、P999网关日志、APM、业务埋点长尾分布比平均值更重要吞吐量QPS / TPS网关日志、压测工具高峰值和当前值对比错误率5xx 比例、超时率LB 日志、应用日志出现突增时优先排查资源CPU 使用率、平均负载top / vmstat持续跑满或波动异常资源内存使用率、交换分区freeswap 大量使用会导致卡顿资源磁盘读写等待iostat高 %util 表示磁盘是瓶颈资源网络连接数、带宽ss、iftop连接耗尽或带宽打满实际运维中如果一个系统没有现成的 APM也可以先用脚本从访问日志里捞出来这些指标。下面是统计接口耗时 TP99 的常用命令思路# 假设 access.log 每行最后一列是耗时毫秒 awk {print $NF} access.log \ | sort -n \ | awk {arr[NR]$1; sum$1} \ END{avgsum/NR; p99arr[int(NR*0.99)]; \ print total NR, avg avg, p99 p99}这里先把耗时排序再用数组按序号取百分位。生产环境建议直接接 APM但临时排查时这种脚本能快速给出一个数值基础。1.2 确认这是普遍变慢还是局部抖动同样是“慢”持续变慢和偶发抖动的排查方向完全不同。持续变慢通常是容量不足、配置退化、数据库 SQL 越来越慢偶发抖动则更像是 GC 停顿、定时任务撞车、缓存集中失效、外部依赖抖动。区分方法很简单看趋势曲线。不要只取一个时间点的快照要看一小时、一天甚至一周的曲线。从三个维度拆解时间维度是否固定在某个时段变慢比如整点任务、凌晨批处理导致资源抢占。接口维度是所有接口都慢还是只有某个接口慢。全部慢通常意味着公共链路出问题单个慢则聚焦业务逻辑。实例维度在多个副本时是所有节点都慢还是某个节点慢。某个节点慢可以先看该节点的 CPU、内存、磁盘和 JVM GC。如果想快速验证当前接口是否持续变慢可以用循环请求配合耗时统计for i in $(seq 1 200); do curl -o /dev/null -s -w %{http_code} %{time_total}\n \ http://127.0.0.1:8080/api/order/detail?orderId1001 done | sort -k2 -n | awk {print $2} | awk {arr[NR]$1; sum$1} \ END{print avg sum/NR, p99 arr[int(NR*0.99)]}上面命令对同一个接口连续请求 200 次输出平均耗时和 TP99。如果平均耗时不高但 TP99 很高说明系统存在明显的长尾问题需要继续抓取更长周期的数据。1.3 准备可复现的排查环境和前置信息排查性能问题最怕环境不一致。生产环境出现慢测试环境永远复现不了这类情况多半是数据量不同、配置不同或并发环境不同。学习或测试环境要尽量做到以下几点使用和线上一致的依赖版本包括 JDK、框架、中间件版本。导入和生产规模相近的测试数据至少要能让 SQL 走到相同执行计划。模拟相近的并发量单线程串行调用发现不了连接池耗尽和线程池排队问题。保存当前部署的版本号、配置文件和最近发布记录。上线前的变更记录是排查性能退化最关键的线索。很多性能问题不是突然发生的而是某次发布造成的。比如有人无意中改掉了一个索引前缀、把一个IN查询改成了OR、或者把缓存 key 的过期时间调成了统一时间。因此接到性能排查任务后先问三件事最近一周有没有发版发版内容是什么。最近有没有改配置比如线程池大小、连接池大小、JVM 参数。最近数据量有没有明显增长比如某张核心表翻了一倍。注意所有优化动作落地前都需要先在测试环境验证并在生产环境具备回滚方案时再操作。性能优化的前提是可恢复而不是为了把系统调慢再返工。2. 从全局拿到数据监控、日志和系统指标明确边界之后需要同时从系统层、应用层和链路层收集数据。很多新手只盯着应用日志忽略了操作系统层面的信息最后绕了很大弯路。2.1 系统层指标采集先看机器是不是已经顶不住了登录服务器后第一组命令应该快速确认 CPU、内存、磁盘和网络状态。以 Linux 环境为例# 查看平均负载和 CPU 核数 uptime # 动态查看 CPU 和内存占用 top -p 12345 # 查看每个线程对 CPU 的占用Java 项目常用 top -H -p 12345 # 查看内存和 swap 情况 free -g # 查看磁盘读写状态 iostat -x 1 5 # 查看网络连接数量 ss -s看uptime时如果平均负载超过 CPU 核数说明系统整体繁忙。但平均负载高不一定意味着 CPU 被应用占满也可能是大量不可中断的磁盘 IO 导致。此时用iostat -x看%util和await如果%util持续接近 100%磁盘基本是瓶颈。内存方面除了看/proc/meminfo更重要的是看 swap 的使用量。Java 进程如果发生内存回收或者堆外内存不足可能会触发 swap而 swap 会导致线程停顿、接口毛刺极其明显。出现下面的现象时要提高警惕free显示 swap 使用量持续增长。top里进程的 CPU 占用不高但系统响应很慢。接口耗时曲线出现周期性尖刺。2.2 应用层指标接口耗时、线程池、GC应用层需要关注请求处理链路里的线程状态、线程池队列、连接池等待和 GC 行为。以 Java Spring Boot 技术栈为例可以按下面的顺序排查。先看 GC 情况。使用 JDK 自带命令可以快速定位 GC 是否过于频繁# 每隔 2 秒打印一次 GC 统计一共打印 10 次 jstat -gcutil 12345 2000 10输出中的FGC表示 Full GC 次数FGCT表示 Full GC 累计耗时。如果 Full GC 次数很多或者单次 GC 耗时超过几百毫秒接口必然会出现停顿。GC 问题还会导致 CPU 占用升高因为 GC 线程会持续消耗 CPU。再看线程池和连接池。如果接口排队卡住需要抓线程堆栈jstack 12345 jstack_$(date %H%M%S).log抓完线程堆栈后重点搜索这些关键字WAITINGBLOCKEDRUNNABLEHttpConnectionPool、HikariPool、Tomcat如果大量的线程停在WAITING状态等待数据库连接说明连接池被打满。如果大量线程停在TIMED_WAITING等待任务队列说明线程池排队严重。如果看到大量BLOCKED可能是锁竞争需要再结合业务代码定位。应用层日志需要关注错误级别日志的突增。不建议人工翻日志文件应该用集中式日志平台。没有平台时至少也要用grep快速统计异常频率# 统计最近一小时某个异常的条数 grep -c ConnectionTimeoutException /var/log/app/error.log2.3 链路层排查慢查询、缓存命中、外部调用Q33 这类老项目往往没有完整的全链路追踪但至少要能从日志里串联出一次请求经过了哪些环节。优先检查三块数据库、缓存、外部 HTTP。开启 MySQL 慢查询日志是排查数据库问题的第一步SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1; SET GLOBAL slow_query_log_file /var/log/mysql/slow-query.log;需要注意慢查询日志落后于实际响应时间。如果一个接口 800ms但 SQL 只要 50ms那瓶颈不在 SQL而在应用代码的循环调用、序列化或 IO 上。如果多个 SQL 都是慢 SQL接口必然慢。如果只有一个 SQL 慢就聚焦数据库执行计划。Redis 缓存要看命中率和命令响应时间。可以用redis-cli --stat观察每秒命令数和内存变化也可以用MONITOR命令临时抓取热命令。要小心MONITOR在生产环境开启会明显降低 Redis 性能通常只在低峰期短时间使用。外部调用最容易被忽略。老系统可能调用了另一个团队的接口对方刚好在降级或者进 CPU 重启导致每次请求都在超时边界上挣扎。此时需要在代码里记录外部调用的耗时或者临时加一个过滤器打印全链路耗时// 示例用过滤器打印一次请求的总耗时和关键步骤耗时 public class CostFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { long start System.currentTimeMillis(); try { chain.doFilter(request, response); } finally { long cost System.currentTimeMillis() - start; // 生产环境建议使用结构化日志 System.out.printf(uri%s cost%dms%n, ((HttpServletRequest) request).getRequestURI(), cost); } } }实际项目里可以把参数、接口路径、耗时、状态码统一打印成一行 JSON方便后续用日志平台分析。3. 按数据定位根因常见性能瓶颈的验证方法数据采集之后接下来要判断瓶颈到底在哪一层。老系统最常见的性能瓶颈集中在数据库、线程池、GC 和缓存四类下面分别给出验证方法。3.1 数据库慢查询与索引失效现象是接口偶发变慢数据库 CPU 升高慢查询日志里出现新增的大查询。可能原因有三种SQL 本身没走索引全表扫描。索引存在但查询条件中的字段类型不匹配导致隐式转换索引失效。数据量增长后原来的索引选择性下降或者回表成本升高。验证方式是先用EXPLAIN查看执行计划EXPLAIN SELECT * FROM t_order WHERE order_no A123;重点关注type字段和rows字段。如果type是ALL说明全表扫描。如果type是ref或range说明索引生效但要继续看rows是否过大。如果Extra列出现Using filesort且排序字段无索引也会影响耗时。对应解决方案是建立必要的联合索引-- 假设查询条件是 tenant_id order_status create_time CREATE INDEX idx_tenant_status_time ON t_order(tenant_id, order_status, create_time);要注意联合索引的最左前缀原则。tenant_id放在最前面可以覆盖按租户过滤的场景。如果查询里经常只按create_time过滤这样的联合索引不会起作用需要单独再建索引。3.2 线程阻塞和连接池耗尽现象是接口单次耗时正常压力一上来立刻排队甚至返回 5xx。此时优先查看线程堆栈和连接池指标。用jstack抓线程后如果看到大量线程在等待数据库连接可以先用ss确认当前数据库连接数ss -tn | grep 3306 | wc -l同时检查数据库的最大连接数配置SHOW VARIABLES LIKE max_connections;如果应用连接池配置为 50而数据库只允许 100 个连接多实例部署时很容易打满。Spring Boot 的 HikariCP 配置可以参考下面的 Java 配置类import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class DataSourceConfig { Bean public HikariDataSource dataSource() { HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://127.0.0.1:3306/q33?useSSLfalse); config.setUsername(root); config.setPassword(password); config.setMaximumPoolSize(30); config.setMinimumIdle(5); config.setConnectionTimeout(3000); config.setMaxLifetime(1800000); return new HikariDataSource(config); } }连接池大小的设置原则不是越大越好。常见的误区是每个应用线程都希望拿到连接所以把连接池调大结果数据库连接数被压垮排队更严重。推荐的起点是CPU 核数 * 2 1再结合接口 IO 时间调整。上面示例中的 30 是根据 Q33 项目的并发情况估算的实际项目需要压测确认。3.3 GC 频繁导致的停顿JVM 老年代持续增长、Full GC 频繁会导致所有线程短暂冻结。验证方式是打开 GC 日志或者用jstat定时采样。下面是一组常见的 JVM 参数示例java -Xms4g -Xmx4g \ -Xmn1g \ -XX:MetaspaceSize256m \ -XX:MaxMetaspaceSize512m \ -XX:UseG1GC \ -XX:MaxGCPauseMillis100 \ -Xloggc:/var/log/app/gc.log \ -XX:PrintGCDetails \ -jar q33-app.jar如果 GC 后面那个Pause时间超过接口 TP99那么 GC 就是长尾的直接原因。优化方向通常是调整堆大小和新生代比例。减少一次性大对象的创建。及时释放静态集合里的无用对象。调整 G1 的-XX:MaxGCPauseMillis目标值。不要一开始就盲目调堆大小。堆太大反而会让 Full GC 时间变长。最有效的方式是压测时打印 GC 日志观察可停顿时间目标和实际停顿之间的差距。3.4 缓存穿透、击穿、雪崩当系统已经接了缓存但接口还是很慢时需要看缓存是不是在“帮倒忙”。常见问题有三种问题现象验证方法缓存穿透大量请求直击数据库数据库 CPU 升高检查查询 key 是否大量不存在缓存击穿某个热点 key 过期瞬间打到数据库观察热 key 过期时间点是否出现数据库尖峰缓存雪崩大量 key 同时过期数据库被瞬时流量打崩对比缓存过期时间和数据库压力曲线缓存穿透的解决思路有两类第一类是缓存空值第二类是使用布隆过滤器。最稳妥的是在数据库查询前加一层参数校验查询不到的数据也写一个短 TTL 的空缓存。缓存击穿适合用互斥锁或者逻辑过期。互斥锁可以用 Redis 的setNx实现// 示例重建缓存时使用分布式锁避免热点 key 击穿 String value cache.get(key); if (value null) { boolean lock redis.setnx(lock: key, 1, 5); if (lock) { try { value loadDataFromDB(key); cache.set(key, value, 60); } finally { redis.del(lock: key); } } else { // 等待并重试或者返回旧值可结合逻辑过期 value loadOldValueFromCache(key); } }缓存雪崩的预防最简单给过期时间加随机值避免大量 key 在同一秒失效。比如基础 TTL 60 秒可以随机加 0 到 10 秒。4. 优化落地的顺序和动作定位到根因后真正动手优化时要遵守“先配置、后代码、再扩容”的顺序否则很容易花大力气改完代码结果发现瓶颈在连接池配置上。4.1 先做成本最低的配置调整配置调整是最容易立竿见影的动作也是最危险的。因为很多人不知道改完配置后系统会有什么副作用。建议优先检查以下配置连接池大小和超时时间。线程池核心线程数、最大线程数和队列长度。HTTP 客户端连接池和超时时间。缓存 TTL 是否合理是否集中失效。JVM 堆大小和 GC 参数。比如线程池配置原项目可能使用默认参数导致高并发时大量任务排队。可以参考下面的 Spring 异步线程池配置Bean(q33AsyncExecutor) public ThreadPoolTaskExecutor q33AsyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(30); executor.setQueueCapacity(500); executor.setThreadNamePrefix(q33-async-); executor.setKeepAliveSeconds(60); // 拒绝策略由调用线程执行保证任务不丢失 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }这里使用了CallerRunsPolicy表示队列满时会由提交任务的线程自己执行任务。这个策略适合不希望丢任务但对响应时间有一定容忍的场景。如果系统对响应时间要求高宁可快速失败也不希望拖垮调用方那么应该使用AbortPolicy并配合降级逻辑。注意配置修改后必须确认修改被正确加载。有的项目把配置放在配置中心有的放在本地 properties有的需要重启才能生效不要出现“改了 max-pool-size 但线程池还是旧值”的低级错误。4.2 再改代码和 SQL优先消除重复和无效开销配置调完之后性能仍然不足就要看代码本身是否存在明显浪费。老项目中最常见的是 N1 查询和重复计算。N1 查询指的是先用一条 SQL 查出列表再在循环里逐条查数据库。比如// 错误示例循环查询数据库数据量大时必然慢 ListOrder orders orderMapper.selectByUserId(userId); for (Order order : orders) { Member m memberMapper.selectById(order.getMemberId()); order.setMemberName(m.getName()); }这种写法在订单列表只有 10 条时看不出问题一旦单页 50 条就会出现 51 条 SQL。优化方案是改成批量查询ListLong memberIds orders.stream() .map(Order::getMemberId) .distinct() .collect(Collectors.toList()); MapLong, Member memberMap memberMapper.selectByIds(memberIds) .stream() .collect(Collectors.toMap(Member::getId, m - m));排查 N1 最简单的方法是在日志里打印 SQL 条数或者用 MyBatis 的mapUnderscoreToCamelCase配合慢监控统计单次请求中执行的 SQL 数量。另一个常见问题是重复查询缓存。有些代码在接口内部多次查询同一个 key或者每次查询缓存都需要一小时序列化和反序列化导致缓存本身成为瓶颈。此时应该把结果放入局部变量避免重复访问 Redis。4.3 最后扩容并用数据验证效果扩容是最容易想到的方案但也是最应该放到最后一步的方案。如果代码存在索引失效加机器只能暂时缓解CPU 和数据库压力会继续增长。如果缓存穿透加实例会让数据库被打得更狠。扩容前必须确认两个问题单实例吞吐量是否已经压测过增加实例后总吞吐量是否能线性提升。下游数据库和缓存是否有足够容量别只扩容应用层把数据库打挂。优化完成后验证不能用“感觉变快了”来收尾必须重新采集优化前的指标做对比。下面是一个简单的优化前后对比表验证项优化前优化后判断标准TP99 响应时间850ms220ms目标小于 300ms错误率2.5%0.2%目标小于 0.5%数据库 CPU85%40%目标小于 60%Full GC 次数/小时121目标小于 3验证时最好做压力测试而不是只靠线上自然流量。压测方式可以用wrk或者 JMeter。线上压测要注意控制流量建议在低峰期用灰度实例并设置回滚开关。5. 常见排查坑为什么你优化了还是没变化性能排查过程中最挫败的时刻是明明改了配置、扩了容指标却没有任何变化。下面这些坑最常见。5.1 只看了平均耗时忽略了 TP99平均耗时低不代表体验好。假设一个接口 95% 请求只要 200ms5% 请求需要 2 秒平均值大约是 290ms看起来正常但用户感受到的是那 5% 的卡顿。判断性能问题一定要看 P95、P99 和 P999。排查时不要只看监控大盘的“平均响应时间”要切换到分位数图。如果当前系统没有分位数监控至少先按前文脚本统计一下耗时曲线。很多时候所谓“偶发抖动”就是 TP99 上涨平均耗时却几乎不动。5.2 在低峰期验证性能掩盖了并发问题低峰期接口调用量低连接池和线程池都处于空闲状态很多问题根本暴露不出来。比如线程池队列过长、连接泄漏、锁竞争只有在高并发下才会显现。生产环境验证时至少要选择业务高峰时段或者用压测工具制造并发。如果高峰期不方便操作可以先在测试环境模拟同样的并发量。压测前要明确目标比如 QPS 500 时 TP99 小于 400ms错误率小于 1%。没有目标就压测只能看出一堆数字不知道系统是否达标。5.3 改了配置但没有真正生效这是最常见也最低级的错误。许多人修改了配置文件后重启应用却发现配置没变。原因可能是修改的是本地配置文件但 Spring Boot 启动时加载的是配置中心的远程配置。修改了测试环境但生产环境没有同步。配置项名称错误例如maxPoolSize应为maximum-pool-size写错后配置静默忽略。应用有多个实例只改了其中一个实例。检查方式是确认“实际加载的配置”而不是“看到的文件配置”。在 Spring Boot 中可以临时打开--debug启动参数查看最终生效的配置也可以通过 actuator 的env接口动态确认。curl -s http://127.0.0.1:8080/actuator/env | grep jdbc如果项目没有暴露 actuator就在日志打印配置关键值线程池大小、连接池大小、缓存过期时间。5.4 日志文件过大导致磁盘 IO 变慢系统变慢不一定只有一个瓶颈。有时应用代码已经优化完了但日志框架每次写日志都要写好几 GB 的文件磁盘%util打满接口照样慢。这属于隐藏的基础设施问题。检查方式很简单登录服务器执行df -h看磁盘剩余空间再执行lsof或lsof L1查看哪些日志文件被进程打开并持续增长。预防方式是配置日志轮转限制单个文件大小并设置保留天数。下面是logback.xml中按大小轮转的简版配置appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender file/var/log/app/q33-app.log/file rollingPolicy classch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy fileNamePattern/var/log/app/q33-app.%d{yyyy-MM-dd}.%i.log/fileNamePattern maxFileSize200MB/maxFileSize maxHistory7/maxHistory /rollingPolicy /appender日志过多不是一天形成的建议监控磁盘空间设置告警阈值。5.5 只测本机忽略了外部依赖和网络开销在本地调试时接口只有 20ms跑到测试环境变成 300ms跑到生产环境变成 800ms。这种差异往往不是应用代码造成的而是外部依赖的网络延迟。排查外部依赖时可以在代码里临时打印每个外部调用的耗时例如long start System.currentTimeMillis(); String result restTemplate.getForObject(url, String.class); long cost System.currentTimeMillis() - start; if (cost 200) { log.warn(external slow, url{}, cost{}ms, url, cost); }如果外部调用耗时稳定但整体接口耗时仍然很高还要检查 DNS 解析、TCP 连接建立、TLS 握手。使用curl -w可以观察详细耗时curl -o /dev/null -s -w dns%{time_namelookup} connect%{time_connect} tls%{time_appconnect} ttfb%{time_starttransfer} total%{time_total}\n https://internal-service.example.com/api/health当time_connect或time_appconnect明显偏高时就要继续排查网络连通性和证书校验逻辑。6. 长期保持“丝滑”的最佳实践一次优化只能解决当下的问题。真正让项目长期保持流畅靠的是建立性能基线、自动化回归和分阶段治理。6.1 建立性能基线和自动化看板优化结束后把关键指标固化到监控看板上覆盖响应时间分位数、QPS、错误率、GC 次数、线程池活跃度、数据库连接数和慢查询数量。每次发布前对比当前基线和历史基线一旦指标异常第一时间发现。基线不需要太复杂只需要记录以下内容正常业务高峰期的 QPS 和 TP99。数据库连接池和线程池的平均使用率。每日 Full GC 次数。慢查询日志数量。核心接口的错误率。把这些数据保存成历史趋势至少保留一个月。有了基线后续的“感觉变慢了”就能快速转化为“TP99 从 200ms 涨到 500ms”问题严重程度和优先级立刻清晰。6.2 在发布流程中加入性能回归性能问题在发版前发现成本最低。建议在 CI/CD 流程中添加一个简单的接口压测任务比如用wrk对核心接口跑 30 秒wrk -t4 -c100 -d30s --latency http://127.0.0.1:8080/api/query压测后解析wrk输出的 Latency 分布如果 TP99 超过阈值就阻断发布。对于老项目不用一开始覆盖所有接口先覆盖用户量最高的 3 到 5 个核心接口。等平台稳定后再逐步扩大到写操作和复杂查询。注意性能回归的前提是压测环境的数据量、并发模型和线上接近。如果测试库只有几百条数据SQL 执行计划会完全不同压测结果没有参考价值。6.3 老旧项目改造的节奏Q33 这类老项目往往积累了大量技术债不适合一次大重写。推荐按下面的节奏推进先治理最影响体验的瓶颈通常是慢 SQL 和连接池配置。在改动过程中补充核心接口的埋点和日志让下次排查不再从零开始。每改完一个模块就跑一次性能回归确保没有把原来的正常接口改慢。对长期不变但占用资源高的历史代码先缓存结果或增加限流而不是立刻重构。重要改动带上功能开关在线上可灰度、可回滚。最后整理一份可复用的性能排查清单供团队使用阶段检查项工具或命令量化统计 TP99、错误率、QPSaccess.log awk / APM系统层CPU、内存、磁盘、网络top、free、iostat、ss应用层GC、线程堆栈、线程池jstat、jstack、日志平台链路层慢 SQL、缓存命中、外部调用slow query log、redis-cli、curl根因判断索引失效、连接池耗尽、GC 停顿、缓存穿透EXPLAIN、Hikari 指标、GC 日志、缓存监控优化验证对比优化前后的 TP99 和错误率压测工具 监控看板回到 Q33 项目最终让它恢复“丝滑”的并不是某一次灵光一闪而是先承认“不丝滑”是一个需要量化的性能问题然后按系统层、应用层、链路层逐层收集数据定位到数据库慢查询和连接池配置后先做配置调整再优化 N1 查询最后用压测数据确认 TP99 降到了目标范围内。下次再有人抱怨“项目变慢了”第一反应不是重启服务器而是打开监控大盘把 TP99、错误率和资源使用率摆在面前再用数据说话。