很多做文件上传功能的同学第一次接到“超大附件”需求时都以为只是加个参数、调大内存就能搞定。结果一跑真实文件几百 MB 可能还能撑住到了几个 GB 甚至十几个 GB要么请求超时要么服务端内存直接打满要么传了半小时突然连接断开什么都白传。问题几乎都出在同一个地方把一个超大的 HTTP 请求体当成普通表单提交来发。这篇文章要和你聊的就是怎么用 Java 通过 HTTP 请求实现超大附件上传。我会从客户端和服务端两个角度把分片上传、断点续传、并发控制、合并校验这些环节讲透并给出可以直接参考的接口设计和代码片段。适合正在做文件上传功能、又确实被大文件搞过头痛的开发者也适合想提前避开这些坑的技术负责人。1. 为什么“一个 HTTP 请求传整个文件”走不通1.1 真实场景中的三道坎先还原一个非常典型的场景某系统需要支持网盘上传文件大小范围从几十 MB 到几十 GB。第一版方案很朴素客户端把文件读取后放进请求体服务端用常规的文件接收方式落盘。测小文件时一切正常测大文件时依次出现三个问题。第一个问题是内存。服务端框架如果默认把文件先读到内存再处理2 GB 的文件可能直接触发堆内存溢出。即便配置了临时文件目录框架的默认阈值也远不够大一个大型文件还是会把内存撑爆。第二个问题是超时。大文件传输耗时很长如果客户端或服务端的 socket 超时时间设置较短连接会被中途关闭。很多框架默认超时只有几十秒几 GB 的文件大概率传不完。第三个问题是网络抖动。广域网上传输大文件时任意一段链路抖动都会导致连接重置。整包上传模式下一旦断线就前功尽弃HTTP 请求本身不支持从某个偏移量恢复传输客户端只能重新传整个文件。1.2 回到 HTTP 本身看限制HTTP 报文分为请求行、请求头和请求体三部分。上传文件时文件内容作为请求体发送。从协议角度看请求头里的 Content-Length 字段表示请求体大小服务端解析时需要据此分配资源。但这里有几个绕不过去的限制请求体越大单次 TCP 连接持续的时间就越长中间任何节点断开都会导致整体失败。服务端通常对请求大小有限制例如容器或网关的默认参数。请求体超过限制时可能直接返回 413 这类错误响应。很多网关、代理服务器或负载均衡器同样会限制单请求大小。即使你的应用层不限制中间一层的限制也可能卡住。这些限制叠加起来导致“一个请求传整个大文件”本质上是一个脆弱且难以扩展的方案。它适合小文件和内网环境不适合真正的超大附件。1.3 分片上传到底解决了什么分片上传的思路并不复杂把一个大文件切分成多个小片段每个片段作为一个独立的 HTTP 请求上传服务端保存好每个片段等到所有片段上传完成后再按先后顺序合并成完整文件。这个方案带来的好处是每个请求体小单个请求的失败影响范围可控。支持断点续传失败只需要重传对应分片不需要传整个文件。可以并发上传多个分片缩短整体传输时间。内存压力大幅降低服务端每次只处理一个分片的数据。打个比方整包上传就像把所有家具一次性塞进一辆卡车超重、路况波动、中途爆胎都可能导致整车报废分片上传则像用多个小包裹分批运送丢了一个补一个就好压力分散容错能力也完全不同。2. 接口与参数设计先定协议再写代码2.1 先设计一个清晰的上传协议直接写代码前最好把接口协议定清楚。用分片上传时至少需要下面几类接口初始化上传客户端告诉服务端文件大小、文件名称、分片大小等信息服务端返回一个上传任务标识。上传分片客户端上传某个分片的数据和分片编号。合并文件客户端在所有分片上传完成后触发合并。查询上传进度用于断点续传或前端展示进度。以某个 B/S 架构系统为例我会定义类似这样的一组接口功能请求方式路径核心参数初始化上传POST/api/upload/initfileName, fileSize, chunkSize, fileMd5上传分片POST/api/upload/chunkuploadId, chunkIndex, file查询进度GET/api/upload/progressuploadId合并文件POST/api/upload/mergeuploadId, fileName, fileSize有人可能会问为什么需要初始化接口其实不初始化也可以直接把分片传过去就行。但有了初始化环节服务端可以提前做磁盘空间校验、任务登记、去重判断也为断点续传提供统一的任务上下文。2.2 分片大小到底怎么选分片大小的选择没有唯一标准需要根据场景调整。太小会急剧增加请求数量太多的小文件对 IO 不友好太大又会回到整包传输的困境网络抖动时单分片失败面积过大。根据我的经验以下几种情况可以这样选内网环境、带宽充足分片可以放到 16 MB 到 32 MB。公网环境、带宽一般推荐 4 MB 到 8 MB。移动端弱网环境推荐 2 MB 到 4 MB。另外还要考虑服务端的磁盘写入性能。如果服务端用的是机械硬盘大量小分片并发写入会造成严重的随机 IO分片大小就适当大一些。如果服务端是 SSD分片小一点问题也不大。在代码中分片数量通常这样计算long totalSize file.size(); long chunkSize 5L * 1024 * 1024; // 5MB int totalChunks (int) ((totalSize chunkSize - 1) / chunkSize);遇到“最后一个分片不满一个分片大小”的情况很常见所以合并时必须以实际读取到的字节数为准不能用固定的分片大小判断边界。2.3 分片文件命名与目录规划分片上传过程中服务端接收到的是零散分片必须有一个明确的临时目录和命名规则否则合并时根本不知道谁是谁也容易在并发上传时出现文件名冲突。建议的命名格式为{uploadId}_{chunkIndex}.part。例如某个上传任务标识为 “abc123”它的分片文件可以是abc123_0.part、abc123_1.part。同时建议把每个任务的分片放在独立子目录中比如临时根目录下按 uploadId 建一个文件夹避免海量分片堆在同一个目录中导致性能下降。uploadId 可以用UUID.randomUUID().toString().replace(-, )生成也可以在初始化接口中由服务端生成并返回。有一点需要强调uploadId 必须由服务端生成或校验不能完全信任客户端传入的值否则容易出现重复写入或目录穿越问题。3. Java 客户端把文件切成 HTTP 请求3.1 用 JDK HttpClient 上传分片Java 11 开始JDK 自带的 HTTP 客户端已经比较成熟。用它写分片上传既不需要额外引入依赖也能满足大部分需求。核心思路是遍历分片对每个分片发起一次 POST 请求请求体中携带分片编号和文件数据。先看一下初始化上传的逻辑public String initUpload(String fileName, long fileSize, long chunkSize) { HttpClient client HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); String body fileName URLEncoder.encode(fileName, StandardCharsets.UTF_8) fileSize fileSize chunkSize chunkSize; HttpRequest request HttpRequest.newBuilder() .uri(URI.create(http://localhost:8080/api/upload/init)) .header(Content-Type, application/x-www-form-urlencoded) .POST(BodyPublishers.ofString(body)) .build(); HttpResponseString response client.send(request, HttpResponse.BodyHandlers.ofString()); // 解析 uploadId return parseUploadId(response.body()); }在 Java 11 的 HttpClient 中BodyPublishers.ofFile 可以直接把一个文件作为请求体发送。但分片上传不能直接把整个文件发出去需要按分片范围发送。JDK 的 API 没有直接提供“指定范围发布”的方法所以更常见的做法是用BodyPublishers.ofInputStream把文件读取流限制在当前分片范围内。这里可以自己封装一个分片读取逻辑public static InputStream openChunkInputStream(Path file, long offset, int length) throws IOException { FileChannel channel FileChannel.open(file, StandardOpenOption.READ); ByteBuffer buffer ByteBuffer.allocate(length); channel.position(offset); while (buffer.hasRemaining()) { int read channel.read(buffer); if (read -1) { break; } } buffer.flip(); return new ByteArrayInputStream(buffer.array()); }然后在发送每个分片时指定Content-Range或把分片索引写进路径或表单字段。服务端可以根据约定的参数识别分片序号。3.2 并发上传和断点续传思路多个分片原本就是相互独立的完全可以通过线程池并发发送。实际操作时需要关注几个细节并发数不宜过高一般 3 到 8 个线程就够。线程太多反而容易触发服务端连接数限制或磁盘 IO 瓶颈。每个分片请求失败后要单独记录不能因为一个分片失败就终止整个任务。整个上传过程不能盲目靠固定 sleep 控制重试建议使用指数退避重试。断点续传的本质是“跳过已上传的分片”。客户端在发送某个分片前先查询服务端当前已有的分片列表如果这个分片已经存在就直接标记为完成。查询接口可以返回已完成分片编号的集合。伪代码如下SetInteger finishedChunks uploadService.getFinishedChunkIndexes(uploadId); ExecutorService pool Executors.newFixedThreadPool(4); CountDownLatch latch new CountDownLatch(totalChunks); for (int i 0; i totalChunks; i) { if (finishedChunks.contains(i)) { latch.countDown(); continue; } int chunkIndex i; pool.execute(() - { try { uploadChunk(uploadId, chunkIndex, file, chunkSize, offset); } catch (Exception e) { failedChunks.add(chunkIndex); } finally { latch.countDown(); } }); } latch.await();等待所有分片发送完后客户端再调用合并接口。最后检查返回值是否成功失败则从返回结果中找出尚未完成的分片再次上传。3.3 另一种常见方案OkHttp 流式上传如果你用的是 OkHttp上传单个分片会更简单一些。OkHttp 允许用自定义 RequestBody 包装文件流并且可以精确控制分片范围。这里给出一个通过RequestBody.create()包装分片数据的示意写法public void uploadChunk(String uploadId, int chunkIndex, Path file, long offset, int length) { RandomAccessFile raf new RandomAccessFile(file.toFile(), r); raf.seek(offset); byte[] data new byte[length]; raf.readFully(data); raf.close(); RequestBody chunkBody RequestBody.create(data, MediaType.parse(application/octet-stream)); MultipartBody body new MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart(uploadId, uploadId) .addFormDataPart(chunkIndex, String.valueOf(chunkIndex)) .addFormDataPart(file, chunk_ chunkIndex, chunkBody) .build(); Request request new Request.Builder() .url(baseUrl /api/upload/chunk) .post(body) .build(); try (Response response client.newCall(request).execute()) { if (!response.isSuccessful()) { throw new IOException(upload chunk failed, code response.code()); } } }这种写法简单直接适合分片数量不算特别大的场景。如果文件非常大最好配合临时分片文件落盘不要一次性把所有分片都读入内存。4. 服务端接收、合并与清理4.1 服务端接收分片时的关键配置服务端承担着接收分片和最终合并的职责。使用 Spring Boot 时接收分片接口可以用MultipartFile接收文件字段。但需要注意的是分片本身一般不会太大4 MB 到 8 MB 是比较安全的范围因此直接在框架层面做配置即可。常见的配置如下spring.servlet.multipart.max-file-size20MB spring.servlet.multipart.max-request-size20MB分片接口的代码可以这样写PostMapping(/api/upload/chunk) public Result uploadChunk(RequestParam String uploadId, RequestParam int chunkIndex, RequestParam(file) MultipartFile file) { Path taskDir Path.of(uploadRoot, uploadId); Files.createDirectories(taskDir); Path partFile taskDir.resolve(chunkIndex .part); file.transferTo(partFile.toFile()); return Result.success(); }这里有一个容易被忽视的点multipart.max-file-size需要适当大于单个分片的大小否则分片稍微大一点就会被拒绝。但也不要设置太大因为总请求大小限制同样会影响并发。4.2 合并分片顺序、校验与效率所有分片上传完成后服务端需要把分片按顺序合并成完整文件。合并的代码并不复杂但有几个性能要点值得注意。第一种方式是用FileOutputStream逐分片追加写。这种方式写起来最简单但在大文件合并时频繁切换输出位置效率一般。第二种方式是用FileChannel.transferTo或transferFrom。这种方式可以借助操作系统的零拷贝能力性能明显更好。示例代码如下public void mergeChunks(String uploadId, Path targetFile) throws IOException { Path taskDir Path.of(uploadRoot, uploadId); ListPath partFiles Files.list(taskDir) .filter(path - path.getFileName().toString().endsWith(.part)) .sorted(Comparator.comparingInt(p - Integer.parseInt( p.getFileName().toString().replace(.part, )))) .collect(Collectors.toList()); try (FileChannel out FileChannel.open(targetFile, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { for (Path partFile : partFiles) { try (FileChannel in FileChannel.open(partFile, StandardOpenOption.READ)) { for (long pos 0; pos in.size(); ) { pos in.transferTo(pos, in.size() - pos, out); } } } } }合并完成后建议对合并结果做一次校验。最简单的校验是比对文件大小更严谨的是计算 MD5 或 CRC32。如果初始化时客户端传了文件的 MD5合并后服务端可以算一遍并比对。4.3 临时分片文件的清理大文件分片上传的临时文件如果一直不清理磁盘空间很快会被占满。清理策略一般有两种上传完成后立即删除分片目录。设置一个定时任务扫描超过一定时间仍未合并的临时目录自动清理。无论哪种方式都要注意一个并发问题如果用户上传了所有分片但合并请求还没发起此时后台定时任务不能把分片目录删掉。建议在初始化时记录任务创建时间在分片上传时更新最近活动时间定时任务只处理超过阈值且未进入合并状态的任务。5. 常见问题与排查技巧实录5.1 413 请求实体过大出现 413 时第一反应别只顾着调大应用层的max-file-size要逐层排查。常见的限制源头包括容器层面的连接器参数。网关或反向代理层面的client_max_body_size等参数。应用框架的 multipart 配置。排查方法很简单先请求一个很小的文件确认正常再逐步增大文件观察在哪个大小阈值上返回 413。如果只是上传接口返回就重点查应用配置如果所有大请求都失败就要看看代理层。5.2 连接超时、断线重传大量上传场景中最让人头疼的是传了很久之后突然出现连接重置或读取超时。这类问题的处理原则是“让客户端具备重试能力”。我通常会在客户端做好以下三点每个分片请求都设置合理的连接超时间和读取超时间。失败时先做指数退避重试连续失败超过一定次数再报错。维护“已完成分片集合”下次启动或重试时跳过已传分片。在服务端尽量不要把 socket 超时时间设得太长否则异常连接会占据线程资源。合理的做法是正常传输的超时设置为 60 秒左右同时让客户端主动上报进度服务端可以定期清理超时的“僵尸任务”。5.3 合并后文件损坏或顺序错乱合并结果不对十有八九是分片顺序处理错了。最常见的原因是并发上传场景下分片到达顺序和分片编号不一致服务端如果依赖到达顺序写文件必然出问题。正确的做法是先保存为独立分片文件合并时按编号排序。另一种原因是分片大小不固定最后一片通常小于设定的分片大小。合并时如果按固定大小计算偏移量会漏掉尾部数据。规范和一致性很重要合并时直接从分片文件读取全部内容即可不要依赖固定长度。5.4 容易被忽略的内存问题很多人以为分片上传后服务端就不会有大内存压力了。实际上如果客户端在读取分片时使用不当还是可能把大片文件读进内存。比如下面的代码就很危险byte[] allData Files.readAllBytes(filePath); // 大文件会 OOM正确做法是使用流或通道按需读取分片范围例如RandomAccessFile、FileChannel、InputStream等。使用 MultipartFile 时服务端框架通常会把临时文件写到磁盘但如果是自研协议直接读取请求体也要避免一次性把整个 body 读进内存。5.5 其他问题文件名编码、鉴权、跨域文件名如果包含中文或特殊字符上传前要统一编码。最常见的方式是用 URL 编码处理文件名或者使用 multipart 表单时设置正确的字符集。鉴权方面要注意分片上传接口应该在每次请求中携带会话信息或签名参数否则别人可以任意往你的上传目录里写入分片文件。更稳妥的做法是在初始化接口生成一个短期有效的上传令牌后续分片请求必须携带该令牌。如果前端直接调用后端接口还需要考虑跨域配置。分片数量多时浏览器会发起大量并发请求需要确认跨域配置支持携带自定义请求头避免请求被拦截。6. 最后的经验碎片在实际项目里我见过太多人一上来就复制网上现成的上传代码测了 1 GB 文件没问题就以为大功告成。真正放到生产环境网络带宽不稳、服务端磁盘读写慢、并发上传用户多各种问题才陆续暴露。现在我处理超大附件上传时一定会先确认几个基础命题单个请求体控制在多少以内、失败重试策略是什么、临时文件何时清理、合并结果如何校验。分片上传本身不是银弹但它把所有“大而脆弱”的问题拆成了“小而可靠”的问题工程上可预期的多。最后分享一个小建议不要只在本地测试大文件最好搭一个限速、丢包模拟环境跑一轮。断点续传是否真的能续上、并发分片是否稳定只有在这种环境下才测得出真实水平。这个环节省不得。