在Java Servlet中流式传输大文件

46

我正在构建一个需要扩展的Java服务器。其中一个Servlet将提供存储在Amazon S3中的图像。

最近在负载下,我的VM内存不足,而这是在我添加代码以提供图像后发生的,因此我非常确定流式传输较大的Servlet响应是导致问题的原因。

我的问题是:是否有任何最佳实践来编写Java Servlet以向浏览器流式传输从数据库或其他云存储读取的大型(> 200k)响应?

我考虑过将文件写入本地临时驱动器,然后生成另一个线程来处理流式传输,以便可以重用tomcat Servlet线程。但这似乎会对io造成很大的负担。

感谢您的任何想法。

8个回答

58

如果可能的话,您不应该将要提供的文件的全部内容存储在内存中。相反,获取数据的InputStream,并将数据分段复制到Servlet OutputStream中。例如:

ServletOutputStream out = response.getOutputStream();
InputStream in = [ code to get source input stream ];
String mimeType = [ code to get mimetype of data to be served ];
byte[] bytes = new byte[FILEBUFFERSIZE];
int bytesRead;

response.setContentType(mimeType);

while ((bytesRead = in.read(bytes)) != -1) {
    out.write(bytes, 0, bytesRead);
}

// do the following in a finally block:
in.close();
out.close();

我同意Toby的观点,你应该“将它们指向S3 URL”。

至于OOM异常,你确定它与提供图像数据有关吗?假设你的JVM有256MB的“额外”内存用于提供图像数据。通过Google的帮助,“256MB / 200KB”=1310。对于2GB的“额外”内存(现在是一个非常合理的数量),可以支持10,000个同时客户端。即使如此,1300个同时客户端已经是相当大的数字了。这是你遇到的负载类型吗?如果不是,你可能需要在其他地方寻找OOM异常的原因。

编辑-关于:

在这种情况下,图像可能包含敏感数据...

几周前我阅读了S3文档,注意到你可以生成时间过期的密钥,可以附加到S3 URL上。因此,你不必向公众开放S3上的文件。我对这种技术的理解是:

  1. 初始HTML页面有下载链接到你的Web应用
  2. 用户点击下载链接
  3. 你的Web应用程序生成一个包含在5分钟内过期的密钥的S3 URL。
  4. 向客户端发送HTTP重定向,其中包含步骤3中的URL。
  5. 用户从S3下载文件。即使下载需要超过5分钟,也可以继续完成一次下载。

1
哦,由于没有设置内容长度,所以Servlet容器必须进行缓冲,因为它需要在流式传输任何数据之前设置内容长度标头。因此,不确定可以节省多少内存? - Peter Kriens
2
Peter,如果你不能直接将用户指向云服务URL,并且你想设置内容长度头,而你又不知道大小,也无法查询云服务的大小,那么我猜你最好先将流式传输到服务器上的临时文件。当然,在将第一个字节发送给客户端之前在服务器上保存副本可能会导致用户认为请求失败,这取决于云->服务器传输需要多长时间。 - John Vasileff
2
@PeterKriens content-length头不是必需的。此外,您可以使用分块传输,在其中只需要指定一个块的长度。 - bluesmoon
当servlet设置了Content-Length头时,输出可以立即发送,无需任何中间缓冲区。否则,服务器必须缓冲或实现分块传输。一般来说,您应该能够通过从blob存储中获取HEAD来获得所需的信息。 - Peter Kriens

17
为什么不直接将它们指向 S3 的 URL 呢?从 S3 中获取资源,然后通过自己的服务器流式传输给我,这样做违背了使用 S3 的目的,S3 的目的是将服务图像的带宽和处理卸载到 Amazon。

1
指向S3 URL - 我猜你是说一开始就给浏览器S3 URL。如果您的图像或视频是医学工件并且很敏感,该怎么办? S3确实支持过期的URL。但是您无法通过电子邮件发送过期的URL?尚未过期的URL仍然可以被其他人使用,所有这些都是不安全的,例如对于医疗保健产品。 - veritas

11

我看过很多像john-vasilef的代码(目前被接受的答案),采用紧凑的while循环从一个流中读取数据块并将它们写入另一个流。

我的观点是反对不必要的代码重复,主张使用Apache的IOUtils。如果您已经在其他地方使用它,或者您正在使用的另一个库或框架已经依赖于它,那么这是一行代码,已经得到了良好的测试和验证。

以下代码中,我正在一个servlet中从Amazon S3中流式传输对象到客户端。

import java.io.InputStream;
import java.io.OutputStream;
import org.apache.commons.io.IOUtils;

InputStream in = null;
OutputStream out = null;

try {
    in = object.getObjectContent();
    out = response.getOutputStream();
    IOUtils.copy(in, out);
} finally {
    IOUtils.closeQuietly(in);
    IOUtils.closeQuietly(out);
}

具有良好定义模式和适当流关闭的6行代码似乎非常可靠。


1
我同意使用现有的资源,但你的代码存在问题:如果 response.getOutputStream() 产生异常,那么你的 InputStream in 对象将不会被关闭。此外,Java 7 的 try-with-resources 特性应该是目前的模式,未来 CommonsIO 将会有 this。帽子很棒 :) - Evandro Pomatti
@Evandro 发现得好--那这个呢?(另外,谢谢 :) - blast_hardcheese
closeQuitely 现已被弃用。http://commons.apache.org/proper/commons-io/apidocs/org/apache/commons/io/IOUtils.html#closeQuietly(java.io.Closeable...) - Sorter

2

toby是正确的,如果可以的话,你应该直接指向S3。 如果不行,那么问题有点模糊,无法给出准确的回答: 您的Java堆有多大? 当您耗尽内存时,有多少个流同时打开?
您的读写/缓冲区有多大(8K很好)?
您从流中读取8K,然后将8K写入输出,对吗? 您不会尝试从S3中读取整个图像,将其缓冲在内存中,然后一次性发送整个内容吧?

如果使用8K缓冲区,您可以在约8兆字节的堆空间中拥有1000个并发流,因此您肯定做错了什么....

顺便说一下,我没有凭空选择8K,它是套接字缓冲区的默认大小,如果发送更多数据(例如1兆),则将在tcp / ip堆栈上阻塞,并且持有大量内存。


当你说“直接指向S3”时,你的意思是什么?你是指将S3 URL传递给浏览器,以便他们可以流式传输吗? - veritas

2
我非常赞同Toby和John Vasileff的观点-如果您可以容忍相关问题,S3非常适用于卸载大型媒体对象。(自己的应用程序实例化10-1000MB FLV和MP4。)例如:没有部分请求(字节范围标头)。但是,必须手动处理它,偶尔停机等等。
如果不行,John的代码看起来很好。 我发现2k FILEBUFFERSIZE的字节缓冲区在微基准测试中最有效。另一种选择可能是共享FileChannel。(FileChannels是线程安全的。)
话虽如此,我也要补充说,猜测什么导致了内存不足错误是经典的优化错误。通过使用严格的指标,您将提高成功的机会。
1.将 -XX:+ HeapDumpOnOutOfMemoryError 放入JVM启动参数中,以防万一
2. 在负载下使用jmap运行JVM(jmap-histo<pid>)
3. 分析指标(jmap -histo输出或由jhat查看堆转储)。 很可能,您的内存不足来自意想不到的地方。
当然还有其他工具可用,但jmap和jhat随Java 5+“开箱即用”。
引用:
我考虑过将文件写入本地临时驱动器,然后生成另一个线程来处理流,以便可以重复使用tomcat servlet线程。 这似乎会非常I / O密集。
啊,我认为您不能这样做。 即使您可以,它听起来也有问题。 管理连接的Tomcat线程需要控制。 如果您遇到线程饥饿,则增加./conf/server.xml中可用线程的数量即可。 同样,指标是检测此问题的方法-不要只是猜测。
问题:您还在EC2上运行吗? 您的tomcat的JVM启动参数是什么?

0

如果您可以将静态文件分离并放置在自己的存储桶中,那么今天最快的性能可能是通过使用Amazon S3 CDN CloudFront 来实现的。


0

你需要检查两件事情:

  • 你是否关闭了流?这非常重要。
  • 也许你正在“免费”提供流连接。流不是很大,但同时有很多流可能会占用所有的内存。创建一个池,以便你不能同时运行一定数量的流。

0
除了John提出的建议之外,您还应该反复刷新输出流。根据您的Web容器,它可能会缓存部分或全部输出,并一次性刷新它(例如,计算Content-Length标头)。这将消耗相当多的内存。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接