Arthas 的trace命令有多好用,用过的人都知道。线上接口慢、第三方 jar 内部逻辑诡异、某个方法调用耗时突然飙高,一条trace com.example.OrderService createOrder发出去,调用树、每层耗时、异常位置全部打在控制台上,整个过程不用改代码、不用重启应用。但痛点也很明显:Arthas 的输出是终端的,看完就没了,团队其他人看不到,也没法和 Jaeger、SkyWalking、Grafana 这些可观测性平台打通。另一边,OpenTelemetry(OTel)已经把 trace 的数据模型、采集、导出标准化了,生态和工具链都成熟,可它想对线上某个具体 Java 方法做动态追踪,基本做不到,只能靠提前埋点或者挂 agent 做自动化插桩,灵活性和 Arthas 差了不止一个量级。
我一直在想,能不能把这两者揉到一起:用 Arthas 负责“动态的、按需的、方法级追踪”,用 OpenTelemetry 负责“标准化的、可导出的、可挂接后端平台的 trace 模型”。EasyTelemetry 就是在这个思路下折腾出来的一个桥接方案。它做的事情很直接:让 Arthas 的 trace 能力接入 OTel 生态,把trace命令的结果转成 OTel Span,送进 Collector、Jaeger 或者任意兼容后端。这篇博客我不讲大道理,只聊方案怎么设计、怎么落地、踩了哪些坑,以及一个能跑通的最小实例。如果你也遇到过“线上问题靠 Arthas 能查但没法沉淀、想接入 OTel 又不想大规模改造”的情况,这篇文章应该能给你一个很实在的思路。
1. EasyTelemetry 到底想解决什么问题
1.1 Arthas trace 的价值和天花板
Arthas 的trace命令,本质是在运行时对目标类做字节码增强,统计方法内部调用路径、每个子调用的耗时、异常信息等。它最爽的地方在于“不需要预先准备”:应用跑得好好的,发现某个接口卡了,连上 Arthas,指定类和方法,调用树立刻出来。这种能力在故障应急和疑难杂症排查里几乎是不可替代的。
但 Arthas 的设计定位是交互式命令行工具,这也决定了它有明显的天花板。
- 输出只留在终端里,没法沉淀、没法查询、没法跨团队共享。
- 它是“人肉触发”的,问题复现时你正好在,才能抓到现场;不能按条件自动启停。
- 一次 trace 只能覆盖一个类和方法,分析链路时如果问题在深层子调用,你得一层层往里追。
一句话总结:Arthas 擅长“把一次调用看穿”,但不擅长“把多次调用变成长期可观测的数据资产”。
1.2 OpenTelemetry 的标准化优点和动态短板
OpenTelemetry 真正厉害的地方,是定义了统一的 trace 模型:TraceId、SpanId、ParentSpanId、SpanKind、Attributes、Events、Status,以及一套和语言无关的导出协议 OTLP。只要能生成符合这个模型的数据,就能无缝交给后端平台去展示、检索、关联、告警。
OTel 的短板在于获取数据的方式。常规做法有两种:一是代码埋点,在业务代码里手动创建 Span;二是运行 Java agent,让 agent 自动拦截常见框架和库的调用。但这两种都解决不了“运行时临时想看某个方法的调用细节”的需求。你可以在代码里写埋点,但不可能把每个方法都埋一遍;你可以挂 agent,但 agent 的插桩规则有限,对自定义类、第三方 jar 内部逻辑未必覆盖。如果只想针对某个线上偶发问题动态追踪一个方法,OTel 这套体系是有些笨重的。
1.3 “按需动态 Trace + 标准化导出”才是刚需
把两个工具放在一起看,需求非常清晰:线上能动态追踪,数据能标准化导出。
比如排查一个订单接口偶发超时的问题,你不想改代码、不想重启,只想知道某次调用里OrderService.createOrder底下到底哪个环节慢了。你要的是:
- 能随时对特定方法开启追踪,不用预埋点;
- 追踪结果能送到 Jaeger 这类平台,按 TraceId 查,能看到完整调用链;
- 问题复现一次就能抓到数据,而不是靠肉眼盯终端。
EasyTelemetry 就是干这个的。它把 Arthas 的“动态能力”和 OTel 的“标准模型”对接起来,让两条技术路线从“互相看不上”变成“各取所需”。对于 Java 后端开发、SRE、性能排查人员来说,这相当于给 OTel 补上了“线上动态方法级追踪”这一环。
2. EasyTelemetry 的整体设计和核心思路
2.1 架构:Arthas 负责定位,OTel 负责传输
EasyTelemetry 的核心架构并不复杂,整体分成四层。
第一层是Attach 层。EasyTelemetry 启动后,通过 JVM Attach 机制挂到目标 Java 进程上,在目标 JVM 里启动 Arthas 的 Core 服务。这一步和手动执行java -jar arthas-boot.jar的逻辑是一样的,只是由 EasyTelemetry 自动完成。
第二层是命令执行层。EasyTelemetry 不是让你在交互终端里敲 Arthas 命令,而是通过 Arthas 的异步命令 API 直接下发trace指令,然后持续读取命令输出。这里有一个关键点:Arthas 的 trace 命令本身是持续输出的,它会一直拦截匹配方法的调用并打印调用树,直到你执行stop或者断开连接。这个持续输出正好和可观测性平台的“持续采集”语义天然吻合。
第三层是解析映射层。Arthas 的 trace 输出是一段文本,比如:
[arthas-9527] $ trace com.example.OrderService createOrder Press Q or Ctrl+C to abort. Affect(class-cnt:1 , method-cnt:1) cost in 98 ms. `---ts=2025-04-08 14:23:11;thread_name=http-nio-8080-exec-3;id=42;is_daemon=true;priority=5; `---[8.1234ms] com.example.OrderService.createOrder() +---[0.0231ms] com.example.PriceCalculator.calculate() | `---[0.0188ms] com.example.PriceCalculator.roundPrice() +---[2.3124ms] com.example.InventoryClient.checkStock() | `---[1.9877ms] com.example.http.HttpUtil.get() `---[5.3301ms] com.example.OrderDao.insert() `---[1.2012ms] com.example.db.ConnectionPool.getConnection()这行文本就是信息源。EasyTelemetry 要做的是解析这段文本,把每次方法调用映射成一个 OTel Span:com.example.OrderService.createOrder()是根 Span,PriceCalculator.calculate()、InventoryClient.checkStock()等是它的子 Span。每一层的缩进关系用来确定 ParentSpanId。
第四层是导出层。解析出的 Span 通过 OTel SDK 的 SpanProcessor 交给 Exporter,用 OTLP 协议发给 OTel Collector,再转发到 Jaeger、Tempo、SkyWalking 等任意后端。此时 EasyTelemetry 已经不再是 Arthas 的“壳”,而是真正接入了标准可观测性管线。
这种“Arthas 做脏活、OTel 做传输”的架构有个很实际的好处:EasyTelemetry 本身不需要关心字节码增强细节,Arthas 在类加载、增强、兼容性上踩过的坑,我们都直接规避了。
2.2 为什么不是自研字节码增强,也不是魔改 Arthas
在设计早期,我考虑过两条其他路线。
一条是自己做字节码增强,在目标方法入口和出口分别插入 Span 逻辑。好处是可控性强,可以和 OTel 的 Context 直接打通。但代价是:需要处理类加载器隔离、JDK 模块限制、异步线程上下文传播、并发调用栈匹配,还要兼容各种类库。如果不是专门做 APM 产品,一个月内大概率做不出稳定版本。
另一条是魔改 Arthas 源码,让它在 trace 时直接生成 OTel Span。这个方案的问题在于 Arthas 是快速迭代的开源项目,改源码意味着要长期维护一个私有分支,每次跟上官方更新都要做大量合并。而且 Arthas 本身并非为可观测性平台设计,直接改它的核心逻辑,风险不可控。
最终 EasyTelemetry 选择了解析 Arthas 文本输出这条路,原因是它最大程度复用了成熟能力,又保持了很低的耦合度。Arthas 的 trace 输出虽然是人读的,但格式相对规整:通过缩进表达调用层级,通过---[耗时]表达调用耗时,通过ts=和thread_name=表达线程信息。只要建立好解析规则,就能稳定还原出调用树。这个方案还有一个额外好处:只要 Arthas 的输出格式不变,EasyTelemetry 就能持续工作,不用跟着 Arthas 内部实现走。
2.3 Trace 模型映射:文本调用树如何变成 Span 树
把 Arthas 文本转成 OTel Span,最核心的问题是“怎么保证父子关系和 TraceId 正确”。
OTel 的 Span 模型是这样的:一个 Trace 有唯一的 TraceId,Trace 里有多个 Span 通过 ParentSpanId 挂成树状结构。Jaeger 展示的火焰图和调用瀑布图,依赖的就是这个树结构。
Arthas 的 trace 输出天然就是一棵树,但问题是它没有 OTel 的 TraceId 和 SpanId。EasyTelemetry 的做法是:在一段时间内,把 Arthas 在同一个线程中的连续输出解析为一条 Trace,每次顶层方法调用生成一个新的函数级 TraceId,方法调用树中的每个节点生成一个 SpanId,缩进关系决定父子关系。
这里有个必须特别注意的问题:Arthas 的 trace 输出是并发环境下混杂在一起的。多个线程同时调用同一个方法时,Arthas 会在每个调用树前打印线程信息,但输出顺序可能交叉。EasyTelemetry 在解析时必须以thread_name为第一分组维度,每个线程单独维护一棵树,否则会得到张冠李戴的调用链。实际实现中,我用一个ThreadLocal维护“当前解析现场”,每次收到一个线程的顶层调用开始行,就关闭上一个现场,开启新现场;收到子调用行,就挂到当前栈顶节点下面。
Span 的映射规则我定为:
- Span Name:使用“类名.方法名”,与 Arthas 输出一致;
- Span Kind:统一使用
INTERNAL,因为这是进程内方法调用,不是远程调用的 Client/Server 语义; - Span Status:如果 Arthas 输出中包含
throw或exception标记,则设置为STATUS_ERROR; - Attributes:记录
class.name、method.name、thread.name、thread.id、component=arthas-trace。
有一点需要说明:这里生成的 TraceId 和业务系统里的 OTel TraceId 是独立的。如果业务系统本身已经接了 OTel,那这条动态 trace 无法做到和业务 trace 自动串联到同一个 TraceId 下。要实现关联,要么让 EasyTelemetry 从当前线程上下文里提取已有 TraceId,要么接受它作为独立 Trace 存在。我最初做的是后者,因为使用场景大多是临时排查,独立 Trace 已经足够。
3. 实操过程:把 EasyTelemetry 跑起来是个什么体验
3.1 环境准备
先列出我实际验证过的一套环境:
- JDK:8 和 11 都试过,17 也可以,但需要额外处理模块访问;
- 目标应用:一个普通的 Spring Boot 服务,端口 8080;
- Arthas:3.6.7 及以上版本;
- OTel Collector:本地 docker 启动,端口 4317 接收 OTLP;
- Jaeger:作为 trace 后端,用 all-in-one 镜像最省事;
- EasyTelemetry:以可执行 jar 方式启动。
准备一个最简单的示例服务,核心接口如下:
@RestController public class OrderController { @GetMapping("/create") public String create() { double price = priceCalculator.calculate(); int stock = inventoryClient.checkStock(); orderDao.insert(price, stock); return "ok"; } }这个接口内部有三次子调用,非常适合演示 trace 树的父子关系。
3.2 EasyTelemetry 的配置
EasyTelemetry 用一个 YAML 文件做配置,下面是我常用的最小配置:
easytelemetry: # attach 目标进程,可以是 pid,也可以按 Main 类名匹配 target: com.example.OrderApplication trace: # 要追踪的类名模式,支持全限定名或通配符 className: com.example.OrderService # 要追踪的方法名 methodName: createOrder # 达到多少 ms 才输出 trace,设置 0 表示全部输出 condition: 0 otel: serviceName: order-service-arthas exporter: endpoint: http://127.0.0.1:4317 protocol: grpc sample: # 仅在有调用时采样,减少持续增强带来的开销 mode: on-demand注意target既可以用 PID,也可以用主类名。实际使用中,如果你有两个同名主类,还是用 PID 更稳妥。condition对应 Arthas trace 命令的#cost参数,意思是“只显示耗时超过该阈值的调用”,默认 0 会显示所有。
这里我把采样模式配置成on-demand。因为 Arthas 的字节码增强是持续生效的,如果不加限制,高频方法的增强会对性能有影响。后面我会专门讲这个坑。
3.3 启动 EasyTelemetry 并触发 trace
启动命令很简单:
java -jar EasyTelemetry.jar --config /path/to/easytelemetry.yamlEasyTelemetry 启动后会做三件事:
- 通过 Attach API 连上目标 JVM;
- 在目标 JVM 中启动 Arthas Core;
- 下发 trace 命令并保持异步读取。
日志会输出:
[EasyTelemetry] attached to pid 87321 [EasyTelemetry] arthas core started [EasyTelemetry] trace command submitted: trace com.example.OrderService createOrder #cost 0 [EasyTelemetry] listening arthas output...此时,访问一次示例接口/create,Arthas 会拦截到OrderService.createOrder的调用并输出调用树。EasyTelemetry 解析后生成 OTel Span,并批量推送到 Collector。
控制台层面,EasyTelemetry 还会同步打印一行摘要,方便你验证解析结果:
[EasyTelemetry] span committed: trace=8f3a1c2e, span=OrderService.createOrder, children=3, cost=8.1234ms3.4 在 Jaeger 里看到动态 trace
打开 Jaeger 的查询页面,选择服务order-service-arthas,就能看到刚才触发的 Trace。点进去之后,span 树长这样:
OrderService.createOrder 8.12ms ├── PriceCalculator.calculate 0.02ms ├── InventoryClient.checkStock 2.31ms └── OrderDao.insert 5.33ms对比 Arthas 命令行里的输出,结构一致,只是从终端搬到了标准 trace 后端。你可以按 TraceId 精确检索某一次调用,也可以按时间段浏览在这段时间内所有被 trace 的调用。
这一步看起来简单,但意义不一样:Arthas 只能告诉你“这一次调用发生了什么”,接入 EasyTelemetry 之后,你能看到“这段十分钟内所有被追踪调用的分布”,能筛选、能排序、能和其他系统指标对照。所谓可观测性,就是数据能留存、能检索、能分析,这一点上 EasyTelemetry 把它补齐了。
3.5 核心流程的最小实现思路
如果你不想直接依赖 EasyTelemetry,自己实现这套逻辑也是可以的。核心链路只有四段。
第一段:Attach 到目标 JVM
VirtualMachine vm = VirtualMachine.attach(pid); String arthasJarPath = "/path/to/arthas-core.jar"; vm.loadAgent(arthasJarPath, config);第二段:执行 Arthas trace 命令并读取输出
Arthas Core 支持通过com.alibaba.arthas.core.command.monitor100.TraceCommand执行 trace,输出会写入一个可读取的流。实际如果自己接,比较容易的方式是走 Arthas 的 Telnet/HTTP 通道,发指令、读输出。
第三段:解析输出为 Span 树
维护一个当前解析栈,遇到带缩进的调用行就解析出类名、方法名、耗时,生成 Span 节点;遇到异常标记就设置错误状态。
第四段:用 OTel SDK 导出
Span span = tracer.spanBuilder(node.getMethodName()) .setSpanKind(SpanKind.INTERNAL) .setAttribute("class.name", node.getClassName()) .setAttribute("method.name", node.getMethodName()) .startSpan();真正做的时候,第二段是最闹心的,因为 Arthas 的异步输出格式在不同版本上有细微差别,比如thread_id字段在 3.6.x 之后变成了id,解析规则必须做兼容。我建议你先手动跑一次trace命令,把完整输出截下来再写解析器,别凭感觉猜。
4. 常见问题与排查技巧实录
4.1 trace 命令没有输出
最常见的现象是:EasyTelemetry 启动成功,也提示trace command submitted,但访问接口之后 Arthas 没有捕获到任何调用。
按优先级排查:
- 类名和方法名是否正确。Spring 的 Bean 大多数是 CGLIB 代理类,类名会变成
com.example.OrderService$$EnhancerBySpringCGLIB$$xxx。如果 Arthas 匹配不到,可以用sc -d查看实际类名。对于接口 trace,建议用实现类名。 - 方法是否真的被调用。有些逻辑走了缓存、短路、分支判断,根本没有进到目标方法。
- 目标类是否已经加载。如果类还没加载,Arthas 的 trace 默认是匹配不到的,需要开启
-D参数或者用trace com.example.OrderService createOrder -n 1配合重试。 - 目标 JVM 是否有运行权限。Arthas attach 在容器环境和受管控环境里经常会失败,报
Can not attach to current VM或Permission denied。容器里尽量挂载/proc,并确保宿主机和容器是同一个 PID namespace。
我踩得最深的一个坑是:Arthas 成功 attach 了,但 trace 命令匹配到的是父类方法,而实际调用是子类重写后的方法。这个问题通过打印实际调用类是看不出来的,要用stack命令确认调用栈,或者直接在目标方法上打一个临时watch。
4.2 Jaeger 里 Span 树是乱的,或者有多个根
如果你看到同一批 Span 在 Jaeger 里被拆成多个 Trace,或者父子关系错乱,十有八九是并发解析问题。
Arthas 在并发调用时,输出流是交叉的:
thread_a: `---[8ms] com.example.OrderService.createOrder() thread_b: `---[5ms] com.example.OrderService.createOrder() thread_a: +---[2ms] com.example.PriceCalculator.calculate() thread_b: +---[1ms] com.example.InventoryClient.checkStock()如果解析器只是简单地把“每次遇到新缩进就挂到当前节点后面”,thread_b 的调用很可能挂到 thread_a 的树下面。我的解决办法是严格以线程名为维度缓存解析栈,收到顶层调用行时,先清理该线程之前的栈,再开启新的树。这个细节必须在解析器设计时就要做,不然后面查起来非常痛苦。
还有个容易忽略的点:Arthas 的 trace 命令在输出到顶层方法前,会先打印一行Affect(class-cnt:1 , method-cnt:1) cost in 98 ms。这行不是调用树的一部分,解析时必须过滤,否则它会变成一个莫名其妙的根节点。
4.3 性能开销比预期高
Arthas 的 trace 本质是增强目标方法,每次调用会走增强代码。如果你 trace 的是一个 QPS 很高的方法,开销会被放大。我实测过,对一个每秒几百次调用的方法开启 trace,CPU 额外增加约 5% 到 10%。单次 trace 能接受,但持续开着不合适。
EasyTelemetry 的解法是:
- trace 命令只在需要时开启,用完就停,不要真当成全链路追踪长期挂载;
- 用
condition设置耗时阈值,只关注慢调用; - 在高频场景下配合 OTel 的采样策略,比如只导出 1% 的 trace。
另外,Arthas 对已增强类需要做恢复。停止 trace 时,Arthas 会移除增强逻辑,但偶尔要等几秒才会完全恢复。如果并发又开了另一个 trace,可能出现 ClassReTransForm 失败,建议两个 trace 之间留至少 1 到 2 秒的间隔。
4.4 JDK 版本和 ClassLoader 兼容性
JDK 11 以上,Arthas attach 有时会报 module 访问错误。操作上我习惯在应用启动脚本里加上--add-opens=java.base/jdk.internal.loader=ALL-UNNAMED这类参数,能省很多事。
ClassLoader 的问题更隐蔽。Arthas 用系统类加载器加载自身,但目标类可能是 Web 容器或框架自定义类加载器加载的。当目标类加载器和 Arthas 类加载器不一致时,增强会静默失败,几乎不报错。排查办法是在 Arthas 里执行classloader命令,确认线程上下文类加载器已经切换到 Tomcat 等容器对应的类加载器。
EasyTelemetry 在启动后会自动尝试切换到目标类的类加载器下发命令,这也算一个大家在自研时必须关注的细节。
4.5 常见问题速查表
| 现象 | 可能原因 | 推荐处理 |
|---|---|---|
| attach 失败 | 容器权限、PID namespace 不一致 | 使用 PID 重试;挂载 /proc;启动参数加 --add-opens |
| trace 无输出 | 类匹配失败 | 用 sc 确认实际类名;开启 -D 等待类加载 |
| trace 无输出 | CGLIB 代理覆盖 | 改为 trace 实现类,或 trace 接口的代理类名 |
| Span 树错乱 | 并发线程输出交叉 | 解析时以线程名分组,独立维护解析栈 |
| Jaeger 找不到 Trace | OTLP exporter 地址错误 | 检查 endpoint 是否可通;查看 EasyTelemetry 日志 |
| trace 输出中断 | Arthas 版本输出格式变化 | 升级到最新版;调整解析规则兼容 id/thread_id 字段 |
| 目标 JVM 崩溃/慢 | 高频方法过度增强 | 增加耗时阈值;减少 trace 持续时间;配合采样 |
| Collector 拒绝数据 | service.name 重复或 metadata 冲突 | 检查资源配置,确认 exporter 与 Collector 协议一致 |
5. 一些落地经验和下一步想法
EasyTelemetry 这个项目做下来,我最深的体会是:架构可以很克制,需求可以很精确。它没有造轮子,没有重新实现字节码增强,也没有去定义一套新的 trace 规范,只是在两个成熟工具中间补了一层“翻译”。这层翻译让 Arthas 的临时动态能力进入了可观测性平台的标准体系,解决了以前那种“查完就丢”的尴尬。
实际使用中,我认为这个方案最合适的定位是可观测性的“应急补充层”。全链路埋点是日常的常态观测,EasyTelemetry 是问题出现时的精确放大镜。它适合在疑难问题排查、性能定位、第三方 jar 黑盒分析时开启,但不建议长期挂在高频路径上。这是 Arthas 这类动态增强工具和正式埋点方案的天然边界,强行跨界只会带来不必要的性能负担。
如果你也想做类似的东西,我的建议是先从解析 Arthas 输出开始,别急着设计分布式采集和管理端。先用一个单机 demo 跑通“目标方法调用 -> 控制台输出 -> 解析 -> Jaeger 展示”这条链路,再逐步考虑多实例、自动启停、对接告警。
后续 EasyTelemetry 我想做三件事。一是支持从 OTel 的已有 Span 上下文里继承 TraceId,让动态 trace 能挂到业务全链路里。二是做一个规则触发模块,比如“请求耗时超过 2 秒时自动对该请求涉及的关键方法开启 trace 并保留现场”。三是把 Arthas 的watch、stack输出的关键字段也转成 Span Event,让诊断信息不只是树的形状,还有具体参数和调用栈。这条路走下去,动态诊断和标准化可观测性之间的边界会越来越模糊,对排查问题的人来说,体验也会好很多。