
前几天看到一个讨论挺有意思一个大家都很关注的核心角色肉眼可见地状态下滑了于是大家开始争论是该等状态回暖还是直接让出核心位转型甚至退役。其实这种两难选择在软件系统里几乎每天都在上演。一个线上服务响应越来越慢、错误率越来越高团队内部也会出现两种声音有人觉得“大改不如重写”有人觉得“还能抢救一下”。本文不评价具体选手和比赛而是借用这个决策模型把“性能劣化 → 根因分析 → 治理还是重构 → 是否‘退役’”的完整流程展开用一套可落地的监控、压测、定位和决策方法帮你给线上服务做一次“状态评估”。下文会以一个代号为 KK 的业务服务为例完整演示从“肉眼可见的性能下滑”到“确定处理方案”的排查过程。无论你是后端开发、运维还是正在负责一个逐渐老化的业务系统这套思路都可以直接复用。1. 从“状态下滑”到“系统性能劣化”问题的本质是一样的1.1 为什么性能会“肉眼可见”地变差不管是选手状态下滑还是系统性能劣化表象上都是“结果变差了”。系统的结果指标就是响应时间、吞吐量、错误率、资源使用率。很多系统并不是在某一个版本上线后突然崩溃而是经历了一个渐变过程数据量持续增长SQL 从毫秒级变成秒级业务代码不断叠加一次请求背后调用链越来越长中间件版本老旧连接池、线程池配置不合理缓存命中率下降大量请求击穿到数据库依赖的下游服务变慢导致整体链路被拖垮无人维护的基础设施出现磁盘、网络、内存瓶颈。很多团队对系统的印象停留在“上线时很快”却忽略了系统是一个持续运行、持续变化的东西。数据在增长代码在腐化依赖在升级用户行为也在变化。等到业务方反馈“最近明显变卡了”时往往劣化已经持续了很长时间。“肉眼可见”本质上是一种主观感受工程上需要把它转化为可量化的指标。只有把性能数据拉出来对比才能判断这是偶发抖动、持续劣化还是已经到了需要“退役”的地步。1.2 退役、转位置与技术方案里的“上策”和“下策”电竞里的“退役”对应到技术系统就是服务下线、停止维护“转位置”则对应模块重构、技术栈更换或架构调整“继续打”对应小步治理、局部优化。这三种选择没有绝对的好坏取决于劣化程度、业务价值、维护成本、团队能力等一系列因素。实际工作中最常见的错误是要么在应该重构时继续打补丁把代码越修越乱要么在应该治理时草率决定重写结果新系统上线后问题更多。一个合理的决策流程应该是建立可量化的性能基线定位劣化的根因评估治理成本与业务价值根据决策矩阵选择优化、重构还是下线。后面几节会按照这个流程逐步展开。2. 环境准备建立一套最小可观测体系在讨论“救不救”之前首先得知道系统现在的真实状态。没有监控数据的性能讨论都只是猜测。2.1 本文使用的技术栈为了便于复现本文示例采用以下环境组件用途版本说明Docker / Docker Compose搭建监控组件以 Docker Compose v2 为例Prometheus采集指标与告警以 2.x 版本为例Grafana可视化看板以 10.x 版本为例Spring Boot示例业务服务3.x 或 2.x 均可配置思路一致MySQL示例数据库以 8.0 为例wrkHTTP 压测工具4.x 常用版本版本不需要完全一致。大多数配置项在相近版本之间是通用的如果某个参数在你的版本里提示废弃优先以官方文档为准。2.2 用 Docker Compose 启动 Prometheus 和 Grafana这里先搭建监控基础设施。在示例项目根目录下新建docker-compose.ymlversion: 3.8 services: prometheus: image: prom/prometheus:v2.47.2 container_name: prometheus volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml ports: - 9090:9090 restart: unless-stopped grafana: image: grafana/grafana:10.2.3 container_name: grafana environment: - GF_SECURITY_ADMIN_PASSWORDadmin123 ports: - 3000:3000 restart: unless-stopped这个文件的作用是同时启动 Prometheus 和 Grafana。Prometheus 负责收集和存储指标Grafana 负责把指标展示成可视化面板。GF_SECURITY_ADMIN_PASSWORD是 Grafana 管理员密码生产环境必须改成强密码并通过环境变量或密钥管理而不是直接写在 docker-compose 里。启动命令docker compose up -d启动完成后可以通过http://localhost:9090访问 Prometheus通过http://localhost:3000访问 Grafana。2.3 配置 Prometheus 采集业务指标在同一个目录下新建prometheus.ymlglobal: scrape_interval: 15s scrape_configs: - job_name: kk-service metrics_path: /actuator/prometheus static_configs: - targets: [host.docker.internal:8080]这里把采集目标指向本机的8080端口。如果你的业务服务不在本机运行需要把host.docker.internal替换为真实 IP。Spring Boot 服务要暴露/actuator/prometheus指标需要添加依赖。以 Maven 为例dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency dependency groupIdio.micrometer/groupId artifactIdmicrometer-registry-prometheus/artifactId /dependency然后在application.yml中开放 Prometheus 端点management: endpoints: web: exposure: include: health,info,prometheus,metrics有了这套采集链路后服务会持续把 JVM、Tomcat 线程池、HTTP 请求耗时等指标推送到 Prometheus。这是后续判断“状态下降”的数据基础。需要注意的是这只是最小可观测体系。生产环境还应该接入日志系统、链路追踪和 APM 组件几者配合才能完成从宏观指标到微观调用的完整诊断。2.4 确定性能基线没有基线就没有“下滑”“下滑”是一个相对概念必须有对比基线。性能基线是指系统在正常业务量、正常代码版本下的一组指标快照通常包括平均响应时间、P95、P99 耗时QPS/TPS 吞吐量错误率CPU、内存、磁盘、网络使用率GC 频率与耗时、线程池活跃度、数据库连接池使用率。下面用一个简单的 Python 脚本演示 P95 和 P99 的计算逻辑。实际项目中这些数据可以直接从 Prometheus 或 APM 平台查询。import random def percentile(sorted_data, percent): if not sorted_data: return 0.0 k int(len(sorted_data) * percent) k max(0, min(k, len(sorted_data) - 1)) return sorted_data[k] # 模拟从监控平台拉取的一批请求耗时单位 ms latencies [random.uniform(80, 320) for _ in range(2000)] latencies.sort() print(fP50 {percentile(latencies, 0.50):.1f} ms) print(fP95 {percentile(latencies, 0.95):.1f} ms) print(fP99 {percentile(latencies, 0.99):.1f} ms)运行后你会看到一组分布在 80 到 320 毫秒之间的耗时数据。P99 通常明显高于 P50这是正常的。真正需要警惕的是 P99 曲线持续上升或者 P99 与 P50 差距被不断拉大说明部分请求正在经历严重排队或慢调用。在实际项目中还要通过一周或一个月的监控数据确定“正常波动范围”。比如基线 P99 是 200ms某天突然涨到 800ms才能说“肉眼可见地下滑了”。没有基线后面所有讨论都是空谈。3. 性能劣化的核心指标与定位思路3.1 先看指标什么算“肉眼可见的下滑”拿到监控数据后不要急着翻代码先建立几个判断维度。指标健康状态危险信号平均响应时间平稳持续上升波动加大P99 耗时无明显长尾长尾严重环比翻倍错误率低于 0.1%超过 1% 且持续上升QPS平稳或预期增长无明显流量增长但资源占用翻倍CPU有波动但可恢复长时间打满负载居高不下内存平稳持续增长GC 后无法回落数据库连接池有富余活跃连接长期接近最大值GC频率低耗时短Full GC 频繁单次耗时超过秒级这里要特别强调单一指标异常不足以定性问题。比如 QPS 翻倍导致 CPU 升高属于自然现象但 QPS 没涨、CPU 却打满说明代码或配置出现了劣化。一定要把多个指标放在一起看才能做出正确判断。从工程经验来看线上性能劣化最先暴露的通常是“长尾请求”。平均耗时可能没有太大变化但 P99 开始明显上扬说明某些请求走到了慢路径上。慢路径可能是慢 SQL、远程调用超时、锁竞争也可能是内存频繁 GC。3.2 再看日志错误与慢请求线索监控指标只能告诉我们“哪里慢”日志则告诉我们“为什么慢”。当接口耗时超过预设阈值时业务代码应该主动打印慢请求日志。# 查看应用中的慢请求日志 grep slow query /var/log/kk-service/app.log | tail -n 50 # 查看异常堆栈 grep -E Exception|ERROR /var/log/kk-service/app.log | tail -n 80日志内容应至少包含请求标识、用户标识、业务参数、耗时、关键调用链路信息。这样从监控面板看到某个接口 P99 上涨后可以顺着 traceId 在日志平台里找回完整请求记录。在实际项目中很多团队的问题不是没有日志而是日志内容太少或太长。太少则定位问题时没有线索太长则真正有用的信息被淹没。建议在网关或入口层统一打印请求摘要日志内容包含耗时、状态码、方法名、traceId在关键依赖调用处打印详细日志并用 warn 级别标记慢调用。3.3 最后下探慢 SQL、GC、线程与连接池日志只能定位到“哪个环节慢”要找到根因还需要结合数据库、JVM、线程栈进一步下探。这里给出常见排查命令。数据库侧开启慢查询日志并分析-- 查看 MySQL 慢查询日志是否开启 SHOW VARIABLES LIKE slow_query_log; -- 分析一条查询是否走了索引 EXPLAIN SELECT id, name, phone, balance FROM t_user WHERE recommend_code R20241001;EXPLAIN输出的type字段如果是ALL说明发生了全表扫描这是最常见的性能杀手之一。rows字段过大也说明扫描行数很多。JVM 侧使用 JDK 自带工具# 查看 GC 情况每 1 秒打印一次共 10 次 jstat -gcutil pid 1000 10 # 打印线程栈分析线程阻塞 jstack pid /tmp/thread.txt # 导出堆转储分析内存对象 jmap -dump:formatb,file/tmp/heap.hprof pidjstat的FGC如果持续增加且耗时很高说明 Full GC 频繁。jstack中大量BLOCKED状态的线程说明可能存在锁竞争或线程池耗尽。jmap导出的堆转储可以结合 Eclipse MAT、VisualVM 分析哪些对象占据了内存。连接池和线程池的排查不能只看配置要结合监控指标看“实际水位”。比如 HikariCP 最大连接数配置为 50监控里长期只有 5 个活跃连接那问题不在连接池如果长期 50 个连接全满且排队等待就要考虑数据库慢查询、下游拖慢或连接池配置过小。3.4 常见劣化根因分类类别典型问题定位方式数据层缺索引、慢 SQL、死锁、连接池满载EXPLAIN、慢查询日志、数据库监控缓存层缓存穿透、击穿、雪崩、命中率低缓存命中率监控、日志、日志分析应用层GC 频繁、线程阻塞、内存泄漏、代码效率低jstat、jstack、jmap、代码走查依赖层下游接口变慢、超时配置不合理压测、链路追踪、调用日志基础设施CPU 争抢、磁盘 IO 高、网络抖动系统监控、容器监控、宿主机排查当然这里的分类不是为了穷举所有根因而是为了在排查时有一个清晰的方向。绝大多数性能劣化问题最终都落在数据层和应用层这两个范畴里。4. 完整实战KK 服务从下滑到决策的排查过程下面通过一个完整案例把“性能劣化评估”的全流程走一遍。假设 KK 服务是一个用户查询服务核心接口是/api/user/query根据 userId 返回用户基本信息。4.1 系统现状与劣化现象某天监控显示该接口 P99 从 180ms 涨到了 950ms错误率从 0.02% 上升到 0.8%。业务方反馈用户查询资料时明显变慢部分请求直接超时。这个场景是不是很像“肉眼可见的实力下滑”排查前先说明这里不讨论具体的代码归属和责任问题而是聚焦技术层面的定位路径。按照上一节的思路我们需要依次查看指标、日志、慢 SQL。4.2 压测复现性能问题为了在一个可控的环境里复现问题先对测试环境做一次压测。这里使用 wrk 工具模拟 8 个线程、200 个并发连接持续压测 60 秒。wrk -t8 -c200 -d60s --latency http://localhost:8080/api/user/query?userId10001压测结果中重点关注两个指标Requests/sec和Latency分布。如果测试环境压出了和线上一致的长尾耗时就说明问题可以在本地复现如果测试环境表现正常那问题可能与数据量、真实用户行为或基础设施环境有关需要再扩大排查范围。复现的价值在于后续每一步验证都有可对比的数据。比如改完 SQL 后重新压测如果 P99 从 950ms 降到 200ms就说明改动生效了。4.3 日志定位慢接口调用链在 KK 服务的入口方法中增加耗时日志这是最基础也最有效的一步。下面给出一个 Spring Boot 控制器示例// 文件路径src/main/java/com/example/kk/controller/UserController.java RestController RequestMapping(/api/user) Slf4j public class UserController { Resource private UserService userService; GetMapping(/query) public ResultUser query(RequestParam(userId) Long userId) { long start System.currentTimeMillis(); try { User user userService.queryById(userId); return Result.success(user); } finally { long cost System.currentTimeMillis() - start; if (cost 300) { log.warn(slow request, userId{}, cost{}ms, userId, cost); } } } }这里把耗时超过 300ms 的请求单独打印为 warn 日志。finally块保证无论正常返回还是抛出异常都会记录耗时。实际项目中建议再打印 traceId 和更多业务参数方便日志检索。通过查看 warn 日志发现慢请求基本都集中在userService.queryById方法上。进一步在queryById内部打点发现数据库查询占了总耗时的 80% 以上。这时候嫌疑就集中到了数据库层。4.4 定位慢 SQL 与索引缺失进入数据库打开慢查询日志找出一条典型慢 SQLSELECT id, name, phone, balance FROM t_user WHERE recommend_code R20241001;在测试环境执行EXPLAINEXPLAIN SELECT id, name, phone, balance FROM t_user WHERE recommend_code R20241001;结果中type ALL表示这条 SQL 走了全表扫描。rows显示扫描了上百万行这就是接口变慢的直接原因。recommend_code字段原来没有建索引随着用户量增长这条查询的代价不断变大。数据量小的时候还“扛得住”数据量一大性能就肉眼可见地下滑了。这个案例非常典型系统上线时没有考虑非主键字段的查询场景业务量增长后慢查询逐渐浮出水面。解决方案是在recommend_code上创建合适的索引ALTER TABLE t_user ADD INDEX idx_recommend_code (recommend_code);创建索引后再次执行EXPLAIN可以看到type变为ref或range扫描行数大幅下降。重新压测后接口 P99 回落到 200ms 以内错误率恢复正常。当然在真实生产环境中给大表加索引必须评估锁表风险。如果表数据量很大建议使用在线 DDL 工具并在业务低峰期操作或通过主从切换、灰度发布等方式降低影响。4.5 制定处理方案治理、重构还是退役KK 服务的性能问题只是因为缺索引属于“轻症”。但实际项目中很多系统会同时存在多种问题。为了能做出决策我整理了一个可复用的决策矩阵劣化程度根因特征业务价值推荐动作轻微局部慢查询、配置不合理高仍在核心链路治理优化中等代码腐化、模块耦合严重高但扩展困难核心模块重构中等技术栈过时、人员维护成本高中短期无法替换逐步迁移、灰度替换严重架构陈旧、缺陷集中爆发低已被新产品替代下线退役“治理优化”对应继续打比赛小步快跑。“模块重构”或“技术栈迁移”对应转位置换一个更适合当前状态的方向。“下线退役”则意味着不再投入资源让新系统接管业务。针对 KK 服务结论很清晰虽然接口变慢但根因只是一个缺失索引业务价值仍然很高治理成本远低于重构和下线成本。因此先创建索引同时补充缓存、限流和降级策略避免下一轮流量增长再次击穿数据库。这里顺便给出一个简单的限流保护示例使用 Guava RateLimiter 对接口做每秒并发限制import com.google.common.util.concurrent.RateLimiter; public class QueryLimiter { private static final RateLimiter limiter RateLimiter.create(200.0); public static boolean tryAcquire() { return limiter.tryAcquire(); } }在进入业务方法前调用tryAcquire()如果获取不到许可就快速返回“系统繁忙”而不是让请求继续打到数据库上。这种保护在性能劣化期间非常有用能够防止雪崩。4.6 验证与回切改造完成后不能只看一两次压测数据还要进行多轮验证重新压测确认 P99、错误率回到基线观察数据库连接池水位确认不再长期打满观察 JVM GC 频率确认没有因改动引入新问题走一次灰度发布先让 10% 流量进入新逻辑对比灰度实例与存量实例的监控指标确认稳定后再全量放开。如果在灰度期间发现指标仍然异常应回滚配置并继续分析根因。回滚能力是每一次性能治理操作的安全底线尤其是 SQL 索引变更、配置调整这类操作必须提前准备好回滚步骤。5. 常见问题与排查清单5.1 高频问题对照表问题现象常见原因解决思路接口耗时从 50ms 涨到 500ms慢 SQL、缺索引打开慢查询日志用 EXPLAIN 分析执行计划高并发下大量超时线程池或连接池耗尽查看连接池指标使用 jstack 分析线程状态服务重启后恢复过几天又变慢内存泄漏或数据量增长对比监控曲线使用 jmap 导出堆转储分析Full GC 频繁单次耗时数秒堆内存不足、大对象过多调整 JVM 参数优化超大对象和缓存机制单机优化后仍然扛不住容量不足架构瓶颈限流、扩容、读写分离或分库分表下游依赖变慢导致整条链路超时超时时间设置过长、未配置熔断设置合理超时引入熔断降级组件这些场景中最危险的是“重启后恢复过几天又变慢”。这类问题往往需要长时间监控才能定位不能靠临时重启掩盖。建议在每次重启前后都保留监控截图和数据报告作为后续判断的依据。5.2 推荐的排查顺序如果下次再遇到类似“肉眼可见的性能下滑”可以按以下顺序排查查看监控大盘确认影响范围是单接口还是全链路确认是否有发布变更变更窗口与劣化时间点是否重合查看错误日志和慢请求日志锁定最典型的失败模式跟进数据库慢查询日志和 EXPLAIN 分析使用 jstack、jstat、jmap 检查 JVM 状态通过压测复现用数据验证根因制定优化方案并准备回滚方案灰度发布逐步验证观测回归。按照这个顺序排查绝大多数性能劣化的问题都能在 30 分钟内完成初步定性。怕的不是问题复杂而是没有数据、没有日志、没有顺序地乱猜。6. 最佳实践与工程建议6.1 把性能基线和 SLO 建立起来很多团队只有“系统不宕机”这一条底线没有明确的性能目标。我建议至少为每个核心接口定义 SLO例如P99 耗时低于 500ms错误率低于 0.1%每月 99.9% 的时间满足上述两个条件。有了 SLO 后性能劣化就不需要靠“肉眼可见”来判断而是由指标是否超出 SLO 自动触发告警。SLO 的制定要和业务方一起确认避免定得过高导致频繁误告警或者定得过低导致问题被掩盖。6.2 用自动化压测和巡检发现问题性能劣化是慢慢发生的人工不可能 7×24 小时盯着监控。建议在 CI/CD 流程中加入自动化性能测试比如在每次发布前对核心接口做冒烟压测对比基线的 P95 和 P99。一旦发现明显回退就阻塞发布并触发代码评审。同时可以设置定时巡检任务比如每天凌晨自动执行一次低峰期压测把结果和基线对比输出性能趋势报告。这样很多缓慢劣化会在早期被发现而不是等到业务方反馈。6.3 治理、重构、退役的灰度思维哪怕已经决定重构甚至下线也不建议一把梭。重构可以按照“绞杀者模式”逐步替换旧模块用新服务慢慢接管流量而不是在某一天同时切换全部逻辑。退役则可以先把读流量切到新系统再切写流量最后下线旧系统。灰度思维的核心是控制爆炸半径。每次变更只影响一小部分用户即使出了问题也能快速回滚。这个原则同样适用于代码重构、配置修改、数据库变更和压测演练。生产环境操作必须遵循最小权限原则所有危险操作都应在测试环境验证并保留完整变更记录。6.4 写在代码之外的工程建议运维侧要保证监控大盘、告警规则、值班手册都有人维护而不是只在出现问题时才打开 Grafana。开发侧要在设计阶段就想清楚数据库索引、缓存策略、超时配置和日志规范避免上线后再靠“救火”来治理。另外团队内应该建立技术复盘文化。每一次性能事故结束后都输出一份复盘报告记录问题现象、根因、处理过程、改进措施。写报告不是追责而是把个体的排查经验沉淀为团队共有的知识资产。这样即使核心同事离职后来者也能在报告中找到排查方向。7. 总结回到开头那个讨论一个问题角色“状态下滑”是继续打、转位置还是退役代码世界没有标准答案但有一点是确定的——所有决策都应该以数据和观测为基础而不是凭感觉下结论。通过本文的案例我们完成了从监控搭建、性能基线、压测复现、日志定位、SQL 分析到最终决策的完整闭环。KK 服务因为缺失索引导致接口变慢属于典型的“轻症可治”场景所以最优方案是治理而不是重构更不是下线。如果下一次你负责的服务也开始“肉眼可见地变慢”不要急着争论“要不要重写”。先把监控补齐、基线定好、压测跑起来当数据摆在面前时你自然知道该优化、重构还是退役。这一步比争论本身有价值得多。