☰
Java实现大文件分片上传与断点续传:原理、代码与踩坑实战
2026/10/16 12:47:01 网站建设 项目流程

干后台开发的,基本都躲不过大文件上传这个坑。文件一旦上了七八百兆甚至几个G,网络稍微抖一下,传输就断了,用户只能重新选文件从零开始传,搁谁谁都崩溃。去年我在公司接手一个内部文件管理平台的上传模块,面对的第一个硬需求就是:支持GB级文件上传,网络中断后必须能续传,而不是重传。今天就把Java实现断点续传和分片上传的原理、设计、代码和踩坑过程完整写出来。

这篇文章默认你在后端用Java、Spring Boot这套体系,但分片和断点的思路是通用的,前端用什么框架都不影响。我会先讲清原理,再给完整的接口协议和核心代码,最后把并发、异常、清理这些生产环境必须面对的问题一起梳理掉。

1. 为什么大文件上传必须做分片和断点续传

1.1 一次性上传的致命短板

很多人一开始觉得上传文件很简单,一个MultipartFile接口接收完事。文件小的时候确实没问题,一旦到几百MB甚至几个GB,一次性上传就会暴露出三个很现实的问题。

第一个是请求体过长导致容器超时。后端从网络层读取完整个请求体需要很长时间,Spring MVC虽然有异步化手段,但一次请求从建立到读完数据,中间任何一个网络节点断开,整个请求就直接失败。更麻烦的是,很多网关和负载均衡设备对单请求体大小和时长都有隐性限制,文件一大还没到业务层就被挡回去了。

第二个是失败成本极高。一次性上传失败后,之前传的所有数据全部作废。用户只能重新选择文件再传一遍,如果文件有2个G,断一次就白传一次,这种体验放到任何B端产品里都算是事故级别的问题。

第三个是服务器内存压力。虽然Spring的MultipartFile默认会把内容落盘到临时目录,但很多初学项目喜欢搞自定义解析,把整个文件读进byte[]再处理,几个GB的文件直接OOM。就算正常落盘,一次性写一个大文件的临时文件,对磁盘IO和后续处理的压力也比分片大得多。

1.2 分片是手段,断点是目标

分片上传和断点续传经常被放在一起说,但两者的关系需要理清楚:分片是为了实现断点续传而采取的技术手段。

把一个大文件按固定大小切成若干个小块,每一块单独上传。上传完的块由服务端记录状态。网络中断后,客户端只需要查询服务端“哪些块还没传”,然后只补传缺失的块,而不是重传整个文件。这就是断点续传的本质。

采用分片方案后,好处不止是能续传。单个分片请求的网络耗时短,超时概率大幅下降;服务端接收单分片时占用资源很少,天然支持并发上传,多个分片可以同时传,整体速度反而比单文件上传更快;某个分片偶发失败也只需要重试这一片,容错粒度细了很多。

分片大小怎么定?我一般建议按文件大小分档。文件在100MB以内,分片可以设成2MB;100MB到1GB之间,用5MB;超过1GB,可以用10MB甚至20MB。分片太小时请求数量会爆炸,每次请求都有固定开销;分片太大又失去了容错粒度。实践下来5MB是一个比较均衡的默认值。

2. 断点续传原理拆解:核心概念与关键设计

2.1 分片切割与上传标识

分片的切割在最前端完成。以浏览器场景为例,File对象支持slice(start, end)方法,直接按字节区间取出一段Blob,这就是一个分片。一个2GB文件切成5MB一份,总共409个分片。

每个分片必须能独立标识,否则服务端没法记录“哪个片传过了”。我常用的标识组合是uploadId + chunkIndex。uploadId是这个上传任务在服务端的唯一编号,chunkIndex是这个分片在整个文件中的序号,从0开始递增。

这里有几个容易踩的坑:

  • 分片大小固定时,最后一片大小可能小于chunkSize,这是正常的,按实际大小上传即可,不能丢掉。
  • chunkTotal的计算必须用向上取整:(fileSize + chunkSize - 1) / chunkSize,或者Math.ceil((double)fileSize / chunkSize),否则最后一个分片会被遗漏。
  • 客户端断点续传时,需要把本地的分片状态和服务端对齐,不能只信自己本地的记录。因为可能上次在别的设备上已经传过一部分,或者服务端清理过旧任务。

2.2 服务端状态记录与冲突处理

服务端要支持断点续传,光会保存分片文件还不够,必须维护一份“分片上传状态表”。每条记录至少包含:uploadId、chunkIndex、chunkSize、是否已上传、上传时间。

客户端每次续传前,调用查询接口拿到“已上传分片下标集合”,然后只上传缺失的部分。这里有一个必须注意的细节:判断一个分片是否已上传,不能只靠表格里的布尔值,还要校验磁盘上分片文件的真实大小。

为什么?因为分片上传过程中如果发生网络中断,服务端可能已经创建了文件但写入不完整,或者写入了部分数据就抛异常了。如果这时状态表里标记的是“已上传”,后面合并时就会出现文件损坏。稳妥做法是:查询已上传分片时,同时读分片文件的大小,和预期大小不一致就视为未完成,允许重新上传覆盖。

另外,同一个分片在上传中可能被重复提交。并发场景下两个请求同时写同一个part文件,后写的可能覆盖先写的,如果文件大小一致且内容一样,结果还算安全;但如果内容不一致,会留下一个损坏的分片。解决办法是在分片状态表上建立(uploadId, chunkIndex)唯一索引,每次上传前先查、再标记、再写文件,保证幂等。

2.3 文件合并与完整性校验

当所有分片都上传完成后,服务端需要把所有part文件按chunkIndex顺序合并成完整文件。合并的方式有两种:

第一种是按顺序读取所有分片文件,用FileOutputStream或RandomAccessFile写到最终目标文件里。这种方式实现简单,磁盘IO是顺序读、顺序写,性能不错,也是我推荐的方式。

第二种是使用RandomAccessFile,直接把每个分片seek到文件对应的偏移量写入。这种方式可以支持乱序合并,但并发写同一个文件时容易出错,而且复杂很多。分片上传本来就是顺序编号,合并时老老实实按顺序读更好。

合并完成后,最好做一次完整性校验。我常用的校验是文件大小对比和MD5对比两种。大小对比简单但有局限,两个内容不同但大小相同的文件校验不出来。MD5对比可靠,但计算一个GB级文件也需要一点时间。折中方案:合并后先比较最终文件大小和原始fileSize是否一致,如果一致再异步计算MD5和客户端传入的fileMd5对比。校验不通过就把文件标记为失败,保留临时分片供客户端重传差异部分,不要直接删掉临时数据。

3. 接口与存储设计:动手之前先把协议定清楚

3.1 上传流程与接口规划

我在设计上传模块时,把整个流程拆成四个步骤,对应四类接口:预创建、上传分片、查询已传分片、合并文件。这样每一步职责单一,前端和后端联调时也特别好对齐。

整个流程是这样的:客户端先调预创建接口,传入文件名、总大小、分片大小,服务端生成uploadId返回;客户端拿到uploadId后,把文件切片逐片上传;如果中间断了,客户端调查询接口拿到已上传分片列表,补传缺失分片;全部传完后调合并接口,服务端把分片合成完整文件。

接口方法核心参数返回内容
预创建上传任务POST /upload/registerfileName、fileSize、chunkSizeuploadId、chunkTotal、chunkSize
上传单个分片POST /upload/chunkuploadId、chunkIndex、file是否成功
查询已上传分片GET /upload/chunksuploadId已上传的chunkIndex集合
合并文件POST /upload/mergeuploadId合并结果、最终文件信息

这里单独说一下为什么要有“预创建”这一步。有些方案是客户端不注册,直接传分片,服务端看到新uploadId就自动建任务。这样也能用,但不利于做合法性校验和任务管理。预创建时服务端可以先校验磁盘空间、文件类型、后缀名,提前拒绝明显不合理的上传,避免后面传了一堆分片才发现不允许传,白白消耗带宽和存储。

3.2 数据表结构和存储目录约定

上传任务记录表,我习惯命名为tb_upload_file:

字段类型说明
upload_idvarchar(64)主键,预创建时生成
file_namevarchar(255)原始文件名,存的时候要对路径字符做过滤
file_sizebigint文件总字节数
chunk_sizebigint分片字节数
chunk_totalint分片总数
file_md5varchar(32)客户端提供的文件MD5,可能为空
statusvarchar(20)INIT、UPLOADING、MERGING、FINISH、FAILED
create_timedatetime创建时间
update_timedatetime更新时间

分片上传状态表tb_upload_chunk:

字段类型说明
idbigint自增主键
upload_idvarchar(64)关联上传任务
chunk_indexint分片序号
chunk_sizebigint实际分片字节数
uploadedtinyint是否已上传
upload_timedatetime上传完成时间

(upload_id, chunk_index)必须建唯一索引,这是防重复上传的关键约束。

磁盘目录我这样规划:

/storage/upload/ temp/ {uploadId}/ 0.part 1.part ... final/ {uploadId}_{fileName}

temp目录放分片临时文件,final目录放合并后的最终文件。把临时和最终目录分开,后面做定时清理、备份、迁移都会方便很多。final目录下的文件名我建议用uploadId_原始文件名,否则不同用户上传同名的report.pdf会互相覆盖。

3.3 为什么不用HTTP Range而自建分片协议

有些人会问,HTTP协议本身有Range请求头,服务端支持206 Partial Content,为什么不直接用Range来实现断点续传?这个问题值得说清楚。

HTTP Range主要用于“服务端向客户端传输文件”时的断点下载,比如下载一个2GB文件,本地已经下了1GB,带上Range: bytes=1024请求后面的部分。但“客户端向服务端上传文件”时,HTTP协议没有一个被广泛支持的“Upload Range”标准。虽然可以在服务端手写RandomAccessFile按偏移量接收数据,但这属于自定义行为,兼容性、幂等性、多分片并发都要自己处理。

更重要的一点是:Range续传只解决了“偏移量”问题,没有解决“状态管理”问题。你中断了继续传,服务端怎么知道你已经传了哪些部分?还是要自己记录。所以与其半吊子用Range,不如完整地把分片上传状态模型设计好。分片协议看上去多几个接口,实际上把问题拆得清清楚楚,后续扩展秒传、并发上传、任务清理都容易。

4. Java服务端代码实现:从接收分片到合并落盘

4.1 项目基础配置

我用的是Spring Boot项目,JDK 8以上,只需要引入spring-boot-starter-web依赖就够了。数据库层我这里用简单的仓储接口代替,实际项目换成MyBatis-Plus或Spring Data JPA都行,核心逻辑不变。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>

application.yml里有两个配置必须调,否则分片稍微大一点就会被容器拒绝:

spring: servlet: multipart: max-file-size: 50MB max-request-size: 200MB

这里max-file-size是单个文件上限,不能小于你的chunkSize;max-request-size是单次请求体总上限,因为一个分片请求里通常只包含一个分片,一般设为max-file-size的几倍就够。如果并发上传某个分片时还携带了其他业务参数导致请求体偏大,这个值要相应调大。

4.2 预创建上传任务

先定义两个实体类,一个表示上传任务,一个表示分片记录。字段和前面表结构对应,这里省略getter/setter。

public class UploadFileRecord { private String uploadId; private String fileName; private long fileSize; private long chunkSize; private int chunkTotal; private String fileMd5; private String status; } public class UploadChunkRecord { private String uploadId; private int chunkIndex; private long chunkSize; private boolean uploaded; }

仓储接口按需定义:保存任务、查询任务、保存分片记录、查询已上传分片列表、更新任务状态。这些都是数据库操作。

预创建接口实现:

@PostMapping("/upload/register") public Result register(@RequestParam String fileName, @RequestParam Long fileSize, @RequestParam(defaultValue = "5242880") Long chunkSize) { String safeName = sanitizeFileName(fileName); int chunkTotal = (int) Math.ceil((double) fileSize / chunkSize); String uploadId = UUID.randomUUID().toString().replace("-", ""); UploadFileRecord record = new UploadFileRecord(); record.setUploadId(uploadId); record.setFileName(safeName); record.setFileSize(fileSize); record.setChunkSize(chunkSize); record.setChunkTotal(chunkTotal); record.setStatus("INIT"); uploadRepository.save(record); return Result.ok(new RegisterResp(uploadId, chunkTotal, chunkSize)); }

注意Math.ceil那段,如果fileSize是0或者直接整除,要处理好边界。文件大小必须大于0,否则直接抛参数异常。chunkTotal应该至少为1。

sanitizeFileName必须做,不然用户传一个../../etc/passwd或者包含/和\的文件名,合并时拼到路径里就是路径穿越漏洞。我一般只保留文件名部分,把/、\、..、空字符全部替换掉。

4.3 分片上传与状态更新

分片上传是整个流程里最核心的接口。这里要注意几点:先检查任务是否存在;判断分片是否已经上传过;上传时不能直接用MultipartFile.transferTo(),因为它的行为在不同容器里不一致,建议读取输入流自己写文件。

@PostMapping("/upload/chunk") public Result uploadChunk(@RequestParam String uploadId, @RequestParam Integer chunkIndex, @RequestParam MultipartFile file) throws IOException { UploadFileRecord record = uploadRepository.getByUploadId(uploadId); if (record == null) { throw new IllegalArgumentException("uploadId不存在"); } if (chunkIndex < 0 || chunkIndex >= record.getChunkTotal()) { throw new IllegalArgumentException("chunkIndex越界"); } File dir = new File(UPLOAD_TEMP_DIR + uploadId); if (!dir.exists()) { dir.mkdirs(); } File partFile = new File(dir, chunkIndex + ".part"); // 幂等处理:如果已上传且大小正确,直接返回成功 if (partFile.exists()) { if (partFile.length() == file.getSize()) { return Result.ok(); } else { partFile.delete(); } } try (FileOutputStream fos = new FileOutputStream(partFile); InputStream in = file.getInputStream()) { byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { fos.write(buffer, 0, len); } fos.flush(); } // 落库标记 uploadRepository.markChunkUploaded(uploadId, chunkIndex, file.getSize()); return Result.ok(); }

这里我用了标准的读写循环,而不是transferTo。原因有两个:一是transferTo在不同容器里实现不一样,有些依赖临时文件目录,遇到跨盘时会出问题;二是手动读写可以灵活加校验码、日志、限速等逻辑。

分片状态的落库和文件写入不是原子的,如果落库失败,文件已经存在。下一次同样的分片请求进来时,通过part文件存在且大小一致就能跳过,不影响正确性。反过来如果写文件成功但落库成功前服务挂了,重启后查询接口查不到这个分片,客户端会重传一次,正好覆盖旧文件,也没问题。这就是幂等设计的好处。

4.4 查询已上传分片

查询接口主要服务两个场景:客户端刚开始上传前的初始化,以及断网恢复后的补传。返回chunkIndex集合就好,没必要把整个列表都返回,因为客户端只关心“哪些片缺失”。

@GetMapping("/upload/chunks") public Result getUploadedChunks(@RequestParam String uploadId) { UploadFileRecord record = uploadRepository.getByUploadId(uploadId); if (record == null) { throw new IllegalArgumentException("uploadId不存在"); } List<Integer> uploaded = new ArrayList<>(); File dir = new File(UPLOAD_TEMP_DIR + uploadId); if (dir.exists()) { File[] files = dir.listFiles((d, name) -> name.endsWith(".part")); if (files != null) { for (File f : files) { int index = Integer.parseInt(f.getName().replace(".part", "")); long expectedSize = index == record.getChunkTotal() - 1 ? record.getFileSize() - (long) index * record.getChunkSize() : record.getChunkSize(); if (f.length() == expectedSize) { uploaded.add(index); } else { // 分片文件不完整,删除让客户端重传 f.delete(); } } } } Collections.sort(uploaded); return Result.ok(Collections.singletonMap("uploadedChunks", uploaded)); }

这里有一个细节:最后一个分片的预期大小不是chunkSize,而是fileSize - (chunkTotal-1) * chunkSize。如果不单独算最后一片的大小,查询时会把最后一片判定为不完整,导致客户端反复重传最后一片,合并时一直缺失。这个坑我确实踩过,排查了很久才发现是预期大小算错了。

4.5 合并文件与完整性校验

全部传完后,客户端调用合并接口。服务端按顺序把所有part写入最终文件,然后做大小和MD5校验。

@PostMapping("/upload/merge") public Result merge(@RequestParam String uploadId) throws IOException { // 防重复合并的锁处理,见第5节 if (!mergeLock.putIfAbsent(uploadId, true)) { throw new IllegalStateException("合并任务正在执行中"); } try { UploadFileRecord record = uploadRepository.getByUploadId(uploadId); if (record == null) throw new IllegalArgumentException("uploadId不存在"); File dir = new File(UPLOAD_TEMP_DIR + uploadId); if (!dir.exists()) { throw new IllegalStateException("分片数据不存在,请重新上传"); } String finalPath = UPLOAD_FINAL_DIR + uploadId + "_" + record.getFileName(); File finalFile = new File(finalPath); File parent = finalFile.getParentFile(); if (!parent.exists()) parent.mkdirs(); FileOutputStream fos = new FileOutputStream(finalFile); BufferedOutputStream bos = new BufferedOutputStream(fos); for (int i = 0; i < record.getChunkTotal(); i++) { File partFile = new File(dir, i + ".part"); if (!partFile.exists() || partFile.length() == 0) { throw new IllegalStateException("分片缺失:" + i); } try (FileInputStream fis = new FileInputStream(partFile); BufferedInputStream bis = new BufferedInputStream(fis)) { byte[] buffer = new byte[8192]; int len; while ((len = bis.read(buffer)) != -1) { bos.write(buffer, 0, len); } } } bos.flush(); bos.close(); // 大小校验 if (finalFile.length() != record.getFileSize()) { throw new IllegalStateException("合并后文件大小不符"); } // 可选:MD5校验 if (StringUtils.hasText(record.getFileMd5())) { String md5 = md5(finalFile); if (!md5.equalsIgnoreCase(record.getFileMd5())) { throw new IllegalStateException("MD5校验失败"); } } uploadRepository.updateStatus(uploadId, "FINISH"); deleteDir(dir); return Result.ok(finalFile.getAbsolutePath()); } finally { mergeLock.remove(uploadId); } }

合并时我用了BufferedOutputStream包装,并且用8KB的缓冲数组循环读写。不要直接fos.write(bytes),大文件时这样频繁调用系统IO效率很低,实测差距非常明显。合并完再对finalFile.length()做比对,这一步能拦截大部分因为分片错乱、缺失导致的问题。

5. 并发安全、异常修复与实战优化

5.1 重复上传与并发合并的防护

断点续传系统里并发问题很常见,主要是两类:同一个分片重复上传,以及合并和上传同时进行。

同一个分片重复上传,前面的代码里已经做了幂等处理:part文件存在且大小一致就跳过。但数据库层面也要兜底,给tb_upload_chunk表加唯一索引uk_upload_chunk(upload_id, chunk_index),否则并发请求绕过文件判断直接写库时,会出现重复记录。入库时如果冲突,捕获唯一约束异常直接当成功处理。

合并接口的并发防护我在代码里用了一个进程内锁:mergeLock.putIfAbsent(uploadId, true)。同一时间同一个uploadId只能有一个合并任务在跑,避免两个请求同时合并导致文件写入互相覆盖。

如果服务是多节点部署,进程内锁就不够了,需要用分布式锁或者数据库锁。实际做法是:合并前把tb_upload_file.status从UPLOADING改成MERGING,用UPDATE ... WHERE status = 'UPLOADING'语句检查更新行数,只有改成功的节点才能执行合并。这是数据库层面的乐观锁,简单可靠。

5.2 磁盘占用与文件清理策略

分片上传会留下大量临时文件,如果不清理,磁盘很快会被撑爆。我一般设计三个层次的处理。

首先是上传完成后的清理。合并接口在成功或者最终失败时,删除temp/{uploadId}目录下所有part文件。这个操作放在合并成功后执行,如果删失败也没关系,后台兜底清理会处理。

其次是超时任务的清理。总会有用户上传到一半就关掉页面,留下一堆半成品。我跑一个定时任务,每天凌晨扫描temp目录下超过24小时未更新的目录,删除掉。客户端如果回来续传,发现uploadId不存在会提示重新创建任务。这个清理时间的阈值要结合业务调整,用户可能上传大型文件中间隔很久,阈值太短会误删。我一般设24小时,如果业务上允许隔天续传,就放宽到48小时。

最后是普通文件的定期归档。final目录下的完整文件,根据业务需要定期转移到冷存储或者直接删除,这个属于文件生命周期管理,不再赘述。

5.3 大文件合并的性能调优

合并几个GB的文件时,IO效率直接决定结束时间。我用三类优化,实测下来效果很明显。

一类是缓冲。读写都用BufferedInputStream和BufferedOutputStream,缓冲区大小设成8KB到64KB之间。太小频繁调用系统调用,太大占内存且提升有限。我习惯用32KB。

二类是尽量减少磁盘寻道。合并时按顺序读取part文件,输出流只开一个,对目标文件顺序写。如果多线程并发放到同一个目标文件,会在文件不同偏移来回跳,磁盘寻道开销非常大,整体性能不升反降。所以我合并时用的单线程顺序读写。

三类是分片文件的写入方式。上传分片时用FileOutputStream写入part文件,不追加、不随机写,保证每个分片文件的写入是连续的。这样合并时读出来的数据也是紧凑的,不会有多余空洞。

如果需要进一步提速,可以把part文件设计成和最终文件的偏移一一对应,合并时直接用RandomAccessFile把每个part写到对应偏移,避免读取part内容再写到另一个文件的拷贝过程。但这种模式要求每个分片大小精确一致,实现复杂度高,我一般只在大文件超过5GB时才考虑。

6. 断网实测与常见问题排查

6.1 本地模拟断网续传的测试方法

写完代码不能只在理想网络下自测,必须模拟断网续传。我用的方法比较直接:本地起服务,用Java写一个客户端脚本,按分片循环上传,循环中间人为Thread.sleep后直接System.exit,模拟“传了一半程序挂了”。然后把未传的分片列表打出来,再启动程序,只传缺失分片,最后合并。

更接近浏览器场景的测试:打开浏览器开发者工具,在Network面板把网络切换成Slow 3G,上传过程中直接切到离线模式,再切回在线,这时前端的续传逻辑应该能自动恢复。如果前端没有自动恢复逻辑,至少也在刷新页面后保留uploadId,让用户手动点击继续。

测试关注点有三个:恢复后已上传分片列表是否正确;缺失分片补传后合出来的文件内容是否和原始文件一致;合并期间再次断网会不会产生脏文件。这三个点都通过,基本可以放心上线。

6.2 高频问题诊断速查表

问题现象可能原因解决办法
上传分片后文件大小为0容器单文件上传大小配置太小,请求被拒绝调大spring.servlet.multipart.max-file-size和max-request-size
合并后文件比原始文件小有分片缺失,或最后一片大小计算错误检查查询接口对最后一片的预期大小处理
合并后文件能打开但内容乱码分片顺序错乱确认所有分片线程按chunkIndex上传,合并严格按顺序读
分片文件在磁盘上堆成山任务没清理增加定时清理任务,按最后修改时间删除过期目录
重启服务后断点续传全部失效状态信息存在内存中,没有持久化改用数据库存储分片状态
文件名带中文上传后乱码请求参数编码不一致前端encodeURIComponent,后端URLDecoder,或使用POST Body传JSON
同一分片并发上传时报唯一约束错误两个请求同时提交捕获约束冲突异常,按幂等成功处理
合并时报“分片缺失”客户端提前调了合并,还没传完合并接口先校验已传分片数量是否等于总数

这里面我特别提醒一下“分片顺序错乱”的排查。如果客户端并发上传多个分片,网络传输各有快慢,服务端接收顺序和分片序号无关,这没问题。真正的乱码往往出现在合并时没有按索引排序,或者part文件名在排序时用了字符串排序,导致10.part排在2.part前面。写代码时合并循环用int i从0开始按数字递增,不要用listFiles默认的字符串排序。

6.3 较为隐蔽的三个坑

第一个坑是MultipartFile.transferTo()的跨盘问题。transferTo在某些容器实现里依赖Servlet容器自带的临时目录,和业务配置的存储目录可能不在同一个磁盘。跨盘移动时如果底层实现不当,会抛FileAlreadyExistsException或直接复制失败。我改用InputStream手动写文件后,这个问题再没出现过。

第二个坑是文件名里的路径分隔符和特殊字符。我遇到过用户上传文件名带..、/、反斜杠的情况,直接拼到存储路径里会写出临时目录之外的文件。过滤逻辑必须放在预创建接口,并且合并时用的文件名也要用同一份安全文件名,而不是重新取原始文件名。

第三个坑是查询已上传分片时,只查数据库表而忽略了part文件的实际状态。数据库标记已上传,但磁盘上的part文件可能因为磁盘突然写满或者进程被杀而长度不对。查询接口合并文件前,一定要以“磁盘文件实际大小”为最终判断依据,数据库记录只作为加速过滤的手段,不能作为唯一标准。

最后再分享一点实际体会

这一套分片上传方案做完后,最大的感受是:真正的难点不在怎么写接口,而在怎么把状态管理、幂等、并发、异常兜底这些“脏活”想清楚。刚开始做的时候,我只关注了正常流程,结果测试阶段连续踩了最后一片大小算错、乱序合并、临时文件不清理好几个坑。后来把接口协议固定下来,数据表加唯一约束,查询接口严格校验文件大小,整个系统才稳定下来。

如果你也是刚接触断点续传,建议先不要急着搞分布式、多节点这些东西,把单体应用的分片状态模型、幂等上传、按序合并这三个基础能力做扎实,后面加并发、加集群都会轻松很多。这个方案是典型的“看着复杂,拆开不复杂”,分片、状态、合并三块各管一摊,每一块都不难,拼起来就是一套能扛生产流量的大文件上传能力。

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

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

立即咨询