第 08 章(节选) Arthas瑞士军刀

本篇来自《Go 程序员的 Java 修炼之路》第 08 章「第 08 章 排错方法论:从异常栈到根因(一套能复用的 SOP)」。 返回:第 08 章索引

08.5 Arthas:Java 排错的”瑞士军刀”

如果本章只允许你记住一个工具,记它。

它解决的问题:不改代码、不重启、不加日志,就能看到线上正在跑什么。把 Java 的反馈周期从”3 分钟一次重启”压回到”3 秒一次命令” —— 也就是说,它把你熟悉的 Go 式快速试错还给了你。

安装与启动

curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar
# 会列出当前机器上所有 Java 进程,输入序号回车即可 attach

几个必知的运维细节:

  • quit 和 stop 不一样:quit 只是断开客户端连接,Arthas 服务端还在目标进程里,字节码增强还在生效;stop 才是彻底关闭并自动 reset 所有增强。用完请 stop
  • 容器里没有 JDK 工具链也能用(Arthas 自带),但目标进程必须有可写的 /tmp
  • 生产环境建议先 options unsafe false(禁止 redefine/retransform 这类改字节码的高危命令)

核心命令:场景 + 输出解读

dashboard —— 排错第一步

dashboard -i 3000     # 每 3 秒刷新一次

一屏给出:所有线程的 CPU/状态、堆各区的实时使用量、GC 次数与耗时、运行时信息。它的价值不是”数据全”,而是”不用敲十条命令就能排除掉一半可能性” —— 扫一眼就知道 CPU 高不高、堆满不满、GC 频不频繁、线程数合不合理。

按 Ctrl+C 退出。

thread —— 线程问题的主力

thread                # 所有线程,按 CPU 增量降序
thread -n 3           # CPU 最高的 3 个线程 + 完整栈
thread -b             # ★ 找出阻塞了其他线程的那个线程(死锁/锁竞争排查神器)
thread --state BLOCKED # 列出所有 BLOCKED 状态的线程
thread -i 2000        # 统计最近 2000ms 内的 CPU 时间
thread 45             # 看某个线程 ID 的完整栈

thread -b 的输出长这样:

"http-nio-8080-exec-12" Id=45 RUNNABLE
    at java.net.SocketInputStream.socketRead0(Native Method)
    at com.mysql.cj.protocol.a.NativeProtocol.readMessage(NativeProtocol.java:581)
    at com.example.order.InventoryService.deduct(InventoryService.java:92)
    - locked <0x00000000d7b3c210> (a com.example.order.InventoryService)

    Number of locked synchronizers = 1
    - java.util.concurrent.ThreadPoolExecutor$Worker@6d2a3f

这一条命令就把”三连 jstack + 人工比对 + 反查 locked”三步自动化了。它相当于把 Go 里 SIGQUIT 那种”一眼看到问题”的体验还给了 Java。

一个必须知道的限制:thread -b 只支持 synchronized 阻塞的线程,不支持 java.util.concurrent.Lock(ReentrantLock 等)。用 ReentrantLock 的代码,thread -b 会告诉你”没有找到阻塞线程” —— 这时要用 thread -n 3 配合 jstack -l 看 Locked ownable synchronizers。

watch —— 不用加日志就能看到线上数据

# 观察 pay 方法的入参、返回值、耗时,展开深度 3
watch com.example.OrderService pay '{params, returnObj, #cost}' -x 3
 
# 只看异常情况(方法抛异常时才输出)
watch com.example.OrderService pay '{params, throwExp}' -e -x 3
 
# 带条件过滤:只观察耗超过 200ms 的调用
watch com.example.OrderService pay '{params, #cost}' '#cost > 200' -x 2
 
# 观察当前对象的字段
watch com.example.OrderService pay 'target.inventoryClient' -x 2

-x 是展开深度,取值 1~4,默认 1。 深度越大输出越详细但越慢 —— -x 4 展开一个复杂对象图可能直接卡死你的终端,甚至拖慢目标进程。 建议从 -x 2 开始,需要更深时用 OGNL 表达式精确取字段(target.cache.size())而不是整体展开。

四个观察点:-b(调用前)、-e(异常后)、-s(返回后)、-f(结束后,默认开)。注意 -b 时 returnObj 不存在。

真实输出示例:

method=com.example.order.OrderService pay location=AtExit
ts=2026-09-02 14:23:11; [cost=1283.502ms] result=@ArrayList[
    @Object[][@Order[id=10086, amount=99.90], @PayReq[channel=ALIPAY]],
    @PayResult[success=false, code=TIMEOUT, msg=null],
    @Long[1283],
]

这一条命令的价值是什么? 你不需要为了看”线上这个订单的 amount 到底是多少”而加一行日志、重新编译、重新部署、重新触发请求。直接看。 这就是把反馈周期从 3 分钟压回 3 秒。

trace —— 性能问题定位神器

trace com.example.OrderService pay                    # 看方法内部每一层调用耗时
trace com.example.OrderService pay '#cost > 500'       # 只显示超过 500ms 的调用
trace -E com.example.OrderService|com.example.PayService pay|refund   # 多类多方法
trace com.example.OrderService pay --skipJDKMethod false   # 包含 JDK 内部调用

输出示例(这是全章最有用的一张输出):

`---ts=2026-09-02 14:23:11;thread_name=http-nio-8080-exec-3;id=2f;is_daemon=true;priority=5;
    `---[1283.502ms] com.example.order.OrderService:pay()
        +---[0.032ms] com.example.order.OrderService:validate()
        +---[1201.338ms] com.example.order.InventoryClient:deduct()   ← 94% 的时间在这
        |   `---[1201.201ms] com.mysql.cj.jdbc.ClientPreparedStatement:execute()
        +---[80.112ms] com.example.order.OrderMapper:insert()
        `---[1.020ms] com.example.order.MqProducer:send()

一眼看到瓶颈在 InventoryClient.deduct(),而它 1201ms 里 1201ms 花在 JDBC 执行上 —— 慢 SQL,不是代码问题。 这个结论如果用”猜”来得到,你可能要先怀疑 GC、再怀疑锁、再怀疑序列化,一天过去了。

注意:trace 默认跳过 JDK 自带方法。如果你怀疑瓶颈在 String.split() 或 HashMap.put() 里,要加 --skipJDKMethod false。

stack —— 谁调了它

stack com.example.OrderService cancelOrder -n 5

回答”这个方法是从哪些路径被调用的”。排查”谁在改这个字段”、“为什么这个方法被调用了两次”这类问题时,比读代码快十倍 —— 因为 Java 的调用关系可能是通过反射、代理、注解触发的,读代码根本连不起来。

tt —— TimeTunnel,偶发问题神器

tt -t com.example.OrderService pay -n 50   # 记录最近 50 次调用
tt -l                                       # 列出所有记录
tt -s 'method.name=="pay"'                  # 按条件检索
tt -i 1004                                  # 看某次调用的详细信息
tt -i 1004 -w 'params[0].amount'            # 用 OGNL 对该次调用求表达式
tt -i 1004 -p                               # ★ 重放这次调用(用当时的入参再跑一次)
tt --delete-all                             # ★ 清空记录(否则一直占内存)

tt -p(replay)的用法:你怀疑某个分支有问题,改了一版代码后,用 tt -i 1004 -p 把当时那次调用的原始参数重新灌进去执行 —— 这是在”没有自动化测试用例”的情况下最接近复现的手段。

两个必须知道的坑:

  1. tt 默认只保留 100 条记录,且这些记录持有对象引用,退出 Arthas 不会自动清理。 长时间挂在高频方法上会导致 OOM。用完 tt --delete-all
  2. tt 保存的是对象引用,不是快照。 如果方法内部修改了入参,或者返回的对象后来被改了,你看到的是修改后的值,不是当时的值。要看当时的精确值,用 watch

sc / sm —— 查类信息(解决 Jar Hell)

sc -d com.example.order.service.OrderServiceImpl
sm com.example.order.service.OrderServiceImpl        # 列出所有方法
sm -d com.example.order.service.OrderServiceImpl pay # 看某个方法的签名

sc -d 的输出 —— code-source 这一行直接解决依赖冲突:

 class-info        com.example.order.service.OrderServiceImpl
 code-source       /app/order-service.jar!/BOOT-INF/lib/order-core-1.2.3.jar!/
 name              com.example.order.service.OrderServiceImpl
 isInterface       false
 isAnnotation      false
 isEnum            false
 isAnonymousClass  false
 isArray           false
 isLocalClass      false
 isMemberClass     false
 isPrimitive       false
 isSynthetic       false
 simple-name       OrderServiceImpl
 modifier          public
 annotation        org.springframework.stereotype.Service
 interfaces        com.example.order.service.OrderService
 super-class       +-java.lang.Object
 class-loader      +-org.springframework.boot.loader.LaunchedURLClassLoader@1a2b3c4
                     +-jdk.internal.loader.ClassLoaders$AppClassLoader@3d4e5586
 classLoaderHash   1a2b3c4

code-source 告诉你这个类是从哪个 jar 的哪个版本加载的,还告诉你加载它的 ClassLoader 是谁。 一个 NoSuchMethodError 用它三秒钟就能定罪。

再加两个技巧:

  • sc -d com.example.OrderService | grep code-source —— 只看这一行
  • 如果有多个 ClassLoader 加载了同一个类,sc 会列出多份,用 -c <classLoaderHash> 指定看哪一个

jad —— 反编译线上代码

jad --source-only com.example.order.service.OrderServiceImpl
jad --source-only com.example.order.service.OrderServiceImpl pay   # 只反编译某个方法

这个命令的用途经常被低估。 它的真正价值不是”看代码”(你有 git 仓库),而是回答”线上跑的是不是你认为的那个版本”。

我见过太多次这种对话:

“不可能啊,我明明修了那个 bug。” jad 一反编译 —— 线上还是旧代码。发布流程有问题、构建缓存没清、或者改的是另一个模块,各种原因都有。

在你开始怀疑逻辑之前,先用 jad 确认线上代码。 这一步 10 秒,能省你两小时。

ognl —— 运行时查看/修改状态

ognl '@[email protected]()'      # 调用静态方法
ognl '@java.lang.System@getProperty("java.version")'       # 看系统属性
ognl '#[email protected]@getCurrentWebApplicationContext(), #ctx.getBean("orderService")'
# 拿到 Spring 容器,然后可以调用任意 Bean 的方法(危险但极有用)

profiler —— 火焰图

profiler start --event cpu --interval 10000000     # 开始采样(内部是 async-profiler)
profiler getSamples                                 # 看已采样本数
profiler stop --format html --file /tmp/cpu.html    # 停止并生成 HTML 火焰图
profiler start --event alloc                        # 内存分配火焰图
profiler start --event wall                         # 墙钟火焰图(含等待)

vmtool / heapdump —— 内存相关

vmtool --action getInstances --className com.example.OrderCache --limit 10 --express 'instances[0].size()'
vmtool --action forceGc                             # 强制 GC(等价于 System.gc(),生产慎用)
heapdump --live /tmp/heap.hprof                     # 堆转储(会 STW,必须摘流量)

vmtool --action getInstances 很实用:不用 dump 整个堆,就能知道某个类的实例有多少个、里面装了什么。怀疑某个静态缓存泄漏时,先用它,比 dump 快一万倍。

其他高频命令

jvm                                    # JVM 信息总览(参数、类加载、内存、GC、线程数)
jvm -X                                 # 只看 JVM 参数
logger --name ROOT --level debug       # ★ 运行时动态改日志级别(出事时临时开 DEBUG)
logger --name com.example.order --level DEBUG
sysprop                                # 系统属性
getstatic com.example.OrderCache INSTANCE      # 看静态字段
monitor -c 5 com.example.OrderService pay      # 每 5 秒统计一次调用次数/成功率/平均 RT

logger --name ROOT --level debug 这一条在生产出事时极有价值:临时打开 DEBUG,复现一次,再关回去 —— 全程不用重启。记得复现完调回 INFO。

【思考】Arthas 是怎么做到的?(提示:Java Agent、Instrumentation、字节码增强。)

想清楚原理之后,再想一层:原理本身是否已经暗示了它的风险是什么?

参考答案

直接答案:Java Agent + JVMTI 的 Instrumentation API + 字节码增强(ASM / ByteKit)。

三层机制:

第一层:Attach 机制(怎么进入目标进程)。

java -jar arthas-boot.jar

背后是 JVM 的 Attach API(com.sun.tools.attach.VirtualMachine)。它的工作方式是:

  1. 通过 Unix domain socket / 信号,给目标 JVM 发一个 SIGQUIT 之外的特殊指令
  2. 目标 JVM 内部有一个常驻的 Signal Dispatcher 线程收到指令
  3. JVM 在目标进程内启动一个 Attach Listener 线程
  4. 这个线程加载你指定的 agent(一个 jar 里的 Agent-Class)
  5. agent 在目标 JVM 的进程空间内执行,拿到 Instrumentation 对象

关键点:Arthas 运行在目标 JVM 内部(不是外部进程通过调试协议连接),所以它能直接操作 Java 对象。这是它跟 dlv(Go 调试器,外部进程 + ptrace)的根本区别。

第二层:Instrumentation API(怎么改字节码)。

public interface Instrumentation {
    // 注册一个字节码转换器
    void addTransformer(ClassFileTransformer transformer, boolean canRetransform);
 
    // 对已加载的类重新走一遍转换流程(★ 这是"不用重启"的关键)
    void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
 
    // 获取堆里所有对象
    Object[] getObjects(Class<?> clazz);   // 简化表示
}

Instrumentation 是 JVM 提供的标准化的运行时插桩接口。它的契约是:

你可以给我一个”字节码转换器”,我(JVM)在加载类的时候(或者你显式要求重新转换的时候)会把原始字节码交给它,然后把返回的字节码作为实际使用的类定义。

这就是”不用重启”的技术基础:retransformClasses() 允许 JVM 替换一个已经加载、甚至已经在运行的类的定义(限制是不能增删字段和方法,只能改方法体)。

第三层:字节码增强(改了什么)。

Arthas 拿到 OrderService.pay() 的原始字节码后,用 ASM(早期)/ ByteKit(现在)做修改,在方法里插入埋点。逻辑上等价于:

// 原始方法
public PayResult pay(Order order) {
    return doPay(order);
}
 
// 增强后(示意,实际是字节码层面的 goto/异常表操作)
public PayResult pay(Order order) {
    long __start = System.nanoTime();
    Object[] __params = {order};
    try {
        PayResult __ret = doPay(order);
        ArthasHook.atExit(__params, __ret, System.nanoTime() - __start);  // 插入的
        return __ret;
    } catch (Throwable t) {
        ArthasHook.atExceptionExit(__params, t, System.nanoTime() - __start);  // 插入的
        throw t;
    }
}

watch 是插入 atExit 钩子,trace 是在每一层子调用前后都插钩子并记录时间戳,tt 是把参数对象存进一个 Map<Integer, TimeFragment>。

为什么 JVM 能做这件事,而 Go 不能? 因为 JVM 执行的是标准化的字节码,字节码格式是公开规范,有一套完整的验证规则。JVM 可以在加载时任意改写它,只要改写结果能通过字节码验证器(verifier)的检查:栈深度平衡、类型匹配、不越界访问。

原理已经暗示了风险 —— 四条:

风险一:字节码增强有性能开销,且开销正比于埋点数量。

每个 trace 会在方法内的每一层子调用前后都插入计时代码。如果你对一个调用链很深、QPS 很高的方法(比如一个 toString())开 trace,开销可能是数倍。

风险二:-x 4 展开复杂对象可能直接把进程拖垮。

watch 的展开深度是靠反射递归遍历对象图,-x 4 展开一个包含 10 万个元素的集合,会在业务线程上同步执行这次遍历和字符串拼接 —— 那是一次 STW 级别的卡顿。

风险三:增强不会因为你断开了连接而消失。

这是最阴的一条。quit 只断开客户端,Arthas 服务端还在目标进程里,字节码还是被改过的那个版本。你以为你退出去了,实际上你的 watch 还在每秒打印几千行、还在拖慢每一个请求。

# 正确的收尾动作
reset      # 撤销所有字节码增强(只 reset,不退出 Arthas)
quit       # 断开连接(增强还在!)
stop       # ★ 彻底关闭 Arthas 服务端,会自动先 reset

纪律:用完 stop,或者至少 reset。

风险四:类加载的 PermGen/Metaspace 压力。

每次 retransform 会产生新的类定义。反复增强/撤销会往元空间里塞东西。正常用没问题,但如果你写脚本循环 retransform 几百次,可能会把元空间顶满。

风险五(隐藏款):增强会绕过某些 JIT 优化。

被增强的方法可能无法内联,或者 JIT 会放弃对它的某些优化。所以在开着 watch 的情况下测出来的性能数据,不能当作真实性能数据。

生产使用 Arthas 的规范(可以直接抄进你们的运维手册):

条目要求
事先验证高危操作(watch 大对象、retransform)先在预发环境跑一遍
展开深度从 -x 2 开始,禁止默认用 -x 4
目标方法用条件表达式('#cost > 200')和 -n 限制输出量,绝不裸监控高频方法
时段避开高峰期做 trace 和 profiler
tt 清理用完必须 tt --delete-all,否则一直持有对象引用导致泄漏
高危命令生产环境 options unsafe false,禁掉 redefine/retransform
收尾用完 stop(不是 quit),并确认 reset 已执行
影响范围一次只 attach 一台机器,不要批量(否则问题没查到,服务先被拖垮)
留痕记录执行过的命令和输出,事后复盘用

更深一层:这个能力是”运行时灵活性”这笔交易的收益侧。

第 00 章讲过:Java 选择了”把决策推迟到运行时”(classpath 寻址、运行时 DI、动态代理),代价是 NoSuchMethodError、启动慢、框架有魔法。

Arthas 就是这笔投资的回报。 正因为一切都是运行时才确定的,所以你可以在运行时观察和修改一切。

Go 在编译期就把一切都定死了(好处:确定性强、性能好、部署简单),也就永久失去了运行时插桩这个能力。这不是”Go 还没做”,是”Go 的架构决定了做不到”。

这就是我在这个系列里反复强调的那个判断的又一次印证:两种 runtime 的差异,几乎全部可以归因于”复杂度和成本付在哪一层”。 Java 把它付在运行时,所以运行时可观测、可干预;Go 把它付在编译期,所以运行时干净、高效、但也封闭。

【思考】Go 里有没有等价工具?

提示:想想 dlv 为什么不能用于生产;再想想 Go 的 net/http/pprof 能提供什么、不能提供什么。

参考答案

直接答案:没有完全等价的。最接近的组合是 net/http/pprof + 结构化日志 + 分布式 tracing,但它们之间有一道无法跨越的能力鸿沟。

Go 侧能做什么:

import _ "net/http/pprof"   // 一行引入,自动注册 /debug/pprof/* 端点
 
go func() { log.Println(http.ListenAndServe("localhost:6060", nil)) }()

然后:

go tool pprof http://localhost:6060/debug/pprof/heap     # 堆
go tool pprof http://localhost:6060/debug/pprof/profile  # CPU(默认采 30 秒)
go tool pprof http://localhost:6060/debug/pprof/goroutine?debug=2  # 所有 goroutine 栈
go tool pprof -http=:8080 http://.../debug/pprof/heap     # 火焰图
curl http://localhost:6060/debug/pprof/goroutine?debug=2 > stacks.txt

这套东西确实很好用,而且是标准库自带的,零成本。再加上 runtime/trace:

f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()
// go tool trace trace.out  → 能看 goroutine 调度、GC、系统调用、阻塞事件的甘特图

但四件事 Go 做不到,Java 能做到:

1. 看不到”某次调用的参数和返回值”。

pprof 是采样(statistical profiling),它告诉你”这个函数消耗了多少 CPU 时间”,但不告诉你任何一次具体调用的入参是什么、返回了什么。

Arthas 的 watch com.example.OrderService pay '{params, returnObj}' -x 2 直接把订单对象打出来。Go 里你要看到这个,只能加日志 → 重新编译 → 重新部署 → 重新触发。

这一条的差距在开发体验上是数量级的:Java 3 秒,Go 3 分钟(Go 编译快些,但也要走一遍发布流程)。

2. 看不到”方法内部的调用链耗时分解”。

pprof 的火焰图能看到调用栈和耗时占比,但这是聚合的 —— 你看到的是”30 秒里 deduct() 占了 40%“,看不到”某一次请求 1283ms 里,deduct 花了 1201ms,insert 花了 80ms”。

Arthas 的 trace 给的是单次调用的逐层分解。排查”为什么这个特定请求慢”时,聚合数据往往没用。

3. 无法回放历史调用。

tt -i 1004 -p 能拿当时的参数重新执行一次。Go 里没有这个概念 —— 你要复现就得自己想办法造出同样的输入。

4. 无法在运行中改任何东西。

logger --name ROOT --level debug 动态调日志级别;ognl 调用任意静态方法;retransform 热替换方法体。Go 里这些统统做不到。

为什么 Go 做不到?两个原因,第二个是根本的:

原因一:dlv 是外部调试器,会暂停进程。

dlv attach 12345      # 用 ptrace 附加到进程
(dlv) break main.pay  # 打断点
(dlv) continue        # 继续(命中断点时整个进程暂停)

dlv 通过操作系统的 ptrace 系统调用工作。命中断点时整个进程被挂起 —— 这在生产环境是不可接受的(所有请求一起卡住)。所以 dlv 只适合本地开发和预发环境。

对比:Arthas 运行在 JVM 进程内部,用 JVM 自己提供的 Instrumentation API,不需要挂起进程(字节码增强是在类加载/重转换时做的,运行时只是执行被插入的钩子代码)。

原因二(根本):Go 编译成本地机器码,没有标准化的运行时插桩机制。

这是我在上一题里点过一次、这里要再说透的那件事。

JVMGo
执行单元字节码(标准化格式,公开规范)本地机器码(x86-64 / arm64)
谁能改写它JVM 自己(加载时 / retransformClasses)只有 OS(改内存页)或编译器(重新编译)
改写的合法性检查有字节码验证器(verifier)保证改写后依然安全无(改机器码没有安全保证)
元数据运行时保留完整的类/方法/字段元数据编译后符号表大部分被裁掉(虽然 Go 保留了 pclntab 用于栈展开和函数名)
官方插桩接口java.lang.instrument(JVMTI 的一部分)无

机器码不是”可以被任意改写的数据”,它是”已经定型的指令流”。 你没法在 mov %rax,%rbx 中间插一条”记录一下参数”的指令 —— 那会改变后面所有指令的偏移量和寄存器分配。要做这件事,你得重新编译。

而字节码是栈式的、符号化的、有验证规则的:局部变量槽、操作数栈深度、跳转目标都是符号引用。你往中间插入一段字节码,只要保证”进入时栈是空的、退出时栈恢复原状”,验证器就放行。

所以这不是”Go 还没做”,是”Go 的架构决定了做不到”。

Go 社区的实际做法(以及它的合理性):

Go 的补偿策略是把可观测性前移到编译期和运行时的显式埋点:

// 1. 显式埋点:在关键路径上主动记录
span, ctx := opentelemetry.StartSpan(ctx, "pay")
defer span.End()
 
// 2. 结构化日志:默认就带上所有上下文
log.Info("payment.result", zap.Int64("orderID", id), zap.Bool("success", ok))
 
// 3. pprof 端点常开(生产环境也可开,开销 < 1%)
import _ "net/http/pprof"
 
// 4. runtime/trace 做深度分析(开销大,按需开)

这套路子的哲学是:既然不能事后插桩,那就事先把观测点写进代码里。 代价是侵入性(代码里到处是埋点)和不灵活(想加一个观测点就得改代码、重新发版);收益是零运行时开销、确定性强。

更深一层:这是”动态语言运行时 vs 静态编译”这个经典权衡的一个具体体现。

维度JVM(字节码 + 运行时)Go(机器码 + 编译期)
运行时可干预性强(Arthas、热部署、动态代理)弱(只能靠预埋的端点)
峰值性能高(JIT 能根据真实 profile 优化,理论上可超过静态编译)高但固定(编译期决定,无法按运行时情况调整)
启动速度慢(要加载、验证、预热)快(mmap + 跳 main)
部署需要 JVM 环境单二进制
可观测性的成本事后按需,零侵入事先埋点,侵入代码

我的判断:对于”长期运行的服务端应用”,JVM 的这笔交易是划算的 —— 一个跑三个月不停的服务,启动慢那 40 秒摊薄后等于零,而”出事时能在线上直接看数据”的价值是巨大的。

对于”短生命周期、频繁启停、规模小”的场景(Serverless、CLI、边缘计算),Go 的选择明显更优。

但最诚实的结论是:Go 生态确实应该补上”事后诊断”这一环。现在的做法是靠”提前把所有东西都埋好”,这要求你在写代码时就知道未来会出什么问题 —— 而排错的本质恰恰是你不知道会出什么问题。

(顺带一个趋势观察:eBPF 正在成为跨语言的运行时观测方案,bpftrace/pixie/parca 这类工具不需要语言 runtime 的支持就能做 profiling。这可能是 Go 在可观测性上”绕过架构限制”的一条路。但 eBPF 能拿到的是”系统层面”的信息(系统调用、函数调用地址),拿不到”应用层面”的语义(这个参数是哪个订单)—— 所以它能补上 pprof 那一层,补不上 Arthas 的 watch 那一层。)