小Cの已经记不起来的博客

给遗留 Java 系统增加指标埋点而不改变业务代码的尝试

最近接手一个挺老的 Java 系统,业务跑得还行,但基本没什么可观测性。接口慢不慢、哪个方法经常抛异常、某条链路是不是被重复调用,全靠自己 grep 日志猜。领导说先加点指标看看,我心里第一反应是:那还不简单,Micrometer 一套接上,Controller 上加注解,Service 里埋点,完事。

结果打开源码才发现,这玩意儿是真的难搞。项目没有统一构建,业务包耦合得很,有些模块还是内部仓库里的小 jar,本地连依赖都拉不顺。更麻烦的是测试环境很脆,改业务代码意味着要重新走一遍回归。于是就只能开始琢磨:能不能不改业务代码,给它硬塞指标。

下面是最后实测能跑起来的一种方式,主要是走 javaagent + ByteBuddy。如果你熟悉这个,可能会说这不早该这样吗,但我一开始还真先踩了几步弯路。

先试了 Spring AOP,然后发现不太够

因为系统里有 Spring 的部分类,我一开始想当然地以为,只要写一个 @Aspect,扫描 @Service@Controller 就行。这样不动业务代码,看起来也挺体面。

真正跑起来过后,问题就来了。

第一,不是所有类都被 Spring 管理,有些遗留类是 new 出来的,切面根本进不去。
第二,有些调用发生在同一个 Bean 内部,走 this.method(),代理对象被绕过,指标直接少一截。
第三,就算指标能采到,启动参数、配置类、依赖版本都还是得动项目本身。所谓“不改业务代码”,最后变成“稍微改一点工程代码”,这跟我想要的还是不太一样。

所以我干脆去掉了这条路线,直接上字节码增强。

思路其实很朴素

目标就一个:业务 jar 不动,启动的时候挂一个 agent,匹配到指定包下面的方法,自动记录调用次数、耗时、异常次数。

说白了就是:

  1. 写一个 metrics-agent,通过 Premain-Class 注入;
  2. ByteBuddy 匹配业务方法;
  3. 在方法进入和退出时织入 Advice;
  4. 把指标先写成简单的日志行,等验证有效后再换到 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。
另一个是对异常做分类统计,比如 NullPointerExceptionTimeoutException、SQL 异常分开计数,这样排障会舒服很多。

不过说到底,这次尝试给我最大的感受还是:给遗留系统做可观测性,第一目标不是优雅,而是先把数据采上来,而且尽量别把原来那坨业务拖下水。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放