给遗留 Java 系统增加指标埋点而不改变业务代码的尝试
最近接手一个挺老的 Java 系统,业务跑得还行,但基本没什么可观测性。接口慢不慢、哪个方法经常抛异常、某条链路是不是被重复调用,全靠自己 grep 日志猜。领导说先加点指标看看,我心里第一反应是:那还不简单,Micrometer 一套接上,Controller 上加注解,Service 里埋点,完事。
结果打开源码才发现,这玩意儿是真的难搞。项目没有统一构建,业务包耦合得很,有些模块还是内部仓库里的小 jar,本地连依赖都拉不顺。更麻烦的是测试环境很脆,改业务代码意味着要重新走一遍回归。于是就只能开始琢磨:能不能不改业务代码,给它硬塞指标。
下面是最后实测能跑起来的一种方式,主要是走 javaagent + ByteBuddy。如果你熟悉这个,可能会说这不早该这样吗,但我一开始还真先踩了几步弯路。
先试了 Spring AOP,然后发现不太够
因为系统里有 Spring 的部分类,我一开始想当然地以为,只要写一个 @Aspect,扫描 @Service 和 @Controller 就行。这样不动业务代码,看起来也挺体面。
真正跑起来过后,问题就来了。
第一,不是所有类都被 Spring 管理,有些遗留类是 new 出来的,切面根本进不去。
第二,有些调用发生在同一个 Bean 内部,走 this.method(),代理对象被绕过,指标直接少一截。
第三,就算指标能采到,启动参数、配置类、依赖版本都还是得动项目本身。所谓“不改业务代码”,最后变成“稍微改一点工程代码”,这跟我想要的还是不太一样。
所以我干脆去掉了这条路线,直接上字节码增强。
思路其实很朴素
目标就一个:业务 jar 不动,启动的时候挂一个 agent,匹配到指定包下面的方法,自动记录调用次数、耗时、异常次数。
说白了就是:
- 写一个
metrics-agent,通过Premain-Class注入; - 用
ByteBuddy匹配业务方法; - 在方法进入和退出时织入 Advice;
- 把指标先写成简单的日志行,等验证有效后再换到 Prometheus 或者别的系统里。
这里我特意没有一开始就做得太花,因为遗留系统最怕“看起来很先进,实际上跑不起来”。
一个能跑的最小实现
pom.xml 里主要依赖就这些:
<dependencies>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.14.18</version>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy-agent</artifactId>
<version>1.14.18</version>
</dependency>
</dependencies>
打包时用 maven-shade-plugin 把依赖一起塞进去,不然运行时容易遇到 NoClassDefFoundError,这个坑我后面单独说。
agent 入口大概是这样:
public class MetricsAgent {
public static void premain(String args, Instrumentation instrumentation) {
new AgentBuilder.Default()
.ignore(nameStartsWith("java.")
.or(nameStartsWith("javax.")
.or(nameStartsWith("jdk.")
.or(nameStartsWith("sun.")
.or(nameStartsWith("org.springframework.")
.or(nameStartsWith("net.bytebuddy.")))))
.type(nameStartsWith("com.oldapp.service.")
.or(nameStartsWith("com.oldapp.web.")))
.transform((builder, typeDescription, classLoader, module, protectionDomain) ->
builder.visit(Advice.to(MetricsAdvice.class)
.on(isMethod()
.and(not(isDeclaredBy(typeDescription)
.or(isDeclaredBy(Object.class)))
.and(not(isConstructor())))))
)
.with(AgentBuilder.Listener.StreamWriting.toSystemOut())
.installOn(instrumentation);
}
}
Advice 这里我没有直接用太重的库,先记录到日志里:
public class MetricsAdvice {
@Advice.OnMethodEnter
public static long enter(
@Advice.Origin("#t") String className,
@Advice.Origin("#m") String methodName) {
return System.nanoTime();
}
@Advice.OnMethodExit(onThrowable = Throwable.class)
public static void exit(
@Advice.Origin("#t") String className,
@Advice.Origin("#m") String methodName,
@Advice.Enter long enterTime,
@Advice.Thrown Throwable throwable) {
long cost = System.nanoTime() - enterTime;
if (throwable == null) {
System.out.printf("METRIC type=timer name=%s.%s cost_ms=%.3f result=success%n",
className, methodName, cost / 1_000_000.0);
} else {
System.out.printf("METRIC type=counter name=%s.%s result=failure error=%s%n",
className, methodName, throwable.getClass().getName());
}
}
}
然后配置 MANIFEST:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<manifestEntries>
<Premain-Class>MetricsAgent</Premain-Class>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</transformer>
</transformers>
</configuration>
</execution>
</plugin>
启动的时候只需要多一行:
java -javaagent:metrics-agent.jar -jar old-app.jar
如果是 Tomcat 或者别的容器,一般改 JAVA_OPTS 也一样。这里我没直接写死容器脚本,因为遗留系统最恶心的一点就是,每个环境的启动方式都不太一样,可能得挨个看。
实际效果
不出问题的话,日志里就会开始刷这种:
METRIC type=timer name=com.oldapp.service.OrderService.createOrder cost_ms=184.210 result=success
METRIC type=timer name=com.oldapp.web.OrderController.submit cost_ms=186.433 result=success
METRIC type=counter name=com.oldapp.service.InventoryService.check result=failure error=java.lang.NullPointerException
虽然粗糙,但已经够用了。至少现在我知道哪个接口最慢、哪个方法经常失败,也不用逼着原业务开发去改代码。
中间踩到的几个坑
第一个坑是 Can-Retransform-Classes 没有开,导致某些已经加载过的类没有被增强。这个挺阴的,表现就是日志只刷一部分,另一部分像没挂 agent 一样,很容易误判成匹配规则写错了。
第二个坑是 Advice 里引用了太多 agent 自己的依赖。比如我一开始想直接在 Advice 里调用一个工具类,结果目标 classloader 根本看不到它,运行时直接炸。最后还是把逻辑写简单,先输出标准日志,别一上来就整太复杂。
第三个坑是包名匹配写得太宽。老系统里有些生成类、代理类、内部匿名类,名字千奇百怪。如果不加 ignore,很容易把增强次数翻倍,指标数据就脏了。
后面还能怎么做
如果你只是先验证“能不能采到有效数据”,这种写法其实够用了。
如果之后要正式一点,我觉得可以往两个方向走:
一个是把指标接进 Prometheus,用 micrometer-registry-prometheus,但这个就不太适合在 Advice 里直接做太重,最好单独放一个 collector。
另一个是对异常做分类统计,比如 NullPointerException、TimeoutException、SQL 异常分开计数,这样排障会舒服很多。
不过说到底,这次尝试给我最大的感受还是:给遗留系统做可观测性,第一目标不是优雅,而是先把数据采上来,而且尽量别把原来那坨业务拖下水。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。