接手过一套内部文件同步系统的维护工作之后,我才真正体会到什么叫“大文件上传的最后一公里最难走”。当时一个2GB左右的数据包走普通表单上传,传了快二十分钟,进度条走到96%,网络一抖连接超时,整个任务直接失败,客户端和服务端拿到的信息都不完整,只能从头再来。后来我把整套逻辑改成大文件断点续传实现,才把这类问题彻底压下去。这篇文章就把我在Java里做断点续传的方案、原理、代码以及生产环境里遇到的各种坑一次性讲清楚,适合正被大文件上传搞到头疼的Java开发者,也适合想系统理解断点续传原理的初学者。
1. 为什么大文件上传会在最后一步翻车
1.1 一次典型的上传失败现场
先描述一个大多数人遇到过的场景:客户端用InputStream.readAllBytes()把整个2GB文件读进内存,然后用一个HTTP POST请求把字节流一股脑推给服务端。听起来简单,实际上问题很早就埋下了。
- 客户端JVM堆内存默认可能只有1GB到2GB,读取2GB文件时直接
OutOfMemoryError。 - 即使内存够,单次HTTP请求要持续保持连接十几分钟甚至更久,任何一个中间节点断开连接,整个文件就得重新上传。
- 服务端把整个请求体内的字节一次性写入文件,如果中间抛异常,服务端临时文件不完整,客户端也不知道从哪恢复。
我第一次处理这类问题时,检查了客户端日志、服务端日志、反向代理日志,最后发现服务端收到的字节数只有总大小的96%,连接就断了。因为没有断点续传机制,服务端那个不完整的临时文件只能删掉,客户端也丢掉了自己的进度信息。这种“差一点成功”的失败,比完全失败的定位更折磨人。
1.2 大文件传输和普通上传到底有什么不同
普通的小文件上传,比如几百KB的图片,可以用“一次性读取、一次性发送、一次性落盘”的模型,即使失败,重试成本也很低。大文件模型则完全不一样,失败代价随文件大小线性增长。
| 维度 | 小文件上传 | 大文件上传 |
|---|---|---|
| 内存占用 | 整体读入内存,可接受 | 整体读入容易OOM |
| 单次连接时长 | 秒级 | 分钟级甚至小时级 |
| 失败代价 | 重试成本低 | 重试成本极高 |
| 服务端落盘 | 一次写入 | 长时间占用IO和连接 |
| 网络波动影响 | 影响小 | 任何抖动都会致命 |
所以要解决大文件传输,核心思路不是靠“更强的网络”或“更大的内存”,而是把大任务拆成多个可独立提交的小任务,每个小任务失败后不需要重头再来,只需要从失败点继续。这就是断点续传方案的逻辑起点。
1.3 断点续传到底解决了什么问题
断点续传并不直接提升传输速度,它解决的是失败后的可恢复性。一次传输被拆成很多个分块后,每个分块都有自己的“完成状态”,客户端和服务端共同维护这个状态,下次继续时,只需要查询“传到了哪里”,然后从那个偏移量继续发送。
这里要特别说清楚一点:断点续传是“失败可恢复”方案,不是“失败可避免”方案。网络抖动、服务端重启、客户端断电,这些问题仍然会发生,但发生之后不会被逼着重来整个文件。理解了这一点,后面的所有设计都会围绕“状态记录”和“偏移量计算”展开。
2. 断点续传的底层原理:分块、游标与元数据
2.1 分块:把大文件拆成可独立重试的小任务
分块是断点续传的前提。假设一个文件1GB大小,我把它按4MB切成约256个块,每个块就是一次HTTP请求的载荷。哪怕网络再差,最多只会导致一个4MB的块失败,重传一个块的成本几乎可以忽略。
块大小的选择会影响传输效率和复杂度。我个人在Java项目里的经验是4MB到8MB比较合适。分块太小,比如256KB,会出现大量HTTP请求,连接建立和销毁的开销会吃掉传输收益;分块太大,比如64MB,又退回到了接近整文件上传的状态,一旦失败重试成本很高。另外还要考虑中间代理的body大小限制,很多网关对单个请求体有上限,4MB到8MB非常安全。
可以把这个过程想象成搬家:一个人搬一整套沙发非常吃力,中途还可能卡在楼梯口;如果拆成几个部件分别搬,每个部件都能独立搬运,就算某次搬运失败,也只需要重新搬那个部件,而不是把整个沙发扔下楼重买。
2.2 游标(offset):记录“传到了哪里”
分块之后,最关键的数据就是游标。游标本质上是一个数字,表示“已经成功传了多少字节”。比如文件总大小fileSize = 1GB,已经传了offset = 734003200字节,那么剩余待传部分就是fileSize - offset。
在编程实现时,游标和块大小是配合使用的:
- 当前需要读取的起点:
offset - 本次要传输的字节数:
min(chunkSize, fileSize - offset) - 本次请求的范围:
bytes start = offset到end = offset + readLen - 1
假设文件总大小100MB,块大小4MB,那么总块数就是25块。当前偏移量如果是41943040(40MB),就意味着前面10块已经全部完成,下一次从第11块开始发起请求。
2.3 元数据记录:客户端和服务端如何知道进度
有了游标还不够,必须把游标持久化下来,否则进程一重启进度就丢了。我在项目里做了两层记录:
- 客户端本地记录:用一个properties或json文件保存上传任务ID、文件大小、已传偏移量、分块状态。每成功传完一个块,就更新这个文件。
- 服务端临时文件记录:服务端收到分块内容后,写到同一个临时文件的指定偏移位置。命名规则可以用
UUID+ 原始文件名 +.part,这样多个上传任务不会互相覆盖。
双端记录的好处是:即使客户端本地记录损坏,服务端临时文件的大小也能作为辅助判断进度的依据。生产环境中,客户端启动续传时,可以请求服务端查询临时文件当前大小,和本地记录交叉校验,如果发现差异过大,优先信任服务端那边更完整的数据,但这套逻辑要配合校验机制来做,后面专门讲。
3. 用RandomAccessFile与HTTP Range实现一套最小可用方案
3.1 为什么选RandomAccessFile而不是FileOutputStream
很多Java开发者在写文件时习惯用FileOutputStream,但它在处理断点续传时有一个明显问题:FileOutputStream默认只能从文件开头写入,要么使用追加模式从末尾写入,没办法指定一个任意偏移量开始写。断点续传恰恰要求服务端把新到达的块写到临时文件的指定位置,所以这里必须用RandomAccessFile。
RandomAccessFile.seek(offset)可以把文件指针移动到任意位置,然后调用write(byte[])从该位置开始写。它底层调用的是操作系统提供的文件定位写入能力,性能上没有想象中那么差,配合固定的块大小使用,完全能满足生产需求。
客户端侧同样需要RandomAccessFile,不过是用只读模式"r"打开,定位到指定偏移量后读取文件内容发送请求。这是这套方案两端会同时用到它的原因。
3.2 客户端:按偏移量读取并上传
下面是一段最简实现,逻辑上先读取本地记录的已传偏移量,然后从这个偏移量开始读取一个块,把块内容作为请求体发送,同时把块范围写进Content-Range请求头。
import java.io.*; import java.net.HttpURLConnection; import java.net.URL; public class SimpleUploadClient { private static final int CHUNK_SIZE = 4 * 1024 * 1024; // 4MB private static final String UPLOAD_URL = "http://localhost:8080/upload"; public static void main(String[] args) throws Exception { File file = new File("/data/bigfile.zip"); long fileSize = file.length(); long offset = readLocalOffset("upload-progress.dat"); if (offset >= fileSize) { System.out.println("文件已上传完成"); return; } try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { raf.seek(offset); byte[] buffer = new byte[CHUNK_SIZE]; int readLen = raf.read(buffer); if (readLen <= 0) { return; } long end = offset + readLen - 1; HttpURLConnection conn = (HttpURLConnection) new URL(UPLOAD_URL).openConnection(); conn.setRequestMethod("POST"); conn.setDoOutput(true); conn.setRequestProperty("Content-Type", "application/octet-stream"); conn.setRequestProperty("File-Name", file.getName()); conn.setRequestProperty("Content-Range", "bytes " + offset + "-" + end + "/" + fileSize); try (OutputStream out = conn.getOutputStream()) { out.write(buffer, 0, readLen); } int code = conn.getResponseCode(); if (code == 200 || code == 201) { long newOffset = offset + readLen; saveLocalOffset("upload-progress.dat", newOffset); System.out.println("本次上传 " + readLen + " 字节,进度 " + newOffset + "/" + fileSize); } else { System.out.println("上传失败,HTTP状态码: " + code); } } } private static long readLocalOffset(String path) throws IOException { File f = new File(path); if (!f.exists()) { return 0L; } try (DataInputStream in = new DataInputStream(new FileInputStream(f))) { return in.readLong(); } } private static void saveLocalOffset(String path, long offset) throws IOException { try (DataOutputStream out = new DataOutputStream(new FileOutputStream(path))) { out.writeLong(offset); } } }这段代码里最容易忽略的细节是raf.read(buffer)的返回值可能小于CHUNK_SIZE,尤其是文件最后一块。这时不能想当然认为读取了完整4MB,必须用readLen作为实际发送长度,如果直接用整个buffer发送,尾部会带上无用的空字节,导致总大小对不上。
3.3 服务端:解析Content-Range并写入指定偏移
服务端用Spring Boot实现,接收File-Name和Content-Range两个请求头,解析出起始偏移量,然后用RandomAccessFile打开服务端临时文件,定位到起始偏移量后把请求体写入。
import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.io.*; import java.nio.file.Paths; @RestController public class UploadController { private static final String TMP_DIR = "/data/upload_tmp"; @PostMapping("/upload") public ResponseEntity<String> upload( @RequestHeader("File-Name") String fileName, @RequestHeader("Content-Range") String contentRange, InputStream inputStream) throws Exception { long[] range = parseRange(contentRange); long start = range[0]; long fileSize = range[2]; File tmpFile = Paths.get(TMP_DIR, fileName + ".part").toFile(); if (!tmpFile.getParentFile().exists()) { tmpFile.getParentFile().mkdirs(); } try (RandomAccessFile raf = new RandomAccessFile(tmpFile, "rw")) { raf.seek(start); byte[] buffer = new byte[8192]; int len; while ((len = inputStream.read(buffer)) > 0) { raf.write(buffer, 0, len); } } // 如果已经写完,重命名临时文件为正式文件 if (tmpFile.length() >= fileSize) { File finalFile = Paths.get(TMP_DIR, fileName).toFile(); if (tmpFile.renameTo(finalFile)) { return ResponseEntity.ok("completed"); } } return ResponseEntity.ok("continue"); } private long[] parseRange(String contentRange) { // 示例: bytes 0-4194303/10737418240 String[] parts = contentRange.replace("bytes ", "").split("[/-]"); long start = Long.parseLong(parts[0]); long end = Long.parseLong(parts[1]); long total = Long.parseLong(parts[2]); return new long[]{start, end, total}; } }这里需要说明,Content-Range头本身是一个标准HTTP头,语义上主要用于服务端返回响应时的断点续传,并不直接规定客户端可以这样用来上传。在实际工业级文件上传方案中,很多实现都会借用这个头的结构来传递“当前块范围”和“文件总大小”,大家约定好格式就能工作。你也可以自定义成X-File-Offset、X-File-Size这类请求头,效果一样,关键是语义清晰。
3.4 一次完整续传的交互流程
把上面两端串起来看,一次带断点续传的上传流程分为两个阶段。
第一阶段是首次上传:
- 客户端读取本地记录文件,发现没有记录,
offset = 0。 - 从文件偏移0开始读取第一个块,发送
Content-Range: bytes 0-4194303/10737418240。 - 服务端创建
.part临时文件,从偏移0写入。 - 客户端收到成功响应后,把本地记录更新为4194304。
第二阶段是断点续传:
- 客户端读取本地记录文件,得到
offset = 4194304。 - 从偏移4194304读取第二个块,发送
Content-Range: bytes 4194304-8388607/10737418240。 - 服务端用RandomAccessFile打开已有临时文件,
seek(4194304)后写入。 - 客户端更新记录,继续下一块。
如果中途服务端已经写入了某一块但客户端没有记录成功,客户端会重复上传这一块,服务端因为永远从固定偏移量写入,内容一致的情况下覆盖写入没有影响,这保证了整个方案在弱网环境下的幂等性。
4. 多线程分块上传:提速同时别让落盘顺序出问题
4.1 多线程划分区间
单线程逐块上传虽然可靠,但速度不算快。大文件场景下想要利用带宽,通常需要多个连接并发上传。做法上可以先把文件总大小划分成N个区间,每个区间分配给一个线程或一个任务去处理。
import java.util.ArrayList; import java.util.List; import java.util.concurrent.*; public class ConcurrentUploader { private static final int CHUNK_SIZE = 4 * 1024 * 1024; private static final int THREAD_COUNT = 4; public static void main(String[] args) throws Exception { File file = new File("/data/bigfile.zip"); long fileSize = file.length(); long blockCount = (fileSize + CHUNK_SIZE - 1) / CHUNK_SIZE; ExecutorService pool = Executors.newFixedThreadPool(THREAD_COUNT); List<Future<?>> futures = new ArrayList<>(); for (long i = 0; i < blockCount; i++) { final long blockIndex = i; futures.add(pool.submit(() -> { long start = blockIndex * CHUNK_SIZE; int length = (int) Math.min(CHUNK_SIZE, fileSize - start); uploadRange(file, start, length, fileSize); })); } for (Future<?> future : futures) { future.get(); } pool.shutdown(); } private static void uploadRange(File file, long start, int length, long fileSize) { // 具体发送逻辑,和上一节的客户端发送代码基本一致 } }需要注意,多线程模式下,每个线程负责一个块,块的起始偏移由blockIndex * CHUNK_SIZE计算得出。每个块的偏移互不重叠,所以服务端并发写入时,只要每个请求都seek到自己的偏移位置,就不会出现互相覆盖的问题。
4.2 服务端并发写入的落盘策略
服务端在每个HTTP请求中都独立地new RandomAccessFile(tmpFile, "rw"),然后seek到请求头指定的偏移量写入。这里有一个Java IO层面的细节:OS本身支持从文件的不同偏移并发写入,每个打开的文件描述符都各自持有自己的文件指针。RandomAccessFile.seek设置的是当前文件描述符的指针,所以多个请求各自打开文件、各自seek、各自写入,互不干扰。如果真的复用同一个RandomAccessFile实例并在线程间共享,那才是会有竞态风险。
另外,文件最终是否完整,不能通过“请求成功数等于分块数”来简单判断,要看.part临时文件的实际字节大小是否等于文件总大小。如果等于,说明所有分块都已经落盘,可以触发合并逻辑。合并逻辑很简单,就是重命名.part文件为最终文件。注意如果服务端判断完整后执行了重命名,那后续客户端再发一个块过来,就会重新创建.part文件,破坏最终文件。所以正确做法是,在重命名完成之后,后续请求如果检测到最终文件已存在,直接返回完成状态即可,不要再去写临时文件。
4.3 进度条计算与前端联动
多线程上传的进度条计算比单线程复杂。总进度不再是一个简单的offset / fileSize,而是“已完成分块字节数之和 / 文件总大小”。所以我建议在本地记录里,用一个BitSet或Set<Long>保存已完成块的索引,而不是只保存一个总偏移量。这样有两大好处:
- 单个块失败后,只需要重传这个块,而不是把总偏移量之前的所有内容重新算一遍。
- 进度条可以根据
已完成块数量 / 总块数精确计算。 - 服务端恢复时也可以按块索引做并发校验,哪一块缺失就补哪一块。
前端要展示进度,后端可以把已完成块数和总块数返回给前端,前端自己算百分比。如果某个块在重试,进度条会有短暂的停滞,这属于正常现象。
5. 校验与秒传:给断点续传加上可靠性兜底
5.1 大小校验只能做初步判断
只靠.part文件大小等于fileSize来判断上传完成,存在一个隐患:如果中间某一块内容写错位置,或者网络传输过程中出现数据损坏,文件大小仍然可能正确,但内容已经错了。这时候重命名临时文件为正式文件,得到的就是一个损坏文件。
所以在生产环境里,我会在分块上传之外再加一层校验。最直接的是先校验大小,上传结束后校验内容。常见的做法是客户端在每次上传前计算整個文件的MD5,服务端在合并完成后重新计算MD5,两者对比一致才算最终成功。这个方案的缺点是整个文件MD5计算耗时较长,一个1GB文件在普通机器上可能需要几秒到十几秒,但对可靠性要求高的系统来说完全可以接受。
5.2 分块校验比整体校验更快定位问题
相比只在最后做整体MD5,我更推荐在分块层面同时做好校验。每个分块在客户端发送前,先计算这个块的CRC32或MD5,随请求头传给服务端。服务端落盘后,同样计算这个块的校验值并对比,如果不一致,立刻返回失败,客户端只需要重传这一个块。
这样做相比整体校验有一个很大的优势:如果一个块在传输过程中损坏,整体校验只能告诉你“文件坏了”,分块校验能直接告诉你“具体第几块坏了”。在弱网环境里,分块校验的失败恢复成本要低得多。块级校验用CRC32还是MD5,我的经验是CRC32足够,计算速度快,碰撞概率对块级校验来说可以接受;整体文件校验则用MD5或SHA-256,确保最终文件完整性。
5.3 秒传:利用已知信息跳过重复上传
断点续传之外,还有一个经常一起讨论的功能叫“秒传”。它其实不是一个传输技巧,而是一个查询技巧。如果服务端已经存在一个和当前文件文件名、文件大小、MD5完全一致的文件,那么客户端不需要真正上传,只需要发起一个校验请求,服务端发现文件已存在,直接返回“上传完成”,整个任务在几毫秒内结束。
实现秒传的关键是服务端要有一个文件索引表,记录文件的MD5、大小、存储路径。每次客户端上传前,先调用一个check接口,把文件名、大小、MD5传过去,服务端查询索引表。如果命中,直接返回已存在并复用;如果没有命中,再走正常的断点续传逻辑。秒传能省掉大量重复上传流量,尤其在内网文件分发、离线包同步这类场景里价值很高。
要注意的是,秒传和断点续传的边界要处理好。如果客户端先用秒传接口检查,发现服务端没有完整文件,然后开始分块上传,但另一个上传任务同时也在传同一个文件,可能出现两个任务写到同一个服务端临时文件的情况。所以生产环境里我会在文件索引表上加一个“上传中”的状态,标记临时文件的归属任务,避免并发冲突。
6. 生产环境里真正容易踩的坑
6.1 服务端临时文件目录被写满
断点续传方案上线后,最容易出的问题就是磁盘被临时文件占满。原因很典型:一批客户端开始上传大文件,服务端每个任务都会创建一个.part临时文件。有些客户端传了一半就放弃了,或者网络中断后重试逻辑不完善,临时文件就会一直留在磁盘上。如果监控没有覆盖这个目录,很快就能把磁盘写满,影响到其他正常服务。
我的处理办法是给临时文件一个过期时间,比如超过24小时没有更新的.part文件,由清理任务定时删除。同时在服务端记录每个上传任务的状态,当客户端明确放弃或长时间不活动时,主动释放临时文件。上线初期我也会把这块目录的磁盘使用量接到监控告警里。
6.2 客户端本地记录文件损坏
本地记录文件是断点续传能否“续得上”的关键。我见过一种情况:客户端进程在上传过程中被强杀,写入记录文件的part文件状态和数据还没落盘,下一次启动时读到一个半截的进度文件,解析失败。如果不能解析,通常我会恢复为offset = 0,但从头传一个大文件代价很大。
修正方案是不要直接改写记录数据文件,而是采用“先写临时记录文件,再原子重命名”的策略:每次进度更新时,先写upload-progress.dat.tmp,写完后调用File.renameTo原子替换旧文件。这样即使进程中途被强杀,变化的也只是临时文件名,原记录文件还是上一次完整写入的状态。这个思路在很多需要持久化状态的客户端场景里都适用。
6.3 反代和网关默认超时问题
断点续传解决的是“大文件上传本身”的问题,但部署环境中还常常有一个隐形限制——反向代理或网关的默认请求超时时间。很多网关默认超过60秒没有收到完整请求体就会断开连接。即使你客户端已经把内容发完了,如果发送耗时超过代理的超时时间,服务端一样收不到完整请求。
应对方法有两个方向。一是调大网关的上传超时和请求体大小限制,需要注意这是全局配置,会影响所有接口。二是把分块大小控制在小范围内,比如4MB,正常网络下单个请求几十秒内就能完成,基本不会触发超时。这两个方向我会同时做,但是以调整分块大小为主,因为即使调大了网关超时,也不能保证端到端链路的每个节点都没有超时限制。
6.4 服务端已重命名但客户端仍在上传
这是一个容易被忽略的时序问题。当最后一个分块上传完成,服务端把.part重命名为最终文件之后,如果客户端由于本地记录更新延迟,又发起了一次重传请求,服务端会重新创建.part文件并写入,用户会看到一个内容不完整或被覆盖的最终文件。
我在服务端代码里加的兜底逻辑是:每次上传请求到达时,先检查最终文件是否存在,如果存在直接返回成功,不再创建临时文件。这样即使客户端多传了几个块,服务端也不会再去动最终文件,保证文件状态不会被二次上传破坏。
7. 总结
断点续传这个功能,看似只是“把文件切成块继续传输”,实际落地时要把状态记录、并发写入、校验、秒传、临时文件管理和各种边界时序都考虑清楚。我在实际项目中前后改了三版,第一版只做了最简单的断点续传,发现进度恢复经常不准;第二版加了分块校验和秒传,可靠性明显提升;第三版才把多线程上传、服务端临时文件过期清理、代理超时这些问题全部理顺。如果从头再来一次,我会先把“临时文件”和“最终文件”的边界设计清楚,把“客户端记录状态”和“服务端已落盘状态”的同步机制想明白,再写具体代码。
最后分享一个实用的调试技巧:把客户端每次记录的已传偏移量和服务端临时文件的字节大小同时打印到日志里。两个数字如果始终保持一致,说明传输状态正常;如果服务端临时文件比客户端记录大,说明有重复上传但没有被覆盖;如果小,说明确实有块没传到服务端。有了这两个数字做对比,断点续传的很多疑难问题一眼就能定位。希望这篇由项目实战总结出的经验能帮你少踩几个坑。