jvm性能调优实战 -55RPC调用引发的OOM故障

本文涉及的产品
日志服务 SLS,月写入数据量 50GB 1个月
简介: jvm性能调优实战 -55RPC调用引发的OOM故障

Pre

这个OOM的案例是发生在微服务架构下的一次RPC调用过程中的,也是一次非常奇怪的故障案例

解决系统OOM故障的核心能力积累,你必须对你线上系统使用的各种技术,从服务框架,到第三方框架,到Tomcat/Jetty等Web服务器,再到各种底层的中间件系统,对他们的源码最好都要有深入的理解。

因为一般线上系统OOM,都不是简单的由你的代码导致的,可能是因为你系统使用的某个开源技术的内核代码有一定的故障和缺陷,这时你要解决OOM问题,就必须深入到开源技术的源码中去分析。


系统架构介绍

在进行服务间的RPC通信时,采用的是基于Thrift框架自己封装出来的一个RPC框架。基于这个RPC框架去进行通信。 我们看一下下面的图,图中就是一个最基本的服务间RPC调用的示例。


故障发生现场

就当时而言,平时是服务A通过RPC框架去调用服务B,但是有一天,负责服务A的工程师更新了一些代码,然后将服务A重新上线之后,服务A自己倒是没什么,结果反而是服务B却突然宕机了!

这可真是奇怪和诡异的场景,因为明明修改代码的是服务A,重新部署的也是服务A,结果怎么成了服务B却挂掉了?

别急,让我们一步一步慢慢分析。我们先看一下下面的图,里面反映出了当时的生产情况。

当时我们立马登录到服务B的机器去查看服务B的日志,结果在里面赫然发现了OOM异常:

java.lang.OutOfMemoryError Java heap space

直接告诉你堆内存溢出了!当时我们就很奇怪,服务B为什么会OOM?

难道是服务B自己的问题?那不如重启一下服务B?

于是我们尝试重启了服务B,结果发现服务B重启过后很快又宕机了,而且原因还是OOM。

这个就奇怪了,因为在服务A修改代码部署之前,服务B从来没出现过这种情况!都是服务A修改了代码部署之后才导致服务B出现了这种情况的!


初步查找内存溢出的故障发生点

一般内存溢出的时候,大家务必要先找一下故障的发生点,其实看日志就可以了,因为在日志中都会有详细的异常栈信息

我们会发现,引发OutOfMemory异常的,居然就是我们自研的那个RPC框架!

当时大致来说你可以看到的一个异常信息如下所示:

java.lang.OutOfMemoryError: Java heap space
xx.xx.xx.rpc.xx.XXXClass.read()
xx.xx.xx.rpc.xx.XXXClass.xxMethod()
xx.xx.xx.rpc.xx.XXXClass.xxMethod()

这里我们初步可以确定,就是自研的RPC框架在接收请求的时候引发了OOM!

我们看看下图,在里面反映出来了RPC框架运行的时候引发OOM的场景。


分析内存快照找到占用内存最大的对象

既然已经定位到OOM故障发生的位置了,也就是谁引发的OOM,接下来肯定得用MAT分析一下发生OOM的时候,对内存占用最大的是哪个对象了。

此时把OOM的时候自动导出的内存快照打开分析,发现占用内存最大的是一个超大的byte[]数组!

当时我们一台机器给的堆内存也不过就是4GB而已,而在内存快照中发现,居然一个超大的byte[]数组就占据了差不多4GB的空间

这个byte[]数组是哪儿来的?

通过分析这个byte[]数组的引用者(这个在MAT里有对应的功能,大家右击一个对象会出现一个菜单,里面会有很多选项,自己多尝试尝试),会发现这个数组就是RPC框架内部的类引用的。

我们在下面的图里给大家反映出来当前的一个场景。


通过分析源代码找出原因

一般分析到这一步,答案几乎就快要揭晓了,因为我们通过日志第一步已经定位到是谁导致的OOM,往往可能就是某个技术,比如Tomcat?Jetty?或者是RPC框架?

第一步我们先得定位到这个主体人

第二步一般就是用MAT之类的工具去分析内存快照,找到当时占用内存最大的对象是谁,可以找找都是谁在引用他,当然一般第一步通过看日志就大概知道导致内存溢出的类是谁,日志的异常栈里都会告诉你的。

第三步,很简单,你得对那个技术的源代码进行分析,比如对Tomcat、Jetty、RPC框架的源代码去进行追踪分析

通过代码分析找到故障发生的原因,如果可能的话,最好是在本地搭建一个类似的环境,把线上问题给复现出来。

我们这里当时就结合日志里的异常栈分析了一下自己写的RPC框架的源代码,先给大家简单说一下,这个框架在接收请求的时候的一个流程。

首先在服务A发送请求的时候,会对你传输过来的对象进行序列化。这个序列化,简单来说,就是把你的类似Request的对象变成一个byte[]数组,大概可以理解为这个意思,我们在下图里给大家反映出来。

然后对服务B而言,他首先会根据我们自定义的序列化协议(当时用的是Protobuf ),对发送过来的数据进行反序列化

接着把请求数据读取到一个byte[]缓存中去,然后调用业务逻辑代码处理请求,最后请求处理完毕,清理byte[]缓存。

我们也在下面的图中反映出来服务B的处理流程。

想必大家都已经看明白上面RPC框架运行的原理了,接着我们自然在源码中要寻找一下,为什么用来缓冲请求的byte[]数组会搞成几个GB那么大?正常情况下,这个数组应该最多不超过1MB的。


铺垫一个关键知识点:RPC框架的类定义

通过源码的分析,我们最终总算搞清楚了,原来当时有这么一个特殊的情况,因为RPC框架要进行对象传输,就必须得让服务A和服务B都知道有这么一个对象

给大家举个例子,比如服务A要把一个Request对象传输给服务B,那么首先需要使用一种特定的语法定义一个对象文件,当时用的是ProtoBuf支持的语法,大家不理解的可以直接忽略,直接看看大概类似下面这样的一个文件就可以了:

然后会通过上面那个特殊语法写的文件反向生成一个对应的Java类出来,此时会生成一个Java语法的Request类,类似下面这样:

接着这个Request类你需要在服务A和服务B的工程里都要引入,他们俩就知道,把Request交给服务A,他会自己进行序列化成字节流,然后到服务B的时候,他会把字节流反序列化成一个Request对象。

我们看下面的图,里面就引入了Request类的概念。

服务A和服务B都必须知道有Request类的存在,然后才能把Request对象序列化成字节流,也才能从字节流反序列化出来一个Request类的对象。

这是继续分析案例之前,大家务必清楚理解的一个概念。


RPC框架的一个bug:过大的默认值!

明白了这个,咱们就可以来继续看了,在上面的图中,服务B在接收到请求之后,会先反序列化,接着把请求读出来放入一个byte[]数组。

但是这里RPC框架我们有一个bug,就是一旦发现对方发送过来的字节流反序列化的时候失败了,这个往往是因为服务A对Request类做了修改,但是服务B不知道这次修改,Request还是以前的版本。

结果比如服务A的Request类有15个字段,序列化成字节流给你发送过来了,服务B的Request类只有10个字段,有的字段名字还不一样,那么反序列化的时候就会失败。

当时代码中写的逻辑是,一旦反序列化失败了,此时就会开辟一个byte[]数组,默认大小是4GB,然后把对方的字节流原封不动的放进去。

所以最终的问题就出在这里了,当时服务A的工程师修改了很多Request类的字段,结果没告诉服务B的工程师。

所以服务A上线之后,序列化的Request对象到服务B那里是没办法反序列化的,此时服务B就会直接开辟一个默认4GB的byte[]数组。

然后就会直接导致OOM了。

我们在下面的图中,把两边Request字段不一致的情况,反序列化失败的情况,以及开辟大数组的情况,都反应出来了,大家结合图可以看一下。


最终的解决方案

肯定很多人会疑惑,当时那个工程师为什么要把异常情况下的数组默认大小设置为几个GB那么大?

这个其实也没办法,因为当时写这段代码的刚好是才毕业的应届工程师,当时他考虑的是万一反序列化失败了,那么就原封不动的封装字节流到数组里去,让我们来自行处理。

但是他又不知道对方字节流里数据到底有多少,所以就干脆开辟一个特别大的数组,保证一定能放下字节流就可以了。

而且一般测试的时候都不会测到这种异常情况,所以之前一直没发现这个问题。

其实解决这个问题的办法很简单,把RPC框架中那个数组的默认值从4GB调整为4MB即可,一般请求都不会超过4MB,不需要开辟那么大的数组。

另外就是让服务A和服务B的Request类定义保持一致即可。上述问题就解决了。


相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
相关文章
|
9天前
|
Arthas 监控 Java
JVM进阶调优系列(9)大厂面试官:内存溢出几种?能否现场演示一下?| 面试就那点事
本文介绍了JVM内存溢出(OOM)的四种类型:堆内存、栈内存、元数据区和直接内存溢出。每种类型通过示例代码演示了如何触发OOM,并分析了其原因。文章还提供了如何使用JVM命令工具(如jmap、jhat、GCeasy、Arthas等)分析和定位内存溢出问题的方法。最后,强调了合理设置JVM参数和及时回收内存的重要性。
|
7天前
|
监控 Java 编译器
Java虚拟机调优实战指南####
本文深入探讨了Java虚拟机(JVM)的调优策略,旨在帮助开发者和系统管理员通过具体、实用的技巧提升Java应用的性能与稳定性。不同于传统摘要的概括性描述,本文摘要将直接列出五大核心调优要点,为读者提供快速预览: 1. **初始堆内存设置**:合理配置-Xms和-Xmx参数,避免频繁的内存分配与回收。 2. **垃圾收集器选择**:根据应用特性选择合适的GC策略,如G1 GC、ZGC等。 3. **线程优化**:调整线程栈大小及并发线程数,平衡资源利用率与响应速度。 4. **JIT编译器优化**:利用-XX:CompileThreshold等参数优化即时编译性能。 5. **监控与诊断工
|
18天前
|
存储 监控 Java
JVM进阶调优系列(8)如何手把手,逐行教她看懂GC日志?| IT男的专属浪漫
本文介绍了如何通过JVM参数打印GC日志,并通过示例代码展示了频繁YGC和FGC的场景。文章首先讲解了常见的GC日志参数,如`-XX:+PrintGCDetails`、`-XX:+PrintGCDateStamps`等,然后通过具体的JVM参数和代码示例,模拟了不同内存分配情况下的GC行为。最后,详细解析了GC日志的内容,帮助读者理解GC的执行过程和GC处理机制。
|
26天前
|
Arthas 监控 数据可视化
JVM进阶调优系列(7)JVM调优监控必备命令、工具集合|实用干货
本文介绍了JVM调优监控命令及其应用,包括JDK自带工具如jps、jinfo、jstat、jstack、jmap、jhat等,以及第三方工具如Arthas、GCeasy、MAT、GCViewer等。通过这些工具,可以有效监控和优化JVM性能,解决内存泄漏、线程死锁等问题,提高系统稳定性。文章还提供了详细的命令示例和应用场景,帮助读者更好地理解和使用这些工具。
|
1月前
|
监控 架构师 Java
JVM进阶调优系列(6)一文详解JVM参数与大厂实战调优模板推荐
本文详述了JVM参数的分类及使用方法,包括标准参数、非标准参数和不稳定参数的定义及其应用场景。特别介绍了JVM调优中的关键参数,如堆内存、垃圾回收器和GC日志等配置,并提供了大厂生产环境中常用的调优模板,帮助开发者优化Java应用程序的性能。
|
1月前
|
存储 监控 算法
JVM调优深度剖析:内存模型、垃圾收集、工具与实战
【10月更文挑战第9天】在Java开发领域,Java虚拟机(JVM)的性能调优是构建高性能、高并发系统不可或缺的一部分。作为一名资深架构师,深入理解JVM的内存模型、垃圾收集机制、调优工具及其实现原理,对于提升系统的整体性能和稳定性至关重要。本文将深入探讨这些内容,并提供针对单机几十万并发系统的JVM调优策略和Java代码示例。
51 2
|
1月前
|
Arthas 监控 Java
JVM知识体系学习七:了解JVM常用命令行参数、GC日志详解、调优三大方面(JVM规划和预调优、优化JVM环境、JVM运行出现的各种问题)、Arthas
这篇文章全面介绍了JVM的命令行参数、GC日志分析以及性能调优的各个方面,包括监控工具使用和实际案例分析。
43 3
|
1月前
|
算法 Java
JVM进阶调优系列(4)年轻代和老年代采用什么GC算法回收?
本文详细介绍了JVM中的GC算法,包括年轻代的复制算法和老年代的标记-整理算法。复制算法适用于年轻代,因其高效且能避免内存碎片;标记-整理算法则用于老年代,虽然效率较低,但能有效解决内存碎片问题。文章还解释了这两种算法的具体过程及其优缺点,并简要提及了其他GC算法。
 JVM进阶调优系列(4)年轻代和老年代采用什么GC算法回收?
|
1月前
|
Java
JVM进阶调优系列(5)CMS回收器通俗演义一文讲透FullGC
本文介绍了JVM中CMS垃圾回收器对Full GC的优化,包括Stop the world的影响、Full GC触发条件、GC过程的四个阶段(初始标记、并发标记、重新标记、并发清理)及并发清理期间的Concurrent mode failure处理,并简述了GC roots的概念及其在GC中的作用。
|
1月前
|
算法 Java
JVM进阶调优系列(3)堆内存的对象什么时候被回收?
堆对象的生命周期是咋样的?什么时候被回收,回收前又如何流转?具体又是被如何回收?今天重点讲对象GC,看完这篇就全都明白了。