Netty技术Spring技术Java技术

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

2019-05-22  本文已影响4人  一字马胡

Java动态问题排查修复工具

问题排查基本思路

问题排查是一个比较体系化的领域,'问题'来源于多种多样,按照我的理解,问题来源可以分为下面几类:

代码问题是最基本的问题来源,又可以细分为代码逻辑错误、组件使用错误、异常处理缺失等;配置错误
和代码无关,是一个系统运行前、运行时、运行后所需要的配置出现错误,或者配置缺失,这类错误理论
上应该在运行前或者测试的时候就要发现;运行时问题可能是最为复杂的问题,它可能来源于不恰当的代码
编写,或者配置错误导致,比如代码中出现死循环导致JVM发送堆栈溢出,又比如JVM参数配置不合理导致
GC过于频繁,使得系统出现性能问题。

问题排查的目标就是定位到问题,然后解决它,这又是两个不同的问题,定位问题是说发现问题所在,可能
是代码问题、配置问题,总之需要找到这个问题点;问题修复是说将找到的问题解决,对于某些问题来说,可能
解决问题是完善配置即可,不需要重启系统,但是更多的时候是需要修复代码,重新编译并发布的。下面根据这两点
分析一下具体的应对措施。

问题定位

问题定位有时候很简单,有时候却很困难;系统运行日志是发现问题的很重要的资源,合理的日志可以快速找到
问题的根源所在,配合自动化报警机制可以快速发现问题。下面是两种基本类型的日志打印策略:

第一种日志对于发现问题可能不太直接,因为是逻辑日志,需要推断一下,并且配合代码上下文才能发现问题,而异常堆栈日志可以
快速发现问题,因为在堆栈中可以快速找到抛出异常的代码行,基于代码行和抛出的异常,应该可以快速发现问题;

问题定位的核心是什么呢?我觉得是两个:

映射到实际问题上,就是,告诉我方法返回的出口在哪里,或者抛出异常的时候运行到哪里,抛出了什么异常,方法退出前的局部变量
信息是什么?是不是可以很快想到我们在IDE里面进行DEBUG的场景,我们为什么要单步执行?不就是为了看看方法是在哪里退出的,每一步
获取到的结果是什么?

但是,我们怎么对运行着的JVM进行'debug'呢?单步调试是会阻塞JVM的,如果对正在运行并且在处理用户请求的JVM进行'debug',那是非常
可怕的,因为JVM被你阻塞住了,无法正常响应其他任何请求了,这显然不是我们想要的结果,单步调试虽然可以快速发现问题,但是只能用在
开发、测试阶段,这让人很困扰。

那问题发现可以归纳出几个诉求:

当然还有更多的诉求,但是基本上,上面这些诉求是我们在运行时系统上进行问题发现的通用诉求,如果能有一种工具可以实现这些功能,那就
对快速定位线上问题太有帮助了。

问题修复

发现问题之后,就需要修复问题,对于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>

可选参数:

命令使用示例
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模块是需要被目标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是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模块的存在是为了解决在使用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的开发规范用于规范开发行为,下面是规范细则:

上一篇 下一篇

猜你喜欢

热点阅读