1.引言
在现代软件开发过程中,性能优化和故障排查是保证应用稳定运行的关键任务之一。Java作为一种广泛使用的编程语言,其生态中涌现出了许多优秀的监控和诊断工具,诸如:SkyWalking、Zipkin等,它们帮助开发者和运维人员深入了解应用的运行状态,快速定位和解决问题。在京东内部,则使用的是自研的pfinder。
本文旨在深入探讨pfinder的核心原理和架构设计,揭示它是如何实现应用全链路监控的。我们将从pfinder的基本概念和功能开始讲起,逐步深入到其具体实现机制。
1.pfinder概述
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类组件对比Zipkin
Pinpoint
SkyWalking
CAT
pfinder
贡献者
韩国公司
华为
美团
京东
实现方式
拦截请求,发送 http/mq 数据到 zipkin 服务
字节码注入
字节码注入
代理埋点(拦截器、注解、过滤器)
字节码注入
接入方式
基于 linkerd/sleuth,引入配置即可
javaagent 字节码
javaagent 字节码
代码侵入
javaagent 字节码
agent 到 collector 传输协议
http、MQ
thrift
gRPC
http/tcp
JMTP
OpenTracing
支持
支持
支持
粒度
接口级
方法级
方法级
代码级
方法级
全局调用统计
支持
支持
支持
支持
traceid 查询
支持
支持
支持
告警
支持
支持
支持
支持
JVM 监控
支持
支持
支持
支持
更重要的一点是:pfinder对京东内部自研组件提供了支持,比如:jsf、jmq、jimdb
1.pfinder背后的秘密
既然pfinder是基于字节码增强实现的,那么讲到pfinder,字节码增强技术自然也是无法避开的话题。这里我将字节码增强技术分两点来说,也是我认为实现字节码增强需要解决的两个关键点:
1.字节码是为了机器设计的,而非人类,字节码可读性极差、修改门槛极高,那么我们如何修改字节码呢?
2.修改后的字节码如何注入运行时JVM中呢?
欲攻善其事,必先利其器,所以下面我们围绕着这两个问题进行展开,当然,对这方面知识已经有所掌握的同学可忽略。
3.1.字节码修改字节码修改成熟的框架已经很多了,诸如:ASM、javassist、bytebuddy、bytekit,下面我们用这几个字节码修改框架实现一个相同的功能,来对比下这几个框架使用上的区别。现在我们通过字节码修改来实现下面的功能:
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); }1.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();1.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 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; } }}1.bytekit实现
// Parse the defined Interceptor and related annotations DefaultInterceptorClassParser interceptorClassParser = new DefaultInterceptorClassParser(); List<InterceptorProcessor> processors = interceptorClassParser.parse(HelloWorldInterceptor.class); // load bytecode ClassNodeNode = AsmUtils.loadClass(HelloWord.class); // Enhanced process of loaded bytecodes for (MethodNode methodNode :Node.methods) { MethodProcessor methodProcessor = new MethodProcessor(classNode, methodNode); for (InterceptorProcessor interceptor : processors) { interceptor.process(methodProcessor); } }public HelloWorldInterceptor { @AtEnter(inline = true) public static void atEnter() { System.out.println("start"); } @AtExit(inline = true) public static void atEit() { System.out.println("end"); }}特性
ASM
Javassist
ByteBuddy
ByteKit
性能
ASM的性能最高,因为它直接操作字节码,没有中间环节
劣于ASM
介于javassist和ASM之间
介于javassist和ASM之间
易用性
需精通字节码,学习成本高,不支持debug
Java语法进行开发,但是采用的硬编码形式开发,不支持debug
比Javassist更高级,更符文Java开发习惯,可以对增强代码进行断点调试
比Javassist更高级,更符文Java开发习惯,可以对增强代码进行断点调试
功能
直接操作字节码,功能最为强大。
功能相对完备
功能相对完备
功能相对完备,对比ByteBuddy,ByteKit能防止重复增强
3.2.字节码注入相信大家经常使用idea去debug我们写的代码,我们是否想过debug是如何实现的呢?暂时先卖个关子。
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)。
1.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<?>...es)
指定一系列的Class对象,被指定的类都会重新变回去(去掉附加的字节码)
1.instrument和ByteBuddy实现javaagent打印方法耗时
1.agent包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>2.agen主类
public 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,Loader, javaModule) -> builder.method(ElementMatchers.isMethod().and(ElementMatchers.isPublic()) ).intercept(MethodDelegation.to(TimingInterceptor.class)) ).installOn(instrumentation); }}3.拦截器
public 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)); } }}4.效果
1.pfinder实现原理
4.1.pfinder应用架构1.pfinder agent启动时首先加载META-INF/pfinder/service.addon和META-INF/pfinder/plugin.addon配置文件中的服务和插件。2.根据加载的插件做字节码增强。3.使用JMTP将服务和插件产生的数据(trace、指标等)进行上报。
4.2.pfinder插件增强代码解析1.service加载
创建SimplePFinderServiceLoader实例,在profilerBootstrap.boot(serviceLoaders)方法中加载配置文件中的service。
使用创建的SimplePFinderServiceLoader实例加载service,并返回一个service工厂的迭代器。
真正的加载走的是AddonLoader中的load方法。service加载完成后,继续看bootService方法:
bootService中完成创建service实例、注册service、初始化service,service的加载至此就完成了。
1.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: {} 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方法,至此插件字节码增强就结束了。
1.我的思考
5.1.多线程traceId丢失问题pfinder目前已经将traceId放到了MDC中,我们通过在日志配置文件中添加[%X{PFTID}]便能在日志中打印traceId。但是我们知道MDC使用的是ThreadLocal去保存的traceId,在跨线程时会出现线程丢失的情况。pfinder在这方面做了字节码增强,无论使用线程池还是@Async,都不会存在traceId丢失的问题。
public 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的补丁)了。
欢迎有兴趣的同学一起学习交流。