首页 / 资讯中心 / 文章详情

FastDFS大文件分片上传实战:从分片策略到断点续传与合并机制

FastDFS大文件分片上传实战:从分片策略到断点续传与合并机制 ★ FEATURED ARTICLE
FastDFS 实现大文件分片上传这事儿听起来挺常规但真正落地时你会发现坑不少。我在实际项目里折腾过好几轮从最初的单文件直接上传到后来被迫改成前端分片、后端合并的方案中间踩过的雷、总结出来的经验今天一次性整理出来希望对正在做类似功能的你有实际帮助。为什么要做分片原因太现实了浏览器和网关层通常对请求体大小有限制比如 Nginx 默认client_max_body_size只有 1mTomcat 默认也是按配置走动不动就给你报 413。更麻烦的是大文件传输过程中一旦网络抖动整个文件就得重传体验极差。分片之后每个分片单独上传、单独校验失败只重传那一块整体成功率直线上升。1 整体设计思路与方案选型1.1 为什么选 FastDFS 而不是直接存本地磁盘项目早期用的是本地磁盘存储文件大了之后问题很明显磁盘 IO 压力大、单机容量有限、备份迁移特别痛苦。后来引入 FastDFS主要看中它这几个特点分布式存储架构Storage 节点可以横向扩容文件会被分散存储到多个节点上容量不再是瓶颈。内置高可用机制Tracker 负责调度和负载均衡Storage 之间可以配置互相备份单点故障时文件依然可读。轻量级部署相比 HDFS 那套重组件FastDFS 的 tracker 和 storage 都是单进程部署和维护成本低很多。但 FastDFS 本身对超大文件的支持一般官方文档里虽然写着可以传大文件但实际操作中超过几百 MB 的文件直接上传等待时间很长而且一旦连接中断就是全线崩溃。所以“FastDFS 分片上传”就成了一个很自然的组合用分片解决传输稳定性问题用 FastDFS 解决存储扩展性问题。1.2 分片策略与前端技术选型分片大小怎么定这个不能拍脑袋。我实践下来比较合理的做法是每个分片 4MB 到 8MB。太小的分片会导致请求次数过多网络开销占比大太大的分片又失去了断点续传的意义重传代价依然很高。前端方案我用了Web Uploader的改进版或者你也可以直接用axios 手工分片逻辑。实际上核心原理都一样读取文件对象按固定大小切块。给每个分片生成唯一标识通常用文件MD5 分片序号。分片顺序上传后端按序号暂存。全部上传完成后前端触发合并请求。这里有个细节容易被忽略大文件的 MD5 计算很耗时几百 MB 的文件算一次可能要几秒钟甚至更久用户会感觉卡住了。我的做法是MD5 计算放到 Web Worker 里异步执行或者干脆用采样计算的简化版抽几个关键位置算哈希虽然碰撞概率略高一点但配合文件大小和文件名一起判断误判率完全可以接受。1.3 后端服务架构调整后端服务原本是单体应用文件上传接口走的是普通 HTTP 请求。引入 FastDFS 之后我在上传链路里加了一层分片暂存目录用的是本地存储而不是直接写入 FastDFS。为什么这么做因为 FastDFS 不支持追加写入。如果每个分片都直接传上去最后合并的时候还得先把分片下载回来再合并多了一道大流量 IO太蠢了。正确做法是分片先写到后端服务的本地临时目录或者挂载的共享存储。全部传完后再合并成完整文件。合并完成后一次性上传到 FastDFS。上传成功后删除本地临时分片。这样可以保证 FastDFS 里只存完整文件Storage 节点的存储效率和读取效率都是最优的。后续看文件内容、做图片缩略图、做视频转码都是拿 FastDFS 返回的完整文件路径直接操作不需要关心分片的细节。2 核心流程与关键技术点拆解2.1 前端分片上传的完整流程前端这块我用的是自己封装的一个上传组件大致逻辑如下// 核心分片逻辑 const CHUNK_SIZE 4 * 1024 * 1024; // 4MB 一个分片 function splitFile(file) { const chunks []; let start 0; while (start file.size) { const end Math.min(start CHUNK_SIZE, file.size); chunks.push({ index: chunks.length, blob: file.slice(start, end), size: end - start }); start end; } return chunks; }每个分片上传时的请求体是FormData包含以下字段字段名说明fileMd5整个文件的标识同一个文件的所有分片共用chunkIndex当前分片的序号从 0 开始chunkTotal总分片数fileSize文件总大小字节originalName原始文件名合并时用chunk分片的二进制数据作为表单文件字段上传接口我用的是 Promise 并发控制同时上传 3 个分片而不是一次全发也不是一个接一个地串行。并发数设太高会导致服务端临时文件 IO 排队严重太低则速度上不去。3 到 5 是个经验值。实测下来3 个并发在百兆带宽下基本能把网络跑满同时不会压垮服务器。2.2 后端核心接口设计与实现后端我用 Java 写的接口设计成三个职责清晰接口一初始化上传任务这个接口的作用是告诉后端“我要传一个文件了”后端干两件事生成一个唯一的上传任务 ID检查一下这个文件是不是之前传过一部分断点续传场景。PostMapping(/upload/init) public Result init(RequestBody FileInitRequest req) { String taskId UUID.randomUUID().toString().replace(-, ); // 检查临时目录是否已有残留的分片 String tempDir getTempDir(taskId); // 如果之前传过一部分返回已上传的分片序号列表 ListInteger uploadedChunks scanUploadedChunks(tempDir); return Result.ok().put(taskId, taskId).put(uploadedChunks, uploadedChunks); }这个“检查已上传分片”的逻辑就是断点续传的核心。用户传了一半网断了重新打开页面上传同一个文件前端先调初始化接口拿到已传的分片列表只需补传剩余部分即可。接口二分片上传PostMapping(/upload/chunk) public Result uploadChunk(RequestParam(taskId) String taskId, RequestParam(chunkIndex) Integer chunkIndex, RequestParam(value chunkSize, required false) Long chunkSize, RequestParam(fileMd5) String fileMd5, RequestParam(originalName) String originalName, RequestParam(chunk) MultipartFile chunk) throws IOException { // 1. taskId 校验 String tempDir getTempDir(taskId); File dir new File(tempDir); if (!dir.exists()) { dir.mkdirs(); } // 2. 保存分片文件命名规则taskId _ chunkIndex File chunkFile new File(tempDir, chunkIndex .part); try (InputStream in chunk.getInputStream(); FileOutputStream out new FileOutputStream(chunkFile)) { IOUtils.copy(in, out); } // 3. 校验分片完整性可选通过 size 判断 if (chunkFile.length() ! chunkSize) { chunkFile.delete(); return Result.error(分片大小校验失败); } return Result.ok(); }这里有两个容易被忽视的点。第一分片文件命名必须满足按数字排序后顺序就是文件顺序所以我直接用chunkIndex.part命名不用补零。第二MultipartFile的接收上限需要在 Spring Boot 配置文件里调大否则还没到业务代码框架就给你拦了spring: servlet: multipart: max-file-size: 8MB max-request-size: 64MBmax-file-size要略大于单个分片的大小因为还有表单字段的空隙max-request-size要大于并发数乘以单分片大小我上面说了并发 3、分片 4MB那就把max-request-size设成 64MB 保险。接口三合并分片并上传 FastDFS这步是最后一道工序也是最容易出问题的环节。PostMapping(/upload/merge) public Result merge(RequestBody MergeRequest req) { String taskId req.getTaskId(); int chunkTotal req.getChunkTotal(); String originalName req.getOriginalName(); String fileMd5 req.getFileMd5(); String tempDir getTempDir(taskId); // 1. 并发控制防止重复合并 String lockKey merge_lock_ taskId; boolean locked redisLock.tryLock(lockKey, 30, TimeUnit.SECONDS); if (!locked) { return Result.error(合并任务进行中); } try { // 2. 检查所有分片是否齐全 for (int i 0; i chunkTotal; i) { File partFile new File(tempDir, i .part); if (!partFile.exists()) { return Result.error(缺少分片 i); } } // 3. 合并为一个完整文件 File fullFile new File(tempDir, full_ taskId .tmp); try (FileOutputStream fos new FileOutputStream(fullFile)) { for (int i 0; i chunkTotal; i) { File partFile new File(tempDir, i .part); Files.copy(partFile.toPath(), fos); } } // 4. 上传到 FastDFS String ext getFileExtension(originalName); StorageClient client new StorageClient(trackerClient); String[] results client.uploadFile(fullFile.getAbsolutePath(), ext, null); // results[0] groupName, results[1] remoteFileName // 5. 清理本地临时文件 FileUtils.deleteDirectory(new File(tempDir)); return Result.ok().put(url, / results[0] / results[1]); } finally { redisLock.unlock(lockKey); } }这里最关键的一环是用Redis 分布式锁防重复合并。不用锁的话前端网络抖动导致重试合并请求后端会重复合并、重复上传 FastDFS产生垃圾文件极端情况下会把正在读的临时文件删掉导致另一路合并失败。这种诡异问题线上出过一次后来加锁解决了。2.3 为什么合并后还要做 MD5 校验小文件传输直接算了整个文件的 MD5 传给后端但大文件的 MD5 计算成本太高前端算好后端不好核对后端得再全量读一遍文件所以我的做法是合并完成后计算合并后文件的长度与前端传来的总大小做比对同时抽查几个分片的 MD5。// 合并后校验 if (fullFile.length() ! req.getFileSize()) { return Result.error(文件大小不一致合并失败); } // 抽查分片 MD5一般抽首尾两个分片 String headMd5 md5(headPartFile); String tailMd5 md5(tailPartFile); if (!headMd5.equals(req.getHeadChunkMd5()) || !tailMd5.equals(req.getTailChunkMd5())) { return Result.error(分片内容校验不一致); }这样做到“轻量但有效的完整性校验”避免了大文件全量 MD5 对 CPU 和磁盘 IO 的浪费。完整性和传输可靠性的矛盾这是我在工程上的折中方案实际运行下来很稳。3 完整实操记录从本地到 FastDFS3.1 环境准备与配置文件先说一下我使用的环境版本保证后面的代码能直接对应上FastDFS 5.12libfastcommon 1.0.43Java 8Spring Boot 2.3.xfastdfs-client-java开源版GitHub上的那个封装部署层面我起了两个 tracker、两个 storagestorage 配置了两组 group分别存图片和普通文件。这里有个关键点上传入口要指向 tracker而不是直接指 storage。tracker 会动态分配 storage 节点实现负载均衡。fastdfs-client.properties配置如下fastdfs.tracker_servers10.0.1.11:22122,10.0.1.12:22122 fastdfs.connect_timeout_in_seconds5 fastdfs.network_timeout_in_seconds30 fastdfs.charsetUTF-8 fastdfs.http_anti_steal_tokenfalse fastdfs.http_secret_keyyour-secret-key fastdfs.tracker_http_port8080网络超时时间这里我给的是 30 秒如果你上传的是超大文件建议把 storage 的网络超时时间再调大一点不然文件稍大一点就会在传输过程中断连。3.2 分片上传全过程代码拼装前端完整流程代码整理如下可以直接在浏览器端跑async function uploadFile(file) { // 1. 初始化 const initRes await axios.post(/api/upload/init, { fileName: file.name, fileSize: file.size }); const taskId initRes.data.taskId; const uploadedSet new Set(initRes.data.uploadedChunks); // 2. 分片 const chunks splitFile(file); // 3. 并发上传缺失的分片并发数3 let offset 0; const concurrency 3; const tasks []; while (offset chunks.length) { const currentSlice chunks.slice(offset, offset concurrency); const batchTasks currentSlice.map((chunk, i) { if (uploadedSet.has(chunk.index)) { return Promise.resolve(); // 已上传过跳过 } const formData new FormData(); formData.append(taskId, taskId); formData.append(chunkIndex, chunk.index); formData.append(chunkTotal, chunks.length); formData.append(fileSize, file.size); formData.append(fileMd5, md5); formData.append(originalName, file.name); formData.append(chunk, new File([chunk.blob], ${file.name}.part${chunk.index})); return axios.post(/api/upload/chunk, formData, { timeout: 60000 // 单分片60秒超时 }); }); await Promise.all(batchTasks); // 一批3个全完成再下一批 offset concurrency; // 更新进度 const loaded Math.min(offset * CHUNK_SIZE, file.size); onProgress(Math.round(loaded / file.size * 100)); } // 4. 触发合并 const mergeRes await axios.post(/api/upload/merge, { taskId, chunkTotal: chunks.length, originalName: file.name, fileSize: file.size, fileMd5: md5, headChunkMd5: headMd5, tailChunkMd5: tailMd5 }); return mergeRes.data.url; }这个版本基本上是一个可用的生产代码框架。如果你在项目里直接复用唯一需要改的是md5变量需要预先算好我用的是spark-md5这个库代码不贴了很常规。注意用 Web Worker 防止界面卡死。3.3 FastDFS 上传与访问路径解析client.uploadFile返回的results数组results[0]是 group 名称results[1]是存储路径。FastDFS 的存储路径很有意思它不是简单的文件名而是一层层的目录结构group1/M00/00/00/wKg0cV7U8bmAZcHdAAEAAAAAUVE1234.zip这个路径其实是 FastDFS 自动生成的里面嵌入了时间戳、storage 编号、文件序号等信息用于定位实际存储位置。后续访问文件时完整 URL 是http://你的storageIP:8888/group1/M00/00/00/wKg0cV7U8bmAZcHdAAEAAAAAUVE1234.zip这里的 8888 端口是 storage 上的 nginx 提供的 HTTP 服务FastDFS 官方推荐通过 nginx 的 fastdfs-nginx-module 模块来做文件访问不用直接暴露 storage 端口安全很多。我在实际操作中还配了一个group 映射域名比如图片走单独的子域名这样以后如果 storage 扩容或者迁移前端访问 URL 不用跟着变只需要改 nginx 上游指向就行了。3.4 断点续传的完整落地断点续传是分片上传最核心的价值之一。它的实现不复杂但需要前后端配合好。前端在初始化时拿到uploadedChunks列表这是个数组比如[0, 1, 2, 4, 5]表示分片 3 上传失败或者丢失了。前端只上传缺失的部分就行。后端怎么知道哪些分片已上传很简单扫描临时目录下的.part文件把文件名解析出来private ListInteger scanUploadedChunks(String tempDir) { File dir new File(tempDir); if (!dir.exists()) { return Collections.emptyList(); } ListInteger result new ArrayList(); File[] files dir.listFiles((d, name) - name.endsWith(.part)); if (files null) return result; for (File f : files) { String name f.getName(); String indexStr name.substring(0, name.indexOf(.part)); result.add(Integer.parseInt(indexStr)); } Collections.sort(result); return result; }还有个关联问题临时目录什么时候清理如果用户传了一半直接关页面临时分片会一直留在服务器上时间长了磁盘会被占满。我的做法是加一个定时任务每天凌晨扫描临时目录删除创建时间超过 24 小时的任务目录。这个逻辑很简单但真的很重要不写的话生产环境早晚会出磁盘告警。4 FastDFS 集群配置与性能优化4.1 storage 节点横向扩容FastDFS 的容量扩容思路和普通分布式存储不太一样它不需要做数据重新分布只需要新增一个 group然后调整 tracker 的配置让新文件默认存储到新的 group 里。# tracker.conf # 添加新的 group store_groupgroup2当然你也可以让多个 group 同时存储通过 tracker 的调度策略分配。不过我这里就一个 group 做标准生产配置扩容时加 group2tracker 会把新上传的文件均匀分散到不同 groupgroup 之间是物理隔离的。4.2 存储文件的监控与告警FastDFS 提供了一套监控命令可以通过 tracker 的 HTTP 接口查看集群状态# 查看集群各 storage 的状态 /usr/bin/fdfs_monitor /etc/fdfs/tracker.conf输出会显示每个 storage 节点的total_mb、free_mb、status等关键指标。我实际用的多的是写脚本定时抓这个输出然后通过内部监控系统报警。当某个 storage 的剩余空间低于 20% 时触发预警提前规划扩容。另外强烈建议给 storage 挂载单独的磁盘分区不要和系统盘混在一起。文件增长太快日志、临时文件、存储文件混在一起出问题很难排查这是实战中踩过的坑。5 常见问题与排查技巧实录5.1 分片上传中的经典报错与解法问题现象根因分析解决方案上传大文件返回 413Nginx 的client_max_body_size限制在 nginx 配置中调大到128m同时注意proxy_request_buffering设 off 避免缓冲过大传输途中连接重置网关或负载均衡超时设置过短调整网关 idle timeout并将分片调小到 2MB 降低单请求耗时合并后文件损坏分片之间顺序错乱检查分片文件命名排序逻辑确保按数字升序合并分片上传极慢后端写临时文件频繁随机 IO临时目录放在 SSD 上或者用内存盘做缓存再异步落盘重复传了同一分片前端并发控制逻辑有误每个分片上传前检查本地记录后端对重复分片做幂等处理存在则覆盖这里重点说说幂等处理。后端接收分片时如果发现chunkIndex.part已存在有两种策略一是直接覆盖二是忽略新请求返回成功。我推荐第二种因为你没法保证前一次上传和这次上传的内容完全一致万一前一次传输过程中产生了半个文件覆盖会引入更隐蔽的错误。忽略新请求、保留首次写入的完整分片逻辑上更安全。还有个小坑前端并发上传时 axios 默认的Content-Type。如果你手动设置multipart/form-data必须带上正确的 boundary否则后端解析会失败。正确做法是把 Content-Type 留空让浏览器自动生成。这个坑当年浪费了我两个小时才排查出来。5.2 合并上传 FastDFS 时遇到的闹心问题合并后上传 FastDFS成功率不是 100%我遇到过这么几个场景场景一上传超时导致 client 连接假死FastDFS 客户端在上传大文件时如果网络波动可能会在uploadFile方法内部卡住直到超时才抛异常。日志上看就是一条SocketTimeoutException或者直接卡着不动。解决办法是设置客户端 socket 超时之外再用 Future 方式包一层业务超时控制到时间直接返回失败给前端让前端走重试逻辑。场景二文件名冲突导致旧文件被覆盖FastDFS 的文件名是它自己生成的所以理论上不会冲突。但有同事图省事用了client.uploadFile(..., filename自行命名)这种带filename入参的方式如果文件名生成的规则不够唯一就会被覆盖。不仅是内容覆盖还可能导致 URL 混乱。所以切记FastDFS 的 remoteFileName 一律让它自动生成自己存数据库对应关系就够了。场景三合并后直接删除临时文件导致上传失败这是个顺序问题。我需要完整流程是合并 → 上传 FastDFS → 确认返回成功 → 删除临时文件。顺序绝不能乱。如果上传没成功就先删临时文件万一用户需要重传本地备份已经被清了只能重新从前端传所有分片那体验就毁了。5.3 前端断点续传的细节经验断点续传在浏览器端有个天然限制刷新页面之后JS 内存中的文件 MD5 就消失了。要让续传真正生效需要把文件标识存到localStorage。// 存储文件标识便于刷新后继续 const key upload_${fileMd5}_${file.name}; localStorage.setItem(key, JSON.stringify({ taskId, uploadedChunks, chunkTotal, fileSize, lastUpdate: Date.now() }));刷新后重新选择文件先算 MD5然后查localStorage如果存在且分片未传完就直接从断点继续不用重新开始。由于 localStorage 有容量上限一般 5MB 左右只存元数据不存文件数据是没问题的。还有一个体验优化点进度条不能只统计传输比例。用户看进度条关心的其实是“剩余时间”可以加上平均上传速度的计算比如每个分片完成时记录时间和字节数const speed (loadedBytes / (Date.now() - startTime)) * 1000; // B/s const remainingTime (file.size - loadedBytes) / speed;这样前端显示“3.5MB/s预计还需 12 秒”比单纯的百分比直观太多了。6 经验总结与后续优化方向这套“FastDFS 分片上传”方案上线后实际表现比我预期的稳。最明显的收益是大文件上传失败率从之前单文件方式的 15% 左右降到了 1% 以下。就算偶尔用户网络抖动造成分片丢失也不用重新选文件、重新传刷新页面继续传剩下的分片就行体感好得多。操作层面有几件事我强烈建议你在自己项目里坚持做临时目录与 FastDFS 存储目录分磁盘避免相互影响。保留前端上传日志特别是taskId、分片序号、响应状态码出问题排查少走弯路。定期清理过期临时文件这个定时任务一定要有不然跑一个月磁盘就满了。合并接口加锁Redis 锁或数据库分布式锁都行防止并发重试导致重复写入。FastDFS 客户端封装一层重试机制针对网络闪断自动重试一次能显著降低偶发失败率。后续如果要进一步优化我觉得可以从两个方向切入。一是为超大文件几 GB 级别加入并发批量合并流程不同批次分片同时合并最后再拼接减少合并时间。二是给上传链路加一层消息队列把上传任务异步化前端提交后就返回“处理中”合并任务在后台排队执行避免大文件合并时占用 HTTP 连接资源。另外强调一点FastDFS 虽然解决了存储扩展性的问题但它的文件管理能力偏弱没有标准的“目录”概念也不支持文件元数据的复杂检索。如果你之后要做文件权限控制、多租户隔离、文件关系图谱可能需要额外开发一层元数据服务把 FastDFS 返回的文件 ID 和数据库中的文件信息表关联起来。这算是对方案边界的一点提示不要指望一个 FastDFS 解决所有问题。这套架构跑了大半年我自己最深的体会是分片上传不是“前端把文件切碎”那么简单真正的复杂度在后端的状态管理和异常恢复上。把任务 ID、分片记录、断点续传、幂等处理这些细节做扎实了整个上传功能才能真正好用。希望这篇总结能让你少走弯路有具体问题欢迎在评论区交流。
阅读完成 · 觉得有帮助?
咨询建站