一个RPC调用引发的生产系统OOM!直接祭天一个程序员

308 阅读5分钟

1 案例

一般线上系统OOM,都不会是简单的由你的业务代码导致,大多可能因为系统使用的某开源技术内部源码有问题。

2 系统架构

服务间RPC通信时,采用基于xxx框架封装的RPC框架。

3 事故现场

日常服务A通过RPC框架调用服务B,但某天,负责服务A的工程师更新一些代码,然后将服务A重新上线后,服务B突然宕机!

img明明修改代码的是A,重新部署的也是A,怎么B挂了?

别忘了,立即登录到服务B的机器查看日志,发现OOM:

java.lang.OutOfMemoryError Java heap space

B为什么会OOM? 难道是服务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。

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

MAT分析OOM,发现占用内存最大的是一个大byte[]数组。当时我堆内存就4G,而内存快照发现,一个大byte[]数组就占了4G。这byte[]数组哪来的?

分析byte[]数组的引用者,发现该数组就是RPC框架内部的类引用的。



分析源码,找出原因

  1. 通过日志定位谁导致了OOM,往往可能就是某技术框架,如Tomcat、Jetty或RPC框架
  2. 用MAT之类的工具去分析内存快照,找到当时占用内存最大的对象是谁,可以找找都是谁在引用他,当然一般第一步通过看日志就大概知道导致内存溢出的类是谁,日志的异常栈里都会告诉你的。
  3. 对那个技术的源代码进行分析,比如对Tomcat、Jetty、RPC框架的源代码去进行追踪分析

于是就结合日志的异常栈分析自己写的RPC框架源码,接收请求时的流程A请求时,会序列化传输过来的对象,将RequestDTO等对象变成一个byte[]数组:

img

对于B, 首先根据自定义序列化协议反序列化发过来的数据:

接着把请求数据读取到一个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[]数组。一旦发现对方发过来的字节流反序列化失败,这往往是因为A对Request类做了修改,但服务B不知道这次修改,Request还是老版本。

结果A的Request类有15个字段,序列化成字节流给你发送过来了,B的Request类只有10个字段,有的字段名还不一,反序列化时就会失败。而代码逻辑是,一旦反序列化失败,此时就会开辟一个byte[]数组,默认大小是4GB,然后把对方的字节流原封不动的放进去。

所以问题就是,A工程师修改了很多Request类字段,结果没告诉B工程师。所以A上线后,序列化的Request对象到B就无法反序列化成功,B就会直接开辟一个默认4G的byte[]数组,直接OOM:

解决方案

当时那人为何把异常情况下的数组默认大小设为几G?这也没办法,因为当时写这段代码的刚好是外包,当时他考虑万一反序列化失败,那就原封不动的封装字节流到数组,让我们自行处理。

但他又不知道对方字节流里数据到底有多少,所以直接开辟特大数组,保证一定能放下字节流。而且一般测试的时候都不会测到这种异常情况。

解决方案:

  • 把RPC框架中那个数组的默认值从4GB调整为4MB即可,一般请求都不会超过4MB,不需要开辟那么大的数组
  • 让服务A和服务B的Request类定义保持一致即可