Arthas 4 热更新实战:线上改一行代码不重启,我踩过的 5 个坑

6 阅读7分钟

去年双十一前夜,我们一个优惠券核销接口在特定条件下把折扣算成了 0 元。改起来就一行——if 判断里的边界写成了 >,应该是 >=。但那个服务是核心交易链路,重启要停 40 秒,运营那边还在跑活动。

我当时第一次在生产用 Arthas 的 redefine 做了热更新。从下载 class 文件到生效,用了 6 分钟,服务没停,交易没断。但第一次尝试并不顺利,前后踩了 5 个坑,这篇把完整流程和坑都写清楚。

一、为什么是 Arthas,而不是别的方案

线上改代码这件事,常见方案有三种:重启发布、配置中心开关、字节码增强热更新。

重启发布最稳,但代价是停机窗口,核心链路基本不可接受。配置中心开关要求你提前把逻辑做成可配置的,临时发现的问题往往没有这个开关。字节码热更新就是 Arthas 这类工具干的事——直接替换 JVM 里已加载的 class,不重启进程。

Arthas 4 相比 3.x 有几个明显变化:redefine 支持了更多 JDK 版本(包括 JDK 17),watch/trace 的性能开销下降,命令分组也重新整理了。掘金热榜上最近一直在讨论 Arthas 4,我这次正好用新版本重走了一遍流程。

先明确一点:热更新是应急手段,不是常规发布方式。它只适合"改一行逻辑、立刻止血"的场景,改完必须尽快走正常发布把代码合进去,否则下次重启你的修改就没了,而且没人知道线上跑的到底是什么。

二、环境准备:attach 这一步就有坑

curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar

启动后它会列出本机所有 Java 进程,让你选一个 attach:

[1] 12345 com.example.order.OrderApplication
[2] 23456 org.apache.zookeeper.server.quorum.QuorumPeerMain

坑 1:attach 失败,报 Unable to open socket file。 这是最常见的。原因通常是启动用户不一致——你的应用以 app 用户跑,你用 root 去 attach,JVM 的 attach socket 在 /tmp/.java_pid12345,权限不对就失败。解决办法是切到同一个用户执行,或者 chown 那个 socket 文件。我们生产是容器化部署,最后是 kubectl exec 进容器用应用用户执行的。

坑 2:JDK 版本不匹配导致工具挂掉。 我们的服务是 JDK 17,第一次用了默认的 arthas-boot,attach 上去之后 trace 命令直接报 Unsupported class file major version 61。换成 Arthas 4 的最新版就好了。JDK 17 记得加 --add-opens 参数,官方文档里有完整列表,我抄了一份常用的:

java \
  --add-opens java.base/java.lang=ALL-UNNAMED \
  --add-opens java.base/java.util=ALL-UNNAMED \
  --add-opens java.base/java.nio=ALL-UNNAMED \
  -jar arthas-boot.jar

三、先定位,再改:trace 和 watch 的用法

改代码之前,得先确认问题到底出在哪个方法。我习惯先用 trace 看调用链耗时,再用 watch 看入参出参。

# 看 CouponService.calcDiscount 的调用链,只显示耗时超过 50ms 的
trace com.example.coupon.CouponService calcDiscount '#cost > 50'

# 看这个方法被调用时的入参和返回值,最多抓 3 次
watch com.example.coupon.CouponService calcDiscount \
  '{params, returnObj, throwExp}' -x 3 -n 3

-x 3 是展开层级,默认只展开 1 层,对象里的字段看不到。我第一次没加,params 打出来全是 Coupon@1f2a3b 这种,等于没看。

坑 3:trace 把接口打挂了。 trace 会增强目标方法,如果方法调用量极大(比如每秒几千次),增强后的开销会拖慢接口。我第一次在一个 QPS 3000 的方法上直接 trace,接口 P99 从 80ms 涨到 400ms,告警立刻响了。正确做法是加条件过滤,或者用 -n 限制次数:

trace com.example.coupon.CouponService calcDiscount '#cost > 100' -n 5

坑 4:watch 抓到的对象被后续逻辑改掉了。 这个坑很隐蔽。watch 默认是方法返回后打印,如果方法内部对入参对象做了原地修改,你看到的是改完之后的。要看到"进方法那一刻"的值,加 -b(before):

watch com.example.coupon.CouponService calcDiscount '{params}' -b -x 3 -n 3

我就是靠这个才发现,params[0].discountRate 传进来的时候已经是 0 了——问题根本不在 calcDiscount,而在上游组装参数的地方。

四、热更新的完整流程

定位到具体方法之后,流程是:反编译 → 改源码 → 编译 → redefine。

第一步,反编译看线上真实代码。 别相信 Git 仓库,线上跑的和你以为的可能不一样(我们那次就是因为线上版本比 master 落后两个 commit)。

jad --source-only com.example.coupon.CouponService > /tmp/CouponService.java

第二步,改源码。 打开 /tmp/CouponService.java,找到那行 if (rate > 0),改成 if (rate >= 0)。注意 jad 反编译出来的代码没有 import,编译时要补上。

第三步,编译成 class。 这一步最容易出问题:

# 需要把应用的依赖 jar 都加到 classpath
javac -cp "/app/lib/*" -d /tmp/out /tmp/CouponService.java

坑 5:编译出来的 class 和原 class 结构不一致,redefine 被拒。 redefine 有个硬限制:不能增删字段、不能增删方法、不能改方法签名。我第一次改的时候顺手把一个没用的私有方法删了,redefine 直接报:

redefine error! java.lang.UnsupportedOperationException: class redefinition failed: attempted to delete a method

后来学乖了:热更新只改方法体,别动结构。真要改结构,老老实实走发布。

第四步,执行 redefine。

redefine /tmp/out/com/example/coupon/CouponService.class

成功会返回:

redefine success, size: 1, classes:
com.example.coupon.CouponService

然后立刻用 watch 验证一下新逻辑生效了:

watch com.example.coupon.CouponService calcDiscount '{params, returnObj}' -x 2 -n 1

第五步,收尾。 这一步很多人会忘:立刻提一个 PR 把改动合进主干,然后安排一次正常发布。热更新的代码在重启后就没了,而且它不会出现在任何构建产物里。我给自己定了个规矩——热更新必须在 2 小时内补上正式发布,否则就回滚。

五、几个必须知道的边界

redefine 不能改的东西,我列个清单,省得你踩:

  • 不能增删字段和方法(前面说过);
  • 不能改类名、父类、实现的接口;
  • 不能改方法的参数类型和返回类型;
  • 不能改注解(部分场景可以,但别指望)。

另外,redefine 只影响当前 JVM 实例。你们如果是多副本部署,得对每个 Pod 都执行一遍。我们那次是 8 个副本,写了个循环脚本批量执行:

for pod in $(kubectl get pod -l app=coupon -o name); do
  kubectl exec -i $pod -- java -jar /opt/arthas/arthas-boot.jar \
    --pid $(kubectl exec $pod -- pgrep -f OrderApplication) \
    -c "redefine /tmp/CouponService.class"
done

不过说实话,8 个 Pod 逐个热更新,中间有几分钟状态不一致,风险不小。后来我们改成了"先热更新 1 个 Pod 验证,确认没问题再全量",这个思路和灰度发布是一个道理。

六、Arthas 4 里我常用的其他几个命令

除了 redefine,这几个命令我在排查时用得最多:

# 看方法调用耗时分布,比 trace 更适合高频方法
profiler start --event cpu
profiler stop --format html

# 看某个类的静态字段当前值
getstatic com.example.coupon.CouponConfig MAX_DISCOUNT_RATE

# 看线程栈,找 CPU 高的线程
thread -n 3

# 看类加载器,排查 jar 冲突
sc -d com.example.coupon.CouponService

profiler 是 Arthas 4 里改进比较大的,生成的 HTML 火焰图可以直接在浏览器打开,比 jstack 采样准得多。我用它在一次 CPU 飙高的问题里,5 分钟就定位到了一个正则表达式回溯。

七、最后说点实在的

热更新这个能力,用好了是救命稻草,用坏了是事故源头。我见过有人把热更新当日常发布手段,线上跑的代码和仓库差了十几个版本,最后没人敢重启那个服务。

我的建议是三条:只在止血场景用;改完必须补正式发布;每次操作都记录到值班日志里,写清楚改了哪个类哪个方法、为什么改、什么时候补发布。

Arthas 4 本身是个很成熟的工具,命令也不复杂,真正难的是流程约束。工具给你能力,纪律决定你能不能长期用下去。