Java动态问题排查修复工具
问题排查基本思路
问题排查是一个比较体系化的领域,'问题'来源于多种多样,按照我的理解,问题来源可以分为下面几类:
- 代码问题
- 配置问题
- 运行时问题
代码问题是最基本的问题来源,又可以细分为代码逻辑错误、组件使用错误、异常处理缺失等;配置错误
和代码无关,是一个系统运行前、运行时、运行后所需要的配置出现错误,或者配置缺失,这类错误理论
上应该在运行前或者测试的时候就要发现;运行时问题可能是最为复杂的问题,它可能来源于不恰当的代码
编写,或者配置错误导致,比如代码中出现死循环导致JVM发送堆栈溢出,又比如JVM参数配置不合理导致
GC过于频繁,使得系统出现性能问题。
问题排查的目标就是定位到问题,然后解决它,这又是两个不同的问题,定位问题是说发现问题所在,可能
是代码问题、配置问题,总之需要找到这个问题点;问题修复是说将找到的问题解决,对于某些问题来说,可能
解决问题是完善配置即可,不需要重启系统,但是更多的时候是需要修复代码,重新编译并发布的。下面根据这两点
分析一下具体的应对措施。
问题定位
问题定位有时候很简单,有时候却很困难;系统运行日志是发现问题的很重要的资源,合理的日志可以快速找到
问题的根源所在,配合自动化报警机制可以快速发现问题。下面是两种基本类型的日志打印策略:
- 1、逻辑日志,一种阻断链路的逻辑异常信息,比如数据获取失败
- 2、异常堆栈,不同系统的代码风格会有差异,差异点在于代码分层,当代码抛出异常的时候,最外层的代码应该将其记录下来
第一种日志对于发现问题可能不太直接,因为是逻辑日志,需要推断一下,并且配合代码上下文才能发现问题,而异常堆栈日志可以
快速发现问题,因为在堆栈中可以快速找到抛出异常的代码行,基于代码行和抛出的异常,应该可以快速发现问题;
问题定位的核心是什么呢?我觉得是两个:
- 哪一行代码抛出了异常
- 问题代码上下文信息
映射到实际问题上,就是,告诉我方法返回的出口在哪里,或者抛出异常的时候运行到哪里,抛出了什么异常,方法退出前的局部变量
信息是什么?是不是可以很快想到我们在IDE里面进行DEBUG的场景,我们为什么要单步执行?不就是为了看看方法是在哪里退出的,每一步
获取到的结果是什么?
但是,我们怎么对运行着的JVM进行'debug'呢?单步调试是会阻塞JVM的,如果对正在运行并且在处理用户请求的JVM进行'debug',那是非常
可怕的,因为JVM被你阻塞住了,无法正常响应其他任何请求了,这显然不是我们想要的结果,单步调试虽然可以快速发现问题,但是只能用在
开发、测试阶段,这让人很困扰。
那问题发现可以归纳出几个诉求:
- 1、告诉我方法结束的方式,正常返回或者异常退出
- 2、告诉我方法入参、方法返回值或者抛出的异常
- 3、告诉我方法运行轨迹,从哪一行退出来的,或者在什么地方抛出的异常
- 4、是否可以将方法的局部变量信息告诉我
- 5、是否可以让我输入个性化参数,告诉我方法运行轨迹
- 6、是否可以让我回放方法请求入参,好让我观察一下这些入参的方法执行路径
- 7、是否可以让我观察特定的入参的方法执行路径,最好是可以支持表达式
- 8、是否可以告诉我方法执行的每一行的耗时统计
- 9、是否可以告诉我方法的QPS信息
- 10、...
当然还有更多的诉求,但是基本上,上面这些诉求是我们在运行时系统上进行问题发现的通用诉求,如果能有一种工具可以实现这些功能,那就
对快速定位线上问题太有帮助了。
问题修复
发现问题之后,就需要修复问题,对于java语言来说,如果涉及代码变更,一般情况下会选择重新启动JVM来修复问题,但重新启动意味着需要一些时间才能将异常修复,是否有一种技术支持,可以快速将类的变更加载到运行时JVM中去,实现秒级恢复故障。
下文中会介绍一个命令,可以不需要重启JVM即可实现类的字节码替换,简称"方法热修复",为什么叫方法热修复呢?因为这种修复技术只能变更方法逻辑,并且要保证不增加方法,当然也不能增减类字段,只能变更方法内部的代码逻辑,当然,这其实很有用,并且在绝大多数故障场景下都已经够用;平时的线上问题要么是没有处理空指针异常造成链路打断,或者某个服务调用超时配置不合理导致超时率过高等,再复杂一些比如方法内部业务逻辑处理有缺陷等,很少有情况是需要增加一个额外的方法(或者删除一个方法,甚至修改类字段以及变更类继承关系等)来修复一个紧急bug的,如果是这种情况,那么就是比较低级又比较严重的事故的。
java-debug整体设计
整体上,java-debug-tool的设计是一个C-S结构,C用于给开发者提供一个交互界面(shell),它的主要功能是处理用户的输入,然后将处理好的输入包装成java-debug-tool的交互协议,然后将这个协议发送到服务端,并等待服务端返回响应结果,之后进行结果解析,并将命令处理结果展示出来,整体上client的处理流程如下:
服务端的处理流程要复杂得多,而且还会存在命令权限控制、流量控制、命令执行超时控制等,但仔细一想,其实服务端复杂的地方在于命令实现,而服务端处理流程是固定死的,只要做好异常处理即可。下文中会提到大量关于服务端以及与命令实现相关的类,作为了解服务端整体实现的窗口。
有了C-S架构,上文提到的整体架构中还有一个角色:Agent,Agent是一个独立的包,这个包仅包含用于挂载到目标JVM的相关代码,当然为了实现某些字节码增强相关的命令,需要包含一些Spy方法,这些方法的具体实现都不会在agent中,整体来说,Agent需要做到对目标JVM侵入最小化,下面会对几个核心模块进行分别介绍。
java-debug核心命令详解
java-debug-tool提供了多个trouble-shot命令,但杀手级的命令就两个,methodTrace和redefineClass;这两个命令分别复杂“问题发现”和“问题修复”两个不同的阶段的工作,前者用于快速问题发现,可以做到不暂停JVM而获取到方法调试信息,后者可以做到不重启JVM而进行类字节码替换,实现方法热修复,下面按不同命令分别详细说明。
methodTrace命令
命令实现功能
获取一次方法调用的执行路径,并可以获取到每一行代码的执行耗时,以及每一行代码涉及到的变量赋值信息,如果方法正常退出,你可以获取到方法的返回值,以及退出的代码位置;如果方法抛出了异常,你可以获取到抛出异常的代码位置,并可以获取到抛出的异常信息。当然,你可以拿到每一次方法调用的参数信息;
更为高级的功能是:
(1)你可以录制方法调用流量,并可以回放这些流量;
(2)你可以自定义方法输入,并对输入进行链路追踪;
(3)你可以等待特定的方法入参,并对特定的方法入参进行方法链路追踪,这里你可以使用Spring强大的表达式进行参数匹配,刺激吧;
(4)你可以等待特定的异常,并对抛出这个异常的方法调用链路进行追踪;
命令参数详解
命令基本格式:
mt -c <class> -m <method>
可选参数:
-d :如果目标类中的目标方法是重载方法,那么你需要提供这个参数,比如int a(int a) => desc = "(I)I";
-
-t:选择具体的功能类型,可选项为:
- return:当方法正常退出的时候,获取到一次方法链路信息;
- throw:方方法抛出异常的时候,获取到一次方法链路信息;
- record:记录方法调用信息,用于回放流量;
- custom:用于实现用户自己输入参数观察,或者回放record的流量进行观察;
- watch:等待特定的参数,使用Spring表达式进行参数匹配,当匹配到目标参数之后,会返回方法链路信息,如果Spring表达式有误,那么会直接在第一次方法调用之后返回;
-i:用于接收用户的参数输入,比如当t=custom的时候,i参数就是用户指定的参数,这个参数是通过特殊处理的json字符串,java-debug-tool将提供工具接口来生成这个字符串,当t=watch的时候,i参数就是用于匹配参数的Spring表达式。
-n:当t=record的时候,n参数的含义就是需要录制的流量数量,当前仅允许录制10个以内;
-time:当t=record的时候,该参数的含义是录制的时间限制,超出则停止录制;
-u:当t=custom的时候,如果提供了u参数,那么i参数将被忽略,u代表record的流量下标,从0开始,如果u参数获取到了具体的流量,那么本次custom输入的参数就会从u参数取出来的流量中拿到参数,如果t=record,并且u参数合法,那么就不会进行录制,而是会从录制好的流量中取出代表u下标的流量,用户可以查看具体的流量信息(包括该流量的方法链路);
-e:如果t=throw,那么如果-e内容合法,那么该参数就代表需要等待的目标异常,如果参数不合法,只要遇到一个异常,本次观察就会结束;
-s:有些情况下,你可能只需要看方法调用的路径,不需要耗时信息,或者不需要变量信息,那么这个参数有很有用,因为可能有些变量很长,展示出来很难看,而有些时候你只需要看看方法到底是从哪里退出来的,这个参数有很有帮助。可以是"line"/"cost"中的一个,前者表示只需要给我方法链路信息,后者其实是"line" + "cost";
-l:这个参数很有用,当某个方法很长,那么链路追踪信息打印出来会很难看,你可能只关心某一行的相关信息,比如就想看看某一行的代码执行耗时,以及这一行相关的变量信息,那么这个参数就可以派上用场,值就是具体的行号(对照源码);
命令使用示例
import java.util.Random;
import java.util.concurrent.TimeUnit;
public class ReturnTest {
public int getIntVal(int in) {
long startTime = System.currentTimeMillis();
String strTag = "the return/throw line test tag";
if (in < 0) {
return strTag.charAt(0);
} else if (in == 0) {
return 1000;
}
// > 0
if (in < 2) {
double dbVal = 1.1;
return (int) (dbVal + 100);
} else if (in == 2) {
float fVal = 1.2f;
return (int) (fVal + 200);
}
// > 2
if (in % 2 == 0) {
Random random = new Random();
int rdm = random.nextInt(100);
if (rdm >= 50) {
throw new NullPointerException("npe test");
} else if (rdm <= 20) {
throw new NullPointerException("< 20");
}
// end time
long end = System.currentTimeMillis();
long cost = startTime - end;
return (int) (rdm * 10 + in + (cost / 1000));
} else {
ParamModel paramModel = new ParamModel();
paramModel.setIntVal(in);
paramModel.setDoubleVal(1.0 * in);
int subVal = getSubIntVal(paramModel);
if (subVal == 100) {
throw new IllegalArgumentException("err occ with in:" + subVal);
}
throw new IllegalStateException("error occ with in:" + in);
}
}
public int getSubIntVal(ParamModel paramModel) {
if (paramModel == null) {
return -1;
}
if (paramModel.getIntVal() <= 0) {
return (int) paramModel.getDoubleVal();
} else if (paramModel.getIntVal() <= 5) {
return 100;
} else if (paramModel.getIntVal() <= 8) {
return 200;
} else {
throw new RuntimeException("ill");
}
}
public static void main(String[] args) {
new Thread(new Runnable() {
private Random random = new Random();
private ReturnTest returnTest = new ReturnTest();
@Override
public void run() {
while (true) {
try {
System.err.println(returnTest.getIntVal(random.nextInt(10)));
TimeUnit.SECONDS.sleep(1);
} catch (Exception e) {
//e.printStackTrace();
}
}
}
}).start();
}
}
public class ParamModel {
public ParamModel() {
}
public ParamModel(int intVal, double doubleVal) {
this.intVal = intVal;
this.doubleVal = doubleVal;
}
public int getIntVal() {
return intVal;
}
public void setIntVal(int intVal) {
this.intVal = intVal;
}
public double getDoubleVal() {
return doubleVal;
}
public void setDoubleVal(double doubleVal) {
this.doubleVal = doubleVal;
}
@Override
public String toString() {
return "ParamModel{" +
"intVal=" + intVal +
", doubleVal='" + doubleVal + '\'' +
'}';
}
private int intVal;
private double doubleVal;
@Override
public boolean equals(Object obj) {
if (obj == null) {
return false;
}
if (!(obj instanceof ParamModel)) {
return false;
}
if (obj == this) {
return true;
}
if (((ParamModel) obj).getIntVal() != intVal) {
return false;
}
if (doubleVal != ((ParamModel) obj).getDoubleVal()) {
return false;
}
return true;
}
}
- 获取一个方法任意一次调用链路
- 获取特定的异常
从这张结果展示图片上可以看到,命令耗时2秒多,说明从执行命令开始等待了2秒多才出现了空指针异常;getIntVal方法的入参为6,方法最后从47行抛出了java.lang.NullPointerException;
- 录制方法调用信息
示例:
mt -c ReturnTest -m getIntVal -t record -n 5
使用这个命令之后,getIntVal方法存储了5条请求信息,下面可以通过-u参数来获取请求相关信息:
- 回放流量
- 观察自定义输入
- 观察符合特定要求的输入参数
tips:命令对方法的入参做了转换,只需要输入p0、p1等就可以获取到对应的参数对象,然后就可以操作这个对象了。
redefineClass命令
该命令用于热修复,当使用mt命令定位到问题之后,修复了的代码如果需要快速上线,那么就可以使用该命令;
命令的使用格式为:
rdf -p [className1:class1Path className1:class2Path]
你可以一次性修复多个类,下面还是以上面的ReturnTest类的getIntVal方法为例,如果我们需要改变该方法的行为,改成只有当输入大于等于7的时候才会正常执行接下来的方法逻辑,否则抛出一个UnsupportedOperationException异常,修改的代码部分为:
public int getIntVal(int in) {
if (in < 7) {
System.out.println("in < 7, return");
throw new UnsupportedOperationException("test");
}
...
首先运行原来的逻辑,然后修改代码,重新编译,然后执行rdf命令,观察方法输出是不是变化了,当然可以使用mt命令继续观察,看看是否和我们的预期一样:
在这个工具命令中,可能有一些命令会变更类的字节码,有一个命令可以回滚类的字节码:
rollback -c ClassName
执行上面的命令,可以实现类回滚的效果,但是要注意的是,这个回滚将直接回滚到类最初的样子,这一点需要特别注意。
findClass命令
这个命令看起来很简单,但是却特别有用,它可以在目标JVM找到你需要的类,并且告诉你类的具体信息,比如类是否已经加载,如果加载了,那么加载类的classLoader是哪一个等,这个命令可以允许你不输入类的全限定名,并可以允许你输入正则表达式去匹配类,下面是该命令的使用方法:
java-debug主要模块及相关类介绍
- agent-module
agent模块是需要被目标JVM加载运行的包,它的职责是在被加载进去之后挂载到目标JVM(通过pid),然后在目标JVM上启动java-debug netty Server,这个server将监听指定的目标端口,默认为11234,之后,client就可以向该jvm发送命令请求了。
agent需要做到对目标JVM影响最小化,不要影响目标JVM,因为是在目标jvm运行时进行attach的(被Java Attach Thread),所以需要特别小心,为此,使用自定义的类加载器进行core-module的加载。
下面是agent-module内部的核心类介绍:
类 | 功能 |
---|---|
io.javadebug.agent.Agent | 实现Agent的逻辑,这个类内部会加载core-module,并且启动NettyServer。 |
io.javadebug.agent.WeaveSpy | 为了实现在目标JVM的类中进行代码插桩,这个类内部定义了一些静态字段,这些字段非常重要,如果想要实现额外的代码桩,需要定义新的字段来表示,并且在Agent内部进行初始化 |
io.javadebug.agent.AgentClassLoader | agent实现的类加载器,主要负责加载core-mudule内部的类 |
agent包中不要随意增加类,目前这几个类已经可以满足需求,新增类需要考虑是否会对目标JVM(运行时)产生任何不可控的影响。
- core-module
core-module是java-debug的核心业务逻辑功能实现,包括client和server,以及command等内容,如果想要实现一个新的command,你需要在这个module内部进行一些相应的扩展。
类 | 功能 | 备注 |
---|---|---|
io.javadebug.core.CommandSource | 命令输入源 ,比如可以从std输入,或者从文件输入,甚至从网络中进行命令输入 | 目前仅支持一种类型的Source安装,后续再考虑支持多source |
io.javadebug.core.CommandSink | 命令结果输出处理,可以将命令的结果进行处理,比如通过std打印,或者输出到文件,甚至输出到网络 | 目前支持多个sink安装,命令处理结果将广播到各个sink |
io.javadebug.core.CommandInputHandler | 命令输入处理器,输入是原始的输入字符串,输出是转换好的命令交互协议对象 | 一个命令的实现包括client端的实现和server端的实现,client端的实现就是将命令输入字符串转换成命令交互协议对象,而服务端的实现正好相反 |
io.javadebug.core.Configure | 服务端所需的启动配置类,包括目标JVM的pid,启动NettyServer所需的ip + port | 配置除了pid之外都是非必填的,默认的ip + port是:127.0.0.1:11234 |
io.javadebug.core.RemoteServer | 远程服务的抽象接口,在Javadebug内部,早期使用了java NIO实现了一个简易的TcpServer,但是代码不太优雅,后期引入了Netty来实现了一个自定义协议的TcpServer,当然,早期的代码已经被删除了,后续可能还会实现其他的server,并且可以让这个server可以选择,目前能预测到的就是基于netty实现一个httpServer,因为很大概率线上机器的端口是不允许随意访问的,TcpServer不太妙 | |
io.javadebug.core.ServerHook | 这是要给各个命令实现使用的hook,它将负责一些多个命令共享的处理实现,在实现一个命令的时候,如果一个功能其他命令可能会同时需要,那么就放在ServerHook中 | ServerHook的本意是handlerHook,就是让命令实现类可以有机会去访问command handler内部的一些数据,但是后续演变为不但可以访问handler的数据,还可以使用一些通用的method |
io.javadebug.core.UTILS | UTILS类是一个工具类,所有需要被共享的处理(无状态)都应该放在这个类内部 | |
io.javadebug.core.ui.UI | 这是命令结果展示的组件,输入是命令响应协议对象,应该将这个协议展示成可视化的结果 | 当前可用的ui实现是 :io.javadebug.core.ui.SimplePSUI |
io.javadebug.core.transport.RemoteCommand | 这是client和server交互的命令协议对象,这个类非常重要 | 请注意协议的版本管理,如果client发送的协议版本与当前server的协议版本不一样,那么server将拒绝命令处理 |
io.javadebug.core.transport.NettyTransportServer | 基于netty的server实现 | |
io.javadebug.core.transport.NettyTransportClient | 这是基于netty的client实现,这个client只能连接到一个目标JVM上,也就是只能同时给一个JVM发送命令(仅调试一个JVM) | |
io.javadebug.core.transport.NettyTransportClusterClient | 这是基于netty的client实现,这个版本的client的实现非常复杂,它能够同时连接多个目标JVM进行调试,并实现了连接管理,灰度调试等功能,如果需要调试多个目标JVM,那么应该使用这个类 | |
io.javadebug.core.handler.ClientCommandRequestHandler | 这是client命令处理handler,就是将命令的原始输入转换为用于传输到目标JVM的协议对象 | |
io.javadebug.core.handler.CommandHandler | 这是一个服务端共享的netty handler,它用于实现命令处理,记录服务端各种状态 | |
io.javadebug.core.enhance.ClassMethodWeaver | 这个类用于类方法的增强,会在目标类的方法字节码中种各种桩 | |
io.javadebug.core.enhance.AbstractMethodTraceCommandAdvice | 实现基本的类方法观察结果处理,以及advice的生命周期管理 | |
io.javadebug.core.enhance.MethodAdvice | 类方法trace追踪的抽象接口,它首先被AbstractMethodTraceCommandAdvice实现,具体类型的trace将继承AbstractMethodTraceCommandAdvice实现个性化的观察 | |
io.javadebug.core.command.HelpCommand | help命令实现,用于查看一个命令的具体使用方法 | |
io.javadebug.core.command.LockClassCommand | 用于锁住一个类,其他类不能对该类进行字节码增强 | |
io.javadebug.core.command.MethodTraceCommand | 实现功能强大的方法debug的命令 | |
io.javadebug.core.command.RedefineClassCommand | 实现方法级别的热修复 | |
io.javadebug.core.command.RollbackClassCommand | 回滚类字节码到原始状态 |
- spring-module
spring模块的存在是为了解决在使用spring的项目中如何便捷的启动java-debug的问题的,这个模块比较简单,就是将agent和core以及一些启动shell打包到spring包中,然后使用Spring技术在目标JVM启动的时候进行attach操作。
类 | 功能 |
---|---|
io.javadebug.spring.JavaDebugInitializer | 在你的spring项目中配置这个bean即可实现启动spring项目的同时启动java-debug: |
<!-- dynamic debug bean -->
<bean id = "javaDebugInitializer" class="io.javadebug.spring.JavaDebugInitializer" factory-method="initializer" destroy-method="destroy" lazy-init="false"/>
java-debug开发规范
java-debug的开发规范用于规范开发行为,下面是规范细则:
- (1)bug优先解决:任意时刻,如果发现bug,都应该首先解决bug。
- (2)不随意引入新的jar包:如果不做此限制,那么java-debug的依赖关系会越来越复杂,不便于关联。
- (3)不随意修改命令的行为:一个命令如果已经被发布,那么就不应该随意修改命令的行为,包括命令输入,参数含义,以及处理逻辑及输出结果,如果需要变更命令行为,应该首先废弃当前命令,使用新的命令进行替代。
- (4)命令发布前需要进行严格的测试,不要随意发布新命令:这样可以保证命令的质量。
- (5)不要随意修改随包发布的shell:这一点很重要,如果修改了shell,那么会让人产生不解。
- (6)一切以稳定、安全、正确为本:不要随意打破现有开发模型,以及工具的整体架构,如果代码复杂到一定程度,可以进行重构,但是需要做到不改变整体架构的前提下进行重构。