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

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

先交代一下背景。手上有一套老 Java 系统,Java 8、Spring MVC、Tomcat,业务代码年头不短了,能跑,但谁都不太想动。最近想给它加一点基础指标,至少能看到请求量、耗时和异常数。问题就在这儿:业务代码不能改,连 pom 都不太想动,改一行就得回归一堆东西,没人愿意背这个锅。

最开始想的是 Spring Boot Actuator,结果一看,这系统根本不是 Spring Boot。又想加 Micrometer,直接在 Controller 里包一层 Timer,但这就回到改业务代码了。后来想用 Filter,在 web.xml 里加一个统一过滤器,理论上能少改点。结果发现这套系统有好几个入口,有的走 Spring MVC,有的直接是 Servlet,还有的静态资源也混在一起,过滤器加下去,指标口径反而不好统一。最重要的是,还是得改配置、改打包,跟“尽量不碰业务”的初衷有点偏。

于是换了个思路:能不能用 Java Agent,在类加载的时候把埋点织进去?业务代码一行不动,只在启动脚本里加一个 -javaagent 参数。听起来有点重,但试下来,对于这种遗留系统反而挺合适。

用 Byte Buddy 做方法级增强

我选的是 Byte Buddy,版本用的 1.12.x,主要是它对 Java 8 兼容比较稳。Agent 的入口还是老一套 premain:

public class MetricsAgent {
    public static void premain(String args, Instrumentation inst) {
        int port = parsePort(args, 9464);
        Metrics.init(port);

        new AgentBuilder.Default()
            .ignore(nameStartsWith("net.bytebuddy.")
                .or(nameStartsWith("com.sun."))
                .or(nameStartsWith("java.")))
            .type(named("javax.servlet.http.HttpServlet"))
            .transform((builder, typeDescription, classLoader, module) ->
                builder.visit(Advice.to(ServletAdvice.class).on(named("service"))))
            .installOn(inst);
    }
}

这里我一开始想拦截所有 HttpServlet 子类,后来发现没必要,直接增强 HttpServlet.service 就行。大部分 Servlet 不会重写 service,Spring 的 DispatcherServlet 也是继承下来的,请求最终会走到这里。少增强一点,少一点重复计数的风险。

Advice 里只做两件事:进方法记开始时间,出方法记耗时和异常。异常一定要 suppress = Throwable.class,埋点自己出问题不能影响业务,这个底线得守住。

public class ServletAdvice {
    @Advice.OnMethodEnter(suppress = Throwable.class)
    public static long enter() {
        return System.nanoTime();
    }

    @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class)
    public static void exit(@Advice.Enter long start,
                            @Advice.Argument(0) Object request,
                            @Advice.Argument(1) Object response,
                            @Advice.Thrown Throwable throwable) {
        Metrics.record(request, response, throwable, System.nanoTime() - start);
    }
}

指标怎么暴露

指标注册用的是 Micrometer 的 PrometheusMeterRegistry,HTTP 端点没用 Spring,直接拿 JDK 自带的 com.sun.net.httpserver.HttpServer 起了一个小服务,监听 9464 端口。Agent 启动时顺手拉起来,Prometheus 来抓 /metrics 就行。

HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
server.createContext("/metrics", exchange -> {
    byte[] body = registry.scrape().getBytes(StandardCharsets.UTF_8);
    exchange.getResponseHeaders().add("Content-Type", "text/plain; version=0.0.4");
    exchange.sendResponseHeaders(200, body.length);
    try (OutputStream os = exchange.getResponseBody()) {
        os.write(body);
    }
});
server.setExecutor(Executors.newSingleThreadExecutor());
server.start();

指标名我起得很朴素,legacy_http_server_requests,标签有 method、uri、status、exception。uri 这里千万别直接用原始 requestURI,老系统里路径参数、ID 一堆,标签基数能炸。我的做法是只取 servletPath,再把纯数字和长 hash 段替换成 {id},够用就行,不追求特别精确。

String uri = path.replaceAll("/\\d+", "/{id}")
                 .replaceAll("/[0-9a-fA-F-]{8,}", "/{id}");

启动脚本里加一行,业务代码和打包完全不用动:

JAVA_OPTS="$JAVA_OPTS -javaagent:/opt/metrics-agent.jar=port=9464"

Prometheus 配置也很简单:

scrape_configs:
  - job_name: 'legacy-java'
    static_configs:
      - targets: ['127.0.0.1:9464']

踩过的坑

第一个坑是类加载器。Agent 的类在系统类加载器里,业务 WebApp 有自己的一套类加载器,Advice 里如果直接引用 HttpServletRequest,有时候会碰到找不到类的问题。我后来在 Advice 里尽量用 Object 接参数,具体取 method、path、status 的时候再做判断,能拿到就拿,拿不到就标 unknown,不跟类加载器较劲。

第二个坑是 Agent jar 的 Manifest。用 Maven Shade 打 fat jar 的时候,别忘了加 Premain-Class,不然启动直接告诉你找不到 agent。还有 Can-Retransform-Classes 也顺手加上,后面想调增强范围会方便点。

第三个坑是指标标签。最开始把异常类名全塞进 exception 标签,结果组合数比想象中多。后来改成只区分 none 和 exception,具体异常还是看日志,指标只负责告诉你“有异常”。

最后效果

上线的时候只改了启动参数,业务代码一行没动。Prometheus 抓上以后,至少能看到每个 Servlet 路径的 QPS、P95 耗时和异常计数。虽然比不上完整 APM,但对这套遗留系统来说,已经够用了。后面如果还想加 JDBC 耗时或者线程池指标,也可以沿着 Agent 这条路继续织,不用再求着业务代码开口子。

不出问题的话就没有问题了。当然,Agent 这东西还是得控制范围,别一上来就全量增强,先挑一个入口试,稳了再扩,不然老系统真能给你整出点新花样。

评论

还没有评论。

发表评论

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

未在播放