关于我们

质量为本、客户为根、勇于拼搏、务实创新

< 返回新闻公共列表

2026 服务器租用 Java 线上故障定位实战:Arthas 链路耗时与方法级排查的避雷手册

发布时间:2026-10-21

半夜两点回源接口 P99 从 80ms 涨到 2s,重启能好两小时

凌晨两点,CDN 回源的一批 502 开始爬,网关侧看到的是上游响应超时。业务方说接口慢,监控图上 P99 从 80ms 一路顶到 2s,P50 却几乎没动。你登上机器,重启 Java 进程,一切恢复正常;两小时后,同样的曲线又爬上来一次。

这种"重启就好、两小时复发"的形状,基本可以先排除掉代码逻辑写错,剩下三类嫌疑:资源在缓慢泄漏(堆、堆外、连接池、文件句柄)、外部依赖在退化(数据库锁等待、下游超时、DNS 抖动)、或者某种周期性触发(定时任务、缓存集中失效、日志滚动压缩)。这三类里,只有第一类在重启后会被清干净,所以"重启能好"本身就是一个重要线索——它说明状态是被进程累积出来的,而不是配置突然坏了。

问题在于:重启销毁了证据。等下一次再爬上去,你手上还是只有一张 P99 曲线。这时候真正需要的是在不重启的前提下,把 2s 拆开看它落在哪一层——是线程在排队、在等数据库、在做序列化,还是干脆什么都没干被 GC 停住了。Arthas 就是干这个用的:它 attach 到运行中的 JVM,不改一行代码,临时给你方法级的耗时和入参返回值。但它是把探针插进正在跑的字节码里,这件事本身有代价。下面按一次真实排查的顺序讲,顺带把每个动作的账单算清楚。

Arthas 到底动了 JVM 的哪一层:attach、字节码增强与 reset 之后的残留

先把机制说清楚,否则后面所有风险都理解不了。Arthas 启动时先让你挑一个 JVM 进程,通过 JDK 的 Attach API(com.sun.tools.attach.VirtualMachine)向目标进程发请求,目标 JVM 里的 AttachListener 线程收到后加载一个 Java agent(agentmain 模式,不是启动时 -javaagent 那种 premain)。agent 拿到 Instrumentation 对象,用 addTransformer 加上 retransformClasses,把已经加载的类的字节码重新转换一遍——用阿里开源的 ByteKit 在方法入口、出口、异常抛出点织入增强代码。JVMTI 是这套能力的底层支撑:Instrumentation 的类重转换与重定义,落到 JVM 内部就是 JVMTI 的 ClassFileLoadHook 和 RetransformClasses 那套机制。

所以 Arthas 不是"看"你的进程,它是真的改了正在运行的字节码。这带来几个必须记住的后果:类被重转换后,相关方法的 JIT 编译结果失效,要重新走一遍编译路径,短时间内该方法的执行表现会变化;JVM 需要为被重转换的类保留原始字节码副本,元数据区(元空间/堆外)的占用会增加;如果同一个类被多个 agent(SkyWalking、Pinpoint、JRebel、另一个 Arthas 会话)同时重转换,后一次的转换会在前一次的结果上叠加,撤的时候顺序错乱就可能留下半增强状态。

收尾命令有两个:reset 是把增强过的类还原回去(清掉 Arthas 加的增强),stop 是关闭 Arthas 服务端并尝试还原。真正危险的不是你忘了敲这两个命令,而是异常退出——ssh 断线、客户端被 kill -9、Pod 被驱逐——这时候 reset 可能没执行完,被增强的类就在进程里继续跑,每次调用都在走增强逻辑,你以为观测结束了,其实开销一直挂着。我的习惯是重启过一段时间后再用一次 reset,然后确认增强列表是空的,再用 ss 或 netstat 看 Arthas 开的端口(默认 telnet 3658、http 8563)是否已经释放。这个端口是关键:它绑定的是目标机器上的真实监听端口,能在进程内执行命令,如果绑到了非本地地址又没人管,等于给机器上留了一个后门。用完 stop 是一回事,确认端口真的没了是另一回事。

dashboard 先看什么:运行时长、GC 累计与线程 CPU 的读法

attach 上去之后第一件事不是 trace,是 dashboard。它一屏给出线程、内存、GC 与运行环境,但要看的不是数字本身,是三个关系的组合。

第一个看运行时长(uptime)。两小时复发的问题,如果 uptime 正好也是两小时上下,说明是个随运行时间累积的东西——堆在涨、连接池在漏、某个缓存没淘汰、文件描述符没关。如果 uptime 是三天但问题是两小时前才开始的,那跟累积无关,要去查两小时前发生了什么变更或外部依赖变化。这一条能直接把排查方向劈成两半。

第二个看 GC 的累计次数与累计耗时,注意是累计不是瞬时。单次 young GC 快慢说明不了什么,累计耗时除以运行时长得到的"GC 占总时间比例"才有意义。如果这个值明显偏高,或者老年代回收次数在持续增长,那批量的请求超时很可能根本不是方法慢,而是所有线程一起被 STW 停住了。判断方法很直观:STW 造成的慢是"一批请求同时变慢、各个方法耗时都涨、但没有任何一个方法特别突出",而真实的热点方法则是"只有某些请求的某些方法涨"。dashboard 的 GC 面板配合这个特征,基本能在十秒内判断要不要往 GC 方向走。

第三个看线程面板的状态分布与 CPU 占用。线程数本身是个基线指标:一个 Spring Boot 应用的线程数通常稳定在一个区间,如果这个数在持续爬升,那是线程池配置或线程泄漏的问题,跟方法耗时无关。CPU 占用高的线程名也很关键——如果是 GC 线程或者 JIT 编译线程排在前面,那问题不在业务代码。

thread 找出吃 CPU 的线程栈,RUNNABLE 不等于在算东西

thread -n 3 给出采样窗口内 CPU 时间最高的几个线程和它们的栈。这个命令便宜,可以放心多敲几次。真正的坑在判读:Java 的线程状态(Thread.State)是 JVM 层面的,不反映线程是否阻塞在 native 调用上。一个正在做 socket 读、等网卡数据的线程,它的状态经常显示为 RUNNABLE,因为 JVM 不知道它卡在系统调用里。所以你看到一堆 RUNNABLE 的线程栈停在 SocketInputStream.socketRead0 或者 sun.nio.ch 的读方法上,别以为它们在"计算"——它们在等网络,CPU 高另有其人。

反过来,thread --state BLOCKED 和 thread -b 才是找锁的。thread -b 会直接找出当前阻塞了最多其他线程的那把锁和它的持有者,这在排查"接口全卡住"的场景里比什么都快。还有一种常见形态:栈停在 java.lang.Object.wait 或者 LockSupport.park,那是在等条件,不是慢。

采样窗口也要注意。thread 统计的是窗口内各线程的 CPU 时间占比,一次采样可能抓到瞬时态。我的做法是间隔几秒连着敲三次,看栈是不是稳定地停在同一处。稳定停在同一个方法才是热点;每次抓到不同地方,说明是大量请求在不同的短暂方法上跑,热点不集中,这种形态通常指向"请求量本身上来了"而不是"某个方法坏了"。还有一点,线程 CPU 时间需要 JVM 支持线程 CPU 时间统计(ThreadMXBean 的 getThreadCpuTime),个别环境或容器限制下可能拿不到,表现为全是零或不可用,这时候要换 profiler。

trace 把 2s 拆到方法级,以及它为什么在高频接口上会反噬

回到那次 P99 到 2s 的排查。thread 没抓到明显热点,GC 累计占比也正常,于是用 trace 打在 Controller 的入口方法上。trace 会输出一棵调用树,每个节点带自身耗时和占总耗时的比例,一眼就能看到 2s 落在哪一段。

实际抓出来的结果是:Controller 本身几乎不耗时,中间一层 Service 方法占了绝大部分,再往下展开,时间全压在一个远程 HTTP 调用的方法上。到这里其实已经可以收工一半了——慢不在自己的计算,在等下游。但这里有个很容易犯的错:如果不继续展开,你会以为"HTTP 方法慢"等于"下游慢",然后在错误的方向上找半天。往下再展一层,才会看到时间是在连接建立阶段还是数据读取阶段,这两者的处置完全不同(后面单独讲)。

trace 的代价必须讲清楚,它是这套工具里最容易把生产搞出事的命令。增强是加在每次调用上的:每次进入方法都要走增强逻辑、构造调用上下文、记录时间戳、维护调用树、退出时计算并输出。开销随调用量线性放大,与"接口慢"无关,只与"接口多"有关。在一个每秒几千次调用的接口上长时间开着 trace,等于给每个请求都加了一份记账成本,而且输出本身也在打日志、占 I/O。判断方法很简单:开启 trace 之前记一下该接口的 P99 和机器 CPU,开启后十秒再看一次,如果曲线明显抬升,说明这台机器的量撑不住全量 trace,立刻停掉换别的办法。

正确的用法是先把范围收窄:用 -n 限制只统计若干次调用就让命令自动结束;用耗时条件表达式只让超过阈值的调用进入统计(比如 #cost 大于某个值才输出),这样正常请求几乎不产生成本;优先在摘掉流量的那一个实例上做,而不是在扛全量流量的机器上做。说实话,我一般给客户的建议是:能在预发或灰度实例上复现的,就别在生产全量实例上 trace。

watch 与 tt 看入参返回值:toString、懒加载与被 tt 拖住的对象

知道哪个方法慢之后,下一步是知道它慢在什么输入上。watch 用来看方法的入参、返回值、抛出的异常,tt(TimeTunnel)更进一步,把每次调用的现场记录成一张表,可以事后回看甚至重放(tt -p 触发一次重放调用)。

watch 最大的坑在对象展开。为了让输出可读,watch 需要把对象转成字符串,这就会触发对象的 toString。听起来无害,实际踩过的雷有三种:一是 ORM 的懒加载——实体对象的 toString 如果关联到延迟加载字段,打印时会触发数据库查询,你只是想看一眼参数,结果给数据库多发了一批 SQL,还可能是慢 SQL;二是大集合——一个装了几万条元素的 List 被展开打印,输出量巨大,命令执行时间被拉长,终端刷屏到卡死;三是自定义 toString 里做了远程调用或加锁的代码,那就更麻烦了。展开层级(-x 参数)越深越贵,我一般从最小层级开始,确认结构后再一格一格加深,而不是上来就深展开。

tt 的坑在内存。它把每次调用的入参、返回值、异常对象都引用着存进一张表,表有容量上限,但关键是:只要记录还在,这些对象就不会被 GC 回收。一个返回值是大列表的方法被 tt 盯上,等于你人为制造了一次内存泄漏,而且是那种"用完之后忘记清理"的泄漏。长时间开着 tt 而不清理,堆会慢慢涨上去,然后你会看到一个由观测动作引发的 GC 问题——排查工具自己变成了故障源,这种事在真实环境里发生过不止一次。用 tt 一定要带上次数限制,用完 tt --delete-all 清干净,并在 dashboard 上确认堆确实回落了。

stack 反查调用来源:同一个慢方法被谁调用是两回事

trace 解决"慢在哪",stack 解决"谁在调"。当你发现某个工具类方法或者某个 DAO 方法耗时异常时,stack 会输出当前有哪些调用路径正在走到这个方法上。

为什么要单独做这一步?因为同一个方法的慢,来源不同结论完全不同。举一个典型场景:某个序列化方法耗时高,stack 显示一半的调用来自一个后台定时任务在大批量导出数据,另一半来自在线接口。那在线接口的 P99 受影响其实是资源争抢(CPU 和堆被批量任务吃掉),不是序列化本身有 bug。不查调用来源就下结论,很容易把定时任务的问题算到在线接口头上,然后去优化一个本来没问题的序列化实现。另一个常见来源是重试:某个方法被调用次数远超预期,stack 一看是上游超时重试在打,那要治的是超时与重试策略,不是这个方法。

stack 的开销比 trace 低,它只在被调用时输出一次栈,不做耗时统计。但同样的道理,在高频方法上开着依然会刷屏和输出开销,用次数限制收住。

八类观测手段各自回答什么问题、代价有多重

手段 回答什么问题 开销级别与判断方法 适用时机 主要风险
dashboard 整体是否健康:线程数、堆与各区占用、GC 累计次数与耗时、运行时长、机器负载 低。刷新是读取 JMX 已有数据,不改动业务类;判断方法是开着看接口曲线有无变化,通常无感 任何时刻,作为 attach 后的第一个动作 几乎无副作用;风险在于误读——把 GC 累计耗时当成单次停顿时长
thread 哪个线程在吃 CPU、谁持锁阻塞了别人、线程状态分布如何 低到中。取线程栈需要进入 safepoint 相关的操作;判断方法是连续多次执行看接口耗时是否抖动 CPU 飙高、接口全卡住、怀疑死锁或锁竞争 RUNNABLE 状态不代表在计算;单次采样可能抓到瞬时态,需多次确认
trace 一次调用的耗时分布在哪几个方法上,各占多少 中到高,随调用量线性放大。判断方法:开启前后对比该接口 P99 与 CPU 使用率,抬升即说明撑不住 已锁定具体接口、需要下钻到方法级;优先在摘流量的实例上、带次数与耗时过滤 高频接口上开着会显著推高耗时与输出 I/O;嵌套过深时输出量失控
watch 与 tt 这次调用的入参、返回值、异常是什么;tt 还能重放一次 中到高。开销取决于对象展开层级与对象大小;判断方法:看命令返回是否变慢、看堆是否因 tt 记录而上涨 需要确认"慢在什么输入上"、需要复现偶发异常 打印触发 toString 可能带出懒加载查询或大对象序列化;tt 记录持有对象引用阻止回收,等同于内存泄漏
monitor 一段时间内方法的调用次数、成功率、平均耗时、失败率的趋势 低到中。只做聚合计数,不保存明细;判断方法:长开时观察堆与 CPU 是否平稳 需要盯一段时间看趋势变化、验证处置是否见效 聚合值会掩盖长尾;平均值正常不等于 P99 正常
profiler(async-profiler) CPU 时间花在哪些栈上(火焰图)、谁在分配内存、锁在哪里争 低到中。采样而非全量埋点;判断方法:采样期间对比 CPU 与接口耗时,通常远低于全量 trace CPU 高但 trace 找不到集中热点、需要全局视角、不宜做全量埋点时 依赖内核 perf 事件权限,容器里常被 seccomp 与 capability 挡住,可能自动退化到精度较低的采样模式
heapdump 堆里现在都是些什么对象、谁在引用它们、疑似泄漏的对象链 极高且集中。伴随一次 Full GC 与长时间 STW;判断方法:先算堆实际占用与磁盘剩余空间,再决定要不要做 怀疑内存泄漏、已经摘掉流量或已做好切换准备、磁盘确认足够 STW 期间业务全部停顿;dump 文件与堆占用同量级,写满磁盘会引发更大范围故障;文件内含业务数据需按敏感数据处理
jad / mc / redefine 线上代码到底是什么版本;能不能不重启替换一段逻辑 jad 为低(只读反编译);mc 与 redefine 为高,涉及类重定义与编译 jad 用于确认线上代码与预期一致;热替换仅限紧急止血且无其他手段 重定义不允许改变类结构(增删字段与方法、改签名);与调试器、其他 agent 冲突;相当于开放进程内代码执行通道,必须审计回滚

monitor 看趋势:为什么长时间盯盘要用周期性统计而不是 trace

处置完之后要验证有没有见效,这时候该用 monitor 而不是继续 trace。trace 给你的是每一次调用的明细,适合"找出那一次";monitor 是周期性的聚合统计——每个周期输出一次该方法的调用次数、成功数、失败数、平均耗时,适合"看一段时间"。

差别在于数据留存。trace 对每次调用都要构造上下文并输出,monitor 只在内存里累加计数器,周期到了算一次平均值输出。所以 monitor 可以开得久得多,我见过盯半小时到几小时的,堆和 CPU 基本平稳。它的局限是聚合会掩盖长尾:平均耗时看起来正常,不代表 P99 正常。所以 monitor 的正确用法是看趋势——失败率是不是在涨、调用量有没有异常突增、平均耗时是缓慢爬升还是台阶式跳变——而不是用来判断"有没有个别请求很慢"。那件事应该交给日志里的慢请求记录和链路追踪。

还有一个实用场景:定时任务类的方法。有些方法只在整点被调用,你想看它每次跑多久、有没有失败,trace 得守着时间点,monitor 开着等就行。

profiler 与 async-profiler 采样:轻在哪,容器里为什么常常起不来

当 CPU 明确高但 trace 找不到集中热点时(通常是热点分散在大量短暂方法上,或者热点在 JDK 内部、在序列化、在日志框架里),就该上 profiler。Arthas 内置的 profiler 命令底层是 async-profiler,工作方式不是给每个方法埋点,而是按固定频率采样:每隔一小段时间,去看所有线程的栈是什么样,采样若干次之后把栈聚合起来,生成火焰图。看火焰图的方法是看"宽度"——越宽的栈帧代表采样命中的比例越高,也就是 CPU 时间花得越多。

采样比全量埋点轻,是因为成本与调用次数无关,只与采样频率和运行时间有关。除了 CPU 事件,它还能采 alloc(对象分配,看谁在制造垃圾)、lock(锁竞争,看线程卡在哪把锁)、wall(墙钟时间,能看到阻塞而不只是在跑的线程)。选哪种事件要看你想回答什么:CPU 高用 cpu,怀疑 GC 频繁用 alloc,怀疑卡住用 wall。火焰图输出到文件后下载到本地看,别在终端里硬读。

Linux 上的主要障碍是内核权限。async-profiler 的 CPU 采样优先使用 perf 事件(perf_event_open),能不能用取决于 /proc/sys/kernel/perf_event_paranoid 这个内核参数的取值——数值越大限制越严,很多发行版的默认值会让非特权进程申请 perf 事件失败;较新的内核引入了 CAP_PERFMON 能力来替代一部分 CAP_SYS_ADMIN 的需求,但容器默认不一定授予。再叠加 Docker/containerd 的 seccomp 配置和 Kubernetes 的 capability 限制,结果就是:profiler start 敲下去,采样起不来或者悄悄退化到 itimer 那种精度和覆盖范围都打折的模式。观察到"火焰图里全是空栈"或者"命令报权限相关错误",基本就是这一层的问题,不要以为是工具坏了。处置方向是调整内核参数、给容器加对应 capability,或者换成 wall 事件并降低采样频率。JFR 相关的输出对 JDK 版本有要求,老版本 JDK 上部分事件不可用。

heapdump 之前先算两笔账:STW 停顿与磁盘空间

heapdump 是我最谨慎使用的一个命令,因为它在一台正在提供服务的机器上做两件很重的事。

第一笔账是停顿。dump 堆快照的过程通常伴随一次 Full GC,而一次 Full GC 是整个进程所有业务线程一起停住的(STW)。停顿持续多久取决于堆有多大、里面有多少活对象、什么垃圾回收器、磁盘写入有多快。堆越大、对象越多、盘越慢,停得越久。在一台扛着在线流量的实例上,这意味着在那一小段时间里,所有打到这台实例的请求都会超时或者排到别的实例上。如果这个服务是单实例,那就是一次业务中断。所以我的判断标准是:要么先把这个实例从负载均衡里摘掉再 dump,要么确认有多副本且其他实例健康、网关能自动 failover。带 --live 选项只 dump 存活对象,代价是一定会触发一次 GC,换来更小的文件。

第二笔账是磁盘。dump 出来的文件大小与堆的实际占用量是同一个量级。如果 Java 堆开得大,而系统盘只剩一点点空间,这一下就能把盘写满,然后引发一连串更麻烦的故障——别的进程写不了日志、监控 agent 挂掉、甚至数据库在同一台机器上时被拖死。动手前先看 df,看目标目录所在分区的剩余空间,确认够,并且把文件写到数据盘而不是系统盘。dump 完分析完立刻删掉,别留着。还有一条容易被忽略的:dump 文件里是真实的业务对象,包含用户信息、token、配置里的密钥,它跟数据库备份一样敏感,下载到本地分析要走跟数据导出一样的管理流程,用完删干净。

jad、mc、redefine 热更新:为什么我把它们划到"生产慎用"

这三个命令经常一起出现:jad 反编译看线上跑的代码到底是什么版本(这个我常用,只读,风险低,而且非常有用——能直接确认线上是不是你以为的那个版本),mc 在内存里把改好的 Java 源码编译成 class,redefine 把新的 class 塞回运行中的 JVM。

redefine 为什么危险,要分三层说。第一层是能力限制:HotSpot 对类重定义有硬性约束,不允许改变类的结构——不能增加或删除字段和方法,不能改方法签名,不能改父类和实现的接口,能改的只有方法体内部逻辑。想加一个字段、加一个方法、改一下参数类型,都会失败。这意味着"热修复"能做的事比你想象的少得多,热更新加日志这种可以,改数据结构这种不行。

第二层是冲突。redefine 走的是跟其他 agent 同一条类重转换通道。如果同一进程里还挂着链路追踪的 agent、热部署工具,或者正有人用调试器挂着断点,重转换会互相覆盖,结果可能是某个类的增强被冲掉,或者方法体出现你没预期的组合。匿名内部类、Lambda 表达式、动态生成的代理类,重定义时的一致性也容易出问题——你改了外部类,相关的内部类还是旧字节码,行为就变得难以预测。

第三层是安全与可审计。能在进程里替换任意类的字节码,等同于能在这个进程里执行任意代码,这是最高级别的权限。它绕过了发布流程、绕过了代码评审、绕过了制品库的审计,而且重启之后修改就消失了——也就是说,这次"修复"没有落到任何可追溯的地方,下次发布还会被打回原形。我给客户的建议一向是:除非是已经确认了根因、只差一行日志或开关、且重启代价确实极高的止血场景,否则不要在生产上 redefine;如果做了,必须记录改了什么、谁改的、改之前 jad 出来的原始版本,并在同一个变更窗口内安排正式发布与重启。

容器与多副本里 attach 的前置条件:PID namespace、JDK 工具与只读文件系统

物理机和虚拟机上 attach 基本不会失败,容器里失败的次数多得多,原因集中在四个地方。

PID 命名空间。Attach API 的原理要求发起方能看到并对目标进程发信号、能访问它在 /proc 下的目录。最稳的做法是 kubectl exec 或者 docker exec 进到容器内部,attach 容器里那个 Java 进程。想在宿主机上或者另一个 sidecar 容器里去 attach,就得让两边共享 PID 命名空间(Docker 用 --pid=host 或共享另一个容器的命名空间,Kubernetes 里设置 shareProcessNamespace),否则你看到的 PID 跟目标进程自己认为的 PID 不是一回事,attach 会失败或挂住。权限校验同样要留意:发起 attach 的用户要与目标 JVM 进程的用户匹配或具备足够权限,用 root 一般能过,用错用户会被拒。

镜像里有没有工具。Arthas 本身是 Java 程序,需要能跑起来;部分能力依赖 JDK 提供的编译器(mc 在只有 JRE 的环境或者模块化 JDK 上容易失败)。很多精简镜像只装了 JRE,甚至用的是 Alpine 加 musl libc,行为跟 glibc 环境不完全一致。精简镜像还常常没有 ps、jps、curl 这些排查用的基础命令,busybox 版 ps 的输出格式也不一样。我的建议是在基础镜像里预留一个包含 JDK 与常用排查工具的调试层,或者准备一个带工具的 sidecar/临时调试容器,别等到半夜才发现镜像里连 jps 都没有。

文件系统限制。Arthas 启动后要解压自身的运行库(默认在用户目录下的 .arthas 目录),运行时也会用临时目录放一些文件。如果容器的根文件系统是只读的,或者 /tmp 不可写、被挂成了很小的 tmpfs,attach 会在看起来莫名其妙的地方失败。解决办法是给 /tmp 或指定目录挂一个可写的 emptyDir,或者预先把 Arthas 装到一个固定目录并用参数指定。安全策略收紧的集群还会禁止在容器里写可执行文件,这类环境要提前跟平台方确认。

多副本定位。Deployment 起了六个副本,只有一个有问题,你要 attach 的是那一个。先在网关或 Ingress 的访问日志里找到超时请求打到了哪个后端 Pod IP,或者在监控上按实例拆分看哪个副本的指标异常,确定 Pod 之后再 exec 进去。还有一点:Pod 一旦被重建,你的 attach 会话和所有增强都会消失,容器环境下的观测结果要随手留存——火焰图、dump、日志片段都先落到持久卷或者传出来,别指望还能连回去再看一次。

提交到线程池之后 trace 链就断了:traceId 跨线程传递与异步归因

这是最常被问到的一个现象:trace 打在接口入口方法上,调用树走到某一步就没了,而那一步之后明显还有活要干。原因很直接——Arthas 的调用树是靠线程内的上下文维护的,任务一旦 submit 或者 execute 到线程池,后续逻辑跑在另一个线程上,那个线程没有父调用的上下文,树自然就断了。

要接上这段,靠的不是 Arthas,是应用自己的链路标识。做法是在入口处生成或接收一个 traceId,放进日志框架的 MDC,然后在提交异步任务时用包装过的 Runnable/Callable 把 MDC 内容一起带过去,在任务开始时塞回 MDC、结束时清理。Spring 环境里可以用 TaskDecorator 统一做这件事,对线程池复用导致上下文串扰的场景,可以用 TransmittableThreadLocal 这类方案来保证传递与隔离。这样日志里同一个 traceId 能把主线程和异步线程串起来,trace 断掉的那一段靠日志补上。

异步归因有几个常见误区值得单独说。第一个是把异步任务耗时算到接口头上:父调用提交完立刻返回,它的 rt 里根本不含异步任务的执行时间,所以"接口很快但整体很慢"往往就是异步堆积。第二是排队时间被忽略:任务在线程池队列里等着的时间,不在任何方法的方法体耗时里,trace 永远看不到它,要判断堆积得看线程池的队列长度、活跃线程数、拒绝次数这些指标。第三是异步任务的失败不会反映到父调用的成功率上,失败被吞掉是很常见的,所以异步任务的异常必须单独记录和告警。第四个是误以为线程池越大越好:盲目加大线程池会让数据库连接池先撑不住,然后表现为数据库慢,实际是自己的并发策略问题。

四种把现象当原因的误判:GC 停顿、数据库锁等待、下游超时、日志刷盘

排查里最浪费时间的不是找不到数据,是找到了数据但归因错了。下面这四种我见过太多次。

把 GC 停顿当成接口慢。特征是"一批请求同时变慢、各个方法耗时都涨、但没有任何一个方法特别突出"。这时候 trace 出来的调用树每个节点都变慢,很容易被误读成"到处都慢,系统烂透了",其实根因是 STW 把所有线程一起冻住了。判断方法是看 GC 累计耗时占运行时间的比例、看 GC 日志里的停顿时间点是否与超时时间点对齐。处置方向是内存与垃圾回收器调优、减少分配,而不是优化业务方法。

把数据库锁等待当成应用问题。trace 显示某个 DAO 方法耗时高,很多人第一反应是 SQL 写得差或者 ORM 有问题。实际上方法耗时里可能绝大部分是在等数据库侧的锁——行锁、元数据锁、或者干脆是别的会话持着事务没提交。另一个容易混淆的是连接池等待:从连接池拿连接的耗时和执行 SQL 的耗时是两件事,前者高说明池子不够或者连接泄漏,后者高才是 SQL 或锁的问题。判断方法是把这两段时间分开看,并去数据库侧查活跃会话与锁等待,不要在应用侧猜。

把下游 HTTP 超时算进自身耗时。这在微服务和调用第三方接口时特别常见:自己的接口 P99 是 2s,其实有 1.8s 是等下游。这种时候优化自己的代码毫无用处。而且下游耗时还要再拆——连接建立慢指向连接复用、DNS 或网络链路问题,连接建立快但读取慢指向下游自身处理慢。判断方法是看客户端埋点的分段耗时,没有埋点就用 trace 往下展开一层,看时间堆在连接阶段还是读阶段。

把 CPU 高归因为计算,实际是日志刷盘或 JSON 序列化。线程栈停在做"计算"的方法上,但真正吃 CPU 的可能是同步的日志 Appender 在写盘、滚动压缩、或者大对象的序列化与反序列化(反射、循环引用处理、超大字段编码)。这两种都表现为 CPU 高且栈看起来"在正常业务代码里"。判断方法是看栈的具体位置是不是落在日志框架或序列化库,以及对比日志量变化与 CPU 变化的时间点。处置方向是日志异步化、调整级别与滚动策略、控制序列化对象大小。

把网络耗时从应用耗时里摘出来:连接、读超时与 DNS

租来的服务器上跑 Java 服务,网络这一层的耗时经常被算进应用耗时里,然后引发一连串错误结论。要拆开需要理解客户端的超时参数分别对应哪一段:连接超时管的是 TCP 握手建立完成之前,读取超时管的是连接建好之后等对方返回数据的那段时间。连接超时大面积发生,说明对端不可达、端口不通、或者链路与安全策略有问题;读取超时大面积发生,才是对端处理慢。

再往下是连接复用。没有启用连接池或者 Keep-Alive 的客户端,每次请求都要重新握手,握手成本被算进每次调用的耗时里,而且会产生大量处于短时状态的连接,端口和时间资源都受影响。判断方法是在机器上观察连接的建立频率与处于 TIME_WAIT 等状态的连接数量,如果新建连接的速率跟请求量几乎一比一,那就是没复用。

DNS 是另一个隐藏项。客户端每次解析域名、JVM 的默认 DNS 缓存策略、以及解析超时,都会让一部分请求凭空多出一段耗时,而且只在缓存失效的那部分请求上出现——这正好能解释为什么 P50 正常而 P99 飙高:只有少数请求撞上了解析或者连接建立的冷路径。判断方法是看超时的请求是不是周期性成批出现(对应缓存过期节奏),以及在机器上直接测解析耗时。还有一层是跨网与跨境链路:回源链路抖动、跨境访问的不确定性,会让应用侧看到一批偶发超时,这类问题在应用里怎么查都查不出来,得看网络质量的监控。

自建全链路追踪要花多少:机器、人天与救火工具的分工账

很多小团队的判断是"上 Arthas 就够了,省一套链路追踪"。这个判断把两类东西混在了一起。Arthas 是手持工具,成本几乎为零——下载一个 jar,attach 上去就能用,不用改代码、不用加机器、不用存储。但它的成本不在钱,在人:它要求有人半夜爬起来、要求那个人知道该敲什么、要求他敲的时候不会把生产搞崩,而且它只在你盯着的时候有效,你不看的时候什么也留不下,问题复现完就过去了。

全链路追踪(SkyWalking 或 OpenTelemetry 体系)的成本是明确的:要部署采集端与存储,链路数据的量级随采样率和服务调用量增长,存储是持续的资源开销,还要有人把埋点接进去、把采样率调好、把看板做出来。这是几个人天加上长期资源占用换来的东西。它换来的是:所有请求都有记录、跨服务跨线程能串起来、事后能回看、能按 traceId 定位到具体那一次调用、不需要人半夜守着。

所以这两者不是替代关系,是分工关系。我的建议顺序是:先把指标做起来(Prometheus 加 Micrometer 暴露 JVM、接口、连接池、线程池指标,配告警),因为它最便宜、最能回答"什么时候开始坏的";再做日志的规范化(结构化输出、全链路 traceId 贯穿、慢请求单独记录),因为它是事后归因的兜底证据;然后才考虑链路追踪,并且一开始就把采样率压住,别全量采;Arthas 留作兜底——当指标告诉你哪里不对、日志告诉你哪个请求不对,但都不够细的时候,用它下钻到方法级。这个顺序不是教科书,是被半夜叫醒太多次之后总结出来的:大部分故障,有前两层就够了,真正需要方法级观测的是少数。

给观测留资源:CPU 余量、堆外内存与磁盘怎么规划

一台机器上跑 Java 服务,配置通常按业务峰值算,观测的开销很少被算进去,结果就是需要救火的时候发现机器已经没有余量了。规划时有几条经验值值得写进选型清单。

CPU 不能打满。所有观测动作——增强逻辑、采样线程、日志输出、dump 写入——都要占用 CPU。如果业务常态已经把 CPU 吃到大半,那么任何一次 trace 或者 profiler 都可能成为压垮响应时间的那根稻草,而且你会分不清接口变慢是故障本身还是观测造成的。选型时给 CPU 留出余量,让常态负载有空间,这既是性能冗余,也是可观测性的前提。

内存不要只按堆算。Java 进程占用的内存远不止 -Xmx 指定的堆:还有元空间、线程栈、直接内存、GC 与编译器的开销,以及类被重转换后 JVM 保留的原始字节码副本。如果按"堆加一点点"来买内存规格,attach 上去做几次增强就可能把内存顶到上限。容器环境更要注意:容器内存限制被触碰时,表现出来的是进程被杀掉或者 GC 行为异常,排查起来非常绕。

磁盘要单独规划。火焰图文件不大,但堆快照文件与堆占用同量级,日志在故障排查时也会临时调大级别。系统盘只放系统,数据和 dump 放独立的数据盘分区,并给数据盘留出足够的空闲空间。用 SSD 也有实际意义: dump 的写入速度直接影响 STW 的持续时间,机械盘上写几个 GB 的文件,停顿会被拉得很长。

还有一条容易被忽略的可维护性配置:带外管理(IPMI/KVM 类)。当进程彻底 hung 住、ssh 都卡、attach 也连不上的时候,带外是唯一能拿到控制台和强制取证的通道。这类能力在平时毫无存在感,在真正的深夜故障里能救命。

同样是半夜出问题:大陆机房、中国香港节点与海外机房的取证差异

同样的 Java 服务放在不同地域的机房,排查条件差别很大,这个差异会直接影响你该准备什么工具。

大陆机房的优势是可达性与现场支持:网络往返延迟低,交互式命令跟手,attach 会话不容易因为网络抖动断掉;需要的时候可以走带外控制台,甚至在极端情况下有现场运维介入;日志与监控数据留在境内,合规上不需要额外考虑跨境问题。备案与等保这类合规要求不影响排查手段本身,但会影响你能在机器上装什么、日志留存多久。

中国香港节点常见于免备案与面向跨境访问的业务。它的排查特点是链路的中间态更多:从大陆访问过来的抖动、跨境段的质量波动,都会表现为应用侧的偶发超时,这类问题在应用内部怎么查都没有线索,必须看网络质量监控。跨网远程操作时会话更容易断,attach 之后务必用 screen 或 tmux 之类的方式把会话兜住,否则断一次线就可能留下没 reset 干净的增强。一万网络在华南、华东、华北以及中国香港、海外多地都有节点资源,跨地域排查时第一件事是确认问题实例到底落在哪一侧,因为两侧的排查路径完全不同。

海外机房的约束主要在合规与操作体验上。跨洋 ssh 的往返延迟会让交互式命令明显卡顿,火焰图与大文件的下载也很慢,能提前把工具放进镜像就别临时传;更关键的是数据合规——堆快照、日志、火焰图里都可能包含用户信息,许多地区对个人数据的跨境转移有明确要求,把 dump 文件拉回本地分析之前要先确认是否允许。不确定的时候,就地分析、只把结论带回来,是更稳妥的做法。

五条现场教训:attach 残留、高频 trace、toString 风暴、误判来源、热更新

第一条:attach 之后没有干净收尾,增强长期留在进程里。原因是 ssh 断线或客户端被强杀导致 reset 没执行完,或者只 stop 了但端口还开着。怎么判断:隔一段时间再连上去看增强列表是否为空,用端口查看命令确认监听是否释放,同时对比接口耗时是否回到基线。怎么避:操作前先开一个持久会话(tmux/screen)把断开的风险兜住;操作完按 reset 再 stop 的顺序收尾;把"确认已还原"写进操作步骤而不是想起来才做;端口不要绑到非本地地址。

第二条:在高频接口上长时间开着全量 trace,把本来就慢的接口推得更慢。原因是 trace 的开销随调用次数线性增长,与接口是否变慢无关。怎么判断:开启前后对比该接口的 P99 与 CPU 使用率,抬升就是信号。怎么避:先摘流量或在灰度实例上做;用次数限制让命令自动结束;用耗时条件过滤只统计超过阈值的调用;能在预发复现就不上生产。

第三条:watch 打印对象触发了不该触发的逻辑。原因是展开对象会调用 toString,而 toString 可能带懒加载查询、序列化大集合、甚至远程调用。怎么判断:执行 watch 之后看数据库的查询量或慢查询是否同步上升,看命令返回是否异常缓慢。怎么避:从最小展开层级开始逐步加深;避开实体对象与未知实现的对象,改看基本类型字段;必要时用条件表达式先筛出具体的那次调用再看。

第四条:把下游或基础设施的问题算到自己头上。原因是方法耗时天然包含它等待下游的时间,GC 停顿和锁等待也会平摊到各个方法上。怎么判断:看变慢的请求是否成批同时发生(指向 STW 或外部依赖),看 DAO 方法里拿连接与执行 SQL 两段的占比,看客户端埋点的分段耗时。怎么避:先分清楚"自己的计算"与"等待外部",再决定优化方向;别在没拆开之前就动业务代码。

第五条:在生产上用 redefine 做热修复,事后无人知晓、重启即丢失。原因是它绕过了发布与评审流程,且能力受限于不能改类结构,还容易与其他 agent 冲突。怎么判断:复盘时发现线上行为与代码仓库对不上、或者重启后问题"复现"。怎么避:jad 出来的原始代码先存档;只在确认根因且重启代价极高的止血场景使用;记录操作人、时间、改动内容;在同一变更窗口内安排正式发布与重启。

一线运维最常问的八个问题

Arthas 能不能直接用在生产环境?能用,这也是它存在的意义,但"能用"不等于"随便用"。它不改代码、不需要重启,这两个优点成立;但它会改动运行中的字节码,增强逻辑会加进每次调用,这是真实的开销。生产上用的前提是:只对自己有权限的机器操作、清楚每个命令的代价、操作完确认还原。高频接口上做全量 trace、随手 heapdump、随手 redefine,这三件事都属于应该在流程上被禁止的动作。

用完不 reset 就断开连接,会留下什么?最直接的后果是被增强的类仍然处于增强状态,每次调用都在走增强逻辑,你以为观测结束了,开销其实还挂着。更值得担心的是,Arthas 在目标机器上开的端口如果没释放,等于留了一个能在进程内执行命令的入口。线程采样、tt 记录这类东西也可能占着资源没清。处置办法是重新连上去执行 reset,确认增强列表为空,再确认端口已释放;连不上去且影响明显时,安排一次重启是最干净的选择。

怎么判断 trace 是不是已经把接口拖慢了?最直接的方法是前后对比:开启之前记下该接口的 P99 与机器 CPU,开启后十秒再看一次曲线。如果 P99 明显抬升或者 CPU 上了一个台阶,说明这台实例的调用量撑不住全量 trace,立刻停掉。也可以用同样的办法反过来验证——停掉之后曲线是否回落。更稳妥的做法是不要做这种试探:先摘流量、先加次数限制与耗时条件过滤,把开销控制在可预期范围内。

为什么 trace 的调用树到线程池那里就断了?因为 Arthas 的调用树依赖线程内的上下文,任务提交到线程池之后,后续逻辑跑在另一个线程上,那个线程没有父调用的上下文,树自然接不下去。这不是工具缺陷,是异步执行的固有特征。解决办法不在 Arthas 侧,而在应用侧:入口生成 traceId 放进 MDC,提交任务时用包装过的 Runnable 把上下文带过去,Spring 可以用 TaskDecorator 统一处理。这样日志里能串起来,trace 断的那一段靠日志补。

容器里 attach 失败一般是什么原因?按概率排,前四类是:不在同一个 PID 命名空间(从宿主机或别的容器去 attach,看到的 PID 对不上),解决方式是 exec 进容器内部操作或者共享 PID 命名空间;用户权限不匹配,发起方要与目标 JVM 同用户或有足够权限;镜像里没有可用的 JDK 或缺少基础命令,精简镜像和 musl 环境尤其常见;文件系统限制,Arthas 要解压运行库、要写临时目录,只读根文件系统或不可写的 /tmp 会让它失败。

heapdump 一定会影响业务吗?什么时机做比较合适?会有影响,而且不小。dump 过程通常伴随一次 Full GC,期间业务线程是停住的,停顿长短取决于堆大小、对象数量、回收器与磁盘速度;同时会产出与堆占用同量级的文件。合适的时机是:已经把这个实例从负载均衡里摘掉,或者确认有多副本且其他实例健康能自动切换;磁盘剩余空间已经确认足够,且目标目录在数据盘而不是系统盘。dump 完分析完立刻删除,文件里含业务数据要按敏感数据管理。

已经有 Arthas 了,还需要上 SkyWalking 或 OpenTelemetry 吗?需要,两者不是一回事。Arthas 是手持救火工具:交互式的、临时的、单进程的、你不盯着就什么都没留下,它的成本是人而不是机器。链路追踪是常态化的:所有请求都有记录、跨服务跨线程能串、事后能回看,成本是部署存储与接埋点的人天。合理的顺序是先指标、再规范化日志(带 traceId),然后按需上链路追踪并压住采样率,Arthas 留作方法级下钻的兜底手段。

观测数据里可能有用户信息,怎么处理才合规?堆快照、日志、火焰图里都可能带上真实业务对象,里面可能有用户信息、凭证、配置密钥。处理原则是把它当成与数据库备份同级别的敏感数据:只在自己有权限的机器上采集,采集范围最小化;下载到本地分析前先确认是否允许跨境或跨部门流转,不允许就在原地分析、只带走结论;分析完删除原始文件;谁在什么时候采集过要有记录。对第三方或未授权的系统,任何形式的探测与采集都不可以做。

谁能 attach:权限边界、审计与做完必须留痕

这一条值得单独讲,因为很多人低估了 attach 的分量。一旦成功 attach 到某个 JVM,你拿到的权限基本等同于该应用本身的权限:能在进程内执行代码(redefine 就是明证),能读到内存里的一切——配置文件里的数据库口令、第三方服务的密钥、正在处理的业务数据、用户信息。它不是一个"诊断工具权限",而是应用级权限,跟数据库管理员权限是一个量级的东西。

管理上要落实四件事。限制人:能登录生产机器的人必须收敛,走堡垒机与账号白名单,别让所有人都能 ssh,也别让应用部署账号拥有超出需要的 sudo 能力。限制面:Arthas 开的端口不要绑到非本地地址,用完 stop;别把隧道服务做成长期开放的内网入口,那等于给内网任何人都开了一扇能执行代码的门。用完还原:reset 加 stop,确认增强列表清空、端口释放,把这一步写进标准操作步骤,而不是靠自觉。留痕:谁在什么时间、对哪台机器的哪个进程、执行了哪些增强或热替换,要有记录可查,有条件就开会话录制。这个记录在复盘时价值极高——尤其是当线上行为跟代码仓库对不上的时候。

边界上只有一句话:只对自己拥有或已获明确授权的机器做这些操作。对第三方系统、客户环境中没有授权书的设备、任何不属于你的资产,任何形式的 attach、探测、抓包、数据导出都不可以做,这是法律问题不是技术问题。同理,采集出来的堆快照与日志含业务数据时,传输、存储、删除都要走跟数据资产一样的管理流程。

什么时候该收手:从救火回到常态观测

一次排查的终点不是找到根因,是把这次找到的东西固化下来,让下次不用再半夜爬起来。具体讲就是三个动作:这次靠人肉判断出来的信号(GC 累计占比、线程池队列长度、下游分段耗时、连接获取耗时),有没有变成常驻的指标和告警;这次靠 trace 才看清的调用路径,有没有在日志里留下足够定位的 traceId 与慢请求记录;这次发现的超时与重试策略问题,有没有真正改掉配置。

Arthas 的定位应该始终是"手持工具"——它在你最需要的时候给你别人给不了的精度,但它不记录、不告警、不跨服务、不持久化。把它当成观测体系本身,就等于默认每次故障都要有一个懂它的人在场。真正省事的是让指标先发现问题、让日志缩小范围、让链路追踪给出那一次调用的完整路径,把方法级观测留给少数确实需要下钻的场景。

一万网络这类机型上跑 Java 服务的观测配置建议

跑 Java 后端的机器,选型时大多数人只算业务峰值,我建议把"可观测性的余量"一起算进去。CPU 上给常态负载留出余量,别按满载买——观测动作本身要吃 CPU,满载的机器在做一次 trace 或采样时就可能把响应时间推到告警线以上,而你根本分不清是故障还是观测造成的。内存上不要只按堆上限算,元空间、线程栈、直接内存、以及类被重转换后 JVM 保留的字节码副本都要占地方,按堆加上一截来选规格,容器环境尤其要留出限制与实际占用的缓冲。磁盘上把系统与数据分开,数据盘留出足够空间给堆快照与日志,介质选 SSD,因为 dump 的写入速度直接决定 STW 停多久。带外管理(IPMI 一类的远程控制台)在进程彻底 hung 住、连 attach 都失效时是唯一通道,这个能力平时没存在感,关键时刻能决定你能不能取证。

一万网络深耕 IDC 行业 19 年(成立于 2007 年),在华南、华东、华北及中国香港、海外多地整合自营节点与 BGP 多线资源,提供服务器租用、机柜托管与带宽接入的一体化交付;具体机型、带宽与报价以官网实时信息或咨询确认为准。如果你的 Java 服务正处在"重启能好两小时"这个阶段,先把上面这几项资源余量确认一遍,再决定要不要上更完整的观测体系——很多时候,问题不是工具不够,是机器已经没有空间让你安全地看一眼。

数据来源

Arthas 官方文档与命令手册(Alibaba 开源项目 arthas):dashboard、thread、trace、watch、tt、stack、monitor、profiler、heapdump、jad、mc、redefine、reset、stop 等命令的用法、参数与限制说明,以及 attach 与目标 JVM 的交互方式。

Arthas 官方关于字节码增强与 ByteKit 的说明:方法级增强的实现方式、增强的织入点与还原机制。

OpenJDK 官方文档:java.lang.instrument.Instrumentation 接口说明,包括 addTransformer、retransformClasses、redefineClasses 的能力边界与"不得改变类结构(增删字段与方法、修改签名)"的限制;Java Agent 的 premain 与 agentmain 两种加载方式。

OpenJDK 官方 JVMTI(JVM Tool Interface)文档:ClassFileLoadHook、RetransformClasses、RedefineClasses 等能力定义,以及 JVMTI 作为底层工具接口与 Java 层 Instrumentation 的对应关系。

OpenJDK 官方对 HotSpot 垃圾回收与 safepoint/STW 机制的说明材料:用于理解 Full GC 停顿与堆转储期间业务线程的行为。

async-profiler 官方项目文档(GitHub):采样原理、支持的事件类型(cpu、alloc、lock、wall、itimer 等)、火焰图输出格式,以及在 Linux 上依赖 perf 事件时的权限要求与常见的容器环境限制。

Linux 内核文档关于 perf_event_open 与 kernel.perf_event_paranoid 参数的说明,以及 CAP_PERFMON、CAP_SYS_ADMIN 相关能力说明:用于判断容器内 CPU 采样能否启用。

Docker 官方文档关于 PID 命名空间共享(--pid 参数)与 seccomp 安全配置文件的说明;Kubernetes 官方文档关于 Pod 内共享进程命名空间(shareProcessNamespace)与容器 capability 配置的说明。

OpenTelemetry 官方概念文档(Observability Primer、Traces/Spans/Context Propagation、Baggage):链路追踪的数据模型、跨服务与跨线程上下文传递的基本定义。

Apache SkyWalking 官方文档:Java Agent 的自动埋点、跨进程与跨线程链路传递(包括线程池场景的上下文传递方案)、采样与存储配置。

Prometheus 官方文档与 Micrometer 官方文档:指标类型(Counter、Gauge、Histogram、Summary)、分位数统计的局限,以及 JVM 与应用指标的暴露方式。

SLF4J 与 Logback 官方文档关于 MDC(Mapped Diagnostic Context)的说明,以及 Spring Framework 官方关于 TaskDecorator 的文档:用于日志链路标识在线程池场景的传递。


上一篇:2026 波黑萨拉热窝服务器租用合规实测:个人数据保护法 12/25 落地后的选型避雷全攻略

下一篇:2026 服务器租用主机入侵检测部署干货:OSSEC 日志关联到文件完整性告警的测评全解