一种Java动态调试与热修复技术实践

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动态调试与热修复技术实践_第1张图片
整体架构设计

整体上,java-debug-tool的设计是一个C-S结构,C用于给开发者提供一个交互界面(shell),它的主要功能是处理用户的输入,然后将处理好的输入包装成java-debug-tool的交互协议,然后将这个协议发送到服务端,并等待服务端返回响应结果,之后进行结果解析,并将命令处理结果展示出来,整体上client的处理流程如下:

一种Java动态调试与热修复技术实践_第2张图片
客户端处理流程概要

服务端的处理流程要复杂得多,而且还会存在命令权限控制、流量控制、命令执行超时控制等,但仔细一想,其实服务端复杂的地方在于命令实现,而服务端处理流程是固定死的,只要做好异常处理即可。下文中会提到大量关于服务端以及与命令实现相关的类,作为了解服务端整体实现的窗口。

有了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 -m

可选参数:

  • -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;
    }
}

  • 获取一个方法任意一次调用链路
一种Java动态调试与热修复技术实践_第3张图片
获取任意一次方法调用信息
  • 获取特定的异常
一种Java动态调试与热修复技术实践_第4张图片
获取空指针异常的链路信息

从这张结果展示图片上可以看到,命令耗时2秒多,说明从执行命令开始等待了2秒多才出现了空指针异常;getIntVal方法的入参为6,方法最后从47行抛出了java.lang.NullPointerException;

  • 录制方法调用信息

示例:

mt -c ReturnTest -m getIntVal -t record -n 5

使用这个命令之后,getIntVal方法存储了5条请求信息,下面可以通过-u参数来获取请求相关信息:

一种Java动态调试与热修复技术实践_第5张图片
查看记录下来的流量信息
  • 回放流量
一种Java动态调试与热修复技术实践_第6张图片
回放记录下来的请求
  • 观察自定义输入
一种Java动态调试与热修复技术实践_第7张图片
观察自定义输入
  • 观察符合特定要求的输入参数
一种Java动态调试与热修复技术实践_第8张图片
观察符合要求的入参-1
一种Java动态调试与热修复技术实践_第9张图片
观察符合要求的入参-2

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命令继续观察,看看是否和我们的预期一样:

一种Java动态调试与热修复技术实践_第10张图片
热修复类命令使用示例

在这个工具命令中,可能有一些命令会变更类的字节码,有一个命令可以回滚类的字节码:


rollback -c ClassName

执行上面的命令,可以实现类回滚的效果,但是要注意的是,这个回滚将直接回滚到类最初的样子,这一点需要特别注意。

findClass命令

这个命令看起来很简单,但是却特别有用,它可以在目标JVM找到你需要的类,并且告诉你类的具体信息,比如类是否已经加载,如果加载了,那么加载类的classLoader是哪一个等,这个命令可以允许你不输入类的全限定名,并可以允许你输入正则表达式去匹配类,下面是该命令的使用方法:

一种Java动态调试与热修复技术实践_第11张图片
findClass命令使用示例

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:
    
    

java-debug开发规范

java-debug的开发规范用于规范开发行为,下面是规范细则:

  • (1)bug优先解决:任意时刻,如果发现bug,都应该首先解决bug。
  • (2)不随意引入新的jar包:如果不做此限制,那么java-debug的依赖关系会越来越复杂,不便于关联。
  • (3)不随意修改命令的行为:一个命令如果已经被发布,那么就不应该随意修改命令的行为,包括命令输入,参数含义,以及处理逻辑及输出结果,如果需要变更命令行为,应该首先废弃当前命令,使用新的命令进行替代。
  • (4)命令发布前需要进行严格的测试,不要随意发布新命令:这样可以保证命令的质量。
  • (5)不要随意修改随包发布的shell:这一点很重要,如果修改了shell,那么会让人产生不解。
  • (6)一切以稳定、安全、正确为本:不要随意打破现有开发模型,以及工具的整体架构,如果代码复杂到一定程度,可以进行重构,但是需要做到不改变整体架构的前提下进行重构。

你可能感兴趣的:(一种Java动态调试与热修复技术实践)