干后台开发的,基本都躲不过大文件上传这个坑。文件一旦上了七八百兆甚至几个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/register | fileName、fileSize、chunkSize | uploadId、chunkTotal、chunkSize |
| 上传单个分片 | POST /upload/chunk | uploadId、chunkIndex、file | 是否成功 |
| 查询已上传分片 | GET /upload/chunks | uploadId | 已上传的chunkIndex集合 |
| 合并文件 | POST /upload/merge | uploadId | 合并结果、最终文件信息 |
这里单独说一下为什么要有“预创建”这一步。有些方案是客户端不注册,直接传分片,服务端看到新uploadId就自动建任务。这样也能用,但不利于做合法性校验和任务管理。预创建时服务端可以先校验磁盘空间、文件类型、后缀名,提前拒绝明显不合理的上传,避免后面传了一堆分片才发现不允许传,白白消耗带宽和存储。
3.2 数据表结构和存储目录约定
上传任务记录表,我习惯命名为tb_upload_file:
| 字段 | 类型 | 说明 |
|---|---|---|
| upload_id | varchar(64) | 主键,预创建时生成 |
| file_name | varchar(255) | 原始文件名,存的时候要对路径字符做过滤 |
| file_size | bigint | 文件总字节数 |
| chunk_size | bigint | 分片字节数 |
| chunk_total | int | 分片总数 |
| file_md5 | varchar(32) | 客户端提供的文件MD5,可能为空 |
| status | varchar(20) | INIT、UPLOADING、MERGING、FINISH、FAILED |
| create_time | datetime | 创建时间 |
| update_time | datetime | 更新时间 |
分片上传状态表tb_upload_chunk:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 自增主键 |
| upload_id | varchar(64) | 关联上传任务 |
| chunk_index | int | 分片序号 |
| chunk_size | bigint | 实际分片字节数 |
| uploaded | tinyint | 是否已上传 |
| upload_time | datetime | 上传完成时间 |
(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文件可能因为磁盘突然写满或者进程被杀而长度不对。查询接口合并文件前,一定要以“磁盘文件实际大小”为最终判断依据,数据库记录只作为加速过滤的手段,不能作为唯一标准。
最后再分享一点实际体会
这一套分片上传方案做完后,最大的感受是:真正的难点不在怎么写接口,而在怎么把状态管理、幂等、并发、异常兜底这些“脏活”想清楚。刚开始做的时候,我只关注了正常流程,结果测试阶段连续踩了最后一片大小算错、乱序合并、临时文件不清理好几个坑。后来把接口协议固定下来,数据表加唯一约束,查询接口严格校验文件大小,整个系统才稳定下来。
如果你也是刚接触断点续传,建议先不要急着搞分布式、多节点这些东西,把单体应用的分片状态模型、幂等上传、按序合并这三个基础能力做扎实,后面加并发、加集群都会轻松很多。这个方案是典型的“看着复杂,拆开不复杂”,分片、状态、合并三块各管一摊,每一块都不难,拼起来就是一套能扛生产流量的大文件上传能力。