☰
Java实现FastDFS大文件上传与断点续传:从分片到秒传的完整方案
2026/9/28 16:07:35 网站建设 项目流程

简介:基于Java的FastDFS大文件上传与断点续传设计源码,面向需要处理大文件传输的Java Web开发者,重点解决上传中断、重复存储及秒传等实际问题,可应用于网盘、视频平台、文件管理系统等场景。压缩包共36个文件,约563KB,包含13个Java后端逻辑文件、5个JavaScript前端脚本、4个ftl页面模板、3个CSS样式表及图片预览、xml/properties配置与说明文档,目录结构清晰,便于按模块研读。资源借助H5与FastDFS实现了高性能断点续传与秒传,并引入redis文件锁保证并发上传一致性,同时涵盖文件上传、处理、存储等完整链路。已有705人学习下载,适合有一定Java基础、想深入掌握分布式文件系统与大文件上传方案的开发者,通过源码可快速上手并迁移到自己的项目中。

1. 基于Java的FastDFS大文件上传与断点续传,真正难点在业务层

第一次看到“基于Java的FastDFS大文件上传与断点续传设计源码”这个标题,很多人会默认FastDFS自带断点续传。我最初也这么以为,直到把一个1.8GB的压缩包切片往上传,才发现FastDFS只解决文件存储,分片状态、重传校验、秒传判断全要自己在Java业务层实现。这个方向的真正价值,是把HTTP传输、文件系统原语、分布式存储三者串成一个可恢复的状态机。做完这一套,不光能回答“大文件上传为什么慢”“断点续传到底续的是什么”这类经典Java面试题,还能直接把方案迁移到MinIO等对象存储上。适合做网盘、素材库、日志归档,或者正在设计百兆级上传接口的开发者。

2. FastDFS的存储模型和Java客户端初始化:先跑通最小上传链路

2.1 看懂tracker/storage和文件ID:为什么它适合扛大文件

FastDFS是C语言写的轻量级分布式文件系统,角色上只有两个:tracker负责调度,storage负责实际数据。客户端要上传文件,先连tracker获取一个storage节点地址,然后把整个文件或文件内容写进去;要下载文件时,拿着fileId让tracker给出storage地址,再去读。这里的fileId不是一个整数主键,而是一个类似group1/M00/00/00/wKg...的字符串。group是存储组名,M00是虚拟磁盘符号,后面是实际相对路径。

很多初次接触的人会把fileId当成路径直接拼URL去下载,结果漏掉group,或者把group当成目录前缀处理,下载时tracker无法定位。正确的做法是把fileId整体当字符串保存,不要拆字段。

FastDFS对大数据量友好是“整文件存储”的思路,而不是像HDFS那样把大文件按固定128MB分块并做副本均衡。整文件存储的最大优势是写路径短:一次tracker交互拿地址,一次storage连接写数据,没有中间块调度和校验开销;而做断点续传时,fileId本身成为最小的恢复单元。这也意味着,FastDFS不需要做数据块级的合并拆分,我们能自己控制哪个分片对应哪个物理文件。

它当然有局限:网络抖动时,文件只写了三分之二,storage侧会留下一个半截文件,读取端不判断长度很容易拿到脏数据。所以我们在应用层必须设计“完整文件校验”而不是只依赖FastDFS返回成功。

2.2 Maven依赖和Tracker初始化:连接参数先从超时开始

Java侧常用的客户端是基于原生fastdfs-client-java改造的封装,Maven坐标形如下面这样,具体版本以你仓库里能拉到的最新为准:

<dependency> <groupId>org.csource</groupId> <artifactId>fastdfs-client-java</artifactId> <version>${fastdfs-client.version}</version> </dependency>

初始化时最关键的是tracker地址列表和两组超时。一个最小可运行的配置类是这样:

public class FastDfsBootstrap { public void init() throws IOException { // tracker多个用逗号分隔 ClientGlobal.initByTrackers("192.168.1.11:22122,192.168.1.12:22122"); ClientGlobal.setG_connect_timeout(5_000); ClientGlobal.setG_network_timeout(30_000); ClientGlobal.setG_anti_steal_token(true); } }

逻辑说明:initByTrackers会把整个tracker列表加载到进程内,客户端后续会自动做节点切换;connect_timeout只作用于建立TCP连接,network_timeout作用于一次上传或下载的数据传输。很多人把后者设为10秒,跑小文件没问题,传2GB大文件时大量分片都死在半路,这就是最典型的参数坑。

这里的anti_steal_token是防盗链token开关,打开后每次请求要带时间戳和密钥。开启后,大文件分片上传时的校验会变得更严格,如果你没有配套计算token的能力,前期可以直接关掉,避免在业务逻辑还没写完时多一层排查难度。

2.3 上传一个最小文件:用一行代码验证存储连接

任何大文件设计都建议先跑通最小上传,再考虑分片和断点。最小上传的核心代码是upload_file:

public String upload(byte[] content, String ext) throws Exception { TrackerServer tracker = new TrackerClient().getConnection(); StorageClient storageClient = new StorageClient(tracker, null); String[] r = storageClient.upload_file(content, ext, null); return r[0] + "/" + r[1]; }

代码说明:StorageClient的第二个参数传null,客户端会在第一次写入时自动向tracker申请storage节点;返回的r[0]是group名,r[1]是文件名,两者拼接才是完整fileId。这里有个高频错误:只保存r[1],下载时没有group,tracker不知道怎么路由。

ext不要带点,比如视频传mp4,图片传jpg。FastDFS用它生成文件名结尾,如果带点,返回的fileId会变成aaa.mp4.这种重复后缀,客户端不兼容。这也是很多“为什么上传正常、下载却打不开”的根因。

2.4 连接池化和故障转移:别在高并发下反复new TrackerClient

原生客户端创建TrackerClient的成本不高,但每次上传都拿它去获取连接,在高并发大文件场景下,tracker会变成瓶颈。我一般用Apache Commons Pool管理TrackerServer连接:

public class TrackerServerPool { private final GenericObjectPool<TrackerServer> pool; public TrackerServerPool(GenericObjectPoolConfig<TrackerServer> config) { this.pool = new GenericObjectPool<>(new TrackerServerFactory(), config); } public TrackerServer borrow() throws Exception { return pool.borrowObject(); } public void returnObject(TrackerServer server) { pool.returnObject(server); } }

参数说明:maxTotal建议设为并发连接数的两倍,maxIdle设为并发数即可。testOnBorrow要开true,因为storage节点可能重启,闲置超过半小时的连接会变黑匣子,借出前校验一次能避免把断掉的连接当健康连接用。

基于这个连接池,大文件上传的基本路径才可靠:借连接、上传、归还。如果归还时抛异常,说明连接已坏,直接销毁,不要放回池子里,否则下个线程拿到一个损坏的socket,会反复超时。

2.5 元数据表设计:uploadId、md5、fileId三者各司其职

大文件设计最终要落一张上传任务表。我一般这样建:

create table upload_record ( upload_id varchar(64) primary key, file_name varchar(255) not null, file_md5 varchar(32) not null, file_size bigint not null, file_id varchar(255), status tinyint not null default 0, created_at datetime not null, updated_at datetime not null, key idx_md5_size (file_md5, file_size) ) engine=InnoDB default charset=utf8mb4;

字段职责很清楚:upload_id是前端每次上传会话生成的UUID,分片合并时都靠它归组;file_md5是文件整体摘要,秒传靠它命中;file_id只有等整个文件合并上传FastDFS成功后才有值;status用0、1、2分别表示上传中、处理中、完成。断点续传时,前端重新发起会话,先查这张表看是否已经有完成记录,否则用Redis里的分片进度续传。

3. 大文件分片上传:分片先落地本地,后台合并进FastDFS

3.1 不直接用FastDFS追加写,先落临时目录才是稳妥路线

FastDFS本身提供了AppenderFile机制,允许创建一个空文件后不断追加数据。用它可以做到每个分片直接写到远端,省掉中间落地。但这里有硬伤:追加操作不具备幂等性。比如一个分片已经写到了storage,TCP连接的ACK丢了,应用层没收到成功响应,客户端重传时,同一个分片会被再次追加到文件尾部,产生脏数据。FastDFS没有truncate接口,一旦出现这种情况,只能删掉整个文件重传。

先落地本地临时目录的方案虽然多一次磁盘IO,却能换来分片级的幂等。分片文件落地以后,每个分片都是一个独立小块,覆盖写可以把上次失败留下的残片整体替换掉。合并阶段再完整上传FastDFS,即使合并上传失败,重新执行合并任务也不会污染原文件。这就是断点续传和完整上传的本质区别:完整上传是一次不可中断的瞬时动作,断点续传是把动作拆成有进度记录的多次幂等操作。

3.2 前端分片大小与Web Worker切割:1MB到8MB是常见区间

分片大小直接影响网络往返次数。我一般对500MB以上文件取5MB,普通文件取2MB。分片太小,HTTP头和FastDFS连接的次数会暴涨,tracker压力大;分片太大,单次传输失败的重传代价高,用户等待单个请求的时间也长。生产上建议下限1MB、上限8MB,既能躲开Nginx默认body限制,又不会把一次请求拖成分钟级。

现代前端可以用Web Worker做File切片,避免拖住UI主线程。核心逻辑是:

const CHUNK_SIZE = 5 * 1024 * 1024; let offset = 0; let index = 0; while (offset < file.size) { const blob = file.slice(offset, offset + CHUNK_SIZE); postMessage({ index: index++, blob: blob, start: offset, end: Math.min(offset + CHUNK_SIZE, file.size) }); offset += CHUNK_SIZE; }

参数说明:CHUNK_SIZE和index必须严格对应,后端合并时不是按文件名排序,而是按index拼接。很多分片错乱问题就是前端在失败后没把index固定,导致重传的分片进入另一个位置。正确做法是,每个分片对象在内存里固定好index,重试时仍然携带同一个index。

3.3 后端接收分片:按uploadId隔离目录,写入后用Redis记账

后端接口用SpringMVC接收每个分片,先落到临时目录。目录结构为/upload_temp/{uploadId}/{index}.part:

@PostMapping("/upload/chunk") public ChunkResult receiveChunk(@RequestParam String uploadId, @RequestParam int index, @RequestParam int total, @RequestPart MultipartFile chunk) throws IOException { Path dir = Path.of(UPLOAD_TEMP, uploadId); Files.createDirectories(dir); File target = dir.resolve(index + ".part").toFile(); chunk.transferTo(target); Long added = redis.opsForSet().add("uploadProgress:" + uploadId, String.valueOf(index)); redis.expire("uploadProgress:" + uploadId, Duration.ofHours(24)); if (added != null && added == 0) { // 说明这个index之前已经成功过,当前是重复请求 return new ChunkResult(uploadId, index, total, true); } return new ChunkResult(uploadId, index, total, true); }

逻辑说明:transferTo会把临时文件写到多部分上传的临时目录,再rename到目标目录,这一步耗的是磁盘IO。Redis里的set记录已完成分片,added返回值表示本次是否真正新写入:如果返回0,说明客户端重复上传,接口仍然返回成功给前端,但不需要覆盖写入,因为这次写入的字节大概率是同一个分片的重复数据。

这里有坑:如果前端是4个分片并发上传,后端会同时处理多个请求。同一个uploadId下,多个线程写不同的index.part是安全的,因为它们文件名不同。真正要加锁的是后续合并任务,不能在一个分片还在写的时候触发合并。

3.4 合并任务:分片齐全后异步拼接并整体上传FastDFS

当Redis里记录的set大小已经等于total,就可以触发合并。这里不要用接口请求线程去合并大文件,否则用户HTTP连接会一直挂到合并结束,超过Nginx超时直接被断开。我用Spring的@Async提交一个异步任务:

@Component public class MergeAndUploadTask { @Async("uploadExecutor") public void doMerge(String uploadId, int total, String md5) throws Exception { Path dir = Path.of(UPLOAD_TEMP, uploadId); long allSize = 0L; for (int i = 0; i < total; i++) { Path partPath = dir.resolve(i + ".part"); if (!Files.exists(partPath)) { throw new IllegalStateException("缺分片 index=" + i); } allSize += Files.size(partPath); } try (FileOutputStream out = new FileOutputStream(mergeFile)) { for (int i = 0; i < total; i++) { Files.copy(dir.resolve(i + ".part"), out); } } String fileId = fastDfsClient.uploadWithStream(mergeFile, extractExt(fileName), length, meta); uploadRecordMapper.markFinished(uploadId, fileId, md5); deleteTempDir(dir); } }

参数说明:合并前先遍历一遍所有分片是否齐全,且记录总字节数,这比信任前端传的total更可靠。Files.copy是流式复制,不会把整个文件读入堆内存;真正完整拷贝完再上传FastDFS,文件内容才完整。

这里如果文件很大,合并后的临时文件也要注意清理时机。上传FastDFS成功以后再把临时目录删掉;上传失败则保留临时文件,让下一个合并任务可以重新执行。所以合并任务必须支持幂等:同样一批分片,执行两次合并,结果完全一致。

3.5 合并上传FastDFS流式上传:避免ByteArrayOutputStream爆内存

不少封装会把上传方法设计成接收byte[],这就意味着合并完大文件还要读进内存,超过JVM最大堆直接OOM。稳妥做法是用文件Stream上传,FastDNS客户端通常暴露接收文件路径的方法,合并后的MergeFile直接交给它,底层会分段读取。如果你用的封装只能接收流,要确保上传完即关闭,并且不能让这个流被重复读取。

4. 断点续传与秒传:把文件进度变成可以恢复的持久状态

4.1 续传的三个状态:文件MD5、已传分片、文件fileId

一次上传被中断后,重新发起时后端必须回答三个问题:这个文件是不是已经完成过?完成过就直接秒传。任务进行中,目前已经成功落地的分片是哪些?这决定前端从第几个分片继续。最后一个没传完的分片,是否需要整体重传?

回答这三个问题的数据分别是:upload_record表里的md5字段、Redis里的一个Set、以及最终回填的fileId。设计键名时,我建议把Set和临时目录都挂在uploadId上,但秒传命中必须挂文件MD5,不能混在一起,否则不同用户上传同一个文件,进度集会互相干扰。

4.2 上传前check接口:先看秒传,再看续传

秒传不是真的跳过上传,而是用文件摘要命中历史记录。核心是md5和size同时匹配:

public PreUploadVO precheck(String md5, long size, String fileName) { UploadRecord record = uploadRecordMapper.selectByMd5AndSize(md5, size); if (record != null && record.getStatus() == Status.FINISHED) { return PreUploadVO.shortcut(record.getFileId()); } Set<String> uploadedChunks = redis.opsForSet().members("uploadProgress:" + md5); return PreUploadVO.resume(uploadedChunks); }

参数说明:md5必须在切片前对完整文件计算。前端大文件计算MD5时,不要在浏览器里一次性读入ArrayBuffer算完再传,应该用FileReader的流式分片更新摘要,或者让Worker循环读取每个分片后递进计算,避免浏览器内存被撑爆。后端这里用md5当Redis键和数据库索引,命中秒传时直接返回fileId给前端,前端从此不需要上传任何分片。

4.3 接收分片前的幂等判断:重复分片不落地、不改size

分片断点续传本质要求是“同一个index,无论传多少次,最终内容完全相同”。所以后端在接收分片时,第一步应该是幂等判断:

if (redis.opsForSet().isMember("uploadProgress:" + uploadId, String.valueOf(index))) { // 说明该分片已经完整写入,直接返回成功 return new ChunkResult(uploadId, index, total, true); }

这段逻辑可以放在receiveChunk方法最前面,让重复请求不产生额外IO。同时要给同一个index的写操作加锁,避免上一个请求还在transferTo,下一个请求已经覆盖同一个part文件。本地锁可以按uploadId + ":" + index锁粒度,如果用分布式,则按这个key做RedisLock。

4.4 分片对不齐的场景:最后一个分片必须按实际长度处理

断点续传最常见的一致性陷阱,是对“最后一个分片”的处理。比如10MB文件按5MB切片,会产生3个分片,第三个只有约0.4MB。如果合并代码里强行按5MB对齐去读,会把临时文件里相邻的残留数据读进来,或越界读失败。

解决方案是在接收分片时记录每个index的实际字节数,把它作为合并阶段的输入。可以用一个Map或RedisHash:

redis.opsForHash().put("uploadSize:" + uploadId, String.valueOf(index), String.valueOf(chunk.getSize()));

合并时:

long partSize = Long.parseLong( redis.opsForHash().get("uploadSize:" + uploadId, String.valueOf(i)).toString()); byte[] buf = new byte[(int) Math.min(8192, partSize)];

这样最后一个不满块的分片也能被精确拷贝,不会多读一个字节。注意chunk.getSize()必须在transferTo之前获取,因为转储后MultipartFile的临时文件可能已经被清理,再取size会得到0。

4.5 进度不是Redis过期就算完:定时清理临时目录

Redis里的进度键我设置24小时过期,这只是为了自动清理进度内容。临时目录不能只依赖Redis过期,因为Redis键失效不会触发文件删除。必须有一个定时任务,周期性扫描/upload_temp下mtime超过24小时的目录,直接删除:

Files.walk(root) .filter(Files::isDirectory) .filter(dir -> dir.toString().length() > root.toString().length()) .forEach(dir -> { long lastModified = Files.getLastModifiedTime(dir).toMillis(); if (System.currentTimeMillis() - lastModified > 24 * 3600 * 1000) { deleteDir(dir); } });

清理任务最好放在后台低优先级线程里执行。对于在线网盘场景,这种过期文件本质上就是上报失败或用户主动放弃的残留物,不及时清理会拖垮磁盘。

5. 大文件上传必踩的五个坑:从连接超时到磁盘占满

5.1 现象:分片传了20个,第21个开始偶发Connection reset

原因:FastDFS的network_timeout默认偏小,大分片在慢网络上还没传完,服务端先读超时,主动断开连接。这个全局参数在进程内共享,谁先初始化就生效,日志里往往看不出具体原因。

解决:启动早期就把ClientGlobal.setG_network_timeout设成足够大。经验值是最大分片大小除以期望带宽的2倍,比如5MB分片在10Mbps带宽下,理论上需要4秒,timeout建议取30秒,留出重排队和磁盘写入的时间。更稳定的是把大分片切成小块,但timeout仍然要留足。

5.2 现象:前端4个分片并发上传,最后合并文件MD5对不上

原因:并发分片顺序无关,只要内容完整,最终合并结果不变。但如果同一个index被多个请求同时写,或者上一次失败的分片还没写完,下一次覆盖写已经开始,part文件内容就会变成两次写入的交叉碎片。表面看分片数量齐全,实际某个index坏了。

解决:前端并发上传时,每个分片只能由一个请求负责;后端在接收分片时对uploadId + index加锁,重复index后到者直接返回成功,因为之前的分片已经完整写入。合并任务必须在所有分片都稳定写入后才触发,用Redisson或ZooKeeper锁把合并和上传隔离,不能一边收分片一边合并。

5.3 现象:续传完成后的文件比原始文件大几个字节,MD5不对

原因:上一次网络中断时,分片已经在临时文件里写了一半,但Redis里没记录这个index。前端重传时从该index开始,第三次请求又会把半个分片的内容追加在后面,导致part文件变成“残缺内容+完整内容”的组合。

解决:接收分片时先检查part文件是否存在且长度等于chunk.getSize(),如果不一致,先把旧part删除再写入新part。不要用追加模式处理分片文件。Redis里一旦标记该index成功,就默认这个分片完整,后续同index请求全部被视为重复请求。

5.4 现象:临时目录以GB级增长,最终磁盘剩余空间为0

原因:Redis里进度键过期后,临时分片目录还在磁盘上;后台清理任务没有匹配执行周期,过期任务会无差别堆积。尤其在用户放弃上传或网络一直失败时,分片目录会反复创建、反复失败,永不删除。

解决:临时目录的清理不能依赖Redis,要单独做。我一般每分钟跑一次扫描,mtime超过24小时即整个uploadId目录直接删除。同时,上传开始时就应该在这个目录里维护一个meta.json文件,记录分片总数和过期时间,清理任务读它比遍历文件名更可靠。

5.5 现象:Nginx代理上传报413 Request Entity Too Large

原因:大文件分片上传走Nginx反代时,Nginx的client_max_body_size默认1MB,单个分片都超过这个限制,被Nginx直接拦截。很多人只调Spring允许的最大请求大小,不调Nginx,结果前端一直收到413。

解决:分片5MB时,把Nginx配置写成:

client_max_body_size 8m; client_body_timeout 60s;

8m留出齐整余地,client_body_timeout照顾慢网络。如果做整包上传,这个值要按最大文件尺寸设置,会带来长期风险;所以分片才是更安全的选择,也让Nginx层参数可以省心。

6. 进阶技巧:Appender模式直传和整链路验证

到这里,分片+临时目录+合并上传的稳妥方案已经足够跑通生产环境。第3章提到的AppenderFile,在低存储占用的场景里也能作为进阶替代:用upload_appender1创建一个空文件,然后每个分片到达时调用append_file1直接追加到storage,省掉一层本地磁盘IO。

它的前提非常苛刻:所有分片必须按序号串行到达,不能并发,也不能跨分片续传。只要有一个分片乱序,追加位置就错了;只要有一次请求超时后客户端重发,就会出现重复追加。只有在你能接受“应用节点宕机时整个上传任务必须重开”的场合,才值得用它。否则,前文的临时目录方案虽然多一次IO,却让存储侧保持简单,我心里更踏实。

验证链路分三步做。第一步,准备一个1GB以上的测试文件,用dd if=/dev/urandom生成随机内容,固定它的MD5值;第二步,模拟中断:上传前50个分片后杀掉进程,再启动服务、重新调用check接口,确认返回的已传分片集合和实际part文件数量一致;第三步,分片传完并合并成功后,用下载接口把FastDFS里的文件取回来,分别计算两个文件的MD5:

md5sum bigfile.bin md5sum downloaded_from_fastdfs.bin

如果两侧MD5不一致,不要急着看FastDFS,先检查临时目录里是否有残留的半截part,再看Redis中分片set是否有重复index。我现在的习惯是,每个上传任务在Redis里存一份前端提供的小写md5,合并完成后服务端再算一次设备侧摘要,两值不一致就不回填fileId,让任务失败。宁可重传一次,也不能把一个坏文件推给下游。

整体这套设计里,代码量最大的是业务层的进度管理和异常恢复,FastDFS本身只是稳定的存储后端。真正有价值的东西,是把“文件传输”这个不可中断的瞬时动作,拆成可记录、可重放、可校验的状态机。希望这个思路能帮你在简历项目或生产网盘设计里少走几条弯路,也希望分片大小、network_timeout、临时目录清理周期这些参数,都成为你上线前必查的checklist项目。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询