从单个服务到集群:一次完整的性能排查复盘

写在前面:这篇是我自己的一次排查复盘。之所以想认真写下来,是因为这次排查彻底改变了我看待"性能问题"的方式------它让我从一个习惯 ssh 登机器 top 一把梭的人,变成了一个会先问"这到底是单机的事还是集群的事"的人。中间走过不少弯路,也有过几次"我以为找到了,结果根本不是"的尴尬,正好都写下来。

一、那个让我夜里被叫醒的告警

故事从一条告警开始。

那天半夜,支付核心交易接口的 P99 告警炸了------平时稳定在 50ms 左右,突然有一段时间飙到 800ms 以上。等我爬起来连上跳板机,告警又自己恢复了。看监控曲线,P99 像心电图一样,一会儿尖刺一会儿平稳,完全没规律地抖

这是我第一次真正意识到"抖动"和"慢"是两码事。如果是稳定的慢,我登上去就能抓现行;可这种偶发的、自己会好的抖动,你登上去的时候它往往根本不在发生。我当时盯着那条心电图一样的曲线,第一反应是懵的------不知道从哪下手。

但有一点我很快想清楚了:我得先判断方向,而不是急着查。 这条接口跑在十几个实例上,到底是所有机器一起抖,还是只有某几台在抖?这两件事的排查路径完全不同。

二、第一个判断:把曲线按机器拆开

我做的第一件有意义的事,是把 Grafana 上那条聚合的 P99 曲线,按实例维度拆开

拆开的一瞬间就清楚了一半:不是所有机器在抖,只有两三台实例的 P99 在周期性尖刺,其余十来台稳如老狗。

这个发现把范围一下收窄了。我心里有了个初步判断:如果是全局抖动,那大概率是大家共用的东西出了问题------数据库、Redis、中间件、网络;可现在只有个别机器抖,那更像是单机问题------这几台机器的 GC、CPU、磁盘,或者它们被打的流量不一样。

这里我顺手记了个教训:看抖动一定要看 P99/P999,别看平均值。 我一开始差点被 P50 骗了------平均延迟看着完全正常,因为大部分请求确实快,只有长尾在恶化。抖动的本质就是长尾,盯着平均数你永远看不见它。

三、第一次"我以为找到了":登机器,发现 Full GC 很猛

锁定那两三台机器后,我登上去,用 Arthas attach 了上去。

说一句题外话,这种时候我已经不太用原始的 jstack/jmap/jstat 了。不是它们不好,而是它们给的是静态快照------你看到的是按下回车那一瞬间的状态。而 Arthas 能 attach 到运行中的进程,动态地看方法实际跑起来是什么样。它底层是靠 JVM Instrumentation 加字节码增强(ASM),attach 上去之后对目标方法做织入,所以能拿到运行期的入参、返回、耗时。

我敲了 dashboard,一屏就把这台机器的家底摊开了:线程 CPU 占用、内存各区、GC 次数和耗时。一眼就看到------这几台机器的 Full GC 频率明显比正常机器高。

那一刻我以为自己找到了。GC 抖动嘛,经典问题,P99 周期性尖刺,时间点和 Full GC 一对齐,数量级也吻合,证据链看着挺完整。我甚至已经开始想该怎么调 GC 参数了。

但有个问题卡住了我:为什么偏偏是这几台机器 GC 猛? 它们和其他实例是同一个镜像、同一套 JVM 参数、同样的规格部署出来的。同样的配置,凭什么这几台对象分配得就是快、GC 就是频繁?

这个"凭什么",是这次排查真正的转折点。

四、真正的根因:原来不在这台机器上

带着"凭什么"这个问题,我没有急着改 GC 参数,而是往上游查了一层。

我去看了链路追踪。一个请求是跨服务的------网关、交易服务、账务、再到 DB,我想看看这几台机器收到的请求和别的机器有没有什么不一样。结合接入层的监控一对,真相出来了:负载均衡把几个大客户的长连接,几乎全路由到了这两三台机器上。

我们用的负载策略对长连接不友好------长连接建立之后就固定在那台机器上不再重新均衡,而恰好几个交易量最大的客户连了过来。于是这几台机器承担的实际业务量远高于别的实例,对象分配速度自然快得多,GC 压力也就大得多,P99 跟着周期性尖刺。

根因压根不在抖动的那台机器上,而在上游的负载均衡策略。

如果我当时顺着"GC 猛"的直觉直接去调 GC 参数,最多是缓解症状,治标不治本------只要流量还是这么倾斜,换什么 GC 参数这几台都会比别人累。这件事给我留下一句话,后来成了我排查分布式问题的口头禅:抖动的根因,常常不在抖动的那台机器身上。

五、回过头说:单机这层,我到底是怎么挖的

上面那次的下钻比较快就转向了上游,但单机诊断这套手艺是绕不过去的------很多时候根因确实就在进程内部。我把 Arthas 的用法按"遇到什么问题用什么"整理一下,这是我平时真正在用的:

CPU 飙高,想知道是哪个线程哪个方法在烧 CPU。dashboard 看全局,再 thread -n 3 把最忙的三个线程的栈拉出来,怀疑死锁就 thread -b 一键检测。

某个方法行为诡异或者慢,想看它实际跑起来什么样------这是 Arthas 最让我离不开的能力。 我印象最深的一次,是线上一个接口偶发返回错误金额,本地怎么都复现不了,日志又没打参数。换以前我得改代码加日志重新发布一轮,线上偶发问题这一轮下来可能还抓不到。那次我直接 watch 盯住那个计算方法的入参和返回值,几分钟就看到------是某个边界入参触发了精度问题。watch 看的是数据 (入参/返回/异常长什么样),如果是想知道慢在哪一步 ,就用 trace,它会把方法内部每一层调用的耗时摊开,是慢在 DB、慢在 RPC 还是慢在某段计算,一目了然。偶发到连 watch 都蹲不到的,我会用 tt 先挂上记录调用现场,等问题出现了再回放。

内存和 GC 问题memory 看各区占用,heapdump 抓快照------这个其实就等价于 jmap dump,抓完拿去 MAT 分析。

怀疑线上跑的根本不是我以为的那份代码 (这种灵异事件比想象中多),jad 直接反编译运行中的类,看 JVM 里加载的到底是哪个版本;sc/sm 排查是不是 jar 包冲突、类被别的包抢着加载了。

用 Arthas 我给自己定了条纪律:字节码增强是有开销的,高频方法上挂 trace/watch 尤其明显,所以观测范围尽量小、用 -n 限次数、用完立刻 stop 它退出时会把增强过的类还原回去,别图省事挂着不管。

六、再讲个单机的真功夫:那次 G1 调优

为了让单机这层更立体,我把另一次纯粹是进程内部的排查也写出来------一次 G1 调优。它和上面那次抖动不同,根因确实就在 JVM 里。

现象类似:核心接口高峰期 P99 从 50ms 飙到 800ms。但这次按实例拆开,是所有机器一起慢,不是个别机器。这本身就提示我方向不一样------要么是共享依赖,要么是大家共有的某个特性,比如同一套 GC 配置在高峰期集体扛不住。

监控上接口毛刺和 GC 时间高度相关,我先把矛头指向了 GC,开了 GC 日志(JDK11 直接 -Xlog:gc*)。日志里跳出来两类东西:偶发的 Full GC,秒级 STW;以及大量 GC 的触发原因写着 humongous allocation------也就是有大对象在直接往老年代里塞。

光知道"有大对象"不够,我得知道是哪个对象 。于是 jmap 抓堆 dump,拿 MAT 打开,按 retained size 排序(这点要注意,看大对象得看 retained size------这个对象被回收能连带释放多少内存,而不是 shallow size 那个对象自己的大小)。排在最前面的,是一批报文序列化后的大 byte 数组,单个就好几兆,超过了默认 Region 大小的一半,所以被 G1 判成了 humongous 对象,直接进老年代。

到这里我才动手调参数。而且我想强调,参数合不合理我不是拍脑袋判断的,是从现象反推的:

  • 日志里 humongous 那么多 → 说明 G1HeapRegionSize 偏小,很多本不该是大对象的东西被判成了大对象。调大 Region,它们就变回普通对象,走正常的 Young 区分配回收了。
  • Full GC 频繁、而且每次 Full GC 之前并发标记几乎没跑完 → 说明并发标记启动太晚 ,对应的就是 IHOP(默认 45%)太高,老年代涨太快标记追不上。调低它,让标记提前启动。
  • 再配合 MaxGCPauseMillis=100 设停顿目标------但这个旋钮我很克制,因为它是停顿和吞吐的权衡,设太小会逼 G1 频繁小批量回收,GC 次数暴涨、CPU 上去、吞吐反而掉。

调完我没敢直接上线。先用 -XX:+PrintFlagsFinal 确认参数真的生效了------这是被坑过的经验,你以为你设了,实际生效值可能完全不是那回事,尤其 JDK9 之后 IHOP 默认是自适应的。确认无误,再在压测环境用同样的流量模型跑前后对比。

结果:P99 从 800ms 压到 100ms 以内,Full GC 基本消失,吞吐量没掉。

这次的收获不在那几个参数,而在那个动作顺序------先从现象反推问题,再有针对性地调,最后验证生效、压测对比收益。 这套闭环比记住任何一个参数都值钱。

七、把两次经历缝起来:我现在排查集群问题的套路

经过这些,我现在再遇到"集群里很多服务、不同机器性能抖动"这种问题,脑子里有一套固定的流程,不会再慌:

第一步,先分类,别急着查。 把监控按实例拆开看 P99,判断是全局抖动还是个别机器抖。全局抖动往共享依赖查(DB、Redis、中间件、网络、缓存雪崩),个别机器抖往单机资源查(GC、CPU 争抢、磁盘 IO、负载不均)。方向错了后面全是无用功。

第二步,靠可观测性收敛,绝不逐台 ssh。 几十上百台机器人工逐台看既不现实也抓不住偶发问题。我靠三样东西配合:Metrics(Prometheus + Grafana)告诉我什么时候、哪台机器、哪个指标在抖;Tracing(SkyWalking 这类)告诉我抖在调用链的哪一环;Logging(ELK)按 traceId 串起来,告诉我那一刻到底发生了什么。这三者里,Tracing 是连接集群层和单机层的桥------它帮我把范围收敛到"是服务 B 的某个方法慢",我才登上服务 B 那台机器用 Arthas 接着挖。

第三步,按根因逐个排,并且永远多问一句"根因是不是在上游"。 这是第一次那个负载不均的教训刻下来的。全局抖动我重点看数据库慢查询/锁/连接池、Redis 大 key 热 key、中间件堆积或 rebalance、缓存集体失效、网络;个别机器抖我重点看它的 GC、有没有被同宿主机的容器抢 CPU、磁盘 IO、以及------是不是上游把流量倾斜给了它。

第四步,针对抖动偶发的特性,专门下功夫抓现场。 抖动难就难在它偶发。我会先看它有没有周期性------整点抖多半是定时任务,固定间隔抖多半是 GC、健康检查、配置拉取这类。再把抖动时间点和发布、定时任务、流量高峰、GC 时间叠在一张图上找相关性。实在蹲不到的,用 tt 或自动 dump(OOM 自动 heapdump、慢请求自动打 stack)、async-profiler 持续采样事后看火焰图来抓。

一句话总结我现在的认知:从集群层收敛到单机层下钻,先看清整片森林,再走到具体那棵树前面用 Arthas 解剖它。 而最反直觉、也最值钱的一条是------抖动的那台机器,往往只是受害者,凶手在别处。

八、写在最后

回头看,这次排查最大的收获不是学会了哪个 Arthas 命令、哪个 GC 参数,而是思维方式的切换

  • 看见抖动,先问"全局还是局部",而不是先登机器;
  • 看见某台机器有问题,先问"凭什么是它",而不是直接对它动手;
  • 调任何参数前,先确认"我是从现象反推出来的,还是凭感觉";
  • 调完一定验证生效、压测对比,而不是改完拉倒。

工具会换,命令会忘,但这套"先收敛、再下钻、永远多问一层"的思路,是真正能带着走的东西。

相关推荐
荣码1 小时前
用Streamlit给AI应用套个界面,10行代码出Web页面
java·python
SamDeepThinking1 小时前
Java微服务练习方式
java·后端·微服务
禅思院2 小时前
Vite vs Webpack 深度对比:从启动原理到生产构建,一篇就够了
前端·架构·前端框架
IT_陈寒2 小时前
Vue的响应式真把我坑惨了,原来问题出在这
前端·人工智能·后端
朦胧之12 小时前
AI 编程-老项目改造篇
java·前端·后端
swipe14 小时前
从 0 到 1 实现大文件上传:分片、秒传、断点续传、暂停、重试与服务端合并
前端·javascript·面试
爱勇宝15 小时前
我做了一个只用来搜歌词的小 App
android·前端·后端
甲维斯15 小时前
用AI还原《坦克大战》并3D化升级!
前端·人工智能·游戏开发
IT_陈寒16 小时前
SpringBoot自动配置坑了我一晚上,原来问题出在这
前端·人工智能·后端