起因
今日收到一个同事的求救信息,说正在做gRPC接口测试,用的是jmeter的一个第三方插件,叫jmeter-grpc-request,平日用着挺好用的,今天设置了100个线程,持续跑,结果才跑了5000来个请求,就卡住了。
卡住了?什么是卡住了呢?
我仔细问了,才知道是jmeter整个没有响应了,只能强行杀进程才能停止。这是怎么回事呢?
场景重现
我问同事要了jmeter的脚本文件,并且下载了这个gRPC取样器的插件,在我本机试了一下,果然,线程数量很少时候,运行正常,但是数量多了一些(仅仅到了50),很快就出现了jmeter无响应的情况。
根据经验,立刻切到启动jmeter的命令行界面,看到的提示是内存溢出(OutOfMemoryException)。
这是很奇怪的现象,按理来说,仅仅是50个线程,才跑了几千的请求量,怎么就内存溢出了呢?我们知道,jmeter默认的运行内存是1G(HEAP=”-Xms1g -Xmx1g -XX:MaxMetaspaceSize=256m”)几千的请求量,什么东西占用了1G的Heap空间?
内存分析
jmeter工具还是挺方便的,在出现了内存溢出后,自动dump出了此时的JVM情况,在当前的运行目录下生成了java_pid.hprof
文件(id是当时jmeter的进程ID)。
所以我用jhat命令来读取hprof文件,看看到底什么东西占用了这么多内存:
1
|
$ jhat -port 7001 java_pidid>.hprof
|
这里我用了-port
参数指定了7001端口,因为默认的7000端口已经被我机器上别的程序占用了。
经过一段时间的等待(dump出来的hprof文件文件太大了,上G),命令行提示已读取完成,此时jhat会启动一个Web服务器,打开浏览器输入http://localhost:7001
就可以看到jvm中加载的所有对象。
当然,我要看的是Heap中这些对象的占用空间情况,所以通过最底下的链接,切换到了heap内存分析页:
很明显,排在前几行的就是“罪魁祸首”了。
也许有的小伙伴不清楚,class [B
是什么,其实就是java中的byte数组,居然占了300多MB的内存空间。
其次,就是FiledDescriptor
对象,占了200多MB的内存空间。这两个大户,耗费了一半的Heap内存,难怪内存不够了。
那么现在的问题就在于两个地方,
这个byte数组,存了什么,为何这么多;
这个FiledDescriptor
为何这么多实例。
这就只能从代码角度来分析了。
代码分析
根据对jmeter组件开发的了解,代码从继承AbstractSampler
的Sampler开始。当在jmeter中开始运行取样器时,执行的就是sample方法,仔细看看sampler中的sample方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
@Override public SampleResult sample(Entry ignored) { GrpcResponse grpcResponse = new GrpcResponse(); SampleResult sampleResult = new SampleResult(); try { initGrpcClient(); sampleResult.setSampleLabel(getName()); String grpcRequest = clientCaller.buildRequestAndMetadata(getRequestJson(),getMetadata()); sampleResult.setSamplerData(grpcRequest); sampleResult.setRequestHeaders(clientCaller.getMetadataString()); sampleResult.sampleStart(); grpcResponse = clientCaller.call(getDeadline()); sampleResult.sampleEnd(); sampleResult.setSuccessful(true); sampleResult.setResponseData(grpcResponse.getGrpcMessageString().getBytes(StandardCharsets.UTF_8)); sampleResult.setResponseMessage("Success"); sampleResult.setDataType(SampleResult.TEXT); sampleResult.setResponseCodeOK(); } catch (RuntimeException e) { errorResult(grpcResponse, sampleResult, e); } return sampleResult; }
|
initGrpcClient()
方法,应该是作者写的一个初始化gRPCClient的方法,进去看看:
1 2 3 4 5 6 7 8 9 10 11
|
private void initGrpcClient() { if (clientCaller == null) { clientCaller = new ClientCaller( getHostPort(), getProtoFolder(), getLibFolder(), getFullMethod(), isTls(), isTlsDisableVerification()); } }
|
嗯,果然是new了一个ClientCaller对象。里面的get方法,is方法都不用管,明显是从sampler界面中获取参数值,跟我们要探寻的东西无关。那么我们要到ClientCaller的构造方法里面看看:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
|
public ClientCaller( String HOST_PORT, String TEST_PROTO_FILES, String LIB_FOLDER, String FULL_METHOD, boolean TLS, boolean TLS_DISABLE_VERIFICATION) { this.init(HOST_PORT, TEST_PROTO_FILES, LIB_FOLDER, FULL_METHOD, TLS, TLS_DISABLE_VERIFICATION); }
private void init( String HOST_PORT, String TEST_PROTO_FILES, String LIB_FOLDER, String FULL_METHOD, boolean TLS, boolean TLS_DISABLE_VERIFICATION) { try { tls = TLS; disableTtlVerification = TLS_DISABLE_VERIFICATION; hostAndPort = HostAndPort.fromString(HOST_PORT); metadataMap = new LinkedHashMap(); channelFactory = ChannelFactory.create(); ProtoMethodName grpcMethodName = ProtoMethodName.parseFullGrpcMethodName(FULL_METHOD);
// Fetch the appropriate file descriptors for the service. final DescriptorProtos.FileDescriptorSet fileDescriptorSet;
try { fileDescriptorSet = ProtocInvoker.forConfig(TEST_PROTO_FILES, LIB_FOLDER).invoke(); } catch (Throwable t) { shutdownNettyChannel(); throw new RuntimeException("Unable to resolve service by invoking protoc", t); }
// Set up the dynamic client and make the call. ServiceResolver serviceResolver = ServiceResolver.fromFileDescriptorSet(fileDescriptorSet); methodDescriptor = serviceResolver.resolveServiceMethod(grpcMethodName);
createDynamicClient();
// This collects all known types into a registry for resolution of potential "Any" types. registry = JsonFormat.TypeRegistry.newBuilder() .add(serviceResolver.listMessageTypes()) .build(); } catch (Throwable t) { shutdownNettyChannel(); throw t; } }
|
嗯,代码有点长,主要方法是init,里面看到了一个有嫌疑的语句:
1
|
fileDescriptorSet = ProtocInvoker.forConfig(TEST_PROTO_FILES, LIB_FOLDER).invoke();
|
invoke
方法返回了一个FileDescriptorSet
对象,这个对象,是Google的ProtoBuf的核心对象。那么他到底是怎么获得的?这引起了我的兴趣。所以果断跟了进去:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
|
public FileDescriptorSet invoke() throws ProtocInvocationException { Path wellKnownTypesInclude; Path googleTypesInclude; try { wellKnownTypesInclude = setupWellKnownTypes(); } catch (IOException e) { throw new ProtocInvocationException("Unable to extract well known types", e); }
Path descriptorPath; try { descriptorPath = Files.createTempFile("descriptor", ".pb.bin"); } catch (IOException e) { throw new ProtocInvocationException("Unable to create temporary file", e); }
// Large folder processing, solve CreateProcess error=206 final ImmutableSet protoFilePaths = scanProtoFiles(discoveryRoot); ImmutableList protocArgs = null;
if (protoFilePaths.size() > largeFolderLimit) { try { File argumentsFile = createFileWithArguments(protoFilePaths.toArray(new String[0])); protocArgs = ImmutableList.builder() .add("@" + argumentsFile.getAbsolutePath()) .addAll(includePathArgs(wellKnownTypesInclude)) .add("--descriptor_set_out=" + descriptorPath.toAbsolutePath().toString()) .add("--include_imports") .build(); } catch (IOException e) { logger.error("Unable to create protoc parameter file", e); } }
if (protocArgs == null) { protocArgs = ImmutableList.builder() .addAll(protoFilePaths) .addAll(includePathArgs(wellKnownTypesInclude)) .add("--descriptor_set_out=" + descriptorPath.toAbsolutePath().toString()) .add("--include_imports") .build(); }
invokeBinary(protocArgs); try { return FileDescriptorSet.parseFrom(Files.readAllBytes(descriptorPath)); } catch (IOException e) { throw new ProtocInvocationException("Unable to parse the generated descriptors", e); } }
|
一大段代码,从开始到invokeBinary(protocArgs)
,都为了做一件事情,根据从界面获取的proto文件的根目录,来生成对应的protoc命令行的参数,像descriptor_set_out
,include_imports
,这些不都是protoc工具的参数嘛。那么,invokeBinary(protocArgs)
这里必然是执行了protoc命令,而protocArgs就是那些传入的参数,根据这些参数,可以知道protoc命令将指定的proto源文件编译为.pb
的二进制文件,并存放在descriptorPath目录下。注意,不管多少个proto文件,都会被编译到一个pb文件中,这个文件,就是所谓的FileDescriptorSet的序列化后的内容。
然后就是
1
|
return FileDescriptorSet.parseFrom(Files.readAllBytes(descriptorPath));
|
parseFrom方法就是从descriptorPath目录下读取pb文件,readAllBytes就是按字节读取文件内容的方法,返回byte数组,并反序列化成FileDescriptorSet对象。
代码逻辑上讲,没什么问题,但是问题就在于,当进行多线程压测时,每运行一次,就会编译并读取一次pb文件,发上千次请求的时候,岂不是读取了上千次?另外,我们公司的proto文件大概有数百个,都放在一个目录下面,而进行压测的小伙伴也没有把依赖的proto文件单独拉出来,直接读取了整个大目录,导致这个编译后的pb文件里面囊括了公司的全部proto文件,这么一来,岂不是很容易导致内存溢出吗?这就是真正出问题的原因。
修改插件源码
处理方式嘛,自然也就比较简单了,把这个FileDescriptorSet对象,当然还有一些其他对象,我把这些本来的fields改造成单例,也就是静态对象:
1 2 3 4
|
private static DescriptorProtos.FileDescriptorSet fileDescriptorSet; private static Descriptors.MethodDescriptor methodDescriptor; private static ServiceResolver serviceResolver; private static JsonFormat.TypeRegistry registry;
|
而代码中原来给这些对象赋值的地方,则改成是否为空的判断:
1 2 3
|
if(ClientCaller.fileDescriptorSet == null) { ClientCaller.fileDescriptorSet = ProtocInvoker.forConfig(TEST_PROTO_FILES, LIB_FOLDER).invoke(); }
|
这样一旦判断有值,就不会反复进行读取值了。
改完了源码,将插件重新进行编译,又发现了源码居然还少一个依赖,也不知道作者日常是怎么编译成功的,哈哈,赶紧补上,并且顺带把他的依赖库升了一下级。
然后执行:
这样打包成jar包后,重新复制到jmeter的lib/ext
目录下,测试了一下100线程300s的压测,顺利完成。嗯,看来问题解决了。
不足
因为FileDescriptorSet被我简单的改成了单例,所以一旦读取过值之后,就不会再次赋值,导致在jmeter中,用过一次这个插件后,要想改测别的proto,会失败,因为不会重新编译并读取了。所以要重新打开jmeter才能改新的测试接口。算是一个小问题吧,不过影响不大,暂时先改到这个程度了。
原文链接:
https://mp.weixin.qq.com/s?__biz=MzU5ODE2OTc1OQ==&mid=2247495341&idx=1&sn=d70b902b18daa8029b5be81a42b1f4a7