一千零一个优化-0003-记一次java脚本引擎groovy的OOM问题

别乱说话

0002号任务结束以后,小树过了一段太平日子。小树开开心心投入到了下一个大型项目中,在日常闲谈时也越来越自信,甚至犯下了大忌讳,说出了,"我们平台现在贼稳定,我都可以不维护了",这样的话。不出意外,就要出意外了。

在一个午休后,突然一个个高亮头像消息出现了。小树点开几个看了看,"小树,怎么502了","哥,在发布?","快快快,搞什么搞,测试都闲了一天了"。赶紧上了两个服务器,查了下当前活跃的进程和top,瞬间懵了,两个服务器啥服务没有了。第一时间重启应用,5分钟后,一个个的回消息,"可以了"...手心手背都是汗。

导火索

怎么回事呢,已经好几周没有事情了,怎么突然就死了,看了下机器的情况最近没有重启信息,服务最后一次重启是在2周前,再查看了下使用数据,最近的自动化频次和之前也基本没有太大的差距。

回想起来,恰好最近有个业务组的同学,来找小树反馈过一个问题,"小树,我们的这个自动化平台施法前摇有点久啊,第一个步骤执行结束时间和第二步的开始执行时间间隔时间,步骤有5-20秒不等的等待时间..."。然后他还告知了一些信息,他的脚本大概写了50多个步骤且包含800多个校验点...

可惜那时候因为种种原因,并没有即刻介入。如今想来会不会有些什么联系呢?没有头绪解决OOM问题,小树就干脆先优化了这个问题看看。

入口:慢场景问题排查

因为测试平台是基于jmeter进行的自动化的调度。那就需要找到jmeter的核心执行类进行查看整个自动化执行流程中为什么步骤间隔会这么大,并且有长有短。通过处理sampler,前后置处理器,校验点检查等步骤逐一进行断点调试,最后通过断点了解到,实际Jmeter会根据断言的种类选择生成检查点相关的逻辑并进行执行。

java 复制代码
//org.apache.jmeter.assertions.JSR223Assertion
public class JSR223Assertion extends JSR223TestElement implements Cloneable, Assertion, TestBean
{
    private static final Logger log = LoggerFactory.getLogger(JSR223Assertion.class);

    private static final long serialVersionUID = 235L;

    @Override
    public AssertionResult getResult(SampleResult response) {
        AssertionResult result = new AssertionResult(getName());
        try {
            // 获取脚本引擎
            ScriptEngine scriptEngine = getScriptEngine();
            Bindings bindings = scriptEngine.createBindings();
            bindings.put("SampleResult", response);
            bindings.put("AssertionResult", result);
            // 执行脚本引擎
            processFileOrScript(scriptEngine, bindings);
            result.setError(false);
        } catch (IOException | ScriptException e) {
            log.error("Problem in JSR223 script: {}", getName(), e);
            result.setError(true);
            result.setFailureMessage(e.toString());
        }
        return result;
    }

    @Override
    public Object clone() {
        return super.clone();
    }
}

结合这个信息,小树立马看了下脚本中的检查点设置,清一色的BeanShell脚本,实际有560个这样的脚本。

因为之前的工作经验,小树联想到性能慢会不会和BeanShell脚本处理有关,如果使用Groovy会不会改善。于是一边查2种类型脚本的性能数据差,一边全局替换脚本类型为Groovy。最终理论数据和实际数据的双重验证下,单脚本运行速度大概相差10倍,单个场景运行速度相差将近5倍。

小树一次性把所有有反应慢的场景全部改成了Groovy脚本。然后让反馈问题的同学进行了实际体验,服务表现很不错。"不愧是,小树","小树威武"。虽然有了这些正面的反馈,但是心中大石还在那里,本次问题还没有得到解决。

挑战:OOM的排查

小树回忆起整个测试平台从搭建到运行到现在,其实也有遇到过类似的这种情况,但因当时时间紧急,任务重(官方说辞),所以选用最经济高效的方法进行解决:服务器升配,从4C8G扩展到4C16G, 又从单节点扩展成双节点。但是现在问题又出现了,这个事情已经迫在眉睫,再用其他方式也只是治标不治本。

其实这次事故事发之前,已经尝试过排查定位,大致方向锁定在脚本执行模块。加上这次优化脚本性能问题,小树更加确定这里存在某种联系。然后通过网上资料查询,检索处"GroovyEngine导致元空间OOM",其中大部分是因为每个脚本文件再经过Groovy编译加载后就变成了一个临时的JavaClass会根据脚本使用数进行命名类似于Script1~n.class这种,不同脚本重新生成所以元数据空间会被持续增加。

这个特性如果是在一个非服务化的工具里面执行,其实没有什么问题因为对象最终会被销毁,但是如果是一个服务,那就需要对关键的位置进行一些控制。

种种迹象表明,已经快摸到线了。于是先找到脚本调用的地方,再同样的位置(org.apache.jmeter.assertions.JSR223Assertion)继续调试。在这次调试的时候小树发现了一个可疑的现象,scriptEngine的对象是再变化的不是同一个,可是已知的是,小伙伴的脚本已经被全改成了Groovy,那为什么这个scriptEngine的对象还是会变化的呢,这个就很奇怪了。然后反复调试,最终确认了一些存在的问题:

  1. 每次获取的scriptEngine对象都是不一样的。

  2. 每次要执行脚本前毛都需要重新产生一个scriptEngine。

  3. 每种类型的语言都会有自己的scriptEngine的生成工厂。

来说一组数字,560个脚本需要560个scriptEngine来执行,比如1天跑500次场景,那大概会生成 560 * 500 = 280000 个engine对象(算上脚本对象 * 2 ),且工厂是单例一直存在,那这里面的这些数据理论上都不会去回收。看到这里,感觉就已经离真相更近了,往代码里面再深挖了一些就确认了这些想法。

java 复制代码
//javax.script.ScriptEngineManager
public ScriptEngine getEngineByName(String shortName) {
    if (shortName == null) throw new NullPointerException();
    //look for registered name first
    Object obj;
    if (null != (obj = nameAssociations.get(shortName))) {
        ScriptEngineFactory spi = (ScriptEngineFactory)obj;
        try {
            // 使用工厂获取脚本引擎
            ScriptEngine engine = spi.getScriptEngine();
            engine.setBindings(getBindings(), ScriptContext.GLOBAL_SCOPE);
            return engine;
        } catch (Exception exp) {
            if (DEBUG) exp.printStackTrace();
        }
    }

    for (ScriptEngineFactory spi : engineSpis) {
        List<String> names = null;
        try {
            names = spi.getNames();
        } catch (Exception exp) {
            if (DEBUG) exp.printStackTrace();
        }

        if (names != null) {
            for (String name : names) {
                if (shortName.equals(name)) {
                    try {
                     // 使用工厂获取脚本引擎
                        ScriptEngine engine = spi.getScriptEngine();
                        engine.setBindings(getBindings(), ScriptContext.GLOBAL_SCOPE);
                        return engine;
                    } catch (Exception exp) {
                        if (DEBUG) exp.printStackTrace();
                    }
                }
            }
        }
    }

    return null;
}

//org.codehaus.groovy.jsr223.GroovyScriptEngineFactory
public ScriptEngine getScriptEngine() {
    // Groovy 工厂的脚本引擎获取方式是直接实例化
    return new GroovyScriptEngineImpl(this);
}

这个问题就是因为每次执行脚本的时候都会去GroovyScriptEngineFactory重新实例化一个工程脚本引擎GroovyScriptEngineImpl。

于是小树就开启了Bug修复工作,因为定位到了位置,修复问题还是比较简单的。

首先,找到Jmeter的调用入口,Jmeter在Assertion 的父类上,比较直接的写明了调用方法。

java 复制代码
//org.apache.jmeter.util.JSR223TestElement#getScriptEngine

protected ScriptEngine getScriptEngine() throws ScriptException {
    String lang = getScriptLanguageWithDefault();
    // 通过脚本类型,选择一个脚本引擎
    ScriptEngine scriptEngine = getInstance().getEngineByName(lang);
    if (scriptEngine == null) {
        throw new ScriptException("Cannot find engine named: '" + lang + "', ensure you set language field in JSR223 Test Element: " + getName());
    }

    return scriptEngine;
}

其次,只要这样那样一下就好了。(这里唯一需要做的改造就是不让重复生成脚本引擎,用同一个脚本引擎处理某一种类型语言的所有脚本。于是乎一个飞快的修复就做完了。)

java 复制代码
//org.apache.jmeter.util.JSR223TestElement#getScriptEngine
// 使用一个缓存对象把所有语言的脚本引擎记录下来
private static final Map<String,ScriptEngine> langScriptEngines = new ConcurrentHashMap<>();
/**
 * @return {@link ScriptEngine} for language defaulting to groovy if language is not set
 * @throws ScriptException when no {@link ScriptEngine} could be found
 */
protected ScriptEngine getScriptEngine() throws ScriptException {
    String lang = getScriptLanguageWithDefault();
    // 使用脚本引擎缓存,替换每一次都需要重新生成的逻辑,如果引擎不存在那重新生成一个。
    ScriptEngine scriptEngine = langScriptEngines.computeIfAbsent(lang,(k)-> getInstance().getEngineByName(lang));
    if (scriptEngine == null) {
        throw new ScriptException("Cannot find engine named: '" + lang + "', ensure you set language field in JSR223 Test Element: " + getName());
    }

    return scriptEngine;
}

就在小树修复问题的时候,又有一个节点因为OOM被服务器守护进程直接杀掉,另一个节点剩余活跃内存也只剩500M。想想就感觉后怕,如果这次问题还不能得到解决的话,那接下来,每天都要人肉看看服务是否还活着了,以及接收各种同学的问题了。说是迟那时快,小树直接构建打包发布一气呵成,迅速发版上线。开启以后,小树就开始盯上了top面板,一切都只能等待时间的考验了...

  • 2个小时过去了,服务占用内存为4个G,机器剩余可用内存10个G。
  • 4个小时过去了,服务占用内存为4个G,机器剩余可用内存10个G。
  • 8个小时过去了,服务占用内存为4个G,机器剩余可用内存10个G。
  • 16个小时过去了,服务占用内存为4个G,机器剩余可用内存10个G。
  • 32个小时过去了,服务占用内存为4.3个G,机器剩余可用内存10个G。
  • ...

幸运的是这次服务器表现的非常稳定,查看了测试平台脚本的执行量,已经执行过了1600+场景,系统还在稳定高效的运行。小树心里别提有多开心。

结尾

这次总算是解决了小树多少个月的心头病了,这次应该可以太平一段时间了(吸取了乱说话的教训)。总结下来其实还是对基础能力不熟悉导致的问题,每个组件每个模块都是有一个使用场景和最佳使用环境的,没有万金油。Jmeter也好,Groovy也好即使都是非常成熟和被公认了不起的开源应用,也不可能了解所有使用者的使用方式方法。作为使用方,需要更加了解依赖的基础能力原理,这样才能避开未知的坑。虽然这次小树解决了这个问题,但是很有可能其他引用的依赖上也会存在某些场景不适合的场景,只是现在用户没有使用到某些特性而已。

"看看下次还会不会这么好运了"那个声音又来了,下次小树又会遇到什么样的新挑战呢...

(未完待续)

相关推荐
MacroZheng23 分钟前
完美替代 Navicat!这款内置 AI 的数据库工具,太香了!
java·后端·mysql
SamDeepThinking1 小时前
从REST到gRPC,一个API选型的思考框架
java·后端·程序员
Zane19941 小时前
接口都能写默认实现了,为什么还需要抽象类
java·后端
未秃头的程序猿1 小时前
读Spring AI源码的方法——不是硬啃,是带着问题去翻
java·后端·spring
用户3126874877201 小时前
别再 Thread.sleep 硬等了!并发工具类到底怎么选?
java
Java内核笔记1 小时前
万字长文剖析 Spring Boot 4 自动配置机制源码:从 @EnableAutoConfiguration 到条件装配
java·后端
evans在进步1 小时前
Spring MVC 核心机制详解:全局异常处理、请求跳转与数据绑定
java·spring·mvc
weixin_BYSJ19871 小时前
springboot小区物业管理小程序---附源码45555
java·javascript·spring boot·python·django·flask·php
coder_lorraine2 小时前
Halo 2.x 回收站文章无法删除?一篇文章教你彻底解决
java·运维
索隆zoro2 小时前
Army 进阶:DML 操作体系与 Spring AI 集成
java