京东自研性能追踪系统pfinder实现原理揭秘

企业   2024-06-28 11:34   广东  


01 
引言


在今年的敏捷团队建设中,我通过Suite执行器实现了一键自动化单元测试。Juint除了Suite执行器还有哪些执行器呢?由此我的Runner探索之旅开始了!
在现代软件开发过程中,性能优化和故障排查是保证应用稳定运行的关键任务之一。Java作为一种广泛使用的编程语言,其生态中涌现出了许多优秀的监控和诊断工具,诸如:SkyWalking、Zipkin等,它们帮助开发者和运维人员深入了解应用的运行状态,快速定位和解决问题。在京东内部,则使用的是自研的pfinder。


本文旨在深入探讨pfinder的核心原理和架构设计,揭示它是如何实现应用全链路监控的。我们将从pfinder的基本概念和功能开始讲起,逐步深入到其具体实现机制。


02 
  

pfinder概述

  


理解,首先 MCube 会依据模板缓存状态判断是否需要网络获取最新模板,当获取到模板后进行模板加载,加载阶段会将产物转换为视图树的结构,转换完成后将通过表达式引擎解析表达式并取得正确的值,通过事件解析引擎解析用户自定义事件并完成事件的绑定,完成解析赋值以及事件绑定后进行视图的渲染,最终将

2.1 pfinder简介

PFinder (problem finder) 是UMP团队打造的新一代APM(应用性能追踪)系统,集调用链追踪、应用拓扑、多维监控于一身,无需修改代码,只需要在启动文件增加 2 行脚本,便可实现接入。接入后便会对应用提供可观测能力,目前支持京东主流的中间件,包括:jimdb,jmq,jsf,以及一些常用的开源组件:tomcat、http client,mysql,es等。

2.2 pfinder功能

pfinder 除了具备 ump 现有功能的基础上,增加了以下重磅功能:
  • 多维监控: 支持按多个维度统计监控指标,按机房、按分组、按JSF别名、按调用方,各种维度随心组合查看

  • 自动埋点: 自动对 SpringMVC,JSF,MySQL,JMQ 等常用中间件进行性能埋点,无需改动代码,接入即可观测

  • 应用拓扑: 自动梳理服务的上下游和中间件的依赖拓扑

  • 调用链追踪: 基于请求的跨服务调用追踪,助你快速分析性能瓶颈

  • 自动故障分析: 通过AI算法自动分析调用拓扑上所有服务的监控数据,自动判断故障根因

  • 流量录制回放: 通过录制线上流量,回放至待特定环境(测试、预发),对比回放与录制时产生的差异,帮助用户补全业务场景、完善测试用例

  • 跨单元逃逸流量监控: 支持 JSF 跨单元流量、逃逸流量监控,单元化应用运行状态一目了然

2.3 APM类组件对比


ZipkinPinpointSkyWalkingCATpfinder
贡献者Twitter韩国公司华为美团京东
实现方式拦截请求,发送 http/mq 数据到 zipkin 服务字节码注入字节码注入代理埋点(拦截器、注解、过滤器)字节码注入
接入方式基于 linkerd/sleuth,引入配置即可javaagent 字节码javaagent 字节码代码侵入javaagent 字节码
agent 到 collector 传输协议http、MQthriftgRPChttp/tcpJMTP
OpenTracing支持
支持
支持
粒度接口级方法级方法级代码级方法级
全局调用统计
支持支持支持支持
traceid 查询支持
支持
支持
告警
支持支持支持支持
JVM 监控
支持支持支持支持

更重要的一点是:pfinder对京东内部自研组件提供了支持,比如:jsf、jmq、jimdb



03 
  

pfinder背后的秘密

  


理解,首先 MCube 会依据模板缓存状态判断是否需要网络获取最新模板,当获取到模板后进行模板加载,加载阶段会将产物转换为视图树的结构,转换完成后将通过表达式引擎解析表达式并取得正确的值,通过事件解析引擎解析用户自定义事件并完成事件的绑定,完成解析赋值以及事件绑定后进行视图的渲染,最终将

既然pfinder是基于字节码增强实现的,那么讲到pfinder,字节码增强技术自然也是无法避开的话题。这里我将字节码增强技术分两点来说,也是我认为实现字节码增强需要解决的两个关键点:

1.字节码是为了机器设计的,而非人类,字节码可读性极差、修改门槛极高,那么我们如何修改字节码呢?

2.修改后的字节码如何注入运行时JVM中呢?
欲攻善其事,必先利其器,所以下面我们围绕着这两个问题进行展开,当然,对这方面知识已经有所掌握的同学可忽略。

3.1 字节码修改

字节码修改成熟的框架已经很多了,诸如:ASM、javassist、bytebuddy、bytekit,下面我们用这几个字节码修改框架实现一个相同的功能,来对比下这几个框架使用上的区别。现在我们通过字节码修改来实现下面的功能:

3.1.1 ASM实现
  @Override        public void visitCode() {            super.visitCode();            mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");            mv.visitLdcInsn("start");            mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);        }        @Override        public void visitInsn(int opcode) {            if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN) || opcode == Opcodes.ATHROW) {                //方法在返回之前,打印"end"                mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");                mv.visitLdcInsn("end");                mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);            }            mv.visitInsn(opcode);        }
3.1.2 javassist实现
        ClassPool cp = ClassPool.getDefault();        CtClass cc = cp.get("com.ggc.javassist.HelloWord");        CtMethod m = cc.getDeclaredMethod("printHelloWord");        m.insertBefore("{ System.out.println(\"start\"); }");        m.insertAfter("{ System.out.println(\"end\"); }");        Class c = cc.toClass();        cc.writeFile("/Users/gonghanglin/workspace/workspace_me/bytecode_enhance/bytecode_enhance_javassist/target/classes/com/ggc/javassist");        HelloWord h = (HelloWord)c.newInstance();        h.printHelloWord();
3.1.3 bytebuddy实现
    // 使用ByteBuddy动态生成一个新的HelloWord类        Class<?> dynamicType = new ByteBuddy()                .subclass(HelloWord.class) // 指定要修改的类                .method(ElementMatchers.named("printHelloWord")) // 指定要拦截的方法名                .intercept(MethodDelegation.to(LoggingInterceptor.class)) // 指定拦截器                .make()                .load(HelloWord.class.getClassLoader()) // 加载生成的类                .getLoaded();
// 创建动态生成类的实例,并调用方法 HelloWord dynamicService = (HelloWord) dynamicType.newInstance();        dynamicService.printHelloWord();
public class LoggingInterceptor {    @RuntimeType    public static Object intercept(@AllArguments Object[] allArguments, @Origin Method method, @SuperCall Callable<?> callable) throws Exception {        // 打印start        System.out.println("start");        try {            // 调用原方法            Object result = callable.call();            // 打印end            System.out.println("end");            return result;        } catch (Exception e) {            System.out.println("exception end");            throw e;        }    }}
3.1.4 bytekit实现
 // Parse the defined Interceptor class and related annotations        DefaultInterceptorClassParser interceptorClassParser = new DefaultInterceptorClassParser();        List<InterceptorProcessor> processors = interceptorClassParser.parse(HelloWorldInterceptor.class);        // load bytecode        ClassNode classNode = AsmUtils.loadClass(HelloWord.class);        // Enhanced process of loaded bytecodes        for (MethodNode methodNode : classNode.methods) {            MethodProcessor methodProcessor = new MethodProcessor(classNode, methodNode);            for (InterceptorProcessor interceptor : processors) {                interceptor.process(methodProcessor);            }        }
public class HelloWorldInterceptor {    @AtEnter(inline = true)    public static void atEnter() {        System.out.println("start");    }
@AtExit(inline = true) public static void atEit() { System.out.println("end"); }}

ASMJavassistByteBuddyByteKit
性能ASM的性能最高,因为它直接操作字节码,没有中间环节劣于ASM介于javassist和ASM之间介于javassist和ASM之间
易用性需精通字节码,学习成本高,不支持debugJava语法进行开发,但是采用的硬编码形式开发,不支持debug比Javassist更高级,更符文Java开发习惯,可以对增强代码进行断点调试比Javassist更高级,更符文Java开发习惯,可以对增强代码进行断点调试
功能直接操作字节码,功能最为强大。功能相对完备功能相对完备功能相对完备,对比ByteBuddy,ByteKit能防止重复增强

3.2 字节码注入

相信大家经常使用idea去debug我们写的代码,我们是否想过debug是如何实现的呢?暂时先卖个关子。
3.2.1 JVMTIAgent
JVM在设计之初就考虑到了对JVM运行时内存、线程等指标的监控和分析和代码debug功能的实现,基于这两点,早在JDK5之前,JVM规范就定义了JVMPI(JVM分析接口)和JVMDI(JVM调试接口),JDK5之后,这两个规范就合并成为了JVMTI(JVM工具接口)。JVMTI其实是一种JVM规范,每个JVM厂商都有不同的实现,另外,JVMTI接口需使用C语言开发,以动态链接的形式加载并运行。
JVMTI接口
接口功能
Agent_OnLoad(JavaVM *vm, char *options, void *reserved);agent在启动时加载的情况下,也就是在vm参数里通过-agentlib来指定,那在启动过程中就会去执行这个agent里的Agent_OnLoad函数。
Agent_OnAttach(JavaVM* vm, char* options, void* reserved);agent是attach到目标进程上,然后给对应的目标进程发送load命令来加载agent,在加载过程中就会调用Agent_OnAttach函数。
Agent_OnUnload(JavaVM *vm);在agent卸载的时候调用
其实idea的debug功能便是借助JVMTI实现的,具体说是利用了jre内置的jdwp agent来实现的。我们在idea中debug程序时,控制台命令如下:

这里agentlib参数就是用来跟要加载的agent的名字,比如这里的jdwp(不过这不是动态库的名字,而JVM是会做一些名称上的扩展,比如在MACOS下会去找libjdwp.dylib的动态库进行加载,也就是在名字的基础上加前缀lib,再加后缀.dylib)。
3.2.2 instrument

上面说到JVMTIAgent基于C语言开发,以动态链接的形式加载并运行,这对java开发者不太友好。在JDK5之后,JDK开始提供java.lang.instrument.Instrumentation接口,让开发者可以使用Java语言编写Agent。其实,instrument也是基于JVMTI实现的,在MACOS下instrument动态库名为libinstrument.dylib。

instrument主要方法
方法功能
void addTransformer(ClassFileTransformer transformer)添加一个字节码转换器,用来修改加载类的字节码
Class[] getAllLoadedClasses()返回当前JVM中加载的所有的类的数组
Class[] getInitiatedClasses(ClassLoader loader)返回指定的类加载器中的所有的类的数据
void redefineClasses(ClassDefinition... definitions)用给定的类的字节码数组替换指定的类的字节码文件,也就是重新定义指定的类
void retransformClasses(Class<?>... classes)指定一系列的Class对象,被指定的类都会重新变回去(去掉附加的字节码)

3.2.3 instrument和ByteBuddy实现javaagent打印方法耗时

3.2.3.1agent包MANIFEST.MF配置(maven插件)

<archive>   <manifestEntries>       // 指定premain()的所在方法       <Agent-CLass>com.ggc.agent.GhlAgent</Agent-CLass>       <Premain-Class>com.ggc.agent.GhlAgent</Premain-Class>       <Can-Redefine-Classes>true</Can-Redefine-Classes>       <Can-Retransform-Classes>true</Can-Retransform-Classes>   </manifestEntries></archive>
3.2.3.2 agen主类
public class GhlAgent {    public static Logger log = LoggerFactory.getLogger(GhlAgent.class);
public static void agentmain(String agentArgs, Instrumentation instrumentation) { log.info("agentmain方法"); boot(instrumentation); } public static void premain(String agentArgs, Instrumentation instrumentation) { log.info("premain方法"); boot(instrumentation); } private static void boot(Instrumentation instrumentation) { //创建一个代理增强对象 new AgentBuilder.Default().type(ElementMatchers.nameStartsWith("com.jd.aviation.performance.service.impl"))//拦截指定的类 .transform((builder, typeDescription, classLoader, javaModule) -> builder.method(ElementMatchers.isMethod().and(ElementMatchers.isPublic()) ).intercept(MethodDelegation.to(TimingInterceptor.class)) ).installOn(instrumentation); }}

3.2.3.3 拦截器

public class TimingInterceptor {    public static Logger log = LoggerFactory.getLogger(TimingInterceptor.class);    @RuntimeType    public static Object intercept(@SuperCall Callable<?> callable) throws Exception {        long start = System.currentTimeMillis();        try {            // 原方法调用            return callable.call();        } finally {            long end = System.currentTimeMillis();            log.info("Method call took {} ms",(end - start));        }    }}

3.2.3.4 效果



04 
  

pfinder实现原理

  


理解,首先 MCube 会依据模板缓存状态判断是否需要网络获取最新模板,当获取到模板后进行模板加载,加载阶段会将产物转换为视图树的结构,转换完成后将通过表达式引擎解析表达式并取得正确的值,通过事件解析引擎解析用户自定义事件并完成事件的绑定,完成解析赋值以及事件绑定后进行视图的渲染,最终将

4.1 pfinder应用架构

pfinder agent启动时首先加载META-INF/pfinder/service.addon和META-INF/pfinder/plugin.addon配置文件中的服务和插件。2.根据加载的插件做字节码增强。3.使用JMTP将服务和插件产生的数据(trace、指标等)进行上报。

4.2 pfinder插件增强代码解析

4.2.1 service加载

创建SimplePFinderServiceLoader实例,在profilerBootstrap.boot(serviceLoaders)方法中加载配置文件中的service。
使用创建的SimplePFinderServiceLoader实例加载service,并返回一个service工厂的迭代器。
真正的加载走的是AddonLoader中的load方法。service加载完成后,继续看bootService方法:

bootService中完成创建service实例、注册service、初始化service,service的加载至此就完成了。

4.2.2 plugin加载&字节码增强

在介绍插件加载前,我们先了解下插件的包含了哪些信息。

增强拦截器:这个类里面放了具体的增强逻辑
增强点类型:增强时根据不同类型走不同逻辑
增强类/方法匹配器:用于匹配需要增强的类/方法
InterceptPoint是个数组,增强点可以配置多个。

plugin的加载和字节码增强发生在初始化service过程中,具体地说发生在com.jd.pfinder.profiler.service.impl.PluginRegistrar这个service初始化的过程中了。

 protected boolean doInitialize(ProfilerContext profilerContext) {     AgentEnvService agentEnvService = (AgentEnvService)profilerContext.getService(AgentEnvService.class);     Instrumentation instrumentation = agentEnvService.instrumentation();     if (instrumentation == null) {       LOGGER.info("Instrumentation missing, PFinder PluginRegistrar enhance ignored!");       return false;     }     this.pluginLoaders = profilerContext.getAllService(PluginLoader.class);     this.enhanceHandler = new EnhancePluginHandler(profilerContext);     ElementMatcher.Junction<TypeDescription> typeMatcherChain = null;     for (PluginLoader pluginLoader : this.pluginLoaders) {       pluginLoader.loadPlugins(profilerContext);
for (ElementMatcher.Junction<TypeDescription> typeMatcher : (Iterable<ElementMatcher.Junction<TypeDescription>>)pluginLoader.typeMatchers()) { if (typeMatcherChain == null) { typeMatcherChain = typeMatcher; continue; } typeMatcherChain = typeMatcherChain.or((ElementMatcher)typeMatcher); } } if (typeMatcherChain == null) { LOGGER.warn("no any enhance-point. pfinder enhance will be ignore."); return false; } ConfigurationService configurationService = (ConfigurationService)profilerContext.getService(ConfigurationService.class); String enhanceExcludePolicy = (String)configurationService.get(ConfigKey.PLUGIN_ENHANCE_EXCLUDE);
LoadedClassSummaryHandler loadedClassSummaryHandler = null; if (((Boolean)configurationService.get(ConfigKey.LOADED_CLASSES_SUMMARY_ENABLED, Boolean.valueOf(false))).booleanValue()) { loadedClassSummaryHandler = new LoadedClassSummaryHandler.DefaultImpl(configurationService, ((ScheduledService)profilerContext.getService(ScheduledService.class)).getDefault()); }
(new AgentBuilder.Default())
.with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION) .with(AgentBuilder.RedefinitionStrategy.REDEFINITION) .with(new AgentBuilder.RedefinitionStrategy.Listener() { public void onBatch(int index, List<Class<?>> batch, List<Class<?>> types) {} public Iterable<? extends List<Class<?>>> onError(int index, List<Class<?>> batch, Throwable throwable, List<Class<?>> types) { return Collections.emptyList(); } public void onComplete(int amount, List<Class<?>> types, Map<List<Class<?>>, Throwable> failures) { for (Map.Entry<List<Class<?>>, Throwable> entry : failures.entrySet()) { for (Class<?> aClass : entry.getKey()) { PluginRegistrar.LOGGER.warn("Redefine class: {} failure! ignored!", new Object[] { aClass.getName(), entry.getValue() }); }
} } }).ignore((ElementMatcher)ElementMatchers.nameStartsWith("org.groovy.") .or((ElementMatcher)ElementMatchers.nameStartsWith("jdk.nashorn.")) .or((ElementMatcher)ElementMatchers.nameStartsWith("javax.script.")) .or((ElementMatcher)ElementMatchers.nameContains("javassist")) .or((ElementMatcher)ElementMatchers.nameContains(".asm.")) .or((ElementMatcher)ElementMatchers.nameContains("$EnhancerBySpringCGLIB$")) .or((ElementMatcher)ElementMatchers.nameStartsWith("sun.reflect")) .or((ElementMatcher)ElementMatchers.nameStartsWith("org.apache.jasper")) .or((ElementMatcher)pfinderIgnoreMather()) .or((ElementMatcher)Matchers.forPatternLine(enhanceExcludePolicy)) .or((ElementMatcher)ElementMatchers.isSynthetic()))
.type((ElementMatcher)typeMatcherChain) .transform(this) .with(new Listener(loadedClassSummaryHandler)) .installOn(instrumentation); return true;   }

第8行,先从上下文中取出注册的PluginLoader(插件加载器),第12行遍历插件加载器加载插件,插件加载逻辑其实和service一样,使用的都是AddonLoader中的load方法。插件加载完成之后被插件加载器持有,第14-19行则收集插件中增强类的匹配器,用于AgentBuilder的创建。AgentBuilder的创建标志着字节码增强的开始,具体的逻辑在transform的实例方法中。

transform方法中遍历插件,enhance方法中对各个插件做增强。

enhance方法中遍历各个插件的增强点数组走enhanceInterceptPoint方法做增强。

enhanceInterceptPoint方法中根据增强点类型做增强。

上图是以Advice方式增强实例方法,传递了interceptorFieldAppender和methodCacheFieldAppender两个参数,并使用AdviceMethodEnhanceInvoker访问并修改待增强的类和方法。AdviceMethodEnhanceInvoker中有onMethodEnter、onMethodExit两个方法,分别表示进入方法后和退出方法前。

AdviceMethodEnhanceInvoker中onMethodEnter、onMethodExit两个方法还会调用插件中配置interceptor对应的onMethodEnter、onMethodExit、onException方法,至此插件字节码增强就结束了。


05 
  

一些思考

  


理解,首先 MCube 会依据模板缓存状态判断是否需要网络获取最新模板,当获取到模板后进行模板加载,加载阶段会将产物转换为视图树的结构,转换完成后将通过表达式引擎解析表达式并取得正确的值,通过事件解析引擎解析用户自定义事件并完成事件的绑定,完成解析赋值以及事件绑定后进行视图的渲染,最终将

5.1 多线程traceId丢失问题

pfinder目前已经将traceId放到了MDC中,我们通过在日志配置文件中添加[%X{PFTID}]便能在日志中打印traceId。但是我们知道MDC使用的是ThreadLocal去保存的traceId,在跨线程时会出现线程丢失的情况。pfinder在这方面做了字节码增强,无论使用线程池还是@Async,都不会存在traceId丢失的问题。

 public class TracingRunnable   implements PfinderWrappedRunnable {   private final Runnable origin;   private final TracingSnapshot<?> snapshot;   private final Component component;   private final String operationName;   private final String interceptorName;   private final InterceptorClassLoader interceptorClassLoader;
public TracingRunnable(Runnable origin, TracingSnapshot<?> snapshot, Component component, String operationName, String interceptorName, InterceptorClassLoader interceptorClassLoader) { this.origin = origin; this.snapshot = snapshot; this.component = component; this.operationName = operationName; this.interceptorClassLoader = interceptorClassLoader; this.interceptorName = interceptorName; } public void run() { TracingContext tracingContext = ContextManager.tracingContext(); if (tracingContext.isTracing() && tracingContext.traceId().equals(this.snapshot.getTraceId())) { this.origin.run(); return; } LowLevelAroundTracingContext context = SpringAsyncTracingContext.create(this.operationName, this.interceptorName, this.snapshot, this.interceptorClassLoader, this.component); context.onMethodEnter(); try { this.origin.run(); } catch (RuntimeException ex) { context.onException(ex); throw ex; } finally { context.onMethodExit(); } } public Runnable getOrigin() { return this.origin; } public String toString() { return "TracingRunnable{origin=" + this.origin + ", snapshot=" + this.snapshot + ", component=" + this.component + ", operationName='" + this.operationName + '\'' + '}'; } }

拿线程池执行Runnable任务来说,pfinder通过TracingRunnable包装我们的Runnable的实现,利用构造函数将主线程的traceId通过snapshot参数传给TracingRunnable,在run方法中将参数snapshot放到上下文中,最后从上下文中取出放到子线程的MDC中,从而实现traceId跨线程传递。

5.2 热部署

既然javaagent能做字节码增强,也能实现热部署,此外, pfinder客户端和服务端通过jmtp有命令的交互,可以通过服务端向agent发送命令来实现类搜索、反编译、热更新等功能,笔者基于这一想法粗略实现了一个在线热部署的功能,具体如下:

类搜索:

反编译:

热更新:

上述只是笔者做的一个简单的实现,还有很多不足的地方:

1.对于Spring XML、MyBatis XML的支持;

2.Instrumentation的局限性:由于jvm基于安全考虑,不允许改类结构,比如新增字段,新增方法和修改类的父类等。想要突破这种局限,就需要使用Dcevm(Java Hostspot的补丁)了。

欢迎有兴趣的同学一起学习交流。

参考阅读


本文由高可用架构转载。技术原创及架构实践文章,欢迎通过公众号菜单「联系我们」进行投稿


高可用架构
高可用架构公众号。
 最新文章