
大文件上传是 Web 开发里一个非常典型的复合问题。它不是单个接口能解决的而是涉及前端文件读取、网络传输、后端存储、异常恢复和一致性校验的一套完整流程。断点续传则是这套流程里最容易出问题的环节很多人知道要分片但说不清分片之后如何保证合并结果正确也不知道网络中断、服务重启、内存溢出这些场景下系统该怎么恢复。面试官问“大文件上传与断点续传有哪些坑”本质上是在看候选人有没有真正处理过分片、合并、校验、并发、内存和异常恢复这些细节而不是只看你背过几个名词。这篇文章会沿着一条完整的技术链路展开先弄清楚大文件上传为什么不能直接做再设计一套前端分片加后端合并的最小可行方案然后重点剖析实际开发中常见的坑、报错现象和排查路径。读完以后你可以用这套思路回答面试问题也能在自己的项目里落地一个可以验证的分片上传功能。1. 大文件上传与断点续传到底要解决什么问题1.1 直接上传在大文件场景下为什么不可行很多初学阶段上传文件都是这样写的前端拿到一个input typefile直接放到FormData里整体提交后端用一个MultipartFile接收。这个写法在小文件场景下没有问题一旦文件变成几百 MB 甚至几个 GB问题就会逐个暴露。直接上传面临四个核心问题请求体过大。服务器对请求体大小通常有限制。Spring Boot 的 multipart 默认配置里max-file-size一般是 1MBmax-request-size一般是 10MB不同版本可能有差异。一个 200MB 的文件直接提交大概率会收到 413 或者解析异常。内存压力大。如果后端没有把MultipartFile尽快写入磁盘而是先做各种处理大文件会占用大量 JVM 内存严重时直接 OOM。失败成本高。网络一抖动整个请求失败用户必须从头再传一次。文件越大重传代价越高。体验差。没有进度、没有已传部分的概念用户无法判断什么时候能传完。分片上传就是为了解决这些问题把一个大文件切成多个小块逐块上传最后在服务端合并。这样单次请求体积可控失败后只需要重传失败的分片用户也能看到真实进度。1.2 分片上传、断点续传、秒传三个概念如何区分这三个词经常混在一起但含义并不相同。分片上传把文件按固定大小切片分别上传到服务端最终由服务端合并成完整文件。它是断点续传和秒传的基础。断点续传在分片上传基础上记录已经成功上传的分片。当上传中断后再次上传时先查询哪些分片已完成只补传缺失部分不需要从第一个分片重来。秒传服务端已经存在内容完全相同的文件客户端上传前先计算整个文件的内容哈希服务端比对哈希一致后直接返回“上传成功”实际没有传输任何数据。一个容易被忽略的点是断点续传的前提是分片状态可以被查询。前端不能只靠本地 localStorage 记录“传到第几片”因为换浏览器、换设备、清缓存之后这些记录都不存在。可靠的做法是服务端保存每个文件分片的上传状态前端上传前先向后端查询。1.3 面试官想听到的完整技术链路如果面试题问“大文件上传与断点续传有哪些坑”可以从这条主线回答前端把文件按固定大小切片例如每片 5MB。前端计算整个文件的内容哈希作为本次上传的唯一标识。后端提供“查询已上传分片”接口前端只上传缺失分片。后端为每个文件哈希建立独立目录每个分片写成一个独立临时文件。所有分片到齐后后端按分片序号顺序合并。合并后校验文件大小或哈希确认无误后删除分片临时文件。秒传是在上传前增加一次哈希匹配检查命中则直接完成。这个链路就是整个方案的主干。后面分析的所有坑几乎都分布在这条链路的某个环节上。2. 从零设计一套分片断点续传方案2.1 前端分片用 File.slice 切文件用 Worker 处理大文件前端拿到 File 对象后可以借助File.slice按字节范围切分不需要把整个文件读进内存。const CHUNK_SIZE 5 * 1024 * 1024; // 每片 5MB const file fileInput.files[0]; const totalChunks Math.ceil(file.size / CHUNK_SIZE); async function uploadChunk(file, fileHash, chunkIndex, totalChunks) { const start chunkIndex * CHUNK_SIZE; const end Math.min(start CHUNK_SIZE, file.size); const chunk file.slice(start, end); const formData new FormData(); formData.append(file, chunk); formData.append(fileHash, fileHash); formData.append(chunkIndex, chunkIndex); formData.append(totalChunks, totalChunks); const resp await fetch(/api/upload/chunk, { method: POST, body: formData }); if (!resp.ok) { throw new Error(分片 ${chunkIndex} 上传失败); } }注意这里分片是在主线程里执行的。对于 1GB 文件切分计算本身不重但如果需要计算整个文件的哈希尤其是用file.arrayBuffer()一次性读取全文件内存会立刻飙升页面也会卡顿。推荐的改进方案是使用 Web Worker。把文件分片和哈希计算放到后台线程避免阻塞 UI 渲染同时用增量哈希算法逐片计算而不是一次性读取整个文件。// 主线程 const worker new Worker(/upload-worker.js); worker.postMessage({ file, chunkSize: 5 * 1024 * 1024 }); worker.onmessage (event) { const { fileHash, totalChunks } event.data; // 用 fileHash 查询已上传分片再继续上传 uploadRemainingChunks(file, fileHash, totalChunks); };// upload-worker.js self.onmessage async (event) { const { file, chunkSize } event.data; const totalChunks Math.ceil(file.size / chunkSize); // 增量计算整个文件哈希避免一次性读入内存 const fileHash await calcHash(file, chunkSize); self.postMessage({ fileHash, totalChunks }); }; async function calcHash(file, chunkSize) { // 这里使用 spark-md5 之类的增量式哈希工具 const spark new SparkMD5.ArrayBuffer(); let offset 0; while (offset file.size) { const chunk file.slice(offset, offset chunkSize); const buffer await chunk.arrayBuffer(); spark.append(buffer); offset chunkSize; } return spark.end(); }这里有几个细节需要注意Web Worker 里拿到的file对象是通过结构化克隆传入的现代浏览器支持这种方式兼容性需要按目标浏览器确认。哈希计算仍然需要逐片读取文件但每片只有几 MB不会一次性把整个文件加载到内存。不要把文件名当作文件标识。相同内容、不同文件名应该视为同一个文件同一个文件名、内容不同也不能用文件名跳过。2.2 后端接收分片接口、合并接口和文件元数据设计后端需要提供四个核心接口接口作用关键参数查询已上传分片断点续传时跳过已完成分片fileHash上传分片接收单个分片并落盘file, fileHash, chunkIndex, totalChunks合并文件校验分片完整性并按序合并fileHash, fileName, totalChunks秒传校验判断服务端是否已有相同文件fileHash, fileSize分片存储目录设计可以很简单rootDir / fileHash / chunkIndex.part。每个文件哈希一个独立目录目录之间互不干扰合并时也能严格按序号读取。RestController RequestMapping(/api/upload) public class ChunkUploadController { Value(${upload.root-dir:./upload}) private String rootDir; GetMapping(/chunks) public Result listChunks(RequestParam(fileHash) String fileHash) throws IOException { Path dir Paths.get(rootDir, fileHash); if (!Files.exists(dir)) { return Result.success(Collections.emptyList()); } try (StreamPath stream Files.list(dir)) { ListInteger chunkIndexes stream .map(path - Integer.parseInt(path.getFileName().toString().replace(.part, ))) .sorted() .collect(Collectors.toList()); return Result.success(chunkIndexes); } } PostMapping(/chunk) public Result uploadChunk(RequestParam(file) MultipartFile file, RequestParam(fileHash) String fileHash, RequestParam(chunkIndex) Integer chunkIndex, RequestParam(totalChunks) Integer totalChunks) throws IOException { if (file.isEmpty()) { return Result.error(分片内容为空); } if (chunkIndex null || totalChunks null || chunkIndex 0 || chunkIndex totalChunks) { return Result.error(分片参数不合法); } Path dir Paths.get(rootDir, fileHash); Files.createDirectories(dir); file.transferTo(dir.resolve(chunkIndex .part)); return Result.success(分片上传成功); } }这个示例里file.transferTo会把分片写入独立文件。每个分片写独立文件避免并发上传时多个请求同时往一个文件追加导致的写入错乱。分片文件名用chunkIndex天然具有顺序信息合并时只需要按索引递增读取。2.3 分片上传记录怎么存本地目录、数据库还是对象存储分片状态可以只靠目录里的.part文件判断也可以额外记录到数据库。两者的取舍要分场景。存储方式适用场景优点注意点本地磁盘临时目录单机或学习项目实现简单合并方便服务重启和磁盘清理可能丢记录数据库记录 本地磁盘有状态业务系统断点记录可靠可查询需要处理分片记录与真实分片的一致性Redis 记录 对象存储分布式或大规模场景状态查询快存储可靠需要处理记录和存储层最终一致对象存储 Multipart API生产标准做法分片、合并、断点由存储层处理客户端需要维护 uploadId 和 PartNumber在单机演示里直接用目录里的.part文件判断已上传分片是最省事的。生产环境建议至少把“文件哈希、文件名、文件大小、总分片数、已上传分片列表”这几项元数据持久化避免服务重启后完全丢失断点能力。3. 关键代码与参数细节3.1 Spring Boot 分片上传接口实现先确认 Spring Boot 的 multipart 配置。分片大小是 5MB那么请求体大小必须留出余量因为除了文件本身还有表单字段和 HTTP 协议头。spring: servlet: multipart: max-file-size: 10MB max-request-size: 12MB这里max-file-size设置成 10MB大于单个分片的 5MBmax-request-size设置成 12MB覆盖文件加表单字段的开销。如果两者设置得过小上传较大的分片会直接失败设置得过大攻击者可能用超大请求体打满内存。分片参数的校验也要做完整不能只依赖前端。PostMapping(/chunk) public Result uploadChunk(RequestParam(file) MultipartFile file, RequestParam(fileHash) String fileHash, RequestParam(chunkIndex) Integer chunkIndex, RequestParam(totalChunks) Integer totalChunks, RequestParam(fileSize) Long fileSize) throws IOException { if (file.isEmpty()) { return Result.error(分片内容为空); } if (chunkIndex 0 || chunkIndex totalChunks) { return Result.error(分片序号越界); } // 分片写入独立目录路径不能使用用户传入的相对路径 Path dir Paths.get(rootDir, fileHash); Files.createDirectories(dir); file.transferTo(dir.resolve(chunkIndex .part)); return Result.success(分片上传成功); }注意file.transferTo(dir.resolve(chunkIndex .part))使用了fileHash作为目录名。这里有一个安全点fileHash必须强制校验格式例如只允许十六进制字符否则恶意用户可能传入../../之类的路径穿越字符。实际项目里建议用 UUID 或经过白名单校验的哈希值作为目录名。3.2 分片合并与完整性校验合并接口是坑最多的地方。最容易犯的错误是合并时对分片列表做错误排序或者不检查缺失分片就直接写文件。PostMapping(/merge) public Result merge(RequestParam(fileHash) String fileHash, RequestParam(fileName) String fileName, RequestParam(totalChunks) Integer totalChunks, RequestParam(fileSize) Long fileSize) throws IOException { Path dir Paths.get(rootDir, fileHash); if (!Files.exists(dir)) { return Result.error(分片目录不存在无法合并); } Path target Paths.get(rootDir, fileHash _ fileName); try (FileOutputStream out new FileOutputStream(target.toFile())) { for (int i 0; i totalChunks; i) { Path part dir.resolve(i .part); if (!Files.exists(part)) { return Result.error(缺少分片 i); } Files.copy(part, out); } } if (Files.size(target) ! fileSize) { Files.deleteIfExists(target); return Result.error(合并后文件大小不匹配); } // 删除分片临时目录 try (StreamPath stream Files.walk(dir)) { stream.sorted(Comparator.reverseOrder()) .forEach(path - { try { Files.deleteIfExists(path); } catch (IOException e) { // 记录日志等待清理任务处理 } }); } return Result.success(合并完成); }合并逻辑的关键点有三个顺序固定。必须从第 0 片开始按chunkIndex递增顺序写入不能依赖文件系统返回的目录顺序。缺失检查。合并前检查每个分片文件是否存在缺失任何一个都应该中止不能继续写出一个残缺文件。大小校验。合并后对比fileSize能拦截大部分分片丢失和写入异常。如果要更严格可以再算一次 MD5 并与前端上传前计算的哈希比对。注意上面这段代码只是说明合并思路的示例。生产环境不建议把大文件合并放在 HTTP 请求线程里同步执行因为合并过程会占用磁盘 IO文件很大时会让请求超时。更合理的做法是把合并任务丢到异步队列前端通过轮询或长连接获知合并结果。3.3 秒传的实现思路全量哈希与元数据查询秒传的判断依据必须是文件内容而不是文件名。正确流程是前端计算整个文件的哈希。调用秒传校验接口传入fileHash和fileSize。服务端查询文件元数据表如果哈希和大小都匹配返回“秒传成功”。如果没有命中则走分片上传流程。PostMapping(/check) public Result check(RequestParam(fileHash) String fileHash, RequestParam(fileSize) Long fileSize) { FileMeta meta fileMetaService.findByHash(fileHash); if (meta ! null meta.getFileSize().equals(fileSize)) { return Result.success(秒传成功, meta.getFileId()); } return Result.success(需要上传, null); }秒传最怕误判。哈希碰撞在 MD5 场景下概率极低但不能完全忽略更常见的问题是只判断哈希、不判断大小。两个不同文件理论上可能哈希相同但大小相同的概率也极低因此“哈希加大小”同时校验是基本要求。3.4 用 Python 做脚本化分片上传的思路后端脚本或自动化工具也可能需要上传大文件。Python 用requests逐片读取文件能有效控制内存占用。import hashlib import os import requests CHUNK_SIZE 5 * 1024 * 1024 UPLOAD_URL http://127.0.0.1:8080/api/upload def calc_file_hash(file_path): h hashlib.md5() with open(file_path, rb) as f: while True: data f.read(CHUNK_SIZE) if not data: break h.update(data) return h.hexdigest() def upload_file(file_path): file_hash calc_file_hash(file_path) file_size os.path.getsize(file_path) total_chunks (file_size CHUNK_SIZE - 1) // CHUNK_SIZE with open(file_path, rb) as f: index 0 while True: chunk f.read(CHUNK_SIZE) if not chunk: break resp requests.post( f{UPLOAD_URL}/chunk, files{file: (f{file_hash}_{index}.part, chunk)}, data{ fileHash: file_hash, chunkIndex: index, totalChunks: total_chunks, fileSize: file_size, }, timeout30, ) if resp.status_code ! 200: # 这里应该记录失败次数超过阈值就退出 continue index 1 requests.post( f{UPLOAD_URL}/merge, data{ fileHash: file_hash, fileName: os.path.basename(file_path), totalChunks: total_chunks, fileSize: file_size, }, )这个示例里每次只read(CHUNK_SIZE)不会把整个文件装入内存。但要注意失败重试不能写成死循环必须有最大重试次数和失败退出逻辑。4. 大文件上传最常见的坑与排查链路4.1 分片顺序和并发写入导致文件损坏现象合并后的文件打不开或者打开后内容错乱。可能原因前端并发上传分片后端把多个分片写进了同一个文件导致互相覆盖。合并时没有按chunkIndex排序直接用了Files.list()返回的顺序。同一个fileHash有多个上传任务同时进行分片目录相互污染。排查方式检查前端是否用fileHash区分文件。检查后端分片文件是否每个分片独立存储。检查合并时是否按序号循环读取。解决方案每个分片写独立文件文件名就是chunkIndex.part。合并时严格for (int i 0; i totalChunks; i)顺序读取。为同一fileHash增加并发锁或上传任务标识避免多个任务写入同一目录。4.2 大文件导致 OOM请求体限制、分片大小和流式处理现象上传过程中服务端内存飙升出现OutOfMemoryError或者前端页面卡死、浏览器崩溃。可能原因后端没有限制请求体大小一个超大请求直接进入服务端。前端用file.arrayBuffer()一次性读取整个文件。业务代码把MultipartFile.getBytes()拉出来做 Base64 或序列化处理。分片大小设置得过大例如 100MB虽然分片了但单片请求仍然吃内存。排查方式看 JVM 内存日志和 GC 日志确认是否在请求处理阶段内存飙升。看请求大小是否超过 multipart 限制。看前端代码是否存在整文件读取逻辑。解决方案分片大小控制在 2MB 到 10MB 之间常见项目用 5MB。强制设置max-file-size和max-request-size。后端尽量用transferTo直接落盘不要反复调用getBytes()。前端哈希计算放到 Worker并按片读取。注意分片不等于不会 OOM。如果单片 100MB同时并发 20 个请求服务端照样会被打爆。分片大小要和并发量一起考虑。4.3 断点续传不生效记录丢失、目录被清理、上传标识不一致现象上传到一半失败重新上传又从第 0 片开始传已经传过的分片没有跳过。可能原因前端上传前没有查询后端已上传分片列表。每次计算的文件哈希不一致例如上次用内容哈希这次用文件名加时间戳。服务端临时目录被定时清理任务删除了。服务重启后分片元数据没有持久化。排查方式查看服务端分片目录是否还有.part文件。查看前端请求/api/upload/chunks的结果。对比两次上传的fileHash是否相同。解决方案文件哈希必须由文件内容计算不能依赖文件名或时间戳。前端上传前先调查询接口过滤已上传的分片。分片目录清理策略要设置足够长的保留时间不能上传中途中就被清理。生产环境把分片元数据记到数据库服务重启后能继续。4.4 秒传误判哈希算法、文件大小和业务判断现象用户报告“秒传成功了但下载下来的文件内容不对”或者“文件明明改了还是秒传”。可能原因只比对文件名不比对内容。只比对哈希不比对文件大小。前端只有文件上传完成后才计算哈希所谓秒传只是本地缓存命中服务端没有真正校验。使用不安全的哈希算法且未考虑碰撞场景。排查方式检查秒传接口的入参和返回。检查文件元数据表里是否同时保存了fileHash和fileSize。用两个不同内容、同一文件名的文件做秒传测试。解决方案秒传必须基于内容哈希加文件大小。上传完成后定期对存量文件计算哈希修正错误元数据。业务上对敏感文件不强依赖单一哈希可以要求前端上传部分分片做交叉验证。4.5 浏览器兼容与控件依赖IE、NTKO 等遗留场景现象在一些旧系统里会看到“不能装载 NTKO 大文件上传控件。请确保使用 IE 浏览器并检查浏览器的安全设置”这类提示。原因早期大文件上传常依赖 ActiveX 控件控件需要在 IE 下注册并调整安全级别。现代浏览器已经不支持 ActiveX这类方案在新环境里基本无法使用。处理建议新项目不要选择控件方案直接使用 HTML5File API加分片上传。如果必须兼容旧浏览器需要明确浏览器版本范围并在项目文档里写清楚支持矩阵。遇到控件加载失败时先检查是否安装了控件安装包、是否在受信任站点、安全设置是否允许运行 ActiveX。这个坑在面试里经常被拿来考察“有没有处理过真实兼容问题”。回答时不必纠结 IE 细节核心是表达清楚控件方案是历史遗留新方案应基于标准浏览器能力。4.6 对象存储场景MinIO 的断点续传到底是怎么实现的很多人会问 MinIO 支不支持断点续传。MinIO 提供的是 S3 兼容的 Multipart Upload API它支持分片上传、查询已上传分片、合并分片但客户端必须自己管理uploadId和分片参数。流程类似调用InitiateMultipartUpload获取uploadId。按分片调用UploadPart每片有对应的PartNumber和ETag。断点续传时调用ListParts查询已上传分片。最终调用CompleteMultipartUpload合并。InitiateMultipartUploadRequest initRequest new InitiateMultipartUploadRequest(bucketName, objectKey); InitiateMultipartUploadResult initResult s3Client.initiateMultipartUpload(initRequest); String uploadId initResult.getUploadId();之后用uploadId上传分片。中断后重新查询已上传分片继续上传缺失分片。所以准确说法是MinIO 的 Multipart API 为断点续传提供了能力但“记录到哪一片了”这个状态仍然由业务层维护。对象存储解决了存储和合并问题不解决业务状态管理问题。5. 怎么验证你的分片传续代码是可靠的5.1 本地最小验证流程先在本地准备一个测试文件最好用随机内容避免文件系统优化影响结果。dd if/dev/urandom oflarge.bin bs1M count200 md5sum large.bin记录输出结果。然后启动后端服务用前端页面或 Python 脚本上传该文件。上传完成后对比服务端保存的文件 MD5 与原文件 MD5。md5sum upload/xxx_large.bin如果两个 MD5 一致说明分片上传和合并逻辑基本正确。如果不等优先检查合并顺序和分片缺失。5.2 断点续传和异常恢复测试断点续传不能只测“能传完”要模拟真实中断场景。测试场景操作方式预期结果正常上传上传 200MB 文件合并后哈希一致中断恢复上传过程中断开网络再恢复只补传缺失分片服务重启上传到一半停止后端服务再启动已传分片仍在继续完成后合并分片缺失手动删除一个.part文件再触发合并合并接口返回“缺少分片”文件不生成并发上传同时上传多个文件各文件目录隔离互不影响弱网重试用浏览器开发者工具限速失败分片自动重试不卡死最容易忽略的是服务重启场景。如果分片只存在本地目录服务重启后目录还在问题不大但如果清理任务把目录删了断点续传就会失效。测试时要有意识地验证“重启后查询接口还能返回已上传分片”。5.3 并发和性能测试生产环境还要看并发上传时的表现。用jstat观察 JVM 内存用top观察 CPU 和磁盘 IO。如果同一时间上传文件数量多可以检查分片临时目录的磁盘增长速度。是否有大量请求堆积在合并接口。是否存在文件描述符泄漏。并发测试不需要等到生产才做。本地用 Python 脚本同时启动几个线程上传不同文件就能暴露大量并发问题例如目录创建冲突、线程安全、磁盘写入竞争。6. 生产环境落地清单与扩展方向6.1 上线前检查清单这里给出一份可以直接对照使用的检查清单分片大小是否与服务端max-request-size匹配是否留出了表单字段和协议头余量。文件哈希是否由文件内容计算是否同时记录文件大小。前端上传前是否调用查询接口过滤已上传分片。后端是否对fileHash、chunkIndex、totalChunks做了参数校验。分片目录是否使用安全目录名是否防止路径穿越。合并接口是否按顺序写入是否在合并前检查缺失分片。合并后是否校验文件大小或哈希。分片临时目录的清理策略是否避免删除正在上传的数据。上传接口是否有鉴权和权限控制是否限制单文件大小和速率。是否有上传日志日志里是否包含fileHash、chunkIndex、失败原因。是否有监控告警覆盖上传失败率、合并失败率、磁盘使用率。生产环境是否考虑异步合并、回滚方案和对象存储扩展。6.2 推荐架构演进方向一套能跑通的分片上传方案可以逐步演进不需要一开始就上复杂架构。第一步本地磁盘加分片目录适合单机小团队项目。第二步把元数据记录到数据库分片文件放到 MinIO 或云对象存储解决存储扩容问题。第三步把合并任务改成异步执行前端轮询合并状态解决大文件合并超时问题。第四步加入秒传、服务端病毒扫描、内容审核、上传速率限制等业务能力。对新手来说最有价值的练习不是直接去写一个完整云盘而是先把“分片上传、断点续传、合并校验、异常恢复”这条最小链路跑通再逐步加并发、加对象存储、加异步化。面试时能把这条链路的每个环节说清楚并且能指出至少三四个真实坑就已经比只会背概念的人强很多。