简介:一套基于Java的FastDFS大文件上传与断点续传设计源码,面向需要处理大文件传输场景的Java开发者和学习者,重点解决H5与FastDFS之间的断点续传、秒传及并发锁问题。资源共36个文件,压缩包约563KB,以13个Java文件为主,辅以5个JavaScript、4个FreeMarker模板、3个CSS及若干图片、配置与说明文档,分别对应上传逻辑、前端交互、页面渲染、样式和项目配置。系统涵盖文件上传、处理、存储及Redis文件锁等核心模块,界面友好,适合作为Web开发中高难度上传场景的实战参考。已有705人学习下载,通过该项目可深入理解分片上传、断点续传的实现细节,并掌握Java技术与分布式存储的整合思路,为后续项目开发提供直接可复用的代码基础。
1. 大文件上传:为什么Java工程师都绕不开FastDFS和断点续传
做网盘、视频回放或者WebDAV这类项目时,“上传”是杯底的那一层。单体应用吞下500MB文件,服务器内存先告急,再撞上Nginx的client_max_body_size,整个请求直接挂掉。于是大家开始把文件拆成几MB的分片,一片一片发到后端,后端收齐后再合并成完整文件,最后交给FastDFS这样的分布式文件系统持久化。这就是标题里“基于Java的FastDFS大文件上传与断点续传设计源码”要解决的问题:不是给你一个只能传小文件的上传接口,而是给出一套能传几个GB、能扛网络抖动、能让用户随时暂停续传的完整链路。
我接到类似需求时,最关注的不是“FastDFS怎么连”,而是断点续传的协议怎么设计:前端哪些状态要上报、服务端怎么判断“你已经传过前20片”、合并时怎么保证文件没有被分包打乱。这些细节没想清楚,哪怕FastDFS部署得再稳,上传链路也是脆的。下面我从选型、拆层、代码实现到踩坑,把整套方案摊开讲。
2. 选型与三层拆分:FastDFS大文件上传的总体设计
提到FastDFS,很多人的第一反应是“文件存储很稳、天生支持分布式,对接客户端就能直接用”。真做项目时就会发现,一次HTTP请求塞进一个2GB文件,中间只要断一次网,整个上传就得重来。不是FastDFS不适合大文件,而是它只管最终文件存储,上传过程的可靠性需要业务层自己补。所以要先在架构上把这条链路拆成三层:前端负责切成小分片并并发调度,Java业务层负责任务状态、分片接收、分片合并,FastDFS只做最终文件落地。这套拆法现在已经成了“Java面试题”里大文件上传的常见标答,因为它把难点从“怎么传输”转移到了“怎么调度”。
2.1 为什么不把整个文件直接扔给FastDFS
FastDFS是典型的C/S结构,tracker负责调度,storage负责存储文件。Java客户端在调用上传时,整文件会作为一个数据流推给storage,期间TCP连接一旦断开,通常就得重新传一遍。几百MB的文件重传一次还好,到了几个GB,带宽、时间和用户耐心都撑不住。更现实的问题是,传统浏览器上传大文件会受到网关、代理和服务器超时影响,整文件上传等于把鸡蛋都放在一个请求里。
常见做法是:把大文件按固定大小切片,前端用worker线程做切片和并发上传,后端每收到一片先落盘到本地临时区,再记录分片序号。当“某个uploadId下的分片数量”达到总分片数后,后端才执行合并,把完整文件上传到FastDFS。这里的FastDFS不参与断点续传逻辑,它只接收服务端合并后的成品。断点续传的“记忆点”在任务状态表和分片记录表里,这也是“FastDFS大文件上传”与“普通FastDFS上传”的本质区别。
2.2 分片上传协议:从前端worker切片到服务端合并
先定协议,否则前后端各写各的,联调时全是误会。我一般按下面这套约定:
- chunkSize:每个分片大小,推荐4MB或8MB。太小的分片会增加HTTP请求数量,太大会失去断点续传的粒度,8MB在普通服务器上比较平衡。
- uploadId:服务端在初始化接口创建的唯一任务ID,后续每个分片请求都带上。
- chunkNumber:从1开始的分片序号,与
File.slice产生的顺序一致。 - totalChunks:总分片数,等于
Math.ceil(fileSize / chunkSize)。 - identifier:文件指纹,一般取MD5,用于秒传和断点续传诊断。
用户点击上传后,前端先调用初始化接口,提交文件大小、文件名、MD5、分片大小。服务端返回uploadId和已上传的分片列表。如果MD5已经完成,可秒传;如果只有一部分分片传过,则把缺失分片补上传。这个流程和很多开源框架一致:先问进度,再补碎片,最后促合并。具体到“前端使用worker上传大文件”时,要额外注意并发数量控制,不然服务端临时文件句柄会被瞬间打满。
2.3 任务表、分片记录表和FastDFS元数据:建表不是随便建
服务端要可靠地完成断点续传,只靠Redis存状态是不够的。FastDFS存放最终文件,但中间状态“哪些分片传过、是否合并、对应storagePath是什么”需要落在关系型数据库里。下面这张上传任务表是我常用的一种:
CREATE TABLE t_file_task ( id BIGINT AUTO_INCREMENT PRIMARY KEY, upload_id VARCHAR(64) NOT NULL, file_md5 VARCHAR(64) NOT NULL, file_name VARCHAR(255) NOT NULL, file_size BIGINT NOT NULL, chunk_size INT NOT NULL, total_chunks INT NOT NULL, storage_path VARCHAR(255), status TINYINT NOT NULL DEFAULT 0 COMMENT '0初始化 1上传中 2合并中 3完成 4失败', create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_upload_id (upload_id), KEY idx_md5 (file_md5, status) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;分片记录单独一张表,更容易跟踪,也方便服务重启后恢复。我会加一张t_chunk_record:
CREATE TABLE t_chunk_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, upload_id VARCHAR(64) NOT NULL, chunk_no INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0未上传 1已上传', UNIQUE KEY uk_upload_chunk (upload_id, chunk_no) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;如果你用的是MyBatis-Plus,可以直接根据Java实体类生成建表SQL,但自动生成的SQL不会带上唯一索引和字段注释,这两张表里的uk_upload_id、uk_upload_chunk是需要手动补的关键。上传中接口大概只有三个:POST /api/upload/init、POST /api/upload/chunk、GET /api/upload/uploadedChunks。这三个接口就能覆盖vue-simple-uploader默认的校验和分片上传行为。初始化接口返回的信息长这样:
{ "uploadId": "a1b2c3d4e5f6", "fileSize": 536870912, "chunkSize": 8388608, "totalChunks": 64, "uploadedChunks": [1, 2, 3, 4, 5, 6, 7], "finished": false }前端拿到uploadedChunks,会先执行continueUpload,跳过已经传完的分片,这就是断点续传的第一道门。后面所有并发、校验和合并操作,都围绕这张表展开。
3. 服务端核心实现:从接分片、合并文件到FastDFS转存
三层结构定下来之后,最重的是Java服务端这一层。它要接收分片、写临时文件、校验完整性,最后把完整文件推给FastDFS。下面我按Spring Boot项目为例,讲三个核心段落的实现方式。
3.1 初始化接口:创建任务并返回已传分片
初始化接口不能只生成uploadId,要同时处理秒传、任务复用和已传分片回显。Controller层收前端参数,Service层写业务逻辑。
@RestController @RequestMapping("/api/upload") public class UploadController { private final UploadService uploadService; public UploadController(UploadService uploadService) { this.uploadService = uploadService; } @PostMapping("/init") public Result<UploadInitVO> init(@RequestBody UploadInitRequest request) { // 入参包含 fileMd5、fileName、fileSize、chunkSize UploadInitVO vo = uploadService.initTask( request.getFileMd5(), request.getFileName(), request.getFileSize(), request.getChunkSize()); if (vo.isFinished()) { // 秒传:前端不再走分片流程 return Result.ok(vo); } // 返回已上传分片列表,前端据此跳过已完成部分 vo.setUploadedChunks(uploadService.getUploadedChunks(vo.getUploadId())); return Result.ok(vo); } }Service层逻辑注意两个点:一是按file_md5 + file_name查到已完成任务时,直接返回finished;二是查到“上传中”的历史任务时,复用原uploadId,防止前端重复创建任务。getUploadedChunks查询t_chunk_record里status=1的记录,组装成List<Integer>。上传中任务超过一定时间没更新,应该允许前端重新初始化并把旧任务标记为过期,否则临时文件会占用大量磁盘。
参数说明:uploadId这里我用UUID去掉横杠后的字符串,方便做数据库索引和Redis key;chunkSize来自前端,但后端必须做上限校验,比如不允许超过16MB,避免有客户端恶意构造超大分片。另外,初始化接口里不要只依赖前端传的fileName,服务端要重命名最终文件名,防止路径穿越。
3.2 接收分片并落盘:校验、去重、写临时文件
前端把文件切成块后,会并发POST到/api/upload/chunk。每个请求都会带uploadId、chunkNumber和这个分片的二进制内容。服务端不能直接存进FastDFS,必须先落到本地临时目录,等合并后再清理。
@PostMapping("/chunk") public Result<Void> uploadChunk(@RequestParam("file") MultipartFile chunk, @RequestParam("uploadId") String uploadId, @RequestParam("chunkNumber") int chunkNumber) { uploadService.saveChunk(uploadId, chunkNumber, chunk); return Result.ok(); }saveChunk的完整逻辑:
public void saveChunk(String uploadId, int chunkNumber, MultipartFile chunk) throws IOException { FileTask task = taskMapper.selectByUploadId(uploadId); if (task == null || task.getStatus() != 1) { throw new BizException("任务不存在或已合并"); } // 先插分片记录,用唯一索引抢锁,避免并发重复写同一个part文件 int inserted = chunkRecordMapper.insertIgnore(uploadId, chunkNumber); if (inserted == 0) { // 前端重传同一个分片,直接返回成功 return; } // 流式写入临时目录:/data/upload_tmp/{uploadId}/{chunkNumber}.part Path chunkPath = Paths.get(TMP_DIR, uploadId, chunkNumber + ".part"); Files.createDirectories(chunkPath.getParent()); try (InputStream in = chunk.getInputStream()) { Files.copy(in, chunkPath, StandardCopyOption.REPLACE_EXISTING); } catch (Exception e) { chunkRecordMapper.deleteByUploadIdAndChunkNo(uploadId, chunkNumber); throw e; } }这里最关键的是并发问题:多个线程同时收到同一个chunkNumber时,如果不加约束,它们都会发现“分片不存在”,然后同时写同一个part文件,轻则内容错乱,重则文件锁冲突。先插记录、再用唯一索引抢锁,是一种非常轻量的幂等方案。如果插入失败,直接判定分片已上传,跳过文件拷贝。
参数说明:MultipartFile本身是一个临时文件,处理分片时尽量用getInputStream()流式拷贝,不要先用getBytes()把整个分片读进内存,否则8MB的分片在100并发下也会吃掉大量堆内存。TMP_DIR建议使用独立的挂载盘,避免和FastDFS的存储盘互相抢占IO。
3.3 合并分片并上传FastDFS:时机、顺序、MD5
合并动作可以放在最后一个分片上传完成后触发,也可以由前端单独调用一个POST /api/upload/merge接口。我习惯让服务端自动判断:每当saveChunk成功,就统计该uploadId下已上传分片数量,等于totalChunks时异步执行合并。合并时按chunkNumber顺序读出所有part文件,写到完整的临时文件中,比对MD5后再上传FastDFS。
public void mergeAndUpload(String uploadId) throws IOException { FileTask task = taskMapper.selectByUploadId(uploadId); // 1. 按顺序合并本地分片 Path mergedFile = Paths.get(TMP_DIR, uploadId + ".merged"); try (FileOutputStream fos = new FileOutputStream(mergedFile.toFile())) { for (int i = 1; i <= task.getTotalChunks(); i++) { Path part = Paths.get(TMP_DIR, uploadId, i + ".part"); if (!Files.exists(part)) { throw new MissingChunkException(i); } Files.copy(part, fos); fos.flush(); } } // 2. 计算合并后MD5,与前端上报值比对 String md5 = DigestUtils.md5DigestAsHex(Files.newInputStream(mergedFile)); if (!md5.equalsIgnoreCase(task.getFileMd5())) { task.setStatus(4); taskMapper.update(task); throw new BizException("合并后MD5不一致,需重新上传"); } // 3. 上传最终文件到FastDFS FastDFSClient fastDFSClient = FastDFSClientFactory.getClient(); String storagePath = fastDFSClient.uploadFile( task.getFileName(), Files.size(mergedFile), mergedFile.toFile()); task.setStoragePath(storagePath); task.setStatus(3); taskMapper.update(task); // 4. 清理临时分片和合并文件 FileUtils.deleteDirectory(Paths.get(TMP_DIR, uploadId).toFile()); Files.deleteIfExists(mergedFile); }逻辑说明:合并时遇到缺失分片会直接抛异常,已经合并到一半的文件不会被提交给FastDFS,前端可以查uploadedChunks后只补缺失分片,不需要整个文件重传。MD5不一致说明某个分片内容已经从源头损坏,这时把任务置为失败,删除临时分片,避免脏文件占空间。
FastDFS客户端,我一般配合com.github.tobato:fastdfs-client的DefaultFastFileStorageClient.uploadFile()来传。它传入File对象,底层会自动连接tracker和storage,上传完成后返回类似group1/M00/00/00/xxx的路径。这个路径存进t_file_task.storage_path,之后前端下载直接拼上FastDFS的Nginx访问地址即可。
4. 状态查询、秒传与前端worker:断点续传的另一半也在客户端
后端做得再好,前端断点续传逻辑不对,用户体验依然会变成“看着进度条100%传完、刷新后又从零开始”。断点续传的另一半责任在客户端,要搞清楚已上传分片怎么查、vue-simple-uploader如何校验断点续传、worker并发上传怎么和服务端状态保持同步。
4.1 已上传分片怎么查:Redis+MySQL的取舍与常见做法
服务端要回答前端“我已经传过哪几片”,最简单的接口是GET /api/upload/uploadedChunks?uploadId=xxx,返回一个整数数组。查询来源可以是MySQL,但分片数量多,且前端会频繁轮询,MySQL压力不小。常见做法是用Redis的Set结构:key=upload:chunks:{uploadId},每个已上传分片号作为member。初始化接口先查Redis,如果Redis不存在该key,再回查MySQL并回填Redis。
Redis和MySQL的一致性是断点续传最容易出问题的点。我建议的写入顺序是:分片落盘完成后先写MySQL,再写Redis。因为MySQL是唯一权威数据,Redis只是缓存。如果两者不一致,以MySQL为准,在Redis miss时重建缓存。Redis的key要设置过期时间,比如24小时,避免长时间未完成的任务占满内存。
4.2 vue-simple-uploader如何校验断点续传?接口对齐才是关键
vue-simple-uploader组件自带分片和断点续传能力,但它默认的校验逻辑是:在真正上传每个分片之前,先发送一个GET请求到target地址,带上chunkNumber、identifier等参数,服务端返回200表示该分片已存在,返回404表示未存在。很多自研项目对接不上,是因为后端只写了POST /chunk,没写这个GET校验接口。
要让vue-simple-uploader和服务端状态对齐,需要实现一个轻量的check接口:
@GetMapping("/upload/checkChunk") public ResponseEntity<Void> checkChunk(@RequestParam String uploadId, @RequestParam Integer chunkNumber) { boolean exists = chunkRecordMapper.exists(uploadId, chunkNumber); return exists ? ResponseEntity.ok().build() : ResponseEntity.notFound().build(); }vue-simple-uploader拿到404后,会把该分片加入上传队列。拿到200后,会直接跳过并增加已完成计数。整套逻辑其实和“前端使用worker上传大文件”时手动维护Set集合是同一套思想:先查漏补缺,再并发上传,而不是无脑把所有分片发一遍。
4.3 使用Worker并发上传、MD5秒传和服务端校验的配合
如果不想引入vue-simple-uploader,直接用原生Worker也可以。核心代码可以写成这样:
self.onmessage = async (e) => { const { file, chunkSize, uploadId, totalChunks, uploadedChunks, uploadUrl } = e.data; // 把已上传分片转换成数字集合,避免字符串与数字比较的坑 const doneSet = new Set(uploadedChunks.map(Number)); for (let i = 1; i <= totalChunks; i++) { if (doneSet.has(i)) continue; const start = (i - 1) * chunkSize; const end = Math.min(start + chunkSize, file.size); const blob = file.slice(start, end); const formData = new FormData(); formData.append('file', blob, `${uploadId}_${i}.part`); formData.append('uploadId', uploadId); formData.append('chunkNumber', i); // 这里可以改成Promise.all并发,但建议控制并发数为3~5 await fetch(uploadUrl, { method: 'POST', body: formData }); self.postMessage({ type: 'progress', chunkNumber: i }); } self.postMessage({ type: 'allDone', uploadId }); };代码里有两个容易被忽视的细节:第一,doneSet.has(i)一定要把i转成数字,很多后端返回的是JSON数字,但经过某些HTTP封装后变成字符串,includes判断就会失败,导致所有分片重传;第二,非要并发时,不要在一个for循环里无限制抛Promise,要把并发数限制在3到5之间。服务端同时写太多part文件,文件句柄和磁盘IO都会成为瓶颈。
MD5秒传一般放在初始化接口处理。前端在上传前先计算整个文件的MD5,交给服务端比对。服务端命中idx_md5索引后,直接返回finished状态和原文件storagePath,前端就不再上传任何分片。
4.4 MD5秒传的边界:同名同大小不代表同一文件
很多项目在初始化接口里看到相同MD5就直接秒传,这会留下一个隐患:同名文件在不同目录或不同用户上传时,可能共享同一个storagePath,导致用户A删除文件时影响用户B。更稳妥的判断条件是“MD5+文件大小”联合校验,并且在秒传接口里查一次FastDFS,确认storage节点上真的存在这个文件,否则会出现“数据库里有记录但文件被运维清理掉”的死链。
另外,断点续传过程中如果前端重新生成MD5,也就是说同一个uploadId在第二次init时传了不同的MD5,服务端要直接拒绝并创建新任务。否则部分分片来自旧文件,部分分片来自新文件,合并出来的文件必然损坏。这个边界不像分片并发那么常见,但一旦出现,排查成本极高。
5. FastDFS接入与大文件上传的常见坑:现象、原因、解决
FastDFS本身的部署不算难,但和大文件上传链路放在一起后,坑会集中在连接配置、并发写入、重启恢复和空间管理上。下面是我踩过且反复被同事问到的几类问题,按“现象→原因→解决”整理。
5.1 tracker和storage参数不一致,Java客户端连不上FastDFS
现象:日志报Cannot get connection from pool或connect to storage server fail,但FastDFS的进程还活着。
原因:Java客户端里的tracker_server只写了tracker地址,而tracker和storage配置的group_name不一致,或者storage的store_path_count与客户端预期不匹配。另一个常见原因是客户端连接池太小,并发上传时连接被抢光。
解决:先用FastDFS自带的fdfs_monitor查看storage状态,确认group_name一致;然后把客户端配置里的connection_pool_max_total调大。我常用的参考配置如下:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| tracker_server | tracker_ip:22122 | 多个tracker用逗号分隔 |
| connection_pool_max_total | 200 | 根据分片并发数和峰值计算 |
| connection_pool_max_idle | 50 | 保留的存活连接 |
| connection_pool_max_wait | 1000 | 获取连接最大等待毫秒数 |
5.2 合并完成后FastDFS文件大小对不上
现象:前端显示上传成功,下载下来之后文件比原始体积小,或者尾部全是空数据。
原因:合并分片时没有处理最后一个分片的大小,某些分片内容在并发写入时被覆盖;另一种情况是Files.copy(part, fos)之后没有执行flush,下个分片和上个分片在缓冲区交错。
解决:合并循环里每写完一个分片就fos.flush()一次;同时最后一片的大小用fileSize - (totalChunks-1) * chunkSize计算,不要直接按普通分片大小读取。如果怀疑分片内容损坏,先把临时分片保留,用md5sum和前端blob的MD5做逐片对比。
5.3 服务器重启后已上传分片全部消失
现象:用户上传到50%,服务端重启,前端刷新页面后已上传分片数变成0。
原因:分片状态只存在Redis里,本地临时分片和MySQL记录没有持久化,或者任务状态还停留在初始化状态,服务端启动时清理了孤儿临时文件。
解决:上传分片时同步写MySQL的t_chunk_record,前端查询已上传分片优先走数据库兜底;服务重启后,扫描临时目录,把存在的part文件反推回chunk_record,再恢复Redis缓存。我一般会在启动任务里做一次“临时文件与分片记录对齐”,不一致的分片直接删除,避免脏分片干扰合并。
5.4 用户断网后重连,参数没对齐导致“断点续传”变成“重传”
现象:前端已经传到20片,断网恢复后,服务端返回了uploadedChunks,但前端仍然从第1片开始传。
原因:uploadedChunks返回的是JSON数组,前端代码里用uploadedChunks.includes(i)判断,但数组元素是数字时还好,一旦被后端或网关转成了字符串,i是数字,判断恒为false,于是所有分片都重传。
解决:前端比较时统一转数字,const doneSet = new Set(uploadedChunks.map(Number)),再用doneSet.has(i)判断。这个坑很小,但可以毁掉整个断点续传体验。
5.5 Storage空间不足,上传报错却没触发重传
现象:某天开始,分片都接收成功,但合并后上传FastDFS返回失败,前端显示“文件上传失败”,没有重试。
原因:storage节点磁盘满了,FastDFS返回错误码,Java客户端异常后任务被标记为失败,临时分片却被清理掉了,用户只能从头再传。
解决:合并上传FastDFS前,先主动检查storage剩余空间;上传失败时保留临时分片,不要立刻清理,等磁盘恢复后调用同一个merge方法继续上传。运维侧要对storage磁盘做容量监控,FastDFS没有自动回收机制,文件写入后不会被修改,空间只会越来越少。
6. 进阶:四步验证断点续传和FastDFS大文件上传的稳定性
验证断点续传不能只靠前端点点点。我会先绕开界面,用接口级方式验证后端逻辑,再上真实场景压一把并发,最后把故障演练作为上线前的固定步骤。
第一步,用curl模拟分片请求。启动Spring Boot项目后,先构造一个任务,然后手动传前几个分片;中途直接杀掉Java进程,重启后再调用初始化接口,确认返回的uploadedChunks和磁盘上的part文件一致。
# 1. 初始化任务 curl -X POST http://localhost:8080/api/upload/init \ -H "Content-Type: application/json" \ -d '{"fileMd5":"abc123","fileName":"test.bin","fileSize":10485760,"chunkSize":1048576}' # 2. 手动上传第1和第2个分片 curl -X POST http://localhost:8080/api/upload/chunk \ -F "file=@chunk1.part" \ -F "uploadId=xxx" -F "chunkNumber=1"这里不需要真的传完所有分片,核心是验证“重启后已上传分片不丢”。如果这一步通过,断点续传的地基就稳了。
第二步,用vue-simple-uploader跑真实场景。选一个1GB到2GB的文件,把浏览器的网络模拟改成“慢速3G”,传一会儿点暂停,再点开始。观察控制台日志,应该只有缺失分片发出的请求。这一步同时验证了/api/upload/checkChunk的404和200判断是否准确。
第三步,压并发。我会用JUnit写一个并发测试,同时提交8个上传任务,每个任务64个分片,用8个线程分别调/api/upload/chunk。重点看三点:临时目录磁盘IO有没有打满、Java进程内存是否稳定、FastDFS上传最终文件是否全部成功。并发下的“最后一片触发合并”容易出现竞态,需要看日志里有没有重复执行merge的痕迹。
第四步,上线前的故障演练很有必要。我会手动停掉storage服务,制造一个“FastDFS暂时不可用”的场景,确认分片接收正常、合并失败、临时文件保留;然后启动storage,继续merge,最终文件完整。这一步能验证整个链路的回滚和恢复能力,而不是祈祷生产环境不出问题。
这套方案做完后,我会保留一个习惯:在任务表里额外记录“合并开始时间”和“FastDFS上传耗时”,每天看一次耗时分布。如果某个时间段上传耗时明显增长,大概率是storage磁盘或tracker连接池快到了瓶颈。大文件上传的价值不只是“能传”,而是“在故障到来时仍然有后悔药可吃”。希望帮到你。
本文还有配套的精品资源,点击获取