Administrator
发布于 2021-06-30 / 7377 阅读
199

Arthas 高级用法:trace 定位慢方法与热更新

现象:一个接口慢,但监控上看不出慢在哪

客服反馈"订单详情页打开要转好几秒",我们查 Grafana,GET /order/{id}/detail 的 P99 从平时的 120 ms 涨到了 900 ms 左右,P50 没变。也就是说不是全量慢,是部分订单慢。

SkyWalking 8.6 上只能看到这个接口总共 900 ms,其中 MySQL 查询 40 ms、Redis 5 ms,剩下 855 ms 全是"应用自己耗掉的",没有更细的埋点。那段代码是两年前写的,中间调了七八个方法,没有任何一行计时日志。

加日志重新发布?走发布流程至少半小时,而且这种"部分数据慢"的问题,加了日志也不一定抓得到。直接上 Arthas。

trace:把耗时摊到每一行

# 登上目标机器,容器里没装的话直接下
$ curl -O https://arthas.aliyun.com/arthas-boot.jar
$ java -jar arthas-boot.jar

[INFO] arthas-boot version: 3.5.4
[INFO] Found existing java process, please choose one and input the serial number of the process.
* [1]: 1 com.xxx.order.OrderApplication
1

先用 trace 抓超过 200 ms 的调用,抓 5 次就停:

$ trace com.xxx.order.controller.OrderController detail '#cost > 200' -n 5

等了几秒,抓到了:

---ts=2021-06-30 15:12:33;thread_name=http-nio-8080-exec-18;id=5b;is_daemon=true;priority=5;
    ---[823.4408ms] com.xxx.order.controller.OrderController:detail()
        +---[0.0382ms] com.xxx.order.controller.OrderController:getLoginUser()
        +---[41.2214ms] com.xxx.order.service.OrderService:getById()
        +---[780.9163ms] com.xxx.order.service.OrderAssembler:buildDetailVO()   ← 就是它
        `---[0.9021ms] com.xxx.order.service.OrderService:logView()

buildDetailVO 占了 780 ms。继续往下钻:

$ trace com.xxx.order.service.OrderAssembler buildDetailVO '#cost > 200' -n 5
---[780.9163ms] com.xxx.order.service.OrderAssembler:buildDetailVO()
    +---[12.4412ms] com.xxx.order.service.OrderAssembler:fillBuyerInfo()
    +---[705.3387ms] com.xxx.order.service.OrderAssembler:fillItemSpec()
    `---[61.9024ms] com.xxx.order.service.OrderAssembler:fillLogistics()

再往下,trace 一次只能跟一层,所以直接对最底层的方法 trace。这里有个坑:trace 默认会跳过 JDK 自带的方法调用,如果要看 List.stream() 这类,得加参数:

$ trace com.xxx.order.service.OrderAssembler fillItemSpec '#cost > 200' -n 5 --skipJDKMethod false
---[705.3387ms] com.xxx.order.service.OrderAssembler:fillItemSpec()
    +---[703.1125ms] java.util.stream.ReferencePipeline:collect()

这下明白了:fillItemSpec 里有个 stream,每次循环都会调一次数据库(或者远程)查规格字典,循环了 47 次,每次 15 ms,累加 705 ms。订单商品条数越多越慢,这解释了为什么 P50 正常——大部分订单只有 2、3 个商品。

看源码确认(jad 直接反编译,不用去找代码仓库):

$ jad --source-only com.xxx.order.service.OrderAssembler fillItemSpec
private List<ItemSpecVO> fillItemSpec(List<OrderItem> items) {
    return items.stream().map(item -> {
        // 每条商品查一次字典表,N+1
        SpecDict dict = specDictMapper.selectById(item.getSpecId());
        return ItemSpecVO.of(item, dict);
    }).collect(Collectors.toList());
}

watch:确认入参和返回值

定位到了,但我还想知道传入的 items 到底多大、返回的 dict 有没有 null,免得改错方向:

$ watch com.xxx.order.service.OrderAssembler fillItemSpec \
        '{params[0].size(), returnObj}' '#cost > 200' -x 2

输出:

method=com.xxx.order.service.OrderAssembler.fillItemSpec location=AtExit
ts=2021-06-30 15:14:07; [cost=712.3391ms] result=ArrayList[size=2]
    @Integer[47]
    @ArrayList[size=47]

确认了:47 个商品,返回 47 个 VO。-x 2 是展开层级,默认 1,对象深了会显示成 @Object[size=47] 看不见内容,一般给 3 就够。

watch 还有几个常用的开关:

# 只看方法进入时的入参(location=AtEnter),-b
$ watch com.xxx.order.service.OrderService getById '{params, target}' -b -x 3

# 只在抛异常时打印,抓偶发异常很好用
$ watch com.xxx.order.service.PayService pay '{params, throwExp}' -e -x 3

# 同时看入参、返回、异常
$ watch com.xxx.order.service.PayService pay '{params, returnObj, throwExp}' -x 3

表达式里能用的变量:params(数组)、target(this)、returnObjthrowExpisReturnisThrow#cost。用 target 还能直接读实例字段,比如 target.specDictMapper

热修复:jad + mc + redefine

问题清楚了,修复方案是批量查一次字典再 map。但线上已经在慢,走发布流程要半小时,我先用热更新顶一下。

第一步,把源码拉到本地改:

$ jad --source-only com.xxx.order.service.OrderAssembler > /tmp/OrderAssembler.java

改掉那段逻辑:

private List<ItemSpecVO> fillItemSpec(List<OrderItem> items) {
    Set<Long> specIds = items.stream().map(OrderItem::getSpecId).collect(Collectors.toSet());
    Map<Long, SpecDict> dictMap = specDictMapper.selectBatchIds(specIds)
            .stream().collect(Collectors.toMap(SpecDict::getId, d -> d));
    return items.stream()
            .map(item -> ItemSpecVO.of(item, dictMap.get(item.getSpecId())))
            .collect(Collectors.toList());
}

第二步,内存编译。这里有个必踩的坑:必须指定类加载器的 hash,否则会 ClassNotFoundException。用 sc 查:

$ sc -d com.xxx.order.service.OrderAssembler | grep classLoaderHash
 classLoaderHash   6bc168d5

$ mc -c 6bc168d5 /tmp/OrderAssembler.java -d /tmp
Memory compiler output:
/tmp/com/xxx/order/service/OrderAssembler.class

第三步,热替换:

$ redefine /tmp/com/xxx/order/service/OrderAssembler.class
redefine success, size: 1, classes:
com.xxx.order.service.OrderAssembler

立刻再 trace 一次,fillItemSpec 从 705 ms 降到 8.3 ms,接口 P99 从 900 ms 掉回 130 ms。Grafana 上那根柱子十秒内就下来了。

热更新的几个限制

这套操作很爽,但限制不少,我在别的场景上吃过亏:

  • 不能改方法签名、不能增删字段和方法,只能改方法体。想加字段就得老老实实发版。
  • 正在执行的方法不会被替换,新请求才生效。
  • redefine 之后原来增强过的类会失效。也就是说你之前 trace/watch 的类,热更完得重新 trace 一遍。
  • 热更完记得 reset,增强是有性能开销的(Arthas 用的是 ASM 字节码增强,每次调用都会有额外判断):
$ reset com.xxx.order.service.OrderAssembler
$ quit

另外 mc 在有些 JDK 版本上编译会失败(找不到 tools.jar,JDK 9 之后被移除了),Arthas 3.5.x 在 JDK 11 上一般没问题,真不行就在本地 IDE 编译好 class 传上去,直接 redefine

另外三个用得多的命令

tt(TimeTunnel)是我后来越用越多的。它能把一次方法调用的入参、返回值、异常完整录下来,事后反复回放,不用重新触发请求:

# 录下最近 100 次调用
$ tt -t com.xxx.order.service.OrderService getById -n 100

 INDEX  TIMESTAMP        COST(ms)  IS-RET  IS-EXP  OBJECT     CLASS              METHOD
 1000   2021-06-30 15:22  712.33    true    false   0x4a2b1c   OrderService        getById
 1001   2021-06-30 15:22   18.42    true    false   0x4a2b1c   OrderService        getById

# 看某一次的详细内容
$ tt -i 1000
# 甚至可以对这条记录重新发起调用(会真的再执行一次方法!)
$ tt -i 1000 -p

-p 是重放,会真的再跑一次方法。生产环境用之前要想清楚副作用,我一般只在查询类方法上用。

stack 用来回答"这个方法到底是谁调的"。有次一个 update 被调用得特别频繁,但代码里有七八处引用,静态看根本不知道哪些是热的:

$ stack com.xxx.order.service.OrderService update 'params[0] != null' -n 20

输出把每次调用的完整调用栈都打出来,统计一下就知道哪个入口占大头。

profiler 直接生成火焰图。Arthas 3.5 内置了 async-profiler:

# 采样 60 秒 CPU,生成火焰图
$ profiler start --event cpu --interval 1000000
$ profiler stop --format svg --file /tmp/cpu.svg

# 也可以采样内存分配
$ profiler start --event alloc

生成的是 SVG,下载到本地用浏览器打开就能交互查看。比起在机器上 top -Hp + jstack 猜,火焰图找热点快一个数量级。注意采样期间会有 5% 左右的性能损耗,别在流量高峰长时间开着。

顺带说一个我一直提醒自己的事:排查完一定要 reset。Arthas 的增强是在运行时改字节码,忘了 reset 就把进程留在一个被改过的状态里,我们有一次忘了,两天后另一个同事排查时被莫名其妙的栈搞晕了半天。

就写到这。如果哪天你也被《Arthas 高级用法:trace 定位慢方法与热更新》里同一个坑绊住,回来翻这篇,能省半小时。

参考