程序环境
- JDK版本: 1.8
- Hutool版本: 5.8.25
问题描述
客服端文件上传主要代码:
HttpRequest httpRequest = HttpUtil.createPost(FILE_UPLOAD_URL);
Resource urlResource = new UrlResource(url, fileName);
httpRequest.form("file", urlResource);
HttpResponse httpResponse = httpRequest.execute();
大文件上传 java.lang.OutOfMemoryError: Java heap space
java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3236) ~[na:1.8.0_275]
at java.io.ByteArrayOutputStream.grow(ByteArrayOutputStream.java:118) ~[na:1.8.0_275]
at java.io.ByteArrayOutputStream.ensureCapacity(ByteArrayOutputStream.java:93) ~[na:1.8.0_275]
at java.io.ByteArrayOutputStream.write(ByteArrayOutputStream.java:135) ~[na:1.8.0_275]
at sun.net.www.http.PosterOutputStream.write(PosterOutputStream.java:63) ~[na:1.8.0_275]
at cn.hutool.http.MultipartOutputStream.write(MultipartOutputStream.java:108) ~[hutool-all-5.8.25.jar!/:5.8.25]
at java.io.OutputStream.write(OutputStream.java:116) ~[na:1.8.0_275]
at cn.hutool.core.io.copy.StreamCopier.doCopy(StreamCopier.java:102) ~[hutool-all-5.8.25.jar!/:5.8.25]
at cn.hutool.core.io.copy.StreamCopier.copy(StreamCopier.java:68) ~[hutool-all-5.8.25.jar!/:5.8.25]
at cn.hutool.core.io.IoUtil.copy(IoUtil.java:162) ~[hutool-all-5.8.25.jar!/:5.8.25]
at cn.hutool.core.io.IoUtil.copy(IoUtil.java:146) ~[hutool-all-5.8.25.jar!/:5.8.25]
at cn.hutool.core.io.IoUtil.copy(IoUtil.java:132) ~[hutool-all-5.8.25.jar!/:5.8.25]
at cn.hutool.core.io.IoUtil.copy(IoUtil.java:119) ~[hutool-all-5.8.25.jar!/:5.8.25]
at cn.hutool.core.io.resource.Resource.writeTo(Resource.java:76) ~[hutool-all-5.8.25.jar!/:5.8.25]
at cn.hutool.http.MultipartOutputStream.appendResource(MultipartOutputStream.java:163) ~[hutool-all-5.8.25.jar!/:5.8.25]
at cn.hutool.http.MultipartOutputStream.write(MultipartOutputStream.java:96) ~[hutool-all-5.8.25.jar!/:5.8.25]
at cn.hutool.http.body.MultipartBody$$Lambda$2190/568941495.accept(Unknown Source) ~[na:na]
at cn.hutool.core.map.TableMap.forEach(TableMap.java:253) ~[hutool-all-5.8.25.jar!/:5.8.25]
at cn.hutool.http.body.MultipartBody.write(MultipartBody.java:78) ~[hutool-all-5.8.25.jar!/:5.8.25]
at cn.hutool.http.body.RequestBody.writeClose(RequestBody.java:27) ~[hutool-all-5.8.25.jar!/:5.8.25]
at cn.hutool.http.HttpRequest.sendMultipart(HttpRequest.java:1402) ~[hutool-all-5.8.25.jar!/:5.8.25]
at cn.hutool.http.HttpRequest.send(HttpRequest.java:1340) ~[hutool-all-5.8.25.jar!/:5.8.25]
at cn.hutool.http.HttpRequest.doExecute(HttpRequest.java:1188) ~[hutool-all-5.8.25.jar!/:5.8.25]
at cn.hutool.http.HttpRequest.execute(HttpRequest.java:1051) ~[hutool-all-5.8.25.jar!/:5.8.25]
at cn.hutool.http.HttpRequest.execute(HttpRequest.java:1027) ~[hutool-all-5.8.25.jar!/:5.8.25]
at com.mbzj.ai.third.RhzClient.execute(RhzClient.java:270) ~[classes!/:1.0-SNAPSHOT]
at com.mbzj.ai.third.RhzClient.uploadKnowledgeFile(RhzClient.java:184) ~[classes!/:1.0-SNAPSHOT]
at com.mbzj.ai.third.RhzService.uploadKnowledgeFile(RhzService.java:132) ~[classes!/:1.0-SNAPSHOT]
at com.mbzj.ai.listener.KnowledgeFileListener.handleAddKnowledgeFileEvent(KnowledgeFileListener.java:64) ~[classes!/:1.0-SNAPSHOT]
at com.mbzj.ai.listener.KnowledgeFileListener$$FastClassBySpringCGLIB$$beafef7e.invoke(<generated>) ~[classes!/:1.0-SNAPSHOT]
at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218) ~[spring-core-5.3.28.jar!/:5.3.28]
at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.invokeJoinpoint(CglibAopProxy.java:793) ~[spring-aop-5.3.28.jar!/:5.3.28]
分析问题
从异常堆栈信息中可以看出这里使用了 java.io.ByteArrayOutputStream 。实际上就是把文件全部都加载到了Byte数组中,如果上传的文件过大必定会导致OOM。
hutool httpRequest执行流程
这里实际上是使用的 java.net.HttpURLConnection。
解决方案
java.net.HttpURLConnection 是支持 StreamingMode 传输HTTP请求的,有两种方式开启:
- setFixedLengthStreamingMode
当预先知道内容长度时,该方法用于使得能够在没有内部缓冲的情况下流式传输HTTP请求主体。
如果应用程序尝试写入比指示的content-length更多的数据,或者如果应用程序在写入指示的数量之前关闭OutputStream,则将引发异常。 - setChunkedStreamingMode
当内容长度为不提前知道。在这种模式下,使用分块传输编码来发送请求正文。请注意,并非所有HTTP服务器都支持此模式。
启用输出流时,无法自动处理身份验证和重定向。如果需要身份验证或重定向,则读取响应时将引发HttpRetryException。
Hutool 的 HttpRequest中只提供了 setChunkedStreamingMode方式,setFixedLengthStreamingMode 方式其实感觉上会更好,不会出现服务端不支持的情况,作者表示下一版本中将会支持setFixedLengthStreamingMode 。
先来测试一下 setChunkedStreamingMode 的效果。
这里自己写一个服务端的接口看看StreamingMode的header有什么区别。
@PostMapping("test")
public void test(MultipartFile file, HttpServletRequest request) {
System.out.println("fileSize" + file.getSize());
// 打印所有header
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String name = headerNames.nextElement();
System.out.println(name + ":" + request.getHeader(name));
}
}
这是修改前会出现OOM的客户端代码
HttpRequest httpRequest = HttpUtil.createPost("http://127.0.0.1:8064/test");
URL fileUrl = new URL("https://xxxx/1a67c727f8a845dd8b0b9825026349dd.mp4");
UrlResource urlResource = new UrlResource(fileUrl, "test.mp4");
httpRequest.form("file", urlResource);
System.out.println(httpRequest);
HttpResponse httpResponse = httpRequest.execute();
System.out.println(httpResponse);
堆内存明显增高
服务端日志输出:
accept:text/html,application/json,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
user-agent:Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36 Hutool
accept-encoding:gzip, deflate
content-type:multipart/form-data; boundary=--------------------Hutool_rV0KKNQCkTkwywrQ
cache-control:no-cache
pragma:no-cache
host:127.0.0.1:8064
connection:keep-alive
content-length:128553150
客户端上传日志:
Request Url: http://127.0.0.1:8064/ai/knowledge/test
Request Headers:
Accept: text/html,application/json,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36 Hutool
Accept-Encoding: gzip, deflate
Request Body:
file=https%3A%2F%2Fcos-uclass.lconrise.cn%2Fbiz%2Fai%2Fknowledge%2Ffile%2F1a67c727f8a845dd8b0b9825026349dd.mp4
Response Headers:
Keep-Alive=[timeout=60]
X-Frame-Options=[DENY]
null=[HTTP/1.1 200]
Cache-Control=[no-cache, no-store, max-age=0, must-revalidate]
X-Content-Type-Options=[nosniff]
Connection=[keep-alive]
Expires=[0]
Pragma=[no-cache]
Content-Length=[0]
X-XSS-Protection=[1; mode=block]
Date=[Wed, 11 Sep 2024 01:59:55 GMT]
Response Body:
客户端通过 setChunkedStreamingMode 开启 StreamingMode:
HttpRequest httpRequest = HttpUtil.createPost("http://127.0.0.1:8064/ai/knowledge/test");
URL fileUrl = new URL("https://cos-uclass.lconrise.cn/biz/ai/knowledge/file/1a67c727f8a845dd8b0b9825026349dd.mp4");
UrlResource urlResource = new UrlResource(fileUrl, "test.mp4");
httpRequest.form("file", urlResource);
httpRequest.setChunkedStreamingMode(1024 * 8);
System.out.println(httpRequest);
HttpResponse httpResponse = httpRequest.execute();
System.out.println(httpResponse);
上传文件时堆内存无明细变化:
服务端日志输出:
accept:text/html,application/json,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
user-agent:Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36 Hutool
accept-encoding:gzip, deflate
content-type:multipart/form-data; boundary=--------------------Hutool_Zn5eac5m74pQH1IJ
cache-control:no-cache
pragma:no-cache
host:127.0.0.1:8064
connection:keep-alive
transfer-encoding:chunked
客户端上传日志:
Request Url: http://127.0.0.1:8064/ai/knowledge/test
Request Headers:
Accept: text/html,application/json,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36 Hutool
Accept-Encoding: gzip, deflate
Request Body:
file=https%3A%2F%2Fcos-uclass.lconrise.cn%2Fbiz%2Fai%2Fknowledge%2Ffile%2F1a67c727f8a845dd8b0b9825026349dd.mp4
Response Headers:
Keep-Alive=[timeout=60]
X-Frame-Options=[DENY]
null=[HTTP/1.1 200]
Cache-Control=[no-cache, no-store, max-age=0, must-revalidate]
X-Content-Type-Options=[nosniff]
Connection=[keep-alive]
Expires=[0]
Pragma=[no-cache]
Content-Length=[0]
X-XSS-Protection=[1; mode=block]
Date=[Wed, 11 Sep 2024 02:02:28 GMT]
Response Body:
正常上传请求包含 content-lengt
header, 来告诉服务端当前请求主体内容的字节数。
StreamingMode
中 没有 content-length
,而是新增了 transfer-encoding:chunked
。
扩展
-
Transfer-Encoding: chunked:
- 这是一种 HTTP 传输编码,允许服务器在不知道整个响应内容长度的情况下,分批次发送数据。
- 每个数据块前会有一个指定大小的头部,表明该块的大小,直到遇到大小为
0
的块,表示传输结束。
-
服务端处理:
- 服务端(如 Tomcat)在接收到
chunked
编码的请求时,会按照分块传输编码的规则来读取数据。 - 服务端会持续读取数据块,直到检测到一个大小为
0
的块,这表示输入流已经结束。
- 服务端(如 Tomcat)在接收到
-
Tomcat 配置:
- Tomcat 允许通过配置
<Connector>
标签的maxPostSize
属性来限制请求体的最大大小。 fileSizeThreshold
参数定义了上传文件写入磁盘的阈值,这对于处理大文件上传尤为重要。
- Tomcat 允许通过配置
-
流式上传:
- Tomcat 支持流式上传,这意味着数据可以边读边写,不需要将整个文件内容一次性加载到内存中。
- 流式上传适用于大文件或实时数据传输,如视频流。
-
异步处理:
- Tomcat 支持 Servlet 3.0 规范中的异步处理机制,允许长时间运行的操作在单独的线程中执行。
- 这可以提高 Tomcat 的并发处理能力和系统吞吐量。
-
异常处理:
- 在文件上传过程中,如果出现异常(如文件大小超出限制),Tomcat 会抛出相应的异常。
- 开发者需要在代码中妥善处理这些异常,并在必要时进行异常捕获和处理。
-
请求结束:
- 处理完所有数据块后,Tomcat 会关闭输入流,并根据请求的内容执行相应的业务逻辑。
用了这么久HTTP, 你是否了解Content-Length和Transfer-Encoding
用了这么久HTTP, 你是否了解Content-Length和Transfer-Encoding
HTTP响应字段Transfer-Encoding含义及作用详解