☰
Java 发送 multipart/form-data 文件上传 POST 请求实战与避坑
2026/10/26 19:43:13 网站建设 项目流程

对接第三方接口时碰到文件上传,十有八九会绕回multipart/form-data这个格式。我做过不少「Java 发送 POST 请求,把文件数据推到对方接口」的活儿,从早期手拼字符串到后来用 HttpClient 的MultipartEntityBuilder,踩过的坑基本能凑成一本小册子:boundary少两个横线服务端直接 400,中文文件名到对面变成问号,几百兆的包一次性读进内存直接把堆撑爆,接口偶发超时但重试又怕传重复。这些问题的共同点是——报错信息都很含糊,真正的原因全在请求体那几十个字节的格式细节里。

这篇东西就围绕「用 Java 发一个带文件的 multipart POST 请求」展开,把报文结构、实现方案选型、手写与用库两条路、长度计算、编码处理、超时重试、大文件内存控制、以及一堆现场排查经验讲透。适合刚接手接口联调的同学,也适合写过几次但总在细节上翻车的朋友。看完你至少能做到:拿到一个上传接口文档,能判断对方要的到底是哪种编码方式,能自己把请求体拼出来,也能在出问题时三分钟内定位到是头不对还是流没写对。

1. multipart/form-data 到底长什么样,先把它拆开看

1.1 一次上传请求的原始报文长这样

很多人用惯了封装好的 API,从没见过请求体的真面目,结果一出问题就抓瞎。multipart/form-data的本质非常朴素:它就是一个用固定分隔符切开的多段字节流,每一段代表一个表单字段,段与段之间靠boundary这个字符串隔开。它跟 JSON 最大的不同在于——它可以同时携带二进制文件和普通文本,而且不需要 base64 编码膨胀体积,所以文件上传场景几乎都用它。

假设我要往接口推一个 Excel 文件和两个业务参数,真实发出去的请求体大概是这样:

------FormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; name="bizType" Content-Type: text/plain; charset=UTF-8 monthly_report ------FormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; name="token" Content-Type: text/plain; charset=UTF-8 abc123 ------FormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; name="file"; filename="月度报表.xlsx" Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet <这里是文件的原始字节> ------FormBoundary7MA4YWxkTrZu0gW--

对应的请求头只有一个关键点:

Content-Type: multipart/form-data; boundary=----FormBoundary7MA4YWxkTrZu0gW Content-Length: 1234567

看明白这段报文,后面的所有问题基本都能自己推。比如服务端报「找不到 file 字段」,你就去看name="file"有没有写错、--加够了没有;报「解析失败」,你就去看换行是不是严格\r\n。

这类需求在实际工作中的分布其实很集中:对接第三方文件网关、往对象存储的代理接口推文件、企业内部系统的批量导入、图片/证件照上传、日志包回传。它们的共同特征是「文本参数 + 二进制内容混在一起」,所以multipart/form-data是默认答案,几乎没有替代品。

1.2 boundary 的三条硬性规则,错一条就报 400

boundary是整个格式的骨架,我用血泪总结出三条:

第一,请求头里声明的 boundary 和报文里实际用的必须完全一致,包括前面的--。头里写的是boundary=abc123,报文里每段开头就是--abc123,注意这里是两个横线加上 boundary 本体,不是三个也不是一个。很多人手写时在头里写了--abc123,报文里也写--abc123,结果服务端按「头里的值就是 abc123」解析,拼出来变成----abc123,直接对不上。规范里头的值是不带前缀横线的,报文行才加。

第二,结尾必须有一个闭合段,也就是--+ boundary +--,后面跟一个 CRLF。少了这个闭合符,有些解析器会一直等下一段,最终超时或报「unexpected end of stream」。

第三,boundary 本身要足够随机,且不能出现在内容里。如果文件二进制里恰好出现了和 boundary 一模一样的字节序列,解析器会提前切断,文件就废了。实际做法是用 UUID 加前缀,长度控制在 30 到 60 字符之间,规范建议不超过 70 字符。有些人图省事用固定字符串,跑测试没问题,上线后偶发文件损坏,查一周都查不出来。

提示:boundary只允许由字母、数字和'()+_,-./:=?以及空格组成,但结尾不能是空格。用 UUID 去掉横线是最省心的做法。

1.3 为什么不能拿 urlencoded 传文件

有人会问,application/x-www-form-urlencoded也是表单格式,为什么不能用。原因是这个格式只处理「键=值」的文本对,二进制数据必须做百分号编码,一个字节可能膨胀成三个字符,一个 10MB 的文件编码完变成 30MB,而且服务端还得反向解码,性能和稳定性都不划算。更关键的是,多数服务端框架的 urlencoded 解析器对超长单值有长度限制,直接给你截断。

multipart/form-data的设计目标就是「把二进制原样搬过去」,文件段只加几十字节的头,体积几乎无损耗。所以只要涉及文件,别犹豫,就是这个格式。唯一需要注意的是:文本字段如果内容里有中文,务必在段头里声明charset=UTF-8,否则服务端可能按 ISO-8859-1 解,出来就是乱码。

2. 三种主流实现方案怎么选

2.1 HttpURLConnection、HttpClient、OkHttp 横向对比

Java 里发 multipart 请求,主流就三条路,我先把结论摆出来,再说理由。

方案依赖multipart 支持上手难度适合场景
HttpURLConnectionJDK 自带,零依赖无封装,需手拼报文中,细节多老项目、不能加依赖、请求简单
Apache HttpClient 4.5.xhttpclient + httpmimeMultipartEntityBuilder开箱即用低绝大多数服务端项目
Apache HttpClient 5.xhttpclient5 + httpcore5同类 API,包名和类名变了中新项目、需要 HTTP/2
OkHttp 3/4okhttpMultipartBody.Builder链式调用低Android、追求简洁 API

HttpURLConnection的短板不在功能,而在「什么都要自己干」:拼接、长度计算、流控、异常处理全得写。它的优势是零依赖,很多对依赖有严格审计的环境只能用这个。

HttpClient 的优势在于成熟:连接池、超时、重试、拦截器、代理配置都是现成的,MultipartEntityBuilder把报文拼装这件事封装得很干净,还能自动算Content-Length。

OkHttp 的 API 最舒服,MultipartBody.Builder().addFormDataPart("file", "a.txt", RequestBody.create(file, mediaType))一行搞定,但多一个依赖,而且部分企业内网对第三方库的来源有要求。

我自己的默认选择是 HttpClient 4.5.x,因为它几乎在所有 Java 服务端项目里都已经间接存在了(很多框架的传递依赖里就有),加个httpmime的成本最低。

2.2 依赖版本带来的 API 分裂

这里有个特别容易踩的坑:HttpClient 4.x 和 5.x 的包名、类名几乎全变了,但网上搜到的代码经常混着给。你在 5.x 的工程里贴一段 4.x 的代码,编译器报「找不到符号」,然后你开始怀疑人生。

区别记这几条就够了:

  • 4.x 的包是org.apache.http.*,5.x 是org.apache.hc.client5.http.*和org.apache.hc.core5.http.*。
  • 4.x 的核心类是CloseableHttpClient,5.x 叫CloseableHttpClient但走的构建器是HttpClients.custom()里的同名方法,实际执行入口换成execute的重载,异常体系也换成了HttpException。
  • 4.x 的 multipart 构建器在org.apache.http.entity.mime.MultipartEntityBuilder,5.x 在org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder。
  • ContentType在 4.x 是org.apache.http.entity.ContentType,5.x 是org.apache.hc.core5.http.ContentType,前者有withCharset返回新实例,后者用ContentType.create(type, charset)。

依赖坐标方面,4.5.x 需要两个包:httpclient加httpmime,后者才是提供MultipartEntityBuilder的地方,很多人只加了前者然后满世界找类。5.x 一般引httpclient5就够了,httpcore5会被传递带进来。

注意:如果项目里同时存在 4.x 和 5.x(比如某个中间件强制带进来一个),类路径会冲突,表现为NoSuchMethodError或ClassNotFoundException。用mvn dependency:tree搜一下httpclient,把多余的版本排掉,比改代码快得多。

2.3 我的默认选型和例外情况

正常服务端项目,我选 HttpClient 4.5.x 加httpmime,理由是生态稳定、示例多、连接池配置成熟。如果项目已经在用 Spring,其实也可以用RestTemplate配MultiValueMap加ByteArrayResource的方式,代码更短,但底层还是 HttpClient 或 JDK 的实现,遇到诡异问题一样要往下挖,所以我更倾向直接控制底层。

如果环境完全不允许加依赖,那就手写报文配HttpURLConnection,这就是第 3 章的内容。别觉得手写很原始,理解了手写过程,你用任何库出问题都能快速定位,因为你知道它背后到底发了什么字节。

还有一种例外:如果上传的文件在 100MB 以上,或者需要断点续传,那重点就不在「怎么发」而在「怎么分」,这时候要跟服务端确认是否有分片接口,单次 multipart 请求本身不适合承载超大文件。

3. 从零手写 multipart 报文:把请求体当字节拼

3.1 boundary 生成与整体长度预估

手写的第一步是生成 boundary,并算出整个请求体的字节长度。为什么非要算长度?因为HttpURLConnection如果不知道长度,会把整个请求体缓冲到内存里再发,大文件必炸。设置固定长度后,它会边写边发,内存占用恒定。

生成方式:

String boundary = "----FormBoundary" + UUID.randomUUID().toString().replace("-", "");

长度计算有个通用公式,记住它比记代码有用:

总长度 = 各段(段头字节数 + 段体字节数 + 2 字节的 CRLF)之和 + 闭合段字节数

其中段头是那段Content-Disposition文本按 UTF-8 转成的字节,段体对文本字段是value.getBytes(UTF_8).length,对文件就是file.length()。闭合段是--+ boundary +--+ CRLF 的字节数。

举个具体例子:boundary 是----FormBoundary加 32 位 UUID,共 48 字符。一个文本段头--{boundary}\r\nContent-Disposition: form-data; name="bizType"\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n大约是 130 字节,值monthly_report是 14 字节,再加末尾 CRLF 2 字节,这一段 146 字节。你自己算的时候不用手数,写个方法把段头字符串getBytes(UTF_8).length打出来累加就行,比心算靠谱。

提示:中文文件名按 UTF-8 算字节,一个汉字 3 字节,length()拿到的是字符数不是字节数,这里必须用getBytes(UTF_8).length,否则长度算少,请求会被截断,服务端报「Malformed multipart body」。

3.2 文本字段和文件字段的拼装代码

我把段头生成抽成两个方法,逻辑就清楚了。先看文本段:

private static final String CRLF = "\r\n"; private static final Charset UTF8 = StandardCharsets.UTF_8; private static byte[] textHeader(String boundary, String name) { String head = "--" + boundary + CRLF + "Content-Disposition: form-data; name=\"" + name + "\"" + CRLF + "Content-Type: text/plain; charset=UTF-8" + CRLF + CRLF; return head.getBytes(UTF8); } private static byte[] fileHeader(String boundary, String name, String filename, String contentType) { String safeName = filename.replace("\"", "%22") .replace("\r", "%0D") .replace("\n", "%0A"); String head = "--" + boundary + CRLF + "Content-Disposition: form-data; name=\"" + name + "\"; filename=\"" + safeName + "\"" + CRLF + "Content-Type: " + contentType + CRLF + CRLF; return head.getBytes(UTF8); }

这里有两个细节。文件名里的双引号和换行必须转义,RFC 7578 给出的处理方式就是百分号编码,否则一个带引号的文件名能直接把整个报文结构打乱。另一个是每个段结束后都要补一个 CRLF,它就相当于「这一行结束了」的标记,少了它,下一个--boundary会粘在文件最后一个字节后面,解析器就找不到了。

写入时严格按顺序:段头字节 → 段体字节 → CRLF,循环往复,最后写闭合段。文件内容用FileInputStream配transferTo或 8KB 缓冲区拷贝,绝对不要Files.readAllBytes。

private static void writeFile(OutputStream out, File file) throws IOException { try (InputStream in = new FileInputStream(file)) { byte[] buf = new byte[8192]; int n; while ((n = in.read(buf)) != -1) { out.write(buf, 0, n); } } }

3.3 流式写入与 Content-Length 设置

拿到总长度之后,事情就好办了:

HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); conn.setRequestMethod("POST"); conn.setDoOutput(true); conn.setDoInput(true); conn.setUseCaches(false); conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); conn.setRequestProperty("Connection", "keep-alive"); conn.setFixedLengthStreamingMode(totalLength); // 关键:流式写入,不缓冲 try (OutputStream out = conn.getOutputStream()) { // 依次写各段 }

setFixedLengthStreamingMode(long)在 Java 7 之后支持 long 参数,这个调用是「大文件不炸内存」的关键。如果不设置,JDK 会默认把请求体会缓冲在内存里,几 MB 看不出来,几百 MB 直接OutOfMemoryError。

如果实在算不出长度(比如文件来自一个长度未知的网络流),那就用setChunkedStreamingMode(8192),它会以分块传输编码发送。代价是有部分网关或老服务端不支持 chunked,会报 400 或直接断开。我的经验是:能算长度就一定算,chunked 只当兜底。

写完之后记得读响应。HttpURLConnection有个经典陷阱:如果不读响应体,连接不会释放,用久了连接池耗尽,表现为请求卡住。正确做法是拿到getInputStream()(2xx)或getErrorStream()(非 2xx)读干净再disconnect()。

3.4 中文文件名的三种处理方式

中文文件名乱码是这类需求的「保留节目」,根因是客户端写进去的字节编码和服务器解出来的编码不一致。三种处理方式,按推荐顺序排:

第一种,直接 UTF-8 写 filename。现代框架基本都按 UTF-8 解析,比如主流的 Servlet 容器配 Spring 的 multipart 解析器,默认就是 UTF-8。这种方式最干净,缺点是碰到老服务端(比如基于老版 commons-fileupload 且没配编码的)会乱码。

第二种,同时给filename和filename*。这是 RFC 5987 的兼容写法,filename里放 ASCII 兜底值,filename*里放UTF-8''加百分号编码的真实名字。支持新标准的服务端会优先用filename*,老的用filename,两边都不空。

Content-Disposition: form-data; name="file"; filename="report.xlsx"; filename*=UTF-8''%E6%9C%88%E5%BA%A6%E6%8A%A5%E8%A1%A8.xlsx

第三种,按对方约定的编码转。有些接口文档会直接写「文件名需 URLEncode 后传输」或「文件名使用 GBK 编码」,这种就别自作主张,照着来。判断依据永远是对方的文档和联调结果,不是哪个更「标准」。

排查时最有效的手段是抓包看实际发出的字节。你把请求体按字节 dump 出来,看filename=后面那串十六进制,一眼就知道是 UTF-8(中文三字节,E6 9C 88开头)还是 GBK(两字节,D4 C2开头)。这一步比改八遍代码有用得多。

4. HttpClient 落地:MultipartEntityBuilder 完整用法

4.1 依赖与最小可运行骨架

引依赖,4.5.x 记两个包:

<dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpclient</artifactId> <version>4.5.14</version> </dependency> <dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpmime</artifactId> <version>4.5.14</version> </dependency>

最小骨架长这样,先跑通再谈优化:

public static String upload(String url, File file, String filename) throws IOException { try (CloseableHttpClient client = HttpClients.createDefault()) { HttpPost post = new HttpPost(url); HttpEntity entity = MultipartEntityBuilder.create() .setMode(HttpMultipartMode.RFC6532) .setCharset(StandardCharsets.UTF_8) .addTextBody("bizType", "report", ContentType.TEXT_PLAIN.withCharset(StandardCharsets.UTF_8)) .addBinaryBody("file", file, ContentType.APPLICATION_OCTET_STREAM, filename) .build(); post.setEntity(entity); try (CloseableHttpResponse resp = client.execute(post)) { int code = resp.getStatusLine().getStatusCode(); String body = EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8); if (code != 200) { throw new IOException("上传失败, code=" + code + ", body=" + body); } return body; } } }

这里有三处容易忽略的配置。setCharset(UTF_8)决定文本字段的实际编码和段头里的 charset 参数,不设的话默认是 ISO-8859-1,中文必乱。setMode(RFC6532)决定文件名的写法,STRICT模式下中文会被替换成问号,BROWSER_COMPATIBLE会输出filename*形式,RFC6532直接 UTF-8 原样写。三种模式对应三类服务端口味,联调时逐个试是最快的办法。

还有一点,不要手动post.setHeader("Content-Type", "multipart/form-data")。MultipartEntityBuilder生成的实体自带正确的 Content-Type 和 boundary,你手动覆盖反而会把 boundary 参数弄丢,服务端解析时找不到分隔符,直接 400。这个错误我在好几个项目里见过,改起来就一行,找起来能耗半天。

4.2 文本字段、文件字段、内存流字段

MultipartEntityBuilder的 add 系列方法对应三种数据来源:

  • addTextBody(name, value, contentType):普通文本,适合业务参数。
  • addBinaryBody(name, file, contentType, filename):本地文件,内部用FileBody,流式读,不会一次性载入内存。
  • addBinaryBody(name, inputStream, contentType, filename):内存或网络流,适合文件内容已经在内存里或者从别处转发过来的场景。注意这个流要自己负责关闭,别在build()之后马上关,要等请求执行完。
  • addBinaryBody(name, byte[], contentType, filename):字节数组,适合小文件,几百 KB 以内还行,再大就别用了。

传InputStream的时候有个隐藏问题:getTotalLength()算不出长度,实体返回 -1,HttpClient 会退化成 chunked 传输。如果对方网关不认 chunked,就会失败。解决办法是把流先落到临时文件,再用File方式传,代价是一次磁盘 IO,换来稳定。

多个文件也很简单,同一个字段名addBinaryBody多次就行,服务端按数组收。字段名不同就分别加。业务参数最多的情况我见过一个接口二十多个文本字段,这时候建议写个循环从 Map 里批量 add,比一行行写可维护。

4.3 超时、连接池与重试

默认HttpClients.createDefault()的超时是不设的,这意味着一个卡住的连接能把线程挂死。生产环境必须显式配置:

RequestConfig config = RequestConfig.custom() .setConnectTimeout(5000) .setSocketTimeout(60000) .setConnectionRequestTimeout(3000) .build(); PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(); cm.setMaxTotal(50); cm.setDefaultMaxPerRoute(20); CloseableHttpClient client = HttpClients.custom() .setConnectionManager(cm) .setDefaultRequestConfig(config) .evictExpiredConnections() .evictIdleConnections(30, TimeUnit.SECONDS) .build();

三个超时的分工要搞清楚,这是排查「请求卡住」类问题的前提。connectTimeout是 TCP 握手时间,设 5 秒够了。socketTimeout是两次数据包之间的最大间隔,也就是「服务端多久没吐一个字就放弃」,上传大文件时这个值要给足,因为服务端可能先收完再处理。connectionRequestTimeout是从连接池借连接的最长等待,池子满了会卡在这里,报ConnectionPoolTimeoutException。

重试要特别小心。POST 上传默认不该自动重试,因为服务端可能已经收到文件只是响应丢了,再发一次就是两份。如果确实要重试,只在「连接建立失败」这种请求还没发出去的情况下重试,用自定义的HttpRequestRetryHandler判断异常类型,别用默认策略。

注意:evictIdleConnections不是必须的,但长跑的服务建议加上,否则服务端的 keep-alive 超时和客户端认知不一致时,会偶发「连接被重置」。

4.4 响应处理与资源释放

响应处理有两个必做动作:读完实体、关闭响应。

try (CloseableHttpResponse resp = client.execute(post)) { HttpEntity respEntity = resp.getEntity(); String body = respEntity == null ? "" : EntityUtils.toString(respEntity, StandardCharsets.UTF_8); // 处理业务 }

EntityUtils.toString会自动消费实体并归还连接。如果只想拿状态码不看内容,也必须显式EntityUtils.consume(resp.getEntity()),否则连接一直被占用,池子慢慢就满了,表现出来就是「跑一段时间后所有请求超时」。

另外提醒一句,很多接口在上传成功时返回的不是 200 而是 201 或者自定义的{"code":0}。判断成功与否要把状态码和业务码都覆盖,别只看code == 200,不然明明成功了却抛异常。

5. 常见问题与排查实录

5.1 问题速查表

现象大概率原因排查动作
400 Bad Requestboundary 头与实际不一致、缺闭合段、CRLF 用了\ndump 请求体,检查每行结尾是不是0D 0A
服务端收不到 file 字段name拼错、段头缺 Content-Disposition对照接口文档字段名,检查段头首行
中文文件名变问号编码不一致抓包看 filename 字节,判断是 UTF-8 还是被替换成?
文件内容损坏boundary 与文件内容撞车、长度算少被截断换随机 boundary,核对 Content-Length
415 Unsupported Media TypeContent-Type 缺 boundary 参数检查是否手动覆盖了 Content-Type
413 Payload Too Large网关或服务端大小限制查 Nginx client_max_body_size 和框架配置
大文件 OOM请求体全缓冲进内存用 setFixedLengthStreamingMode 或 FileBody
请求一直不返回未设 socketTimeout、服务端等流结束但客户端没 flush补 flush、补超时
偶发 Connection reset连接池中的连接已被服务端关闭开启空闲连接清理
重试后出现重复文件POST 被自动重试关闭自动重试或加重试幂等标识

5.2 三次印象深刻的排查过程

第一次是服务端一直报「Malformed multipart body」。我一开始怀疑是 boundary 写错,检查了十几遍都对。后来把请求体按字节打到日志里,发现每行结尾只有0A没有0D。原因是我用了一个文本编辑器手改代码,把\r\n保存成了实际换行,Java 字符串里的\r变成了字面的反斜杠加 r。改成显式的"\r\n"常量后立刻通了。这个教训让我此后所有 CRLF 都用常量引用,绝不手打。

第二次是中文文件名乱码。服务端收到的文件名是??.xlsx。现象很像编码问题,但我把setCharset(UTF_8)加上了还是问号。最后发现是HttpMultipartMode.STRICT模式导致的,这个模式按 ISO-8859-1 输出文件名,中文直接被替换。改成RFC6532之后正常。这说明「设置了 charset」和「文件名的编码模式」是两个独立的开关,不能混为一谈。

第三次是跑批量任务时内存曲线一路往上。定位到是某个同事用了addBinaryBody("file", Files.readAllBytes(path), ...),20 个 50MB 的文件并发上传,内存直接见底。换成addBinaryBody("file", new File(path), ...)之后,堆占用平得像一条直线。后来我把这条写进了团队的上传规范:超过 5MB 一律用 File 或流,禁止 byte 数组。

5.3 我自己踩出来的避坑清单

  • 请求头里的Content-Type只写一次,交给实体去生成,别手动拼。
  • 文本字段一定显式指定 UTF-8 的 ContentType,别依赖默认值。
  • 文件名里的引号、换行、分号都要转义,RFC 7578 有明确处理方式。
  • 上传前先算体积,超过对方限制就提前拦截,比等 413 报错友好。
  • 大文件用流式或 File,禁止readAllBytes。
  • POST 上传默认别开自动重试。
  • 所有请求配 connect 和 socket 超时,socket 超时按最大文件体积估。
  • 响应实体必须消费,否则连接泄漏。
  • 联调阶段打开 HttpClient 的 wire 日志,能看到实际发出的每一行报文:
System.setProperty("org.apache.commons.logging.Log", "org.apache.commons.logging.impl.SimpleLog"); System.setProperty("org.apache.commons.logging.simplelog.showdatetime", "true"); System.setProperty("org.apache.commons.logging.simplelog.log.org.apache.http.wire", "DEBUG");

这段配置能救命,尤其是面对「对方说没收到文件」这种查无可查的情况,直接看日志里>>开头的那几行就知道发了什么。

6. 大文件上传的性能与稳定性

6.1 内存占用到底来自哪里

一个上传请求占用的内存有三块:请求体缓冲、响应体缓冲、连接池里的 Socket 缓冲区。最容易被忽视的是第一块。HttpURLConnection在没设置 streaming 模式时会用ByteArrayOutputStream缓存整个请求体,文件多大就占多大。HttpClient 用FileBody时不会缓冲内容,但会计算长度,内存开销可以忽略。

判断你的代码有没有踩坑,看两点:一是有没有把文件内容读成byte[]或String,二是用的库有没有流式支持。改完之后可以在-Xmx256m的堆里传一个 500MB 的文件验证,如果能稳定跑完,就说明是流式的。

6.2 进度监控怎么做

MultipartEntityBuilder本身不提供进度回调,要自己包一层。思路是自定义一个HttpEntity包装原实体,在writeTo时把OutputStream替换成一个计数流,每写 n 字节回调一次。这算是比较标准的做法,比外部起个线程定时查文件句柄靠谱。

如果只是想知道「有没有卡住」,更简单的办法是给 socketTimeout 设成 30 秒,然后观察异常的时间点。上传过程中如果 30 秒没数据交互,客户端就会超时抛异常,说明链路不通或服务端不读数据。这个成本最低,先做这个。

6.3 分片和重试的边界

单次请求传几百 MB 不是不能做,但风险高:任何一次网络抖动都要从头再来,而且中间经过的每一层网关都有体积上限。真要做大文件,跟服务端确认有没有分片接口,通常是「先调初始化拿一个 uploadId,再按固定大小切片上传,最后调合并」。这个模式下每次请求都是小体积,失败只重传一片。

分片还有一个好处是并发,多片并行上传能明显提速。代价是复杂度上去了,要处理片序号、重传、超时和最终一致性。我的判断标准是:小于 50MB 走单次 multipart,超过就走分片,中间那一段视服务端能力决定。

注意:如果走分片,每一片仍然是multipart/form-data格式,只是文件内容变成了一个切片流。别以为分片了就不用管格式细节。

最后分享一个小技巧,我在联调阶段会先在本地起一个极简的接收端,把收到的请求头、字段名、文件名、文件大小全打出来。Java 里用HttpServer加几十行代码就能做,或者用命令行工具临时收一下。这么做的好处是把「对方的问题」和「自己的问题」彻底隔离开——如果本地接收端收得对,那就不是客户端的问题,理直气壮地让对面查服务端。这一招帮我省过很多来回拉扯的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询