FEATURED · 精选文章

Java实现HTTP断点续传:Spring Boot服务端与客户端全流程实战

发布时间 / 2026/8/5 16:44:30
来源 / 创域科博编辑部
栏目 / 资讯中心
Java实现HTTP断点续传:Spring Boot服务端与客户端全流程实战 1. 项目概述为什么我们需要断点续传在文件传输这个老生常谈的话题里断点续传一直是个“痛点”功能。想象一下你正在上传一个10GB的工程源码压缩包到公司的文件服务器进度走到99%时网络抖动了一下或者你的开发IDE卡顿了一下导致进程中断整个上传任务前功尽弃需要从头再来。这种体验对于任何一个开发者来说都足以让人抓狂。断点续传就是为了解决这个“从头再来”的噩梦而生的。简单来说断点续传允许我们将一个大文件的传输任务分割成多个小块。当传输因任何原因中断后再次发起请求时可以从已经成功传输的“断点”处继续而不是重头开始。这不仅仅是提升了用户体验更是对服务器和网络资源的合理利用。在Java生态中无论是构建一个内部的文件管理系统、一个云存储服务的客户端SDK还是实现一个支持大文件上传的Web应用后端掌握断点续传的实现原理和细节都是一项非常实用且能体现工程深度的技能。它涉及到的不仅仅是简单的IO读写更涵盖了HTTP协议规范、并发控制、状态管理和数据一致性等核心问题。2. 核心原理与协议基础拆解要实现断点续传首先必须理解其赖以生存的协议基础主要是HTTP/1.1及更高版本中定义的相关头部字段。这是客户端与服务器进行“断点对话”的语言。2.1 HTTP范围请求Range Request这是断点续传的基石。HTTP协议定义了Range和Content-Range头部来实现对资源部分内容的请求。客户端发起请求当客户端需要下载文件的某一部分时会在请求头中加入Range字段。其格式为Range: bytesstart-end。例如Range: bytes0-1023表示请求文件开头的1024个字节Range: bytes1024-表示请求从第1024字节开始到文件末尾的所有内容。服务器响应如果服务器支持范围请求对于成功的部分内容请求会返回状态码206 Partial Content并在响应头中通过Content-Range告知客户端返回的内容范围以及文件总大小格式为Content-Range: bytes start-end/total。例如Content-Range: bytes 0-1023/2048。同时Content-Length头部表示的是本次返回的片段长度例如1024而非文件总长度。注意服务器必须正确响应Accept-Ranges: bytes头部来声明自己支持范围请求。对于不支持的文件或不支持该操作的请求服务器应返回200 OK和整个资源或者416 Range Not Satisfiable状态码。2.2 服务端的关键职责记录与校验对于上传场景的断点续传HTTP协议本身没有像Range那样直接的标准头部。通常需要应用层自行设计协议。其核心思想是分片客户端将大文件切割成固定大小如1MB或5MB的片段Chunk。唯一标识客户端在上传前先向服务器申请一个本次上传任务的唯一标识如uploadId。通常服务器会基于文件内容如MD5、文件名、用户等信息生成。分片上传客户端按顺序或并发地上传每一个分片请求中需携带uploadId、分片索引partNumber和分片数据。记录状态服务器为每个uploadId维护一个上传状态记录哪些分片partNumber已经成功上传。这个状态通常需要持久化到数据库或分布式缓存中。查询与续传在上传开始前或中断后客户端可以询问服务器通过uploadId当前已上传的分片列表。然后客户端只上传那些缺失的分片。合并文件当全部分片上传完毕后客户端发起一个“完成上传”的请求。服务器根据uploadId找到所有分片按partNumber顺序将它们拼接合并成完整的原始文件。这里服务器端的状态管理是可靠性的关键。状态丢失续传就无法进行。3. 服务端核心设计与实现我们将构建一个Spring Boot服务同时支持基于HTTP Range的下载断点续传和基于分片的上传断点续传。3.1 项目结构与依赖首先创建一个标准的Spring Boot项目。核心依赖如下pom.xmldependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 用于生成文件MD5等 -- dependency groupIdcommons-codec/groupId artifactIdcommons-codec/artifactId /dependency !-- 持久化上传状态这里用Redis作为示例 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- 可选用于更便捷的IO操作 -- dependency groupIdcommons-io/groupId artifactIdcommons-io/artifactId version2.11.0/version /dependency /dependencies3.2 下载断点续传实现下载的断点续传主要由Servlet容器如Tomcat和我们的控制器配合完成。我们只需要正确读取文件并响应即可。import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import javax.servlet.http.HttpServletRequest; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; RestController RequestMapping(/download) public class DownloadController { GetMapping(/file) public ResponseEntityResource downloadFile(RequestParam String filename, HttpServletRequest request) throws IOException { // 1. 定位文件此处为示例实际应从安全路径获取 Path filePath Paths.get(/your/upload/dir, filename).normalize(); File file filePath.toFile(); if (!file.exists()) { return ResponseEntity.notFound().build(); } // 2. 创建Resource对象 Resource resource new FileSystemResource(file); // 3. 处理断点续传检查请求头中的Range String rangeHeader request.getHeader(HttpHeaders.RANGE); long fileLength file.length(); if (rangeHeader ! null rangeHeader.startsWith(bytes)) { // 解析Range头这里简化处理单个范围请求 String range rangeHeader.substring(6); String[] ranges range.split(-); long start Long.parseLong(ranges[0]); long end fileLength - 1; if (ranges.length 1 !ranges[1].isEmpty()) { end Long.parseLong(ranges[1]); } long contentLength end - start 1; // 4. 构建206响应 return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .header(HttpHeaders.CONTENT_TYPE, Files.probeContentType(filePath)) .header(HttpHeaders.ACCEPT_RANGES, bytes) .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(contentLength)) .header(HttpHeaders.CONTENT_RANGE, bytes start - end / fileLength) .body(resource); } else { // 5. 普通完整下载 HttpHeaders headers new HttpHeaders(); headers.add(HttpHeaders.CONTENT_DISPOSITION, attachment; filename\ file.getName() \); headers.add(HttpHeaders.CONTENT_TYPE, Files.probeContentType(filePath)); headers.add(HttpHeaders.CONTENT_LENGTH, String.valueOf(fileLength)); headers.add(HttpHeaders.ACCEPT_RANGES, bytes); // 告知客户端支持断点续传 return new ResponseEntity(resource, headers, HttpStatus.OK); } } }关键点解析HttpHeaders.ACCEPT_RANGES在普通下载响应中也加上此头部告知客户端本资源支持范围请求。Range头解析实际生产环境需要处理更复杂的范围定义如多范围请求bytes0-50, 100-150但大多数客户端如浏览器、标准下载工具使用单范围。Content-Range在206响应中必须准确返回本次响应的字节范围以及文件总大小。注意文件路径安全上述代码中直接拼接文件名是危险的存在目录遍历漏洞。生产环境必须对filename参数进行严格的校验和净化或使用数据库记录的文件存储路径。3.3 上传断点续传实现上传的实现更为复杂需要设计一套完整的状态管理API。3.3.1 定义数据模型与状态首先定义分片信息和上传任务状态。// UploadTask.java Data public class UploadTask { // 上传任务唯一ID由服务端生成 private String uploadId; // 文件唯一标识如前端计算的MD5 private String fileIdentifier; // 原始文件名 private String originalFilename; // 文件总大小 private Long totalSize; // 分片大小 private Integer chunkSize; // 总分片数 private Integer totalChunks; // 已上传成功的分片索引集合如 [0, 1, 3] private SetInteger uploadedChunks new ConcurrentSkipListSet(); // 任务创建时间 private LocalDateTime createTime; // 文件最终存储路径在所有分片合并后设置 private String storedPath; } // ChunkUploadRequest.java Data public class ChunkUploadRequest { // 上传任务ID private String uploadId; // 当前分片索引从0开始 private Integer chunkNumber; // 总分片数 private Integer totalChunks; // 当前分片大小 private Long currentChunkSize; // 文件总大小 private Long totalSize; // 文件标识MD5 private String identifier; // 文件名 private String filename; // 分片二进制数据也可用 RequestPart(file) MultipartFile 接收 private MultipartFile file; }3.3.2 核心控制器实现RestController RequestMapping(/upload) Slf4j public class UploadController { Autowired private UploadTaskService uploadTaskService; Value(${app.upload.dir:/tmp/uploads}) private String uploadBaseDir; // 1. 初始化上传任务 PostMapping(/init) public ResponseEntity? initUploadTask(RequestBody InitUploadRequest request) { // 验证参数fileIdentifier, filename, totalSize, chunkSize UploadTask task uploadTaskService.initTask(request); return ResponseEntity.ok(Collections.singletonMap(uploadId, task.getUploadId())); } // 2. 上传分片 PostMapping(/chunk) public ResponseEntity? uploadChunk(ModelAttribute ChunkUploadRequest request) { try { // 2.1 验证请求合法性uploadId, chunkNumber等 UploadTask task uploadTaskService.getTask(request.getUploadId()); if (task null) { return ResponseEntity.badRequest().body(Invalid uploadId); } // 2.2 检查该分片是否已上传幂等性处理 if (task.getUploadedChunks().contains(request.getChunkNumber())) { log.info(Chunk {} already uploaded for task {}, request.getChunkNumber(), request.getUploadId()); return ResponseEntity.ok().body(Chunk already uploaded); } // 2.3 存储分片文件 String chunkFileName String.format(%s_%d.part, request.getUploadId(), request.getChunkNumber()); Path chunkPath Paths.get(uploadBaseDir, chunks, chunkFileName); Files.createDirectories(chunkPath.getParent()); request.getFile().transferTo(chunkPath.toFile()); // 2.4 更新任务状态记录该分片已上传完成 uploadTaskService.recordChunkUploaded(request.getUploadId(), request.getChunkNumber()); // 2.5 检查是否所有分片都已上传完成 if (task.getUploadedChunks().size() task.getTotalChunks()) { // 触发异步合并 uploadTaskService.mergeChunksAsync(task); } return ResponseEntity.ok().build(); } catch (IOException e) { log.error(Failed to upload chunk, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } // 3. 查询上传进度用于续传前查询 GetMapping(/progress/{uploadId}) public ResponseEntity? getUploadProgress(PathVariable String uploadId) { UploadTask task uploadTaskService.getTask(uploadId); if (task null) { return ResponseEntity.notFound().build(); } MapString, Object progress new HashMap(); progress.put(uploadedChunks, new ArrayList(task.getUploadedChunks())); progress.put(totalChunks, task.getTotalChunks()); progress.put(isComplete, task.getUploadedChunks().size() task.getTotalChunks()); return ResponseEntity.ok(progress); } }3.3.3 分片合并服务这是上传流程的最后一步也是最容易出错的环节。Service Slf4j public class UploadTaskService { Autowired private RedisTemplateString, Object redisTemplate; // 用于存储UploadTask状态 Value(${app.upload.dir:/tmp/uploads}) private String uploadBaseDir; private static final String TASK_KEY_PREFIX upload:task:; public UploadTask initTask(InitUploadRequest request) { String uploadId UUID.randomUUID().toString().replace(-, ); UploadTask task new UploadTask(); task.setUploadId(uploadId); task.setFileIdentifier(request.getFileIdentifier()); task.setOriginalFilename(request.getFilename()); task.setTotalSize(request.getTotalSize()); task.setChunkSize(request.getChunkSize()); task.setTotalChunks((int) Math.ceil((double) request.getTotalSize() / request.getChunkSize())); task.setCreateTime(LocalDateTime.now()); // 存储到Redis设置过期时间如24小时 redisTemplate.opsForValue().set(TASK_KEY_PREFIX uploadId, task, 24, TimeUnit.HOURS); return task; } public void recordChunkUploaded(String uploadId, Integer chunkNumber) { String key TASK_KEY_PREFIX uploadId; redisTemplate.execute((RedisCallbackVoid) connection - { // 使用Redis的Set数据结构存储已上传的分片索引 connection.sAdd(key.getBytes(), String.valueOf(chunkNumber).getBytes()); // 续期 connection.expire(key.getBytes(), TimeUnit.HOURS.toSeconds(24)); return null; }); } Async // 异步执行避免阻塞上传请求 public void mergeChunksAsync(UploadTask task) { log.info(Start merging chunks for task: {}, task.getUploadId()); Path chunkDir Paths.get(uploadBaseDir, chunks); Path finalFilePath Paths.get(uploadBaseDir, files, task.getFileIdentifier() _ task.getOriginalFilename()); try { Files.createDirectories(finalFilePath.getParent()); try (OutputStream finalFileOs new BufferedOutputStream(Files.newOutputStream(finalFilePath, StandardOpenOption.CREATE, StandardOpenOption.WRITE))) { // 按分片索引顺序合并 for (int i 0; i task.getTotalChunks(); i) { Path chunkPath chunkDir.resolve(String.format(%s_%d.part, task.getUploadId(), i)); if (!Files.exists(chunkPath)) { log.error(Missing chunk file: {} for task {}, i, task.getUploadId()); // 处理缺失分片理论上不应该发生因为合并前已校验 throw new IOException(Missing chunk: i); } Files.copy(chunkPath, finalFileOs); // 可选合并后删除分片文件以释放空间 Files.deleteIfExists(chunkPath); } finalFileOs.flush(); } task.setStoredPath(finalFilePath.toString()); // 更新任务状态为完成并持久化最终文件信息 redisTemplate.opsForValue().set(TASK_KEY_PREFIX task.getUploadId(), task, 1, TimeUnit.HOURS); // 完成后缩短过期时间 log.info(File merged successfully for task: {}, path: {}, task.getUploadId(), finalFilePath); } catch (IOException e) { log.error(Failed to merge chunks for task: task.getUploadId(), e); // 清理可能已部分创建的文件 try { Files.deleteIfExists(finalFilePath); } catch (IOException ex) { log.warn(Cleanup failed, ex); } } } }实操心得分片存储策略分片文件命名最好包含uploadId避免不同任务的分片互相干扰。存储路径也应隔离。合并顺序必须严格按照chunkNumber的顺序合并否则文件会损坏。使用ConcurrentSkipListSet存储已上传分片索引可以方便地获取有序列表。异步合并合并文件是IO密集型操作尤其对于大文件耗时较长。一定要使用异步方式如Async、消息队列执行避免阻塞HTTP请求线程。状态持久化这里用Redis做示例因为它读写快且支持丰富的数据结构如Set。生产环境需要考虑Redis持久化策略或者将最终状态落盘到数据库。任务状态必须设置合理的过期时间避免垃圾数据堆积。幂等性uploadChunk接口必须实现幂等。客户端可能因网络超时重传同一个分片服务端通过检查uploadedChunks集合来避免重复存储和处理。4. 客户端实现要点与配合服务端准备好了客户端如Web前端、Java客户端、移动端也需要相应配合。4.1 前端JavaScript实现要点现代前端通常使用File API的Blob.prototype.slice方法来切割文件。// 计算文件MD5使用spark-md5等库作为fileIdentifier async function calculateFileHash(file) { // ... 返回文件hash } // 初始化上传 async function initUpload(file) { const fileHash await calculateFileHash(file); const chunkSize 5 * 1024 * 1024; // 5MB const totalChunks Math.ceil(file.size / chunkSize); const response await fetch(/upload/init, { method: POST, body: JSON.stringify({ fileIdentifier: fileHash, filename: file.name, totalSize: file.size, chunkSize: chunkSize }), headers: { Content-Type: application/json } }); const { uploadId } await response.json(); return { uploadId, totalChunks, chunkSize }; } // 上传分片 async function uploadChunk(uploadId, chunkNumber, chunkBlob, totalChunks) { const formData new FormData(); formData.append(uploadId, uploadId); formData.append(chunkNumber, chunkNumber); formData.append(totalChunks, totalChunks); formData.append(file, chunkBlob); // 切割后的Blob await fetch(/upload/chunk, { method: POST, body: formData // 注意不要设置Content-Type浏览器会自动设置multipart/form-data }); } // 主上传逻辑支持暂停、续传 async function uploadFile(file) { const { uploadId, totalChunks, chunkSize } await initUpload(file); let uploadedChunks new Set(); // 续传先查询已上传的分片 const progressResp await fetch(/upload/progress/${uploadId}); const progress await progressResp.json(); uploadedChunks new Set(progress.uploadedChunks); for (let i 0; i totalChunks; i) { if (uploadedChunks.has(i)) { console.log(Chunk ${i} already uploaded, skipping.); continue; // 跳过已上传的 } const start i * chunkSize; const end Math.min(file.size, start chunkSize); const chunkBlob file.slice(start, end); await uploadChunk(uploadId, i, chunkBlob, totalChunks); // 更新本地进度可用于UI显示 } // 所有分片上传完成后可以通知服务端合并或服务端自动检测合并 }4.2 Java客户端实现要点对于Java客户端如使用HttpClient核心也是分片读取和上传。public class ResumableUploadClient { private final CloseableHttpClient httpClient HttpClients.createDefault(); public void uploadFile(Path filePath, String serverUrl) throws IOException { String fileHash calculateFileHash(filePath); // 实现略 long fileSize Files.size(filePath); int chunkSize 5 * 1024 * 1024; // 5MB long totalChunks (fileSize chunkSize - 1) / chunkSize; // 1. 初始化 String uploadId initUpload(serverUrl, fileHash, filePath.getFileName().toString(), fileSize, chunkSize); // 2. 查询进度用于续传 SetInteger uploadedChunks queryProgress(serverUrl, uploadId); // 3. 分片上传 try (RandomAccessFile raf new RandomAccessFile(filePath.toFile(), r)) { byte[] buffer new byte[chunkSize]; for (int i 0; i totalChunks; i) { if (uploadedChunks.contains(i)) { continue; } raf.seek((long) i * chunkSize); int bytesRead raf.read(buffer); if (bytesRead -1) break; // 构建Multipart请求 HttpPost post new HttpPost(serverUrl /upload/chunk); MultipartEntityBuilder builder MultipartEntityBuilder.create(); builder.addTextBody(uploadId, uploadId); builder.addTextBody(chunkNumber, String.valueOf(i)); builder.addTextBody(totalChunks, String.valueOf(totalChunks)); // 注意只发送实际读取的字节 builder.addBinaryBody(file, buffer, 0, bytesRead, ContentType.DEFAULT_BINARY, chunk); post.setEntity(builder.build()); try (CloseableHttpResponse response httpClient.execute(post)) { if (response.getStatusLine().getStatusCode() ! 200) { // 处理错误可能需要重试 } } } } // 4. 所有分片上传完成服务端应自动合并 } }5. 生产环境进阶考量与问题排查一个基础的断点续传服务跑起来后要投入生产环境还需要考虑更多。5.1 分布式环境下的挑战上述单服务示例在分布式部署时会遇到问题用户请求可能被负载均衡到不同的服务器实例而分片文件存储在本地磁盘其他实例无法访问。解决方案共享存储使用分布式文件系统如MinIO、Ceph、阿里云OSS或对象存储服务来存储分片文件和最终文件。所有服务实例都向同一个存储空间读写。集中式状态管理上传任务状态必须存储在中心化的存储中如Redis集群或数据库确保所有实例都能访问到一致的状态。5.2 安全性增强身份认证与授权所有上传/下载接口必须集成认证如JWT确保用户只能操作自己的文件。文件校验分片校验客户端上传分片时可以附带分片的MD5或CRC32校验码服务端接收后立即校验确保数据传输无误。整体校验在所有分片合并成最终文件后计算整个文件的哈希值与客户端最初提供的fileIdentifier进行比对。不一致则说明合并过程或传输过程有误需要清理并让客户端重传。防恶意攻击限制单个文件大小、总上传大小、分片大小范围防止资源耗尽攻击。5.3 性能优化并发上传客户端可以并发上传多个分片如3-5个充分利用带宽。但服务端需要处理好并发写入和状态更新的线程安全问题。分片大小选择分片太小请求次数过多 overhead大分片太大单次失败重传成本高。通常选择1MB到10MB之间需要根据平均网络状况和服务器性能权衡。使用零拷贝技术在服务端合并文件时如果使用Java NIO的FileChannel.transferTo方法可以减少内核态与用户态之间的数据拷贝提升大文件合并效率。5.4 常见问题排查实录问题客户端报告上传成功但最终合并的文件损坏或无法打开。排查检查分片合并顺序是否正确。检查客户端分片切割逻辑和服务端接收逻辑是否一致特别是最后一个分片的大小。在服务端合并前校验每个分片的哈希值。查看服务端日志确认是否所有分片都成功写入磁盘没有IO错误。问题续传时服务端返回的已上传分片列表不全导致重复上传。排查检查状态存储如Redis的持久化策略。如果是Redis是否配置了RDB/AOF是否发生了重启导致数据丢失检查记录分片上传成功的方法是否是原子操作。在高并发下非原子操作可能导致状态记录失败。检查Redis键的过期时间设置是否合理是否在任务完成前就过期了。问题上传大文件时服务端内存溢出OOM。排查检查Spring Boot的MultipartFile配置spring.servlet.multipart.max-file-size,max-request-size。确保其值足够大但更重要的是大文件上传不应该一次性读入内存。确保在接收分片时使用transferTo方法将数据直接流式写入磁盘文件而不是通过getBytes()等方法将整个分片读入内存。检查文件合并时的内存使用避免将整个文件读入内存再写入。问题下载断点续传时客户端收到的Content-Range不正确或206响应被当成200。排查使用Postman或curl工具手动测试Range请求检查服务端返回的Content-Range头格式是否正确bytes start-end/total。确保在响应206时Content-Length是本次返回的分片长度而不是文件总长度。检查Servlet容器如Tomcat的版本和配置某些旧版本或特定配置可能对Range请求支持不完善。实现一个健壮的断点续传服务是对后端开发者综合能力的一次很好锻炼。它要求你不仅理解HTTP协议还要处理好文件IO、并发、状态一致性和分布式架构等问题。从最简单的单机版本开始逐步考虑分布式存储、安全、性能监控和容错你会在这个过程中积累下非常宝贵的实战经验。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻