ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java大文件上传:FastDFS分片与断点续传全解析

Java大文件上传:FastDFS分片与断点续传全解析 简介一套基于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 为什么不把整个文件直接扔给FastDFSFastDFS是典型的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_recordCREATE 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 ResultUploadInitVO 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里status1的记录组装成ListInteger。上传中任务超过一定时间没更新应该允许前端重新初始化并把旧任务标记为过期否则临时文件会占用大量磁盘。参数说明uploadId这里我用UUID去掉横杠后的字符串方便做数据库索引和Redis keychunkSize来自前端但后端必须做上限校验比如不允许超过16MB避免有客户端恶意构造超大分片。另外初始化接口里不要只依赖前端传的fileName服务端要重命名最终文件名防止路径穿越。3.2 接收分片并落盘校验、去重、写临时文件前端把文件切成块后会并发POST到/api/upload/chunk。每个请求都会带uploadId、chunkNumber和这个分片的二进制内容。服务端不能直接存进FastDFS必须先落到本地临时目录等合并后再清理。PostMapping(/chunk) public ResultVoid 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 已上传分片怎么查RedisMySQL的取舍与常见做法服务端要回答前端“我已经传过哪几片”最简单的接口是GET /api/upload/uploadedChunks?uploadIdxxx返回一个整数数组。查询来源可以是MySQL但分片数量多且前端会频繁轮询MySQL压力不小。常见做法是用Redis的Set结构keyupload: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 ResponseEntityVoid 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_servertracker_ip:22122多个tracker用逗号分隔connection_pool_max_total200根据分片并发数和峰值计算connection_pool_max_idle50保留的存活连接connection_pool_max_wait1000获取连接最大等待毫秒数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 filechunk1.part \ -F uploadIdxxx -F chunkNumber1这里不需要真的传完所有分片核心是验证“重启后已上传分片不丢”。如果这一步通过断点续传的地基就稳了。第二步用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连接池快到了瓶颈。大文件上传的价值不只是“能传”而是“在故障到来时仍然有后悔药可吃”。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进