美文网首页Netty技术Spring技术Java技术
一种Java动态调试与热修复技术实践

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

作者: 一字马胡 | 来源:发表于2019-05-22 20:20 被阅读4次

    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参数来获取请求相关信息:

    查看记录下来的流量信息
    • 回放流量
    回放记录下来的请求
    • 观察自定义输入
    观察自定义输入
    • 观察符合特定要求的输入参数
    观察符合要求的入参-1 观察符合要求的入参-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命令继续观察,看看是否和我们的预期一样:

    热修复类命令使用示例

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

    
    rollback -c ClassName
    
    

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

    findClass命令

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

    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:
        <!-- 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)一切以稳定、安全、正确为本:不要随意打破现有开发模型,以及工具的整体架构,如果代码复杂到一定程度,可以进行重构,但是需要做到不改变整体架构的前提下进行重构。

    相关文章

      网友评论

        本文标题:一种Java动态调试与热修复技术实践

        本文链接:https://www.haomeiwen.com/subject/vbkpzqtx.html