ARTICLE DETAIL

资讯详情

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

线上故障排查实战:CPU飙高、内存GC与慢接口的定位路径

线上故障排查实战:CPU飙高、内存GC与慢接口的定位路径 接到告警电话的那一刻最忌讳的就是一头扎进日志里翻。先花两三分钟把“影响了谁、影响面多大、是入口还是出口出了问题”搞清楚后面的排查才会有效率。我做了十来年后端开发和稳定性保障处理过的线上事故少说也有上百起最深的体会是线上问题排查这件事拼的不是谁懂得多而是谁脑子里的排查路径更清晰。这篇文章就把我自己的排查思路整理一遍覆盖 CPU 飙高、内存与 GC、慢接口与超时、偶发故障这几类最常见的线上问题每一类都会把定位链路、常用工具、实操细节和踩过的坑讲清楚。这篇文章适合谁看后端开发、运维工程师、SRE以及带过小团队、需要自己扛线上稳定的技术负责人。无论是刚接手线上系统的新人还是已经有几年经验但还没系统整理过排查方法的工程师按这条路径走一遍都能把排查效率提上一个台阶。1. 故障响应前五分钟先别急着查日志把影响面捞出来很多人出错就出在第一步。告警一响马上打开日志平台搜异常堆栈这是直觉但往往会走偏。线上系统的日志量很大报错堆栈不一定代表根因有时候只是症状。正确做法是先回答三个问题谁在报错影响了多少流量依赖和被依赖的关系是什么1.1 从告警平台看“症状”而非“原因”监控项的第一层筛选告警平台上常见的监控项无非几类错误率、RT响应时间、QPS/TPS、CPU 使用率、内存水位、GC 耗时、网络出入流量、连接池使用率。收到告警后先把这些指标按照“入口指标”和“资源指标”分个类。入口指标错误率、RT、QPS、成功率反映的是用户请求实际感受到的质量。资源指标CPU、内存、GC、连接池、IO反映的是系统内部运行状态。如果入口指标正常资源指标告警多数是容量水位问题可以适当放慢节奏观察趋势如果入口指标异常说明有真实故障在发生优先级立刻提高。实际操作中我习惯先看错误率是从哪个时间点开始抬升的再看这个时间点前后发生了哪些发布、变更、流量峰值。线上故障八成以上关联变更这个经验反复被验证。1.2 快速画出调用链路谁在调用、依赖谁、有没有降级开关第二个动作是理清调用关系。一个服务通常有上游调用方、下游依赖方、中间件依赖三个维度。出问题时先判断自己是源头还是受害者。判断依据很简单看自己的入口流量和出口调用量。如果入口流量没变但下游依赖调用大量报错那大概率是被依赖方拖累如果入口流量本身激增那可能是上游业务活动导致。这个判断直接影响后续动作——是查自己还是找别人一起排查。这里有个实用习惯在架构文档里维护一份“核心链路依赖清单”标注每个依赖的超时时间、降级开关、熔断阈值。平时不觉得有用出了事它就是救命稻草。我有一次排查一个连锁超时问题就是依靠这份清单快速识别出某个强依赖的熔断配置失效避免了一个大事故。1.3 影响范围的三板斧错误率、RT、容量水位怎么看把三个数字拉出来基本就能判断故障规模错误率抬升幅度是局部报错还是全量报错。举例来说如果 5% 的请求超时可能只是某个慢实例拖后腿如果 95% 请求报错基本可以确定是全局性问题。平均 RT 和 TP99 的走势TP99 抬升但平均 RT 平稳说明少量长尾请求异常两者同时抬升说明整体资源竞争加剧。容量水位CPU、内存、连接池、磁盘 IO 是否逼近上限。水位高位运行的问题和突发故障的处理策略完全不同。看这三个数字时建议同时把时间范围拉长到告警前的 15 分钟别只看告警时间点的瞬时值。很多问题在故障前就有苗头比如错误率缓慢爬升了十分钟才开始告警这类问题往往是资源耗尽型的处理方式和突然暴跌型不一样。1.4 什么时候需要止损什么时候可以慢慢查这是整个排查过程中最重要的决策点。止损动作包括重启实例、降级非核心功能、切流量到备用节点、开启限流、回滚变更。越快做出止损决策故障影响越小。我的经验法则是重大故障先止损再排查一般故障先排查再止损。判断标准是影响面是否在扩大以及是否涉及核心链路。错误率在快速攀升、用户侧已经明显感知到故障这时候任何“再观察一下”的想法都是危险的。先回滚/降级/重启让系统恢复再去看日志找根因。回滚前记得保留现场——日志、堆栈、监控截图、线程状态这些东西在重启之后可能就没了。止损动作的复盘同样重要。每次止损之后记录清楚“当时看到了什么指标、做了哪个决策、效果如何”时间长了就是自己的故障应对手册。我现在处理线上问题能比较果断靠的就是这些年积累的决策经验。2. CPU 与负载异常从 top 到火焰图的定位路径CPU 告警是线上最高频的问题类型之一。很多人一看到 CPU 100% 就慌了其实它的排查路径非常固定按顺序走几分钟就能定位到大致的代码方向。2.1 用户态 CPU 高业务线程的计算瓶颈在 Linux 上排查第一件事永远是top然后按P排序看 CPU 占用——但别只看进程要看线程。具体定位流程# 找到高 CPU 的进程 PID top # 进入进程视角按 H 开启线程显示 top -H -p PID # 把高 CPU 线程 ID 转成十六进制 printf %x\n TID # 抓取线程栈 jstack PID | grep -A 30 十六进制TIDjstack输出里的线程状态和栈信息会直接告诉你这个线程在执行什么代码。用户态 CPU 高的常见原因集中在几类死循环或者空转、大集合遍历、正则表达式回溯、JSON 序列化频繁、加解密计算。看到栈信息之后对照代码基本能锁定。这里有个容易被忽略的细节线程栈抓取要连续抓几次间隔 3-5 秒。单次抓取的栈可能恰好落在某个非热点位置连续多次都出现同一个栈帧才是真正的热点。我见过有人抓了一次栈就开始改代码结果改了个寂寞因为高 CPU 的线程在不停切换执行位置。2.2 内核态 CPU 高系统调用、锁竞争与软中断top输出里%sy系统态 CPU偏高的话问题往往不在业务代码而在系统层面。常见原因包括系统调用频繁比如System.currentTimeMillis()在高并发下调用早期 JVM 版本每次调用都会触发系统调用后来默认实现改成了缓存时钟才缓解。锁竞争激烈线程在争抢锁时反复进行 futex 系统调用表现为%sy高、线程状态大量处于 BLOCKED。软中断/硬中断偏高网卡多队列未开启、虚拟机 CPU steal 都可能导致。内核态 CPU 问题的定位工具vmstat和pidstat比top更有效。用pidstat -d可以看进程的上下文切换次数用vmstat 1可以观察cscontext switch和sy列的变化。如果上下文切换次数每秒在几十万以上锁竞争的可能性就很大。排查锁竞争的标准动作是抓两样东西线程栈和jstat -l。线程栈看 BLOCKED 状态的线程在等哪把锁jstat -l看锁的信息。也可以直接采样/proc/PID/task下的线程状态不过日常我用脚本循环抓jstack更多。2.3 Load 高但 CPU 不高不可中断睡眠与 IO 等待还有一种很容易误判的场景top显示 load average 很高动辄几十甚至上百但 CPU 使用率并不高。这种情况通常意味着有大量线程处于 D 状态不可中断睡眠——说白了就是进程在等待 IO 完成典型的元凶是磁盘 IO 慢、网络文件系统超时、内存交换swap。判断方法很简单top # 查看 D 状态的进程数量 ps -eo state,pid,cmd | grep ^D再用iostat -x 1看%util和await指标。如果磁盘%util接近 100%就是存储层瓶颈如果await很高但%util不高可能是磁盘队列或者硬件问题。云环境下还要注意磁盘 IO 指标经常会被虚拟化层稀释实际业务侧的表现比监控上看到的更严重需要结合接口 RT 一起判断。最典型的一个场景是日志写太多。业务代码里打了大量不必要的 info/debug 日志日志框架异步写盘能力跟不上最终把磁盘 IO 打满进而拖垮所有业务线程。排查时看到 D 状态线程大量堆积下一步就该检查磁盘 IO 和日志量。2.4 火焰图的正确打开方式别拿 5 秒的样本说事jstack适合快节奏的初步定位但遇到 CPU 问题在多个线程间漂移、或者热点分布在多个调用栈里时火焰图更直观。推荐用async-profiler它支持 Java、Go、C 等采样开销远小于jstack循环抓取。# 采样 60 秒并生成火焰图 ./prof.sh -d 60 -e cpu -o flamegraph -p PID -o /tmp/cpu.html使用火焰图有两个要点采样时间别太短。5 秒的火焰图只能告诉你那 5 秒里发生了什么如果问题是间歇性的就会错失关键热点。我通常至少采样 30 秒问题随机性强就 60 秒甚至更久。另一个要点是结合“调用链上下文”看火焰图顶部越宽的栈帧是热点但热点背后的调用来源同样重要——是某个流量高峰触发的持续计算还是异常路径上的补偿逻辑处理方案完全不同。2.5 实战案例一次 Full GC 引起的 CPU 毛刺用个实际案例把上面串起来。有次接到告警某服务 CPU 飙高到 300% 左右多核场景持续十几秒后回落每天出现两三回完全随机。第一反应先查线程栈连续抓了五次只看到一次栈落在ConcurrentHashMap的扩容相关代码其他几次都是正常业务调用没有明显热点。回头看监控注意到每次 CPU 毛刺前都伴随一次 Full GC。Full GC 会触发 Stop-The-World所有业务线程暂停恢复后大量线程同时重新竞争 CPU 和锁形成瞬时毛刺。CPU 飙高只是 Full GC 的副作用根因在 GC 本身——当时那个服务的老年代设置了固定大小流量上涨后老年代总在被占满CMS 频繁触发 Full GC。调整堆参数、增大老年代空间之后CPU 毛刺随之消失。这个案例的启示是CPU 异常和 GC 异常经常纠缠在一起排查时千万别只盯一个指标。CPU 高就查代码热点查不到回头看看 GC 曲线往往能找到关联。3. 内存与 GC 问题的排查套路内存问题是线上第二大类高频问题。它的特点是外显表现可能是 OOM 崩溃、接口越来越慢、CPU 飙升甚至节点被重启但根因往往是内存泄漏或堆参数配置不当。先给内存问题分个类再对症排查。3.1 线上内存问题分类泄漏、膨胀、GC 压力我习惯把内存问题分成三类内存泄漏对象只增不减最终堆耗尽导致 OOM。内存膨胀短时间内创建了大量对象堆内存快速上涨但释放后能回落。GC 压力大不是因为内存不够而是 GC 过于频繁导致业务线程被大量暂停。三类问题的表现有微妙差别。泄漏型问题看堆内存曲线会发现老年代占用一路走高、从不回落膨胀型问题则表现为锯齿状曲线走走停停但基线不抬升GC 压力大的问题堆占用并不一定高但 GC 日志显示 Young GC 非常频繁暂停时间占比很高。先分清楚类型再决定分析工具效率高得多。3.2 heap dump 怎么拿、怎么分析遇到疑似内存泄漏最直接的证据就是 heap dump。jmap是经典工具但线上大堆服务有个痛点jmap -dump:live会强制触发一次 Full GC在流量高的时段可能引发长时间停顿。我现在的做法是尽量用gcore或者jmap非 live 模式优先保留现场而不是追求 dump 文件大小线上服务能用arthas的heapdump命令更好它不会阻塞业务线程。拿到 dump 文件之后分析工具首选Eclipse MAT重点看“Leak Suspects”报告能自动给出可疑的保持者对象。不过我很少只看自动报告会手动做几步看 char[]、byte[]、Object[] 等基础数组的占用总量判断是数据内容太大还是容器膨胀。看业务实体类实例数量是否异常比如订单实体有几十万个还在堆里基本就是集合未清理。从 GC root 路径分析确认这些对象的引用链。分析时最常见的结果是缓存无界、线程池队列积压、静态集合被不断写入、流未关闭导致 DirectByteBuffer 堆积。每一种都有对应的修复方向加容量上限、换带过期策略的缓存、用 try-with-resources 闭合流、控制线程池队列长度。3.3 GC 日志的阅读顺序看停顿、看回收量、看晋升GC 日志是排查 GC 问题的第一手材料。开启 GC 日志是上线前的必备动作命令形如-Xlog:gc*:logs/gc.log:time,uptime,level,tags:filecount10,filesize20m阅读 GC 日志我习惯按固定顺序来先看 Full GC 出现的频率和间隔。频繁 Full GC 是明确红线通常是老年代空间不足或元空间问题。再看 Young GC 的耗时是否有趋势性增长。Young GC 从 20ms 逐渐涨到 200ms多半是对象晋升路径出了问题。对比每次 GC 前后堆占用。如果回收量很大但堆占用始终降不下来泄漏的判断基本坐实。重点看晋升情况。高并发下动态年龄判定可能导致对象提前晋升老年代老年代被迅速填满表现为频繁 Full GC。有一个特别容易踩的坑在容器环境K8s/Docker里JVM 默认的堆大小是按宿主机内存计算的不是按容器 limits 计算的。很多团队 JDK 8 版本没有设置-XX:MaxRAMPercentage导致容器内存限制 4GB 而 JVM 以为有 64GB最终节点 OOM killed。这类问题在日志里往往看不到 JVM 的 OOM 异常只看到实例突然消失排查时要把这层因素考虑进去。3.4 容器化环境下容易误判的内存指标说完 JVM 参数再往外扩一层。容器环境中free看到的宿主机内存并不等于容器可用内存查问题要以 cgroup 的数据为准# 查看容器内存上限 cat /sys/fs/cgroup/memory.max # 查看当前内存使用 cat /sys/fs/cgroup/memory.currentJava 应用在容器里还要特别关注堆外内存和元空间。有时候堆内存看起来有空余但容器还是触发了 OOM原因通常是堆外内存——比如用了堆外缓存、Netty 的 DirectByteBuffer 或者 JNI 资源没有释放。遇到实例莫名宕掉、监控显示内存还没到上限的情况优先怀疑堆外内存用pmap和cat /proc/PID/status看VmallocChunk和RssAnon的走向。4. 慢接口和超时不一定是代码问题链路各段的耗时拆解接口变慢是另一种常见线上问题。慢背后的原因可能是代码效率低、数据库查询慢、下游依赖阻塞、网络抖动、连接池不够用、GC 停顿、CPU 竞争……如果不能快速定位到具体层就会陷入“每个团队都觉得不是自己问题”的僵局。拆解思路其实很清楚把一次请求分成段逐段看耗时。4.1 从客户端到网关、到服务、到依赖的耗时分层一次请求的耗时可以粗略分成四段客户端到网关包括 DNS 解析、TCP 建连、TLS 握手、网络传输。网关到应用内部网络的传输耗时、网关路由和限流耗时。应用内部Servlet 容器排队、业务逻辑执行、线程调度等待、GC 停顿。应用调用依赖RPC 调用网络耗时、下游处理耗时、连接池获取连接耗时。怎么量化各段耗时最直接的方案是全链路追踪系统比如 SkyWalking、Zipkin、Jaeger没有的话用最原始的办法也能拆——在关键入口打印耗时日志每个 RPC 调用前后打点对比时间段内的耗时分布。通常有一个反直觉的规律一个服务整体 RT 是 800ms但自己的业务逻辑只花了 100ms剩下 700ms 都在等下游。遇到这种比例失衡很多人盯着自己的代码优化实际上先定位到具体下游或者网络段更快。4.2 数据库慢查询的排查从 explain 到锁等待数据库是慢接口的重灾区。排查数据库问题主路径是三条慢 SQL 日志、EXPLAIN执行计划、数据库锁和连接池状态。慢 SQL 日志先确认慢查询是否真的来自该 SQL还是连接池早已阻塞SQL 排队等连接导致的时间全部记进了 SQL 执行时间。EXPLAIN看执行计划type是否走了索引、rows扫描行数是否异常、是否有Using filesort或临时表。见过太多例子查询条件里用了函数导致索引失效或者字符集不一致导致隐式转换彻底绕开索引。锁等待如果慢 SQL 本身执行计划合理但依然慢用SHOW ENGINE INNODB STATUS看锁等待或者查information_schema.innodb_trx。一个事务长时间持有写锁会拖垮所有读请求这类问题查 A 表却能发现 B 表的锁在作祟。补充一个容易被忽略的点分页深翻页。LIMIT 100000, 20会让数据库扫描十万行之后丢弃非常浪费。线上真实案例里因为这个导致的慢查询不少解决方案是改成基于游标的翻页或者缩小偏移量。4.3 网络抖动和连接池耗尽的表现差异慢接口如果是偶发性的网络和连接池是重点嫌疑。两者的表现有一个关键差异网络抖动错误率不一定高但 RT 会出现长尾TP99 明显拉高平均 RT 变化不大。抓包或者看系统 netstat 能看到 TCP 重传率上升。连接池耗尽表现为调用方大量报超时/连接不可用且这种现象在流量高峰更明显。看监控里的连接池活跃数如果长期贴着最大值同时在等待获取连接的时间持续走高结论就出来了。连接池耗尽的修复方向多数是调大连接池上限、缩短空闲超时、尽快释放资源、给下游做合理的超时设置。但要注意调大连接池不是万能的如果下游本身处理能力有限盲目的连接数只会把下游打垮。正确动作是先确认下游容量再调整。4.4 慢调用链路的复现与验证排查慢接口最好能从一次真实的慢请求里拿到完整链路数据而不是靠猜。如果没有全链路追踪可以用一个临时方案在关键入口打印 traceId。用中间件或 RPC 框架的过滤器在每次调用的前后打点。请求结束后把整个链路耗时聚合到日志。抓到一条慢请求之后先看哪个环节耗时占比最大再看这个环节在当时的资源画像——CPU、GC、连接池、下游 RT。很多时候慢的根因是“当时的系统状态”而不是“代码本身”这就是为什么偶发性慢问题特别难通过 code review 发现。经验之谈处理慢接口要有耐心做“条件筛选”。比如只对耗时超过 500ms 的请求开启详细日志降低日志量同时保留下次排查的线索。这类条件日志配置一次后续排查能省很多事。5. 偶发故障最难查从线程栈 snapshot 到定时任务排查最后聊一类最让人头疼的问题偶发故障。它不规律、持续时间短、难以复现经常出现“抓包的时候好了、打开监控又没了”的情况。偶发问题的本质是“某些条件下才触发的确定性 bug”所以核心策略是把偶发变成可观察、可复现的东西。5.1 偶发问题的特征与排查难点偶发问题通常长这样每天凌晨出现一次超时、每几百次请求中出现一次报错、某个节点在高峰期偶发掉线。难点在于日志里可能只有结果没有过程留下的线索不完整——报错堆栈是有的但堆栈往往只记录错误发生的位置没法还原触发条件。排查偶发问题我的第一个建议是改变收集信息的方式不要等事后去翻日志而是提前把关键环节的现场信息埋好。比如在高概率发生的时段加一个定时采集任务每隔几秒抓一次线程栈、GC 状态、数据库连接数、网络指标。偶发问题只要发生过就一定有现场没有现场才会永远找不到。5.2 线程栈 snapshot 法抓“卡住的瞬间”偶发问题里很大一部分和“卡住”有关——线程阻塞、锁等待、死锁、等待 IO。这类问题最好的观察窗口是线程栈但人不可能 24 小时盯着jstack。可以做一个轻量脚本在疑似时段内高频抓栈for i in $(seq 1 60); do jstack PID thread_$(date %H%M%S).txt sleep 2 done60 次采样里如果某几个线程栈在多次采样里都卡在同一个位置基本就是阻塞点。2018 年有次经典的同事排查案例一个定时任务偶发不执行脚本抓栈发现线程一直停在Object.wait()进一步看是任务调度框架在等待某个外部配置刷新而配置刷新依赖一个从不释放的分布式锁两层嵌套导致偶发失效。线程栈采样法的关键是时机和频次。问题一天出现一次、每次持续几秒采样间隔就不能大于 1-2 秒否则极有可能漏掉。可以配合告警联动告警触发瞬间自动抓栈把抓好的文件上传到对象存储事后慢慢看。5.3 定时任务、随机超时重试和其他“坏味道”偶发问题虽然随机但概率上更倾向于发生在几类代码坏味道里定时任务并发多个节点的定时任务没有做分布式互斥某个时间点全部触发导致下游瞬间被打满。排查时看监控里定时任务相关调用是否呈现周期尖峰。随机超时重试一个接口超时后客户端立刻重试重试请求又遇到同一问题形成“重试风暴”。看起来是偶发实际上是超时配置和重试策略叠加的结果。懒初始化的竞态第一次请求触发初始化逻辑多个线程同时进入初始化方法导致资源重复创建或者死锁。这类问题只在重启后第一波流量下出现偶发但规律明显。连接空闲超时连接池里的连接空闲时间超过服务端 keepalive 时间服务端悄悄断开了连接客户端复用该连接时报错表现是偶发的“Connection reset”。如果偶发问题总是出现在“某个固定时间段”优先检查定时任务如果总是出现在“某个固定实例”优先检查该实例的负载均衡和健康检查机制。5.4 把偶发问题转变成可复现问题日志染色与链路追踪偶发问题最终解决往往不是因为找到了根因而是因为让它复现了。让偶发问题变得可复现有几种有效手段日志染色对特定条件某个用户、某个订单号、某个 IP的请求打印完整链路日志。一旦问题再次发生就能拿到从入口到出口的完整上下文。流量回放在测试环境对线上录制的真实请求做回放特别是加大并发倍数很多偶发问题在放大流量后就变成了必现问题。参数化模拟根据线索猜测可能的触发条件——连接空闲时长、缓存过期时间、并发阈值——逐个去压测验证。保留现场发生问题的节点不要立刻重启先保留内存快照、线程栈、网络抓包再做恢复操作。日志染色这块值得多投入一点。很多系统已经有灰度发布和全链路追踪的基础设施但染色场景做得不够细。我的做法是在打点工具里支持“按用户维度强制开启 debug”操作上就是一条配置开关平时关闭某个用户反馈问题时打开抓完整链路后关闭。这个习惯帮助定位过不少“用户说慢但我们看平均 RT 完全正常”的隐性长尾问题。说到最后我真正想分享的经验不是某个命令怎么用而是排查故障时的态度线上问题排查首先是个信息收集问题其次才是技术分析问题。你收集到的现场越完整、越贴近故障发生时刻分析就越快、越准。所以我在团队里一直强调两件事——监控指标曲线要全日志上下文要细现场保留动作要快。另外每次故障处理完花半小时把时间线、决策过程、根因、改进项记录下来形成自己的“事故复盘笔记”这些东西积累到一定程度你看到告警时的第一反应会沉稳很多。线上问题永远不会消失但处理它的手感会随着一次次的复盘一点点涨上来。
返回列表