说在前面的话
朋友, 你经历过部署好的服务突然内存溢出吗?
你经历过没有看过 Java 虚拟机, 来解决内存溢出的痛苦吗?
你经历过一个 BUG, 百思不得其解, 头发一根一根脱落的烦恼吗?
我知道, 你有过!
但是我还是要来说说我的故事..................
背景:
有一个项目做一个系统, 分客户端和服务端, 客户端用 c++ 写的, 用来收集信息然后传给服务端 (客户端的数量还是比较多的, 正常的有几千个),
服务端用 Java 写的 (带管理页面), 属于 RPC 模式, 中间的通信框架使用的是 thrift.
thrift 很多优点就不多说了, 它是 facebook 的开源的 rpc 框架, 主要是它能够跨语言, 序列化速度快, 但是他有个不讨喜的地方就是它必须用自己 IDL 来定义接口
thrift 版本: 0.9.2.
问题定位与分析
步骤一. 初步分析
客户端无法连接服务端, 查看服务器的端口开启状况, 服务端口并没有开启. 于是启动服务端, 启动几秒后, 服务端崩溃, 重复启动, 服务端依旧在启动几秒后崩溃.
步骤二. 查看服务端日志分析
分析得知是因为 java.lang.OutOfMemoryError: Java heap space(堆内存溢出) 导致的服务崩溃.
客户端搜集的主机信息, 主机策略都是放在缓存中, 可能是因为缓存较大造成的, 但是通过日志可以看出是因为 Thrift 服务抛出的堆内存溢出异常与缓存大小无关.
步骤三. 再次分析服务端日志
可以发现每次抛出异常的时候都会伴随着几十个客户端在向服务端发送日志, 往往在发送几十条日志之后, 服务崩溃. 可以假设是不是堆内存设置的太小了?
查看启动参数配置, 最大堆内存为 256MB. 修改启动配置, 启动的时候分配更多的堆内存, 改成 java -server -Xms512m -Xmx768m.
结果是, 能坚持多一点的时间, 依旧会内存溢出服务崩溃. 得出结论, 一味的扩大内存是没有用的.
** 为了证明结论是正确的, 做了这样的实验:**
> 内存设置为 256MB, 在公司服务器上部署了服务端, 使用 Java VisualVM 远程监控服务器堆内存.
>
> 模拟客户现场, 注册 3000 个客户端, 使用 300 个线程同时发送日志.
>
> 结果和想象的一样, 没有出现内存溢出的情况, 如下图:
> 上图是 Java VisualVM 远程监控, 在压力测试的情况下, 没有出现内存溢出的情况, 256MB 的内存肯定够用的.
步骤四. 回到 thrift 源码中, 查找关键问题
服务端采用的是 Thrift 框架中 TThreadedSelectorServer 这个类, 这是一个 NIO 的服务. 下图是 thrift 处理请求的模型:
** 说明:**
> 一个 AcceptThread 执行 accept 客户端请求操作, 将 accept 到的 Transport 交给 SelectorThread 线程,
>
>AcceptThread 中有个 balance 均衡器分配到 SelectorThread;SelectorThread 执行 read,write 操作,
>
>read 到一个 FrameBuffer(封装了方法名, 参数, 参数类型等数据, 和读取写入, 调用方法的操作) 交给 WorkerProcess 线程池执行方法调用.
>
>** 内存溢出就是在 read 一个 FrameBuffer 产生的.**
步骤五. 细致一点描述 thrift 处理过程
>1. 服务端服务启动后, 会 listen() 一直监听客户端的请求, 当收到请求 accept() 后, 交给线程池去处理这个请求
>
>2. 处理的方式是: 首先获取客户端的编码协议 getProtocol(), 然后根据协议选取指定的工具进行反序列化, 接着交给业务类处理 process()
>
>3.process 的顺序是,** 先申请临时缓存读取这个请求数据 **, 处理请求数据, 执行业务代码, 写响应数据,** 最后清除临时缓存 **
>
> ** 总结: thrift 服务端处理请求的时候, 会先反序列化数据, 接着申请临时缓存读取请求数据, 然后执行业务并返回响应数据, 最后请求临时缓存.**
>
> 所以压力测试的时候, thrift 性能很高, 而且内存占用不高, 是因为它有自负载调节, 使用 NIO 模式缓存, 并使用线程池处理业务, 每次处理完请求之后及时清除缓存.
步骤六. 研读 FrameBuffer 的 read 方法代码
可以排除掉没有及时清除缓存的可能, 方向明确, 极大的可能是在申请 NIO 缓存的时候出现了问题, 回到 thrift 框架, 查看 FrameBuffer 的 read 方法代码:
?
public boolean read() {// try to read the frame size completely if ( this.state_ == AbstractNonblockingServer.FrameBufferState.READING_FRAME_SIZE) { if (! this.internalRead()) { return false; } // if the frame size has been read completely, then prepare to read the actual time if ( this.buffer_.remaining() !=0) { return true ; } int frameSize = this.buffer_.getInt(0); if (frameSize <= 0) { this.LOGGER.error( "Read an invalid frame size of" + frameSize +". Are you using TFramedTransport on the client side?"); return false ; } // if this frame will always be too large for this server, log the error and close the connection. |
?
if ((long )frameSize > AbstractNonblockingServer.this.MAX_READ_BUFFER_BYTES) { this.LOGGER.error( "Read a frame size of" + frameSize +", which is bigger than the maximum allowable buffer size for ALL connections." ); return false ; } if (AbstractNonblockingServer.this .readBufferBytesAllocated.get() + (long)frameSize > AbstractNonblockingServer.this .MAX_READ_BUFFER_BYTES) { return true; } AbstractNonblockingServer.this.readBufferBytesAllocated.addAndGet(( long)(frameSize +4)); this.buffer_ = ByteBuffer.allocate(frameSize + 4); this.buffer_.putInt(frameSize); this.state_ = AbstractNonblockingServer.FrameBufferState.READING_FRAME; } if (this.state_ == AbstractNonblockingServer.FrameBufferState.READING_FRAME) { if (!this.internalRead()) { return false; }else { if (this.buffer_.remaining() == 0) { this.selectionKey_.interestOps( 0); this.state_ = AbstractNonblockingServer.FrameBufferState.READ_FRAME_COMPLETE; } return true; } }else { this.LOGGER.error("Read was called but state is invalid (" +this.state_ +")"); return false; } } |
** 说明:**
>MAX_READ_BUFFER_BYTES 这个值即为对读取的包的长度限制, 如果超过长度限制, 就不会再读了 /
>
> 这个 MAX_READ_BUFFER_BYTES 是多少呢, thrift 代码中给出了答案:
?
public abstract static class AbstractNonblockingServerArgs extends AbstractNonblockingServer.AbstractNonblockingServerArgs extends AbstractServerArgs public long maxReadBufferBytes = 9223372036854775807L; public AbstractNonblockingServerArgs(TNonblockingServerTransport transport) { super(transport); this.transportFactory(new Factory()); } } |
> 从上面源码可以看出, 默认值居然给到了 long 的最大值 9223372036854775807L.
所以 thrift 的开发者是觉得使用 thrift 程序员不够觉得内存不够用吗, 这个换算下来就是 1045576TB, 这个太夸张了, 这等于没有限制啊, 所以肯定不能用默认值的.
步骤七. 通信数据抓包分析
需要可靠的证据证明一个客户端通信的数据包的大小.
这个是我抓到包最大的长度, 最大一个包长度只有 215B, 所以需要限制一下读取大小
步骤八: 踏破铁鞋无觅处
在论坛中, 看到有人用 http 请求 thrift 服务端出现了内存溢出的情况, 所以我抱着试试看的心态, 在浏览器中发起了 http 请求,
果不其然, 出现了内存溢出的错误, 和客户现场出现的问题一摸一样. 这个读取内存的时候数量过大, 超过了 256MB.
> 很明显的一个问题, 正常的一个 HTTP 请求不会有 256MB 的, 考虑到 thrift 在处理请求的时候有反序列化这个操作.
>
> 可以做出假设是不是反序列化的问题, 不是 thrift IDL 定义的不能正常的反序列化?
>
> 验证这个假设, 我用 Java socket 写了一个 tcp 客户端, 向 thrift 服务端发送请求, 果不其然! java.lang.OutOfMemoryError: Java heap space.
> 这个假设是正确的, 客户端请求数据不是用 thrift IDL 定义的话, 无法正常序列化, 序列化出来的数据会异常的大! 大到超过 1 个 G 的都有.
步骤九. 找到原因
某些客户端没有正常的序列化消息, 导致服务端在处理请求的时候, 序列化出来的数据特别大, 读取该数据的时候出现的内存溢出.
查看维护记录, 在别的客户那里也出现过内存溢出导致服务端崩溃的情况, 通过重新安装客户端, 就不再复现了.
所以可以确定, 客户端存在着无法正常序列化消息的情况. 考虑到, 客户端量比较大, 一个一个排除, 再重新安装比较困难, 工作量很大, 所以可以从服务端的角度来解决问题, 减少维护工作量.
最后可以确定解决方案了, 真的是废了很大的劲, 不过也是颇有收获
问题解决方案
非常简单
在构造 TThreadedSelectorServer 的时候, 增加 args.maxReadBufferBytes = 1*1024 * 1024L; 也就是说修改 maxReadBufferBytes 的大小, 设置为 1MB.
客户端与服务端通过 thrift 通信的数据包, 最大十几 K, 所以设置最大 1MB, 是足够的. 代码部分修改完成, 版本不做改变 **
修改完毕后, 这次进行了异常流测试, 发送了 http 请求, 使服务端无法正常序列化.
服务端处理结果如下:
thrift 会抛出错误日志, 并直接没有读这个消息, 返回 false, 不处理这样的请求, 将其视为错误请求.
3. 国外有人对 thrift 一些 server 做了压力测试, 如下图所示:
使用 thrift 中的 TThreadedSelectorServer 吞吐量达到 18000 以上
由于高性能, 申请内存和清除内存的操作都是非常快的, 平均 3ms 就处理了一个请求.
所以是推荐使用 TThreadedSelectorServer
4. 修改启动脚本, 增大堆内存, 分配单独的直接内存.
修改为 java -server -Xms512m -Xmx768m -XX:MaxPermSize=256m -XX:NewSize=256m -XX:MaxNewSize=512m -XX:MaxDirectMemorySize=128M.
设置持久代最大值 MaxPermSize:256m
设置年轻代大小 NewSize:256m
年轻代最大值 MaxNewSize:512M
最大堆外内存 (直接内存)MaxDirectMemorySize:128M
5. 综合论坛中, StackOverflow 一些同僚的意见, 在使用 TThreadedSelectorServer 时, 将读取内存限制设置为 1MB, 最为合适, 正常流和异常流的情况下不会有内存溢出的风险.
之前启动脚本给服务端分配的堆内存过小, 考虑到是 NIO, 所以在启动服务端的时候, 有必要单独分配一个直接内存供 NIO 使用. 修改启动参数.
增加堆内存大小直接内存, 防止因为服务端缓存太大, 导致 thrift 服务没有内存可申请, 无法处理请求.
总结:
真的是一次非常酸爽的过程, 特此发个博客记录一下, 如果有说的不对的对方, 欢迎批评斧正! 如果觉得写的不错, 欢迎给我点个推荐, 您的一个推荐是我莫大的动力!
来源: https://juejin.im/entry/5b6bee0c6fb9a04fd450c347