Netty 实现文件上传的过程及内存泄露问题排查

Posted 毕小宝

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Netty 实现文件上传的过程及内存泄露问题排查相关的知识,希望对你有一定的参考价值。

背景

一个用 Netty 实现的 Web 应用,压力测试期间,其文件上传功能一直报io.netty.util.internal.OutOfDirectMemoryError: failed to allocate 16777216 byte(s) of direct memory (used: 939524103, max: 954728448) 异常,但是解析文件表单的地方已经加了资源释放代码。

通过和官方提供的示例代码比对,发现不同的表单解析方式,对资源释放有影响。本文将分析 Netty 实现文件上传功能的过程与内存泄露问题,以及文件上传需要释放内存的几个地方。

文件上传表单解析

项目对文件上传的需求是不能本地化,文件上传流程为:

  1. Handler 解析文件表单,读取文件的字节数据并存储到业务处理队列中;
  2. 业务处理线程消费队列数据,完成对上传文件的分析。

最初实现是参考网络代码 《实现Netty文件上传》,在 demo 验证时没问题,但是经过压力测试运行一两天后,就大量报内存泄露。

经过反复排查内存泄露的原始代码,加上必要的资源释放后,最终得到正确代码。MultipartRequest 类完成了对文件表单的解析,出参是一个 Map 对象,值是文件的二进制数组 byte[] ,支持多文件表单。

import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.HttpContent;
import io.netty.handler.codec.http.multipart.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.HashMap;
import java.util.Map;

public class MultipartRequest {
    private final static Logger logger = LoggerFactory.getLogger(MultipartRequest.class);

    /**
     * 解码器工厂类,公用
     */
    private final static HttpDataFactory factory = new DefaultHttpDataFactory(DefaultHttpDataFactory.MINSIZE);

    /**
     * 文件表单名称及文件表单对象:直接存文件的二进制数据
     */
    private Map<String, byte[]> fileDatas;

    public Map<String, byte[]> getFileDatas() {
        return fileDatas;
    }

    public void setFileDatas(Map<String, byte[]> fileDatas) {
        this.fileDatas = fileDatas;
    }

    /**
     * 解析表单请求,分离出文件字节数据,忽略普通表单
     * @param request
     * @return
     */
    public static MultipartRequest createMultipartBody(FullHttpRequest request) {
        HttpPostRequestDecoder httpDecoder = null;

        try {
            //使用HTTP POST解码器
            httpDecoder = new HttpPostRequestDecoder(factory, request);
            httpDecoder.setDiscardThreshold(0);

            if (httpDecoder != null) {
                //获取HTTP请求对象,并加入解码器
                final HttpContent chunk = request;
                httpDecoder.offer(chunk);

                //自定义返回对象和相关属性
                MultipartRequest multipartRequest = new MultipartRequest();
                Map<String, byte[]> fileContents = new HashMap<>();

                try{
                    // while 循环方式遍历表单内容
                    while (httpDecoder.hasNext()) {
                        InterfaceHttpData formData = httpDecoder.next();
                        if (formData == null) {
                            continue;
                        }

                        // 文件表单类型,收集文件内容为 byte[]
                        if (formData.getHttpDataType() == InterfaceHttpData.HttpDataType.FileUpload) {
                            FileUpload fileUpload = (FileUpload) formData;
                            if (fileUpload.isCompleted()) {
                                byte[] readData = fileUpload.get();
                                httpDecoder.removeHttpDataFromClean(fileUpload); //remove
                                fileContents.put(formData.getName(), readData);
                                // 1、第一个容易内存泄露的地方
                                fileUpload.release();
                            }
                        }
                    }
                }catch (Exception e) {
                    // Ignore HttpPostRequestDecoder$EndOfDataDecoderException
                }

                //存放文件信息
                multipartRequest.setFileDatas(fileContents);
                return multipartRequest;
            }
        } catch (Exception e) {
            logger.error("解析日志文件异常",e);
        } finally {
            // 资源释放
            if (httpDecoder != null) {
                try{
                    // 2、第二个容易磁盘空间泄露的地方
                    httpDecoder.cleanFiles();
                    // 3、第二个容易内存泄露的地方
                    httpDecoder.destroy();
                    httpDecoder = null;
                }catch (Exception e) {
                    logger.error("释放资源异常",e.getMessage());
                }
            }
        }
        return null;
    }
}

代码涉及到资源释放的三个地方,还有一个异常:

  1. FileUpload 文件表单对象
  2. 磁盘上存储的表单临时文件
  3. HttpPostRequestDecoder 解码器
  4. while 遍历时的 EndOfDataDecoderException 异常,可以忽略

内存泄露问题排查

第一步,添加检测参数。出现资源泄露后,先对应用添加 -Dio.netty.leakDetectionLevel=advanced,定位到解码器需要释放:

第二步,对 httpDecoder 进行资源释放,相当曲折啊,最初遍历解码器数据用的下面这段代码:

List<InterfaceHttpData> InterfaceHttpDataList = httpDecoder.getBodyHttpDatas();
for (InterfaceHttpData data : InterfaceHttpDataList) {
   //如果数据类型为文件类型,则保存到fileUploads对象中
   if (data != null && InterfaceHttpData.HttpDataType.FileUpload.equals(data.getHttpDataType())) {
     FileUpload fileUpload = (FileUpload) data;
     fileUploads.put(data.getName(), fileUpload);
     continue;
   }
}

后面再调用 httpDecoder.destroy(); 时一直报错,说引用计数器已经归零了,但是内存泄露日志又固执地提示这里没释放,这个怪圈。代码看起来没问题,但在压力测试和长时间运行的场景下堆外内存溢出问题始终存在。

意识到还是应该参考 Netty 官网的文件上传的例子,对比后发现,遍历方式不一样,改成一样的 while (httpDecoder.hasNext()) 后,解码器资源就能正常释放了,终于见到了曙光。

第三步,继续观测,还是存在堆外内存溢出。溢出日志显示,在释放解码器资源的时候,文件表单 fileUpload.get() 还有相关引用:

至此,完善代码后,看不到内存泄露问题了。

启示录

这个内存泄露问题的诡异之处在于,用 for 方式遍历解码器,调用资源释放时,不知道哪里把引用计数器设置为 0 了,但实际上并没有真正释放,这就导致了内存泄露。

另外,关于泄露日志级别配置,advanced 级别,由于是采样 1%,不太容易观测到泄露。在解码器资源释放后,还以为问题解决了呢,改成 paranoid 后,刚运行没一个小时,就出现了,所以测试排查阶段调成最高等级,有利于快速定位问题。

最后一点,是关于使用文件表单的,既然不需要本地化,最好直接用 get() 获取 byte[] ,因为文件表单对象对应的 ByteBuf 同样也存在内存泄露风险,不好控制。

以上是关于Netty 实现文件上传的过程及内存泄露问题排查的主要内容,如果未能解决你的问题,请参考以下文章

netty 堆外内存泄露排查盛宴

Netty实战:netty 堆外内存泄露排查盛宴

netty-socketio堆外内存泄露排查盛宴

超溜!Netty 堆外内存泄露排查与总结

jvm调优四:netty堆外内存泄露

用Netty发生堆外内存泄露,看老司机一顿排查