共计 7456 个字符,预计需要花费 19 分钟才能阅读完成。
作者: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#encodeResponse
protected 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#checkPayload
protected 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 调参,而是一个全方面的继续过程。