ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

真实工程师的“徒手”排障:从CPU飙升到慢SQL的根因挖掘之路

真实工程师的“徒手”排障:从CPU飙升到慢SQL的根因挖掘之路 “Real Engineers Dig with Their Bare Hands”这句话第一次看到时我就愣住了。它不像一句口号倒像是一巴掌拍在脸上。过去几年我在后端项目里排查过很多线上问题越来越清楚地意识到真正把问题解决到根上的工程师往往不是手里工具最多的那个人而是愿意在抽象层之下亲手把泥土一层一层刨开的人。本文想借这句话和你聊一聊工程师的“动手深度”。我会结合真实的后端排障场景从CPU飙升、慢SQL、内存泄漏等常见问题出发展示一个人从“会用工具”到“徒手挖根因”的完整路径。内容适合有 1 到 3 年后端开发经验、正在从“熟练调参”走向“能独立定位复杂问题”的工程师。读完你至少能带走一套可复用的排查思路也能重新审视自己平时的工作习惯。1. 为什么是“Bare Hands”而不是“Better Tools”1.1 工具的边界在哪里现代软件开发里工具已经足够强大。IDE 能自动补全APM 能监控调用链AI 能帮你生成代码片段甚至 Kubernetes 能把应用部署到几十台机器上而不需要你手动登陆。但工具解决的是“已知问题的效率”它不能替代你判断“未知问题的方向”。你肯定遇到过这样的场景监控面板显示接口 P99 延迟从 200ms 涨到 2s但日志里没有任何报错。数据库慢查询日志出现了一条你无论如何也解释不了的 SQL。应用内存稳步上涨一周后 OOM但 dump 文件里全是框架的类名。线上偶发连接超时重试几次又好了重启后问题就“消失”了。这些问题的共同特点是没有一个人能直接告诉你根因你必须自己去挖。工具能告诉你“系统很慢”但不会告诉你“慢是因为某个对象的引用链把整个 Old Gen 撑爆了”。后者需要你把内存 dump 文件下载下来用 MAT 打开一步一步追引用链看到具体代码行——这个过程没有任何框架能替你完成。1.2 “用手挖”的隐喻“Bare Hands”不是让你不用工具而是说在工具触及不到的地方你依然要靠自己对操作系统、对语言运行时、对中间件原理的理解亲手探索下去。我们可以把一个工程师的成长大致分成三个阶段阶段典型行为遇到问题时初级抄代码、调参数、看日志搜索报错复制解决方案中级理解框架原理能设计模块能定位到模块排查依赖关系高级深入底层运行时/内核/网络协议能复现问题分析根因提出根治方案“用手挖”对应的正是高级阶段。它要求你在“业务的表象”和“底层的现实”之间建立一条通路。这需要两样东西清楚的技术原理和足够的实战勇气。2. 工程世界的“表层抽象”与“底层现实”2.1 一个请求背后的层级随便举一个例子。当用户点击一个按钮前端发送请求你的 Spring Boot 应用处理这个请求查询 MySQL返回 JSON。从表面看这就是一次普普通通的 HTTP 调用。但“手挖”会告诉你请求真正经历的层级远比你想象得多HTTP 连接建立涉及 TCP 三次握手、连接池复用、超时设置。应用线程处理涉及线程池排队、任务拒绝策略、上下文切换。JVM 内存分配涉及 Young GC、对象晋升、堆大小。数据库查询执行涉及索引选择、行锁、MVCC、磁盘 IO。操作系统调度涉及 CPU 亲和性、文件描述符数量、网络缓冲区。当线上出现问题时问题可能发生在任意一层。你能看到的是顶层的“接口报错”或“响应慢”但这一层已经被框架封装得非常平滑。封装意味着开发效率也意味着故障时你看到的全是结果而不是过程。2.2 从现象到根因的三级跳用手挖的本质就是不断追问“下一层是什么”。现象接口响应变慢 - 哪一层慢客户端还是服务端 - 服务端是应用线程阻塞还是数据库慢 - 应用层CPU 高吗GC 频繁吗线程在等锁吗 - 数据库层SQL 走了全表扫描锁等待IO 瓶颈 - 操作系统层CPU 核数够吗内存 swap 了吗这个追问过程很多工程师会中途放弃。比如确认“ SQL 走了全表扫描”之后加个索引就以为解决了但过几天问题又回来才发现之前的索引根本没有被 MySQL 使用。真正用手挖的人会继续追问为什么索引没被使用是因为函数包裹了列是因为字符集不一致导致隐式转换还是统计信息过期导致优化器做出了错误选择这就是差别。3. 一场真实“手挖”演练线上 CPU 飙升与全链路排查为了把“用手挖”变成看得见摸得着的过程下面我们模拟一个非常典型的生产故障应用 CPU 占用持续 90% 以上接口响应极慢但没有新增流量日志里也没有报错。3.1 现象确认假设你已经通过监控发现了异常CPU 使用率95% 接口响应时间P99 从 300ms 涨到 3500ms 错误率0.2%有明显上升但不高 应用实例8 台每台都出现同样情况此时你如果只看业务日志大概率一无所获。因为 CPU 高并不等于业务代码抛异常。这时候就要“徒手”进入操作系统层面。3.2 用命令行定位线程首先登上一台机器用 top 找到 CPU 占用最高的 Java 进程top -c你会看到类似输出PID USER PR NI VIRT RES SHR S %CPU %MEM TIME COMMAND 12345 app 20 0 12.6g 2.1g 1.8g R 180.0 5.0 125:33.21 java -jar app.jar注意这个进程的 PID 是 12345。CPU 180% 说明它至少占满了 1.8 个核。接下来用top -Hp查看该进程内部哪个线程最消耗 CPUtop -Hp 12345输出中会列出该进程下的所有线程找到 CPU 占用最高的线程 TID比如 12380。把这个十进制 TID 转成十六进制printf %x\n 12380 # 输出305c然后抓取一份线程栈jstack 12345 /tmp/stack_$(date %Y%m%d_%H%M%S).txt在栈文件中搜索十六进制线程号grep -A 30 305c /tmp/stack_*.txt这一步往往能直接看到问题线程在执行什么代码。3.3 发现线程在频繁 Full GC假设栈输出里的关键信息是http-nio-8080-exec-15 #25 daemon prio5 os_prio0 tid0x... java.lang.Thread.State: RUNNABLE at java.lang.System.gc(Native Method) at ...如果是GC task thread#0 (ParallelGC)类型的线程占满 CPU那说明 JVM 在疯狂执行垃圾回收。此时可以进一步用jstat查看 GC 情况jstat -gcutil 12345 1000 10预期输出S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 0.00 62.50 99.80 95.20 92.10 5800 12.300 980 450.200 462.500重点看 O 列老年代使用率和 FGCFull GC 次数。如果 O 一直在 99% 附近徘徊FGC 次数持续增长说明老年代空间被占满而且每次 Full GC 都无法回收多少对象。3.4 内存 dump从堆对象到业务根因GC 频繁不一定等于内存泄漏也可能是内存确实不够。要判断到底是“不够”还是“泄漏”需要抓堆 dumpjmap -dump:live,formatb,file/tmp/app_heap.hprof 12345注意生产环境抓 dump 会触发一次 Full GC并且 dump 文件可能很大最好在低峰期操作并先确认有合法授权。抓好后用 MAT 或 JProfiler 打开。在 MAT 中重点看Histogram和Leak SuspectsHistogram 能列出占用内存最多的对象类型。Leak Suspects 能自动分析一条嫌疑引用链。假设你看到一个自定义业务对象OrderCacheItem占用了 2GB 内存引用链指向某个静态 Mappublic class OrderCacheHolder { // 问题这个 Map 只增不减key 是订单号value 是完整对象 public static final MapString, OrderCacheItem CACHE new ConcurrentHashMap(); }顺着引用链回到业务代码你会发现每次查询订单时都会把订单对象放入 Map但没有任何淘汰策略。时间一长老年代被塞满频繁 Full GC 就成了必然结果。3.5 修复方案这种问题的修复方案很多取决于业务需求如果订单对象需要短期缓存给 Map 增加 TTL 过期机制。如果数据量不大改用 Caffeine 等本地缓存库配置最大容量。如果确实需要全量缓存评估是否应该放到 Redis而不是堆内存。这里给一个使用 Caffeine 的示例思路import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import java.time.Duration; public class OrderCacheManager { private final CacheString, OrderCacheItem cache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(30)) .build(); public OrderCacheItem get(String orderId) { return cache.getIfPresent(orderId); } public void put(String orderId, OrderCacheItem item) { cache.put(orderId, item); } }相比手写静态 MapCaffeine 提供了容量上限、过期策略、统计指标等能力更适合生产环境。如果项目里没有引入 Caffeine可以参考 Maven 依赖dependency groupIdcom.github.benmanes.caffeine/groupId artifactIdcaffeine/artifactId version3.1.8/version /dependency版本需要根据你的项目实际情况调整。核心思路是缓存必须有边界有淘汰有监控。4. 数据库问题中的“徒手挖掘”从慢 SQL 到执行计划第二个常见场景是慢 SQL。它比 CPU 飙升更贴近业务但也更容易被错误处理。4.1 表结构与 SQL假设有一张订单表CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, status TINYINT NOT NULL, create_time DATETIME NOT NULL, KEY idx_user_id (user_id), KEY idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;业务 SQL 是查询某个用户近 30 天的订单SELECT * FROM t_order WHERE user_id 10086 AND create_time DATE_SUB(NOW(), INTERVAL 30 DAY) ORDER BY create_time DESC LIMIT 20;这条 SQL 看起来很正常user_id 和 create_time 都有索引。但上线几个月后慢查询日志开始出现它执行时间从 20ms 涨到 1200ms。4.2 打开执行计划用 EXPLAIN 查看执行计划EXPLAIN SELECT * FROM t_order WHERE user_id 10086 AND create_time DATE_SUB(NOW(), INTERVAL 30 DAY) ORDER BY create_time DESC LIMIT 20;结果id | select_type | table | type | possible_keys | key | rows | Extra 1 | SIMPLE | t_order | ref | idx_user_id,idx_create_time | idx_user_id | 50000 | Using index condition; Using filesort注意几个细节type 是 ref说明使用了idx_user_id索引。rows 估算扫了 5 万行说明这个用户的历史订单很多。Extra 里有Using filesort说明 order by create_time 没有走索引。表面看 SQL 没毛病索引也走了但性能还是慢。用手挖的工程师会继续往下看为什么有Using filesort为什么create_time索引没有参与排序4.3 向下追一层MySQL 优化器的选择通过EXPLAIN FORMATJSON可以看到更详细的信息EXPLAIN FORMATJSON SELECT * FROM t_order WHERE user_id 10086 AND create_time DATE_SUB(NOW(), INTERVAL 30 DAY) ORDER BY create_time DESC LIMIT 20;输出会比较长但核心在于optimizer的considered_execution_plans。MySQL 最终选择idx_user_id主要是认为通过 user_id 过滤后再在内存中排序会更高效但如果这个用户订单量大排序成本就会上升。更深一层的问题是为什么不能直接用idx_create_time因为创建时间索引并不能高效过滤 user_idMySQL 必须在一个索引上同时处理“范围过滤”和“排序”两个目标而当前索引结构做不到。4.4 真正的解决方式一种有效做法是建立联合索引(user_id, create_time)ALTER TABLE t_order ADD KEY idx_user_id_create_time (user_id, create_time);建立联合索引后再次执行 EXPLAINid | select_type | table | type | key | rows | Extra 1 | SIMPLE | t_order | ref | idx_user_id_create_time | 20 | Using index condition你会发现rows 大幅度减少。不再有 filesort因为联合索引天然按 user_id、create_time 排序。这个案例告诉大家不要只看 SQL 有没有走索引还要看走的索引是否是最合适的那一个。慢 SQL 的根因往往藏在执行计划、数据分布和索引结构三者的博弈里。5. 常见误区工具自动化不等于理解根因在手挖过程中工程师容易陷入几种误区。这里整理成一张表格误区典型表现正确做法日志没报错 没问题看到日志里 no error 就跳过关注 CPU、GC、线程池、延迟等指标重启能解决 不用查重启后恢复正常放弃排查保留 dump/日志先复现再修复有索引 查询快EXPLAIN 显示走了索引就放心继续看 type、rows、Extra确认索引选择合理AI 能替代排查问 AI 报错就按答案改用 AI 当辅助但要自己验证根因监控告警 定位完成收到 CPU 高告警就结束告警只是起点继续向底层挖缓存能解决所有性能问题遇到慢就加缓存先确认慢在哪一层再决定缓存方案这些问题背后有一个共同心理我们希望问题停在“能解释”的那一层。但工程故障从来不会因为你不继续追问就自动消失。它只会在你放松警惕时换个形态再出现。6. 成为“用手挖”的工程师工程素养清单那么现实工作中怎么培养“动手挖”的能力单纯告诉你“多思考”是没用的下面是一份比较具体的清单也是我自己带团队时反复强调的内容。6.1 打好三个底层基础操作系统基础至少要理解进程、线程、CPU 调度、内存分页、文件描述符、网络连接状态。很多高级问题最终都会下沉到操作系统层面。语言运行时基础如果你是 Java 工程师JVM 内存结构、GC 算法、类加载机制必须熟悉如果你是 Go 工程师Goroutine 调度、内存逃逸、GC 触发条件需要掌握。数据库内核基础不要只停留在 CRUD 和索引。要理解 InnoDB 的锁、事务隔离级别、redo log/undo log、优化器选索引的逻辑。6.2 建立自己的排障命令库不要每次都百度把常用命令整理到自己的笔记里。以下是我个人常用的“挖掘工具”清单分享出来供你参考# CPU 与进程 top -c top -Hp pid mpstat -P ALL 1 pidstat -p pid 1 # JVM 内存与线程 jps -l jstack pid jmap -heap pid jmap -dump:live,formatb,fileheap.hprof pid jstat -gcutil pid 1000 10 # 网络排查 ss -antp netstat -s tcpdump -i eth0 port 8080 -w /tmp/cap.pcap # 磁盘与 IO iostat -x 1 vmstat 1 dmesg -T | tail -50每一条命令不是背下来就够了而是要亲手在一台测试机器上跑一遍知道输出里每一列的含义。6.3 让 AI 当助手而不是救命稻草现在很多人习惯把报错直接丢给 AI。这在排查常见问题时效率很高但它有一个隐性代价你跳过了“亲手观察现场”的过程。而恰恰是这个过程能帮你建立对系统的直觉。正确的用法是先用监控和命令收集现场数据。自己形成两三个可能的假设。把现象、日志、命令输出给 AI让它帮助展开分析。最后用实验验证 AI 给出的方向。AI 可以压缩“分析时间”但不能替代“观察现场”。6.4 生产环境处理的边界越是手挖到深水区越要遵守红线线上操作前必须有变更评估和授权避免未经确认直接重启或清缓存。抓 dump、开 profiler、执行诊断命令前先确认对峰值流量的影响。任何修改 SQL、索引、配置的动作先在测试环境验证再灰度上线。数据库删除、更新、批量操作必须备份、可回滚、确认 WHERE 条件。涉及生产数据时永远假设“我可能看错了”用只读方式或低峰窗口操作。“用手挖”不是蛮干而是在保证安全的前提下尽可能接近事实。6.5 维护自己的排障手册每解决一个复杂问题都值得花 30 分钟写成文档包括问题现象与影响范围。排查路径与关键命令。根因分析。修复方案。如何避免再次发生。这份手册积累两三年后就是你最强的工程资产。它比任何培训课程都贴合你自己的系统。7. 结语动手挖过的人眼里没有黑盒回到标题那句话“Real Engineers Dig with Their Bare Hands.”我见过很多优秀的工程师他们并不一定特别聪明也不一定读了多少论文。他们有一个共性遇到看不懂的问题时第一反应不是绕开而是继续往下挖。挖到 JVM 堆里的对象挖到 SQL 执行计划里的行为挖到 TCP 重传的时序图挖到一行不起眼的并发代码。这个时代的技术工具越来越完善完善到很多问题都不需要你亲手处理了。但也正因为如此那些愿意亲手去挖的人才越来越值钱。因为当工具失效、文档缺失、框架抽象出现裂缝的时候最终能站在问题面前面对它的只有你。希望这篇文章能让你意识到真正区分工程师能力边界的从来不是会几个框架、几个命令而是你愿意把问题挖到多深。如果本文对你有帮助欢迎收藏也可以在评论区聊聊你遇到过最奇葩的线上问题。下一篇我可以继续写一写“如何从一场故障中提炼通用排障方法论”希望对你有用。
返回列表