大文件上传下载实战:分片、断点续传与Range下载 1. 百兆文件到底卡在哪先把瓶颈点摊开算清楚大文件上传下载这件事很多人第一反应是换个上传组件或者把超时时间调大点。我最初也是这么想的直到某个内部系统里一个 300MB 的数据包上传前端转圈转到浏览器自己放弃后端日志里连个异常都没有。后来才发现问题根本不在组件而在于我们把一个流式问题当成了请求问题来处理——一个 300MB 的文件被塞进一次 HTTP 请求体中间要穿过浏览器内存、网关缓冲区、容器临时目录、应用内存四道关卡任何一道扛不住整个链路就断在那儿。所谓大文件上传下载本质上是把一次性搬运拆成可中断、可重试、可并行的小块搬运。百兆以上是个经验分水岭低于这个量级多数框架的默认配置还能兜住一旦过线内存、超时、中断、校验这四件事会同时冒出来。这篇内容适合正在做文件服务、对象存储对接、数据导入导出、日志包回传这类需求的开发者也适合被上传到 99% 就失败折磨过的同学——我会把分片协议、断点状态、合并 IO、Range 下载、上线后才会暴露的坑一条条摊开讲代码能直接抄。1.1 一次全量上传链路上到底发生了什么先把一次老实的全量上传拆开看。浏览器把 File 对象塞进 FormDatafetch或XMLHttpRequest开始发送这时文件内容要先被读进内存或走流式发送请求到达网关网关默认会缓冲整个请求体再转发缓冲区大小是有上限的转发到应用容器容器把请求体写进临时目录Tomcat 默认的location就是系统临时目录再由框架的 multipart 解析器读成MultipartFile最后你的业务代码调用transferTo或getInputStream落到目标磁盘。这条链上至少有四个隐形上限网关的请求体上限、网关的读超时、容器的单文件上限、JVM 堆里能容纳的缓冲大小。任何一个被突破表现都是卡住或连接被重置而不是一个漂亮的异常堆栈。这就是为什么很多团队调完max-file-size之后还是失败——他们只改了最后一道门。排查顺序建议从外往里先看网关日志有没有 413再看容器有没有写临时文件最后才怀疑业务代码。反着查会浪费大量时间。1.2 把总耗时拆成四段才知道该优化哪一段一次上传的总耗时大致可以拆成连接建立 数据传输 服务端落盘 校验。百兆文件的瓶颈几乎永远在数据传输这一段而它又由上行带宽决定。举个具体数字假设客户端上行 20Mbps换算成字节约 2.5MB/s传一个 300MB 的文件纯传输就要 120 秒。这 120 秒里只要网络抖动一次、代理空闲超时一次前面全白干。算出这个数字的意义在于你立刻明白调大超时只是把赌注押得更大真正该做的是把 120 秒切成 24 个 5 秒让每一段都能独立成功或独立重试。分片不是为了快是为了失败的成本可控。至于并行上传能不能提速要看上行带宽是否被单连接吃满——很多场景下单连接就能跑满上行开并发反而因为竞争导致整体更慢这一点后面会细说。2. 分片协议怎么定前后端先对齐再写代码我见过太多项目在这一步翻车前端按 0 开始编号后端按 1 开始前端算的 MD5 是整个文件的后端拿分片算前端传完最后一片就认为成功后端还在异步合并。这些问题的根子都是协议没定死就开始写代码。分片上传的协议其实很小三个接口、四个字段但每个字段的语义必须写进文档双方按同一份文档实现。2.1 分片大小的测算与取舍分片大小没有标准答案但有测算方法。核心是两个约束单片上传时间要短于网关的空闲超时总请求数不能多到把开销放大。假设上行 2.5MB/s网关proxy_read_timeout60 秒那么单片理论最大可以到 150MB但显然不能这么定——单片越大失败重传的代价越大。反过来分片定成 256KB一个 300MB 文件就是 1200 个请求每个请求都要带一遍 HTTP 头、走一遍鉴权、开一次数据库会话光开销就够呛。我的经验区间是2MB 到 10MB百兆级文件取 5MB 比较稳300MB 切成 60 片每片理论 2 秒传完失败重传只损失 2 秒。如果是 GB 级文件可以上浮到 10MB 到 20MB。还有个小技巧最后一片通常远小于设定值合并时要按实际写入长度处理不能硬按固定大小算偏移。文件量级建议分片理论片数单片耗时2.5MB/s100MB 左右2MB50约 0.8 秒300MB 左右5MB60约 2 秒1GB 左右10MB102约 4 秒5GB 以上20MB256约 8 秒2.2 三个接口的最小契约整套分片上传只需要三个接口语义定清楚就能跑检查接口POST /upload/check入参是文件指纹、文件名、总大小、分片大小。返回三态——已存在秒传、部分存在带已上传的分片索引数组、全新。分片接口POST /upload/chunk入参是指纹、分片索引、分片内容。返回该片是否落盘成功。这个接口必须幂等同一索引重复提交要覆盖而不是追加。合并接口POST /upload/merge入参是指纹、总分片数、文件名。服务端按索引顺序合并校验整体哈希返回可访问地址。字段命名上我建议统一用fileHash、chunkIndex、chunkTotal、chunkSize这类显式名称别用id、num这种含糊词。分片索引从 0 开始还是从 1 开始都行但要在文档里加粗写死并且后端做一次范围校验——收到负数或大于等于总数的索引直接拒绝这类脏数据往往是前端并发逻辑写错导致的。合并接口必须做幂等如果目标文件已经存在且哈希一致直接返回成功别再合一遍否则用户手抖点两次就会出问题。2.3 文件指纹怎么算才不拖垮浏览器秒传和断点续传都依赖文件指纹。最直觉的做法是前端对整个文件算 MD5但一个 300MB 的文件在浏览器里整读一遍算 MD5低配机器能卡十几秒主线程直接僵住。正确做法是分块增量计算把文件切成固定大小的块比如 2MB逐块读进ArrayBuffer喂给增量哈希实现块与块之间用setTimeout或requestIdleCallback让出主线程界面上还能显示进度。如果嫌 MD5 太慢可以退一步用采样指纹取文件大小 首 1MB 中间 1MB 末尾 1MB 一起算哈希。碰撞概率在实际业务里可以接受而且计算量骤降。真正需要强一致的场景比如金融单据才值得上全量哈希而且最好放到 Web Worker 里做。提醒一句前端算出的哈希必须原样存下来合并完成后后端要用它做一次最终校验。如果两次算出来不一致说明传输过程有损坏必须让用户重传不能先收下再说。3. 断点续传的落地状态存哪、怎么恢复、怎么清理断点续传听起来高级拆开就是记住哪些片传过了。真正麻烦的不是记录本身而是记录的并发安全、过期清理、以及刷新页面后的恢复。我踩过最典型的一个坑是用户传了一半关掉浏览器第二天回来重新上传检查接口返回已传 30 片但那些分片早被运维的临时目录清理脚本删了合并时才发现文件是残缺的而且没报错——因为合并逻辑只检查文件存在没检查文件大小。3.1 状态存储选型内存、Redis 还是数据库分片状态的读取频率极高每片上传前后都可能查写入也不低所以不适合直接压在主库上。常见组合是热状态放 Redis用upload:chunk:{fileHash}作为 Set 结构存已上传的索引过期时间设 24 小时同时异步落一条记录到数据库用于审计和故障恢复。Redis 的 Set 天然适合这个场景SADD加片、SMEMBERS查全量、SCARD查数量全是 O(1) 操作。判断是否传完只需要比较SCARD和chunkTotal是否相等不用遍历。要注意的是合并完成后必须立刻删掉这个 key否则同一文件二次上传会直接命中已传完的分支跳过实际校验。如果团队没有 Redis用本地ConcurrentHashMap加定期清理也能撑但多实例部署会出问题用户的分片请求被负载均衡打到不同节点A 节点记了 30 片B 节点只认 10 片检查接口返回的结果就是错的。这种情况要么做会话粘滞要么老老实实上集中式存储没有第三条路。3.2 秒传判定与并发重复上传秒传的判定逻辑是指纹已存在 目标文件存在 文件大小一致 → 直接返回已有地址。三个条件缺一不可只判指纹是最危险的做法因为指纹可能是采样算出来的。并发重复上传是另一个高频问题同一个用户开了两个标签页传同一个文件或者前端重试逻辑没做去重两路请求同时进来。这时检查接口可能同时返回全新两路都开始传片最后两路都调合并。解决方案有两层一是合并接口加分布式锁key 用文件指纹抢不到锁的直接返回处理中请稍后查询二是合并前再查一次目标文件是否存在存在就直接返回不再执行合并。3.3 过期分片的定时清理分片文件散落在临时目录里一个没传完的文件可能留下几十个碎片。清理策略要跟状态存储的过期时间对齐Redis key 24 小时过期清理任务就每天扫一遍临时目录删掉修改时间超过 24 小时的分片目录。这里有个细节——清理任务要和上传任务错开时间别在业务高峰期跑全盘扫描find一个几十万文件的小目录能把 IO 打满。另外建议分片按临时根目录/{fileHash}/组织而不是全平铺在一个目录里。一是好清理直接删目录二是避免单目录文件数过多导致文件系统性能下降。Ext4 在单目录文件数超过十万后readdir会明显变慢这个坑在压测时不一定暴露上线三个月后才开始疼。4. 合并分片的IO细节别在这里OOM合并是整个流程里最容易写出内存问题的一步。我见过一段代码用Files.readAllBytes逐个读分片再write到输出流本地测 50MB 文件没问题线上一跑 300MB 直接OutOfMemoryError。原因很简单readAllBytes返回的是完整字节数组5MB 的分片还行但如果分片大小被配置成 50MB一次就读进 50MB 堆内存几个并发叠加就爆了。4.1 顺序追加与随机写的取舍合并有两种写法。顺序追加是开一个输出流按索引从小到大依次读分片、写出去内存占用只有一个缓冲区大小8KB 到 64KB最省资源。随机写是用RandomAccessFile或FileChannel按索引 × 分片大小定位写入好处是分片可以乱序到达合并时不用等前面的片坏处是要处理空洞——如果某片没传对应位置就是空洞最终文件是残缺的。我的选择是分片上传阶段用随机写合并阶段用顺序追加。上传时每片直接定位写入预分配好的文件避免大量小文件堆积合并时新建文件顺序拼接逻辑简单、可校验、可重试。预分配用RandomAccessFile.setLength(totalSize)好处是提前占位磁盘空间不足能第一时间发现而不是写到一半才报错。public void merge(String chunkDir, int total, String targetPath, long totalSize) throws IOException { File target new File(targetPath); try (FileOutputStream fos new FileOutputStream(target, false)) { byte[] buf new byte[64 * 1024]; for (int i 0; i total; i) { File chunk new File(chunkDir, String.valueOf(i)); if (!chunk.exists()) { throw new IllegalStateException(分片缺失: i); } try (FileInputStream fis new FileInputStream(chunk)) { int len; while ((len fis.read(buf)) ! -1) { fos.write(buf, 0, len); } } } } if (target.length() ! totalSize) { throw new IllegalStateException(合并后大小不符: target.length() vs totalSize); } }这段代码的关键不在写法在最后那个大小校验。它是分片缺失和分片被截断的最后一道防线成本几乎为零但能挡掉绝大多数静默失败。4.2 大文件哈希校验该怎么读合并完要算一次整体哈希跟前端传的指纹比对。这里同样不能用readAllBytes。正确姿势是流式读public String sha256(File file) throws Exception { MessageDigest md MessageDigest.getInstance(SHA-256); try (DigestInputStream dis new DigestInputStream(new FileInputStream(file), md)) { byte[] buf new byte[64 * 1024]; while (dis.read(buf) ! -1) { // 数据被自动喂给 digest } } return HexFormat.of().formatHex(md.digest()); }DigestInputStream的好处是把哈希计算融进读流的过程不需要额外一次遍历。如果前端用的是采样指纹后端就不能直接比对整体哈希得按同样规则重算采样部分——这一点必须在协议文档里写死否则永远是合并成功但校验失败查起来极其费劲。校验失败时不要直接删文件。先保留现场把它挪到一个隔离目录记录指纹和时间方便事后分析是传输问题还是分片覆盖问题。直接删掉等于销毁证据。4.3 合并失败的补偿与幂等合并可能因为磁盘满、进程重启、校验不过而失败。失败后的重试必须幂等先删掉半成品目标文件再重新合并不能直接在原文件上追加。我见过一个实现合并失败后重试时没清文件结果文件长度翻倍因为FileOutputStream的 append 参数写成了true。另外合并是个耗时操作几百兆的合并加哈希可能要好几秒。如果同步返回网关的读超时可能先到前端收到 504但后端其实还在跑。这种前端以为失败、后端其实成功的状态最恶心。我的做法是合并接口先返回 202 加一个任务 ID前端轮询查询合并状态或者干脆在合并前把目标状态写成合并中合并完成后改成已完成查询接口按状态返回。多写一个状态字段能省掉一堆客诉。5. 大文件下载Range请求与流式输出的组合拳下载这边翻车的姿势跟上传不太一样。上传是塞不进去下载是吐不出来或者吐到一半断了没法续。我印象最深的是一次导出 800MB 数据文件接口直接返回byte[]堆内存瞬间拉满GC 疯狂跑最后整个服务不可用。原因是byte[]会被当成一个完整对象放进堆里800MB 需要连续的堆空间直接触发 Full GC 甚至 OOM。5.1 为什么不能一次性读进内存下载接口的正确写法永远是流式拿到文件输入流边读边写到响应输出流内存里只留一个缓冲区。这样无论文件多大堆占用都是常数级。GetMapping(/download/{id}) public void download(PathVariable String id, HttpServletResponse resp) throws IOException { File file resolve(id); resp.setContentType(application/octet-stream); resp.setHeader(Content-Disposition, attachment; filename\ URLEncoder.encode(file.getName(), UTF-8) \); resp.setContentLengthLong(file.length()); resp.setHeader(Accept-Ranges, bytes); try (FileInputStream fis new FileInputStream(file); OutputStream os resp.getOutputStream()) { byte[] buf new byte[64 * 1024]; int len; while ((len fis.read(buf)) ! -1) { os.write(buf, 0, len); } os.flush(); } }有两个细节容易被忽略。一是setContentLengthLong一定要用长整型版本setContentLength是 int超过 2GB 会溢出成负数浏览器直接报错。二是文件名编码中文名不经过URLEncoder处理在部分浏览器上会变成乱码或者整个头解析失败。5.2 Range 请求断点下载的核心Accept-Ranges: bytes这个响应头是在告诉客户端我支持分段下载。客户端第二次请求时带上Range: bytes1048576-表示从第 1MB 开始要。服务端解析这个头设置Content-Range: bytes 1048576-{total-1}/{total}状态码返回206不是 200然后只输出对应区间的数据。String range request.getHeader(Range); long start 0; long end file.length() - 1; if (range ! null range.startsWith(bytes)) { String[] parts range.substring(6).split(-); start Long.parseLong(parts[0]); if (parts.length 1 !parts[1].isEmpty()) { end Long.parseLong(parts[1]); } } if (start end || start file.length()) { resp.setStatus(416); resp.setHeader(Content-Range, bytes */ file.length()); return; } long len end - start 1; resp.setStatus(206); resp.setHeader(Content-Range, bytes start - end / file.length()); resp.setContentLengthLong(len); try (RandomAccessFile raf new RandomAccessFile(file, r)) { raf.seek(start); byte[] buf new byte[64 * 1024]; long remaining len; while (remaining 0) { int read raf.read(buf, 0, (int) Math.min(buf.length, remaining)); if (read -1) break; resp.getOutputStream().write(buf, 0, read); remaining - read; } }用RandomAccessFile.seek而不是跳过流是因为跳过的部分照样会读白白消耗 IO。分段下载配合客户端多线程可以显著提升大文件下载速度——注意这里和多线程上传不同下行带宽通常远大于上行多连接确实能提速一般 3 到 5 个线程就够了再多会互相抢占。5.3 限速与背压别把带宽打满内部系统做批量导出时如果多个用户同时下载大文件很容易把出口带宽打满影响其他业务。简单的限速做法是分段休眠每写 1MB 记录一次时间如果超过预期耗时就Thread.sleep补回来。这种方式精度一般但实现简单、不依赖额外组件。long start System.currentTimeMillis(); long written 0; // 目标速率 5MB/s long limitBytesPerSec 5L * 1024 * 1024; while (...) { // 写入逻辑 written read; long expected written * 1000 / limitBytesPerSec; long actual System.currentTimeMillis() - start; if (expected actual) { Thread.sleep(expected - actual); } }背压这块要留意如果客户端读得慢比如用户点了暂停或网络差服务端的写操作会被阻塞连接一直挂着占着线程。所以下载接口的读超时和连接超时都要配置不能无限等。Tomcat 的connectionTimeout和 NIO 的写超时配合起来用能避免线程池被慢连接占满。6. 那些只有上线才暴露的坑本地跑通和线上稳定之间隔着一堆配置和环境差异。这一节我按踩坑—排查—修复的顺序复盘三个印象最深的场景排查链路原样保留方便你遇到类似问题时照着走。6.1 网关与容器的隐形限制现象上传 120MB 的文件前端进度条走到 100% 后卡住30 秒后报连接被重置后端没有任何日志。排查链路先看后端 access log发现根本没有这条请求记录说明请求没到应用层问题在网关。查网关配置请求体上限只开到了 50MB超过的直接拒绝但因为没有配置自定义错误页返回给前端的是一个裸的连接重置。把上限调到 200MB 后再试这次请求到了应用但 60 秒后又断这回是proxy_read_timeout默认 60 秒而应用处理大文件的时间超过了它。修复三层配置一起改。网关层放行请求体大小和读超时容器层调整单文件上限和请求体最大吞吐应用层把spring.servlet.multipart.max-file-size和max-request-size同步放大。这里有个经验三层的数值要留出余量且从小到大递增否则会出现网关放行了、容器拦住了的错配。6.2 临时目录被系统清理现象上传成功率高但偶发合并失败分片缺失重试一次又好了。排查链路先怀疑并发覆盖加了分片写入的锁问题依旧。再查日志时间分布发现集中在凌晨和上午十点左右——正好是运维清理脚本的执行时间。系统临时目录下的文件被定期清理而我们的分片正好存在那里一个传了一半的文件被清掉几片合并自然失败。修复把分片目录从系统临时目录迁到独立的数据盘目录并在应用启动时检查目录存在性和可写性。同时给清理脚本加白名单只清自己管理的子目录不做全盘扫描。这个坑的教训是永远不要假设系统临时目录只属于你。6.3 一次少一片的完整复盘现象某次上传 60 片检查接口显示已传 60 片合并却报分片缺失: 47。排查链路第一步看分片目录确实没有 47 号文件第二步看上传日志47 号片的请求返回了 200第三步打印落盘路径发现 47 号片的写入用了临时文件 重命名的方式重命名在finally块里而那次请求正好在写完后抛了个非 IO 异常finally里又因为目标文件已存在前一次重试留下的空文件导致重命名失败异常被吞掉了。修复去掉临时文件 重命名的方案改成直接按索引写入预分配文件并用RandomAccessFile定位同时在写完后做一次length()校验长度不符就报错finally里不再吞异常全部往上抛。改完之后这个问题再没复现过。这个案例我最想强调的一点是分片上传的每一步都要有落地确认。返回 200 只代表请求处理完了不代表数据真的在磁盘上。写完读一下长度、合并前查一下每片是否存在、合并后比一下总大小这三个校验加起来成本不到一毫秒但能挡掉绝大部分玄学问题。如果还要再往上做一层可以在分片目录里维护一个manifest文件每次成功写入就追加一行索引加长度合并前先校验这份清单和实际文件是否一致。这属于工程上的多此一举但在我经历过的几次磁盘异常里它是最先给出明确信号的那个东西。