
简介基于Java的FastDFS大文件上传与断点续传设计源码包聚焦H5端与FastDFS存储之间的高性能上传场景核心解决断点续传、秒传、大文件分片上传以及Redis文件锁问题适合需要搭建稳定上传服务的Java开发者学习实践也可作为高校Web项目或企业内网传输模块的入门参考。压缩包共36个文件体积约563KB其中13个Java文件承担上传与存储核心逻辑5个JS文件配合CSS与FTL模板完成前端交互及页面渲染另有XML、properties等配置文件和说明文档结构清晰便于快速定位实现细节Java源码中可提炼分片上传、文件合并、锁控制等关键代码前端脚本覆盖切片与进度展示流程。目前已有705人学习下载可用于系统了解FastDFS与Java后端整合流程、分片与续传状态记录方式以及Redis锁在并发上传中的防重应用借助自带的界面与脚本可快速搭建可运行的大文件上传演示环境作为Web项目传输模块的设计参考。1. FastDFS大文件上传先存应用服务器再转存为什么这条路走不远做过文件上传的Java后端基本都经历过这个场景前端传一个1.5GB的视频浏览器先假死后端接文件时内存顶到2GBTomcat的connection超时直接断开用户那边进度条卡在99%然后整包重传。把spring.servlet.multipart.max-file-size调大只是把问题往后推网关和负载均衡还有一层client_max_body_size在等着更别提下载时单台应用服务器的磁盘很快就满了。标题里这套「基于Java的FastDFS大文件上传与断点续传设计源码」本质是把大文件上传这件事拆成三段客户端按固定大小分片、Java服务端按序接收并合并、合并后的完整文件交给FastDFS存储。这个方案解决的是「大文件不能让应用服务器当搬运工也不能让用户失败后从头再来」的痛点适合做内容管理、视频平台、教研资料库、网盘类业务的Java工程师。分片断点续传FastDFS这三件事拆开都不难难的是把参数、状态和边界条件串对。2. 存储底座选型为什么FastDFS适合做断点续传的终点2.1 Tracker与Storage先搞懂FastDFS的调度和同步模型FastDFS只有两个角色。Tracker负责调度不存文件Storage负责实际存储按组group划分。客户端上传时先问Tracker要一台可写的Storage然后直连Storage上传下载时再问Tracker要一台可读的Storage。这套机制和HDFS、MinIO最大的区别在于组内Storage之间的同步是异步的Tracker不知道哪台机器上刚写完的文件是否已经同步到副本。所以「刚上传成功马上读」在FastDFS里是有概率失败的这个玄学问题后面避坑章单独说。理解了这个模型你就知道FastDFS做断点续传的终点是合适的。分片上传过程中每一个分片都在应用服务器本地只有全部合并完成后才交给FastDFS。FastDFS不感知分片它只负责收一个完整大文件。这样FastDFS那边不需要任何定制你用官方客户端调一个uploadFile接口就行。真正要设计的是应用服务器上的分片接收、合并和状态管理。2.2 为什么不直接用MinIO或MongoDB GridFS常见做法里有几条路线但最后大多回到FastDFS原因很实际。MinIO的S3接口和分片上传做得确实好支持multipart upload但如果你所在的团队已经有一套内网FastDFS集群在跑业务再引一套MinIO意味着运维要维护两套存储。MongoDB GridFS会把文件拆成255KB的chunk存到fs.chunks表看起来天然分片但读文件时要从MongoDB里流式拼装大文件频繁读会给数据库带来额外压力而且Java驱动对GridFS大文件的内存控制并不比你自己写分片省心。对比下来更适合的选型参考是这样的方案分片支持运维成本大文件读性能适合场景应用服务器本地磁盘无靠HTTP低但容量有限单机IO小文件、临时文件MongoDB GridFS自带中高一般要拼chunk已有MongoDB且文件量不大MinIOS3 multipart中好对外S3协议、对象存储即服务FastDFS无要自己分片低好Tracker调度分发内网私有化内容存储FastDFS的部署就两个进程tracker和storage配置集中在tracker.conf和storage.conf没有复杂的选主逻辑这对一线运维很友好。文件上传到FastDFS后返回一个group1/M00/00/00/xxx.bin这样的fileId记录这个字符串就能定位文件设计上非常朴素。2.3 最小可跑通的FastDFS环境配置要点与验证你在本地复现这个方案不需要一上来就上集群。一台机器上先起一个tracker和一个storage就够开发联调了。常见做法是装好libfastcommon和fastdfs后改三份配置tracker.conf里确认端口默认22122storage.conf里关键有三处base_path、store_path0、tracker_server其中store_path0指向实际存储目录这一项决定文件落在哪块磁盘client.conf里写tracker_server127.0.0.1:22122。启动顺序是先fdfs_trackerd tracker.conf再fdfs_storaged storage.conf最后用自带的命令行工具验证fdfs_upload_file /etc/fdfs/client.conf /tmp/test.bin这条命令执行后会返回一个group1/M00/00/00/wKgrCmXXX.bin格式的fileId。能返回这个字符串说明tracker、storage、client三者链路是通的。返回的fileId要保存好后面Java客户端上传完文件后拿到的也是这个格式。注意libfastcommon和fastdfs的版本要配套很多新手翻车是因为storage起不来日志里报libfdfsclient.so: cannot open shared object file这就是版本不匹配或ldconfig没刷新的典型症状。3. 用Java实现分片上传唯一标识、分片参数与服务端接收3.1 三个必调参数分片大小、并发数、重试次数分片大小是最影响体验的参数没有标准答案取决于你的网络链路。内网传输稳定分片可以调到10MB公网环境特别是跨运营商2MB到5MB更稳。分片越小单次失败重传的代价越低但分片数量越多服务端合并时打开文件的次数越多状态表记录也越碎。我一般按「目标文件按分片数控制在100到500片之间」来倒推比如最大2GB的文件用5MB分片就是410片左右这个量级对MySQL和文件系统都算友好。并发数默认不要超过4。前端或者Java客户端并发上传分片时并发太高会让服务端临时目录的IO被打满还会触发Nginx的client_max_body_size之外的连接超时。重试次数设2到3次失败后指数退避每次间隔1秒、3秒、7秒。真正让断点续传有价值的不是重试本身而是「重试时跳过已成功的分片」这个由服务端的状态表来回答。3.2 分片上传的Java客户端幂等提交是关键先定义一套接口协议。前端把文件切成固定大小的分片后对每个分片发起一次POST请求后端接口是/upload/chunk参数带文件的唯一标识fileId、当前分片序号index、总分片数total。关键设计是幂等同一个fileId index重复提交时服务端如果发现该分片已存在直接返回成功不重复写盘。这样前端断网重传时不需要精确知道断了哪一片把所有分片重新提交一遍服务端会跳过已存在的。用Java的HttpClient写一个最小可用的分片上传逻辑public class ChunkUploader { private static final int CHUNK_SIZE 5 * 1024 * 1024; // 5MB public static void upload(Path filePath, String fileId, String uploadUrl) throws Exception { long fileSize Files.size(filePath); int totalChunks (int) Math.ceil((double) fileSize / CHUNK_SIZE); byte[] buffer new byte[CHUNK_SIZE]; try (InputStream in Files.newInputStream(filePath)) { for (int index 1; index totalChunks; index) { int len in.read(buffer); if (len 0) break; // 每次请求都带 fileId index服务端靠这个做幂等 HttpRequest request HttpRequest.newBuilder() .uri(URI.create(uploadUrl ?fileId fileId index index total totalChunks)) .timeout(Duration.ofSeconds(60)) .POST(BodyPublishers.ofByteArray(buffer, 0, len)) .build(); HttpResponseString resp HttpClient.newHttpClient() .send(request, BodyHandlers.ofString()); // 服务端返回 200 表示该分片已存在或写入成功 if (resp.statusCode() ! 200) { throw new RuntimeException(chunk failed: index , status resp.statusCode()); } } } } }这段代码把逻辑说清楚了每个分片独立POST查询参数里带fileId和index。fileId的生成规则我建议用UUID拼接原始文件名比如uuid -- originalName不要把原始文件名直接暴露到path里。前端组件的实现也是如此像vue-simple-uploader这类组件本身就支持把文件切成chunk并发上传后端的接口只要按「分片序号文件标识」接收两边就能对上表其中「如何校验断点续传」的关键就是服务端对每个分片返回「已经有了」还是「写成功」。3.3 前端用Worker还是Java后端直接分片这里有个分岔如果上传方是浏览器用Web Worker做分片和并发上传避免主线程卡死如果上传方是Java服务端到服务端的传输就用上面的HttpClient。标题既然写着Java说明服务端既要接收分片也可能作为客户端上传分片。两种情况我都做过给你一个判断标准当源文件在用户手里必须前端分片因为Java后端拿不到尚未上传的文件当源文件在应用服务器磁盘上比如迁移历史数据就直接在Java里按上面的方式循环POST给接收端。「前端使用worker上传大文件」的优势在于浏览器UI不卡而且能精确控制每个分片的大小和并发数。但注意Worker本质还是发HTTP请求服务端接口必须正确处理三个边界分片乱序到达、分片重复到达、最后一小片不足CHUNK_SIZE。这三个边界在接收端的代码里是必须处理的否则测试环境一切正常生产环境一旦出现乱序合并出来的文件就是坏的。4. 断点续传的服务端索引临时分片落盘、合并与状态表4.1 分片先落临时目录按fileId组织分片命名固定宽度服务端收到分片后不直接合并而是先把分片写入临时文件。临时目录按fileId建子目录这个子目录下的分片文件按序号命名比如000001.part、000002.part。命名用固定宽度是为了最后合并时String.compareTo排序能自然得到正确顺序不要用1.part、2.part这种不补零的命名否则第10片会排在第二片前面。RequestMapping(/upload/chunk) public ResponseEntityString uploadChunk( RequestParam(fileId) String fileId, RequestParam(index) int index, RequestParam(value total, required false) Integer total, RequestParam(file) MultipartFile chunk) throws IOException { String tmpDir TEMP_ROOT File.separator fileId; Files.createDirectories(Paths.get(tmpDir)); // 分片重复提交时先检查文件是否存在存在直接返回 200 Path partFile Paths.get(tmpDir, String.format(%06d.part, index)); if (Files.exists(partFile) Files.size(partFile) chunk.getSize()) { return ResponseEntity.ok(exists); } // 写入临时分片注意不能直接用 transferTo(target) 反复覆盖 try (InputStream in chunk.getInputStream()) { Files.copy(in, partFile, StandardCopyOption.REPLACE_EXISTING); } return ResponseEntity.ok(ok); }这里的核心判断是「分片存在且大小一致就直接返回exists」。为什么还要比大小因为HttpClient重传时可能上一次写了一半就断了这时候文件存在但大小不完整不能当作成功必须覆盖写入。另一种做法是在partFile旁边写一个.meta文件记录分片MD5校验维度更准但写入成本也高一点。比较务实的方案是重传覆盖时用REPLACE_EXISTING正常情况下分片大小固定大小不一致就说明上次没写完整。4.2 合并分片并交给FastDFS别踩transferTo的坑所有分片到齐后按固定宽度排序逐个合并。合并时Java里最常见的一个坑是FileChannel.transferTo在部分JDK实现中超过2GB会出问题表现为文件后半段内容错乱或抛出异常。所以合并我一般不用一个transferTo调用完成而是用手动读写循环或者分段transferTo。public static File mergeChunks(String fileId, String mergedName) throws IOException { File tmpDir new File(TEMP_ROOT, fileId); File[] parts tmpDir.listFiles((dir, name) - name.endsWith(.part)); Arrays.sort(parts, (a, b) - a.getName().compareTo(b.getName())); File merged new File(tmpDir, mergedName); try (FileOutputStream fos new FileOutputStream(merged); FileChannel outChannel fos.getChannel()) { ByteBuffer buf ByteBuffer.allocate(1024 * 1024); for (File part : parts) { try (FileInputStream fis new FileInputStream(part); FileChannel inChannel fis.getChannel()) { while (inChannel.read(buf) ! -1) { buf.flip(); outChannel.write(buf); buf.clear(); } } } } return merged; }合并完成后计算整个文件的MD5然后调用FastDFS的官方Java客户端上传// 假设用的是官方 fastdfs-client-java先初始化 TrackerClient TrackerClient trackerClient new TrackerClient(); TrackerServer trackerServer trackerClient.getTrackerServer(); StorageClient1 storageClient new StorageClient1(trackerServer, null); String remoteFileId storageClient.uploadFile1( mergedFile.getAbsolutePath(), extension, null); System.out.println(remoteFileId); // 输出 group1/M00/00/00/xxxremoteFileId就是整个方案的产出它等价于命令行fdfs_upload_file返回的那个字符串。拿到它之后要把文件状态表里的记录更新为UPLOADED并保存remoteFileId和MD5值这样秒传和断点续传的判断依据都在库里了。4.3 文件状态表断点续传的「账本」断点续传能不能续不靠客户端记进度靠服务端这张表回答「传到哪里了」。表结构常用这些字段字段类型说明idbigint主键file_idvarchar(64)客户端上传时生成的唯一标识file_namevarchar(255)原始文件名file_sizebigint总字节数chunk_sizeint分片大小chunk_countint总分片数uploaded_chunkstext已上传分片列表JSON数组file_md5varchar(32)全文件MD5合并后回填remote_file_idvarchar(128)FastDFS返回的fileIdstatustinyint0上传中1已合并2已取消create_time / update_timedatetime时间戳uploaded_chunks用JSON数组或者逗号分隔的字符串在小规模场景够用比如「1,3,5,9」。但文件数量大了以后这个字段会越来越长更新时每次都要重写整个字符串并发合并时还可能互相覆盖。我一般建议当文件量预估超过万级时拆一张file_chunk子表每条记录对应一个分片主表只统计chunk_count和done_count。用MyBatis-Plus的话实体类建好后可以直接让框架根据实体类生成建表的SQL语句注意对逻辑删除字段和update_time这种自动填充字段要做好策略配置避免合并完成后更新状态时把remaining_chunks以外的字段清空。5. FastDFS断点续传避坑清单同步延迟、临时文件与Nginx转发5.1 上传成功但立刻读不到Storage组内同步延迟现象合并完成后调FastDFS的downloadFile接口返回404或连接被重置。原因FastDFS的组内同步是异步的客户端写完Storage A后Tracker把下载请求转发到了还没完成同步的Storage B。这不算Bug是FastDFS一致性的设计。解决最可靠的办法是合并后直接用上传时返回的storage ip:port fileId去读一次做校验而不是走Tracker调度业务侧如果必须要「写完马上能读」可以接受极小概率的重试下载接口里对404做一次「换一台Storage重试」的逻辑。这个小坑属于典型的FastDFS黑匣子日志看不出问题只有读流量打多了才暴露。5.2 分片上传被Nginx 413拦截现象前端分片正常但传了十几片后突然全部413。原因分片虽然只有5MB但Nginx默认的client_max_body_size是1MB这个参数在http、server、location三层都可能被配置。解决在接收分片的location里显式调大location /upload/ { client_max_body_size 20m; proxy_request_buffering off; proxy_read_timeout 300s; }proxy_request_buffering off的作用是让Nginx收到请求体后立即转发给后端而不是先缓冲整个body。分片场景下这个参数比client_max_body_size更关键因为并发4个5MB分片同时进来Nginx默认会先落盘缓冲磁盘IO一忙连接就挂起。记得改了Nginx配置后要nginx -t再reload很多线上事故都是改完配置忘记reload。5.3 合并时文件超过2GB内容后半段全是乱的现象合并3GB文件成功后用fdfs_file_info查CRC或直接md5校验和源文件不一致。原因FileChannel.transferTo在部分JDK实现中用32位int记录传输位置超过2GB后位置回绕。解决按4.2节的代码用固定缓冲区分段读写或者循环调用transferTo每次最多传输1GB。这是一个非常隐蔽的翻车点单测时用2GB以下的文件测不出来生产环境一上大文件就出问题。5.4 storage的store_path0写满上传报No space left on device现象上传到一半FastDFS返回No space left on devicetracker日志里没有明显报错。原因storage.conf里配置了多个store_path但FastDFS选择store_path时按剩余空间比例和配置顺序综合判断如果所有大文件都默认落到store_path0单盘先满。解决给大文件单独分一个group比如group_bigstorage只配这个group专用的store_path这样大文件上传路径独立不会和小文件争抢磁盘。运维侧建议用fdfs_monitor /etc/fdfs/client.conf定期看group状态关注总容量和剩余容量两列容量低于20%就要准备扩容。5.5 fastdfs-client-java版本混乱导致连接泄漏现象应用运行一段时间后上传偶尔报SocketTimeoutException重启后恢复。原因FastDFS的Java客户端有多个分支版本有些个人维护的版本连接池管理有缺陷连接用完后没有正确释放。解决优先用官方那个fastdfs-client-java仓库的代码不要从博客下载打包好的jar初始化时设置合理的soTimeout和connectTimeoutClientGlobal.setSoTimeout(30000); // 读取超时大文件合并上传要留足余量 ClientGlobal.setConnectTimeout(10000); // 连接超时 ClientGlobal.setTrackerServers(127.0.0.1:22122);这条经验是血泪换来的——连接泄漏问题连日志都看不出来只能通过lsof -p pid | grep ESTABLISHED看连接数是否只增不减。如果你发现FastDFS的连接数肉眼可见地在涨先往客户端版本上怀疑。6. 把续传做成秒传状态表复用、验证方法与收尾技巧6.1 秒传判断先查状态表再发分片断点续传做到位了自然可以往前再走一步实现秒传。上传请求进来时先拿文件MD5查file_record表如果statusUPLOADED且file_md5一致直接返回历史remote_file_id前端提示「秒传成功」如果statusUPLOADING把uploaded_chunks返回给前端前端挑出还没传的分片继续传。这个判断逻辑和vue-simple-uploader这类组件完全是天然对接的——组件靠服务端返回的「已上传分片列表」来做续传和秒传校验。MD5的计算成本要控制。2GB文件算一次MD5大约需要几秒如果每次上传都全量计算用户等到超时也未必发起第一个分片。常见做法是客户端先传文件大小 文件名做一次弱校验只有大小完全一致时才继续算全文件MD5或者在后端合并完成后计算MD5回填秒传判定优先用这个回填值。6.2 验证这个方案做没做对三个检查点第一用fdfs_upload_file和fdfs_file_info做静态校验。先手动上传一个小文件拿到fileId然后用fdfs_file_info client.conf fileId查文件大小和CRC和你本地源文件对比。第二断点续传的验证要故意制造中断上传到一半kill掉接收分片的Java进程重启服务再传同一个文件观察服务端日志中已存在的分片直接返回exists传输时间比首次缩短。第三用脚本并发上传100个不同大小的文件把合并后的MD5列表和源文件MD5列表逐一比对能全量通过说明合并逻辑和分片命名没有边界问题。验证通过后还要安排清理策略。临时分片目录TEMP_ROOT下超过24小时没完成合并的目录要定期删除否则中断上传的孤儿分片会占满磁盘。常见做法是写一个定时任务扫描临时目录按lastModifiedTime删除过期目录同时把file_record表里statusUPLOADING超过一天的数据置为CANCELLED。做文件系统这几年我最大的教训就是最初图省事把所有文件都留在应用服务器本地磁盘把multipart.max-file-size调到2GB结果网关先掐了连接用户那边一片红。后来老老实实分片、建状态表、接FastDFS才算把大文件上传这件事从「靠运气」变成「可预期」。现在每接一个新系统我都会先问三个问题要不要秒传、最大的文件多大、读多还是写多答案不同分片大小和存储选型就跟着不同。这套思路希望你少走弯路有用就好。本文还有配套的精品资源点击获取