作者:vivo 互联网服务器团队 - Liu Zhen、Ye Wenhao
服务器内存问题是影响应用程序性能和稳定性的重要因素之一,须要及时排查和优化。本文介绍了某外围服务内存问题排查与解决过程。首先在JVM与大对象优化上进行了无效的实际,其次在故障转移与大对象监控上提出了牢靠的落地计划。最初,总结了内存优化须要思考的其余问题。
一、问题形容
音乐业务中,core服务次要提供歌曲、歌手等元数据与用户资产查问。随着元数据与用户资产查问量的增长,一些JVM内存问题也逐步露出,例如GC频繁、耗时长,在高峰期RPC调用超时等问题,导致业务外围性能受损。
图1 业务异样数量变动
二、剖析与解决
通过对日志,机器CPU、内存等监控数据分析发现:
YGC均匀每分钟次数12次,峰值为24次,均匀每次的耗时在327毫秒。FGC均匀每10分钟0.08次,峰值1次,均匀耗时30秒。能够看到GC问题较为突出。
在问题期间,机器的CPU并没有显著的变动,然而堆内存呈现较大异样。图2,黄色圆圈处,内存应用急速回升,FGC变的频繁,开释的内存越来越少。
图2 老年代内存应用异样
因而,咱们认为业务性能异样是机器的内存问题导致的,须要对服务的内存做一次专项优化。
- 步骤1 JVM优化
以下是默认的JVM参数:
-Xms4096M -Xmx4096M -Xmn1024M -XX:MetaspaceSize=256M -Djava.security.egd=file:/dev/./urandom -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/{runuser}/logs/other
如果不指定垃圾收集器,那么JDK 8默认采纳的是Parallel Scavenge(新生代) +Parallel Old(老年代),这种组合在多核CPU上充分利用多线程并行的劣势,进步垃圾回收的效率和吞吐量。然而,因为采纳多线程并行形式,会造成肯定的进展工夫,不适宜对响应工夫要求较高的应用程序。然而,core这类的服务特点是对象数量多,生命周期短。在零碎特点上,吞吐量较低,要求时延低。因而,默认的JVM参数并不适宜core服务。
依据业务的特点和屡次对照试验,抉择了如下参数进行JVM优化(4核8G的机器)。该参数将young区设为原来的1.5倍,缩小了进入老年代的对象数量。将垃圾回收器换成ParNew+CMS,能够缩小YGC的次数,升高进展工夫。此外还开启了CMSScavengeBeforeRemark,在CMS的从新标记阶段进行一次YGC,以缩小从新标记的工夫。
-Xms4096M -Xmx4096M -Xmn1536M -XX:MetaspaceSize=256M -XX:+UseConcMarkSweepGC -XX:+CMSScavengeBeforeRemark -Djava.security.egd=file:/dev/./urandom -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/{runuser}/logs/other
图3 JVM优化前后的堆内存比照
优化后成果如图3,堆内存的应用明显降低,然而Dubbo超时依然存在。
咱们推断,在业务高峰期,该节点呈现了大对象降职到了老年代,导致内存应用迅速回升,并且大对象没有被及时回收。那如何找到这个大对象及其产生的起因呢?为了升高问题排查期间业务的损失,提出了长期的故障转移策略,尽量升高异样数量。
- 步骤2 故障转移策略
在api服务调用core服务出现异常时,将出现异常的机器ip上报给监控平台。而后利用监控平台的统计与告警能力,配置相应的告警规定与回调函数。当异样触发告警,通过配置的回调函数将告警ip传递给api服务,此时api服务能够将core服务下的该ip对应的机器视为“故障”,进而通过自定义的故障转移策略(实现Dubbo的AbstractLoadBalance抽象类,并且配置在我的项目),主动将该ip从提供者集群中剔除,从而达到不去调用问题机器。图 4 是整个措施的流程。在该措施上线前,每当有机器内存告警时,将会人工重启该机器。
图4 故障转移策略
- 步骤3 大对象优化
大对象占用了较多的内存,导致内存空间无奈被无效利用,甚至造成OOM(Out Of Memory)异样。在优化过程中,先是查看了异样期间的线程信息,而后对堆内存进行了剖析,最终确定了大对象身份以及产生的接口。
(1) Dump Stack 查看线程
从监控平台上Dump Stack文件,发现肯定数量的如下线程调用。
Thread 5612: (state = IN_JAVA) - org.apache.dubbo.remoting.exchange.codec.ExchangeCodec.encodeResponse(org.apache.dubbo.remoting.Channel, org.apache.dubbo.remoting.buffer.ChannelBuffer, org.apache.dubbo.remoting.exchange.Response) @bci=11, line=282 (Compiled frame; information may be imprecise) - org.apache.dubbo.remoting.exchange.codec.ExchangeCodec.encode(org.apache.dubbo.remoting.Channel, org.apache.dubbo.remoting.buffer.ChannelBuffer, java.lang.Object) @bci=34, line=73 (Compiled frame) - org.apache.dubbo.rpc.protocol.dubbo.DubboCountCodec.encode(org.apache.dubbo.remoting.Channel, org.apache.dubbo.remoting.buffer.ChannelBuffer, java.lang.Object) @bci=7, line=40 (Compiled frame) - org.apache.dubbo.remoting.transport.netty4.NettyCodecAdapter$InternalEncoder.encode(io.netty.channel.ChannelHandlerContext, java.lang.Object, io.netty.buffer.ByteBuf) @bci=51, line=69 (Compiled frame) - io.netty.handler.codec.MessageToByteEncoder.write(io.netty.channel.ChannelHandlerContext, java.lang.Object, io.netty.channel.ChannelPromise) @bci=33, line=107 (Compiled frame) - io.netty.channel.AbstractChannelHandlerContext.invokeWrite0(java.lang.Object, io.netty.channel.ChannelPromise) @bci=10, line=717 (Compiled frame) - io.netty.channel.AbstractChannelHandlerContext.invokeWrite(java.lang.Object, io.netty.channel.ChannelPromise) @bci=10, line=709 (Compiled frame)...
state = IN_JAVA 示意Java虚拟机正在执行Java程序。从线程调用信息能够看到,Dubbo正在调用Netty,将输入写入到缓冲区。此时的响应可能是一个大对象,因此在对响应进行编码、写缓冲区时,须要消耗较长的工夫,导致抓取到的此类线程较多。另外耗时长,也即是大对象存活工夫长,导致full gc 开释的内存越来越小,闲暇的堆内存变小,这又会加剧full gc 次数。
这一系列的连锁反应与图2相吻合,那么接下来的工作就是找到这个大对象。
(2)Dump Heap 查看内存
对core服务的堆内存进行了屡次查看,其中比拟有代表性的一次快照的大对象列表如下,
图5 core服务的堆内存快照
整个Netty的taskQueue有258MB。并且从图中绿色方框处能够发现,单个的Response竟达到了9M,红色方框处,显示了调用方的服务名以及URI。
进一步排查,发现该接口会通过core服务查问大量信息,至此根本排查分明了大对象的身份以及产生起因。
(3)优化后果
在对接口进行优化后,整个core服务也呈现了非常明显的改良。YGC全天总次数升高了76.5%,高峰期累计耗时升高了75.5%。FGC三天才会产生一次,并且高峰期累计耗时升高了90.1%。
图6 大对象优化后的core服务GC状况
只管优化后,因外部异样导致获取外围业务失败的异样申请数显著缩小,然而仍然存在。为了找到最初这一点异样产生的起因,咱们打算对core服务内存中的对象大小进行监控。
图7 零碎外部异样导致外围业务失败的异样申请数
- 步骤4 无侵入式内存对象监控
Debug Dubbo 源码的过程中,发现在网络层,Dubbo通过encodeResponse办法对响应进行编码并写入缓冲区,通过checkPayload办法去查看响应的大小,当超过payload时,会抛出ExceedPayloadLimitException异样。在外层对异样进行了捕捉,重置buffer地位,而且如果是ExceedPayloadLimitException异样,从新发送一个空响应,这里须要留神,空响应没有原始的响应后果信息,源码如下。
//org.apache.dubbo.remoting.exchange.codec.ExchangeCodec#encodeResponseprotected void encodeResponse(Channel channel, ChannelBuffer buffer, Response res) throws IOException { //...省略局部代码 try { //1、查看响应大小是否超过 payload,如果超过,则抛出ExceedPayloadLimitException异样 checkPayload(channel, len); } catch (Throwable t) { //2、重置buffer buffer.writerIndex(savedWriteIndex); //3、捕捉异样后,生成一个新的空响应 Response r = new Response(res.getId(), res.getVersion()); r.setStatus(Response.BAD_RESPONSE); //4、ExceedPayloadLimitException异样,将生成的空响应从新发送一遍 if (t instanceof ExceedPayloadLimitException) { r.setErrorMessage(t.getMessage()); channel.send(r); return; } }} //org.apache.dubbo.remoting.transport.AbstractCodec#checkPayloadprotected static void checkPayload(Channel channel, long size) throws IOException { int payload = getPayload(channel); boolean overPayload = isOverPayload(payload, size); if (overPayload) { ExceedPayloadLimitException e = new ExceedPayloadLimitException("Data length too large: " + size + ", max payload: " + payload + ", channel: " + channel); logger.error(e); throw e; }}
受此启发,自定义了编解码类(实现org.apache.dubbo.remoting.Codec2接口,并且配置在我的项目),去监控超出阈值的对象,并打印申请的详细信息,不便排查问题。在具体实现中,如果特意去计算每个对象的大小,那么势必是对服务性能造成影响。通过剖析,采取了和checkPayload一样的形式,依据编码前后buffer的writerIndex地位去判断有没有超过设定的阈值。代码如下。
/** * 自定义dubbo编码类 **/public class MusicDubboCountCodec implements Codec2 { /** * 异样响应池:缓存超过payload大小的responseId */ private static Cache<Long, String> EXCEED_PAYLOAD_LIMIT_CACHE = Caffeine.newBuilder() // 缓存总条数 .maximumSize(100) // 过期工夫 .expireAfterWrite(300, TimeUnit.SECONDS) // 将value设置为软援用,在OOM前间接淘汰 .softValues() .build(); @Override public void encode(Channel channel, ChannelBuffer buffer, Object message) throws IOException { //1、记录数据编码前的buffer地位 int writeBefore = null == buffer ? 0 : buffer.writerIndex(); //2、调用原始的编码方法 dubboCountCodec.encode(channel, buffer, message); //3、查看&记录超过payload的信息 checkOverPayload(message); //4、计算对象长度 int writeAfter = null == buffer ? 0 : buffer.writerIndex(); int length = writeAfter - writeBefore; //5、超过告警阈值,进行日志打印解决 warningLengthTooLong(length, message); } //校验response是否超过payload,超过了,缓存id private void checkOverPayload(Object message){ if(!(message instanceof Response)){ return; } Response response = (Response) message; //3.1、新的发送过程:通过状态码BAD_RESPONSE与错误信息辨认出空响应,并记录响应id if(Response.BAD_RESPONSE == response.getStatus() && StrUtil.contains(response.getErrorMessage(), OVER_PAYLOAD_ERROR_MESSAGE)){ EXCEED_PAYLOAD_LIMIT_CACHE.put(response.getId(), response.getErrorMessage()); return; } //3.2、原先的发送过程:通过异样池辨认出超过payload的响应,打印有用的信息 if(Response.OK == response.getStatus() && EXCEED_PAYLOAD_LIMIT_CACHE.getIfPresent(response.getId()) != null){ String responseMessage = getResponseMessage(response); log.warn("dubbo序列化对象大小超过payload,errorMsg is {},response is {}", EXCEED_PAYLOAD_LIMIT_CACHE.getIfPresent(response.getId()),responseMessage); } } }
在上文中提到,当捕捉到超过payload的异样时,会从新生成空响应,导致失去了原始的响应后果,此时再去打印日志,是无奈获取到调用办法和入参的,然而encodeResponse办法步骤4中,从新发送这个Response,给了咱们机会去获取到想要的信息,因为从新发送意味着会再去走一遍自定义的编码类。
假如有一个超出payload的申请,执行到自定编码类encode办法的步骤2(Dubbo源码中的编码方法),在这里会调用encodeResponse办法重置buffer,发送新的空响应。
(1)当这个新的空响应再次进入自定义encode办法,执行 checkOverPayload办法的步骤3.1时,就会记录异样响应的id到本地缓存。因为在encodeResponse中buffer被重置,无奈计算对象的大小,所以步骤4、5不会起到理论作用,就此结束新的发送过程。
(2)原先的发送过程回到步骤2 继续执行,到了步骤3.2 时,发现本地缓存的异样池中有以后的响应id,这时就能够打印调用信息了。
综上,对于大小在告警阈值和payload之间的对象,因为响应信息胜利写入了buffer,能够间接进行大小判断,并且打印响应中的要害信息;对于超过payload的对象,在从新发送中记录异样响应id到本地,在原始发送过程中拜访异样id池辨认是否是异样响应,进行要害信息打印。
在监控措施上线后,通过日志很疾速的发现了一部分产生大对象的接口,以后也正在依据接口特点做针对性优化。
三、总结
在对服务JVM内存进行调优时,要充分利用日志、监控工具、堆栈信息等,剖析与定位问题。尽量升高问题排查期间的业务损失,引入对象监控伎俩也不能影响现有业务。除此之外,还能够在定时工作、代码重构、缓存等方面进行优化。优化服务内存不仅仅是JVM调参,而是一个全方面的继续过程。