☰
大文件上传下载方案:分片、断点续传与零拷贝
2026/10/26 2:58:09 网站建设 项目流程

1. 百M级文件为什么不能照搬小文件那套写法

做后台系统的人迟早会撞上这个需求:用户要往系统里塞一个几百兆甚至几个G的文件,可能是设计源文件、视频素材、数据导出包,也可能是某个业务方的离线备份。小文件上传下载那套写法——前端一个FormData丢过去,后端一个MultipartFile接住,file.transferTo()存盘——在这时候基本都会翻车。翻车姿势还挺统一:请求超时、网关 504、服务端 OOM、进度条卡在 99% 再也不动、用户刷新一下页面就得从头再来。

大文件上传下载和分片、断点续传、流式读写这几个词是绑死在一起的。我这些年在这块踩的坑比写业务代码还多,下面把整套思路、参数怎么定、代码怎么写、坑在哪,一条一条摊开讲。适合两类人看:一类是正在做这块功能、需要一个能直接抄的方案;另一类是系统里已经有上传下载但老出问题,想搞清楚到底哪一环拖了后腿。下面给的分片大小、并发度、临时目录清理策略这些,我不给“标准答案”,因为不存在,我给的是推算方法和我实测下来的经验区间。

1.1 一把梭读进内存,到底会炸在哪

先算笔账,把“为什么不能”量化。一个 500MB 的文件,如果服务端写成byte[] data = file.getBytes()或者用ByteArrayOutputStream拼装,JVM 里至少占 500MB 的堆。Spring Boot 默认启动参数下堆可能就 512MB 到 1GB,一个请求过来直接把堆吃满,接着就是OutOfMemoryError: Java heap space,而且这个错误会导致整个应用不可用,不是单个请求失败那么简单。

就算你把堆调到 4G 硬扛,问题也没完。GC 会因为这块大对象频繁触发 Full GC,接口 RT 从几十毫秒抖到几秒;同时网络传输层还得把这几百兆复制一遍——从 socket 缓冲区到内核,再到用户态数组,再写回内核落盘,一次上传可能产生三到四次全量数据拷贝。单机看着还行,十个人同时上传就直接躺平。

下载侧同理。很多人习惯把文件读到内存再response.getOutputStream().write(bytes),或者更“现代”一点用Files.readAllBytes(),本质一样。浏览器那边下载一个 800MB 的文件,服务端就实打实吃 800MB 内存,并发几个就没了。更隐蔽的一个坑:用InputStream.transferTo(OutputStream)时如果不设缓冲区大小,某些实现默认 8KB,看着内存没问题,但吞吐极低,一个百兆文件能传十几秒,用户以为卡死了。

所以第一条原则很简单:大文件的字节流绝对不能整体进入 JVM 堆。要么在堆外的直接内存里流转,要么干脆让操作系统内核完成搬运。这不是优化技巧,是底线。

1.2 真正要解决的问题清单

很多人一上来就写分片,写完发现还是有一堆问题。因为在动手之前没把需求拆全。一个大文件传输功能,实际要满足的是这么几件事:

  • 传输不中断:网络抖动、用户切后台、浏览器崩溃之后,能接着传,不是从头再来。
  • 内存可控:无论文件多大,服务端内存占用应该是常数级,不随文件大小线性增长。
  • 进度可视:前端要能拿到真实进度,不是假动画。
  • 重复文件不重复传:同一份文件第二次上传应该是秒完成。
  • 并发安全:多个人同时传、同一个人开多个窗口传同一个文件,数据不能乱。
  • 可恢复的失败:单个分片失败只重传那一片,不是整个文件重来。
  • 存储与带宽可规划:磁盘峰值占用、带宽峰值要能算出来,不能靠猜。

这七条里,第 2 条和第 5 条是最容易被忽略的。我见过一个系统分片做得挺漂亮,结果合并的时候用synchronized锁了整个方法,两个用户同时合并,第二个等到超时;也见过临时分片目录从来不清,半年后磁盘告警,运维一查发现是几十万个 5MB 的碎片。这些问题跟分片算法无关,跟工程习惯有关。

2. 方案怎么选:分片、断点续传与秒传的组合

2.1 整体链路长什么样

我的习惯是先画一条数据流,把每个环节的责任划清楚,再动代码。一条完整的大文件上传链路大致是这样:

  1. 前端对文件做指纹计算(通常是首尾各取一段 + 文件大小 + 文件名拼起来做哈希,全量哈希太慢),把指纹和文件元信息发给后端做预检查。
  2. 后端查库/缓存,如果这个指纹已经有完整文件,直接返回“已存在”,前端秒完成,这就是秒传。
  3. 如果不存在,后端创建一条上传会话,返回一个uploadId,并告诉前端“已收到哪些分片序号”(断点续传的关键)。
  4. 前端按约定大小切片,并发上传各分片,每个分片带上uploadId、序号、分片校验值。
  5. 后端每收到一片,落到临时目录,更新会话状态。
  6. 全部分片到齐,后端异步触发合并,合并后做一次整体校验,校验通过则落正式存储、清理临时文件。
  7. 前端轮询或通过回调得知完成,拿到最终文件 ID。

下载侧的链路要简单得多,但同样有设计取舍:前端请求时必须带Range头,后端按区间返回206 Partial Content,这样浏览器和服务端都只处理一段,天然支持断点续传和拖动进度条。

注意:秒传的判定依据是文件指纹,不是文件名。同名不同内容的情况太常见了,用文件名做去重会直接把用户文件覆盖掉,这是生产事故级别的错误。

2.2 分片大小到底定多少,别拍脑袋

这是问得最多的问题。我的建议是拿公式算,别凭感觉。分片大小主要由三个因素决定:失败重传的代价、请求开销占比、并发能力。

先说失败重传代价。分片越大,一片失败重传的量越大。在移动网络下,我实测单个 HTTP 请求的失败率大概在 0.5% 到 2% 之间波动,分片越大越容易在中途断掉。所以分片不宜超过 10MB。

再说请求开销。每个分片请求都有固定的开销:TCP/TLS 握手(复用了连接就省了)、HTTP 头、服务端鉴权、日志落盘、数据库更新。这部分大概 20ms 到 80ms 不等。如果分片只有 512KB,在 10MB/s 的上行带宽下传输只需 50ms,那开销占比就超过一半,纯属浪费。经验上,单分片传输时间应该是单请求固定开销的 10 倍以上。

把两个约束一综合,公式大致是:

理想分片大小 ≈ 有效上行带宽 × (固定开销 × 10)

举个具体例子:假设用户上行 100Mbps,考虑到实际损耗按 60% 算,有效带宽约 7.5MB/s;固定开销按 50ms 算,那理想分片约 7.5 × 0.5 × 10 ≈ 37.5MB,明显超出“不超过 10MB”的上限。所以取 5MB 到 8MB 之间比较稳。再算总耗时:

总耗时 ≈ 文件大小 / 有效带宽 + (分片数 / 并发数) × 单请求开销

500MB 文件、7.5MB/s 带宽,纯传输 67 秒;分片 5MB 得 100 片,4 并发,每批 25 轮,每轮开销 50ms,额外 1.25 秒,可以忽略。所以 5MB 在这个场景下完全够用。

不同场景我的经验区间:

场景建议分片大小并发数理由
普通 Web 后台,内网或宽带4MB ~ 8MB3 ~ 4请求开销占比低,重传代价可接受
移动端、弱网环境1MB ~ 2MB2 ~ 3单请求成功率优先,宁可多几个请求
超大文件(数 GB)10MB ~ 20MB4 ~ 6分片数过多会让元数据管理成本上升
跨地域、高延迟链路2MB ~ 4MB3高 RTT 下小分片能更快填满管道

分片数控制在几百到两千这个量级比较舒服。分片数过万会有新问题:会话状态存储变大、合并时打开文件句柄多、前端需要维护的状态数组变长导致页面卡顿。一个 10GB 的文件用 10MB 分片是 1024 片,正好。

2.3 断点续传和秒传的判定依据

断点续传的核心是“服务端要知道已经有哪些分片”。实现方式有三种,各有取舍。

第一种是查询接口:前端拿着uploadId调一次GET /upload/{id}/chunks,后端返回已收到的分片序号集合。简单直观,但每次续传都要多一次请求。我一般用这个,因为逻辑最清楚,出问题好排查。

第二种是前端本地记录:把已上传分片序号存 localStorage,下次直接跳过。快,但不可靠——换设备、清缓存、换浏览器就废了,而且本地记录和服务端实际落盘状态可能不一致,容易传出“缺片”的文件。

第三种是服务端返回偏移量:适合顺序上传的场景,简单但只能从头往后接,不支持乱序并发。我基本不用。

我的做法是第一种为主,第二种做加速。前端先读本地记录,把可能已完成的分片先跳过,同时后台异步拉一次服务端状态做校正。这样绝大多数情况下省了一次等待,又不会出错。

秒传的指纹设计也有讲究。全量哈希一个 500MB 文件,前端用 Web Crypto 大概要 1 到 2 秒,如果还要算 SHA-256 可能更久,用户会明显感觉到卡顿。我的方案是抽样哈希:取文件前 2MB、中间 2MB、末尾 2MB,加上文件总字节数和文件名,拼成一个字节数组做 MD5。碰撞概率在业务上可以接受,计算耗时基本在 100ms 以内。要注意的是抽样哈希只用于“秒传判定”这种允许极低误判的场景,最终的完整性校验必须用全量哈希,两者不能混。

提示:抽样哈希有个边界要处理——文件小于 6MB 时,前三段会重叠。代码里要判断size < 6MB就直接算全量哈希,否则拼出来的字节数组会重复计数,导致同样的小文件算出不同的指纹。

3. 服务端上传实现:从接口约定到分片落盘

3.1 接口与状态机设计

接口设计我倾向于极简三条:

POST /api/upload/init 创建上传会话,返回 uploadId 和已存在分片 POST /api/upload/chunk 上传单个分片 POST /api/upload/complete 通知合并,返回最终文件标识

创建会话时前端传fileName、fileSize、fingerprint、chunkSize、chunkTotal。后端算一下chunkTotal是否等于ceil(fileSize / chunkSize),不等直接拒掉——这一步能挡掉很多前端算错分片数的低级问题。

会话状态我用一张表加一个枚举来管:INIT(已创建)、UPLOADING(有分片进来)、MERGING(合并中)、SUCCESS(完成)、FAILED(失败)。合并必须异步,请求线程不能扛着几百兆的磁盘 IO 干等。这里有个细节:MERGING状态下如果前端又发来重复的complete请求,要直接返回“处理中”,不能重复触发合并,否则两个线程同时往同一个目标文件写,结果就是一个损坏文件。我用 Redis 的SETNX加会话 ID 做合并锁,过期时间给 10 分钟,比数据库行锁轻。

分片落盘的目录结构建议按会话隔离:

/data/upload/tmp/{uploadId}/0.part /data/upload/tmp/{uploadId}/1.part ...

不要把所有分片丢一个目录里。原因很实在:一个会话几千个分片,几十个会话并发就是十几万个小文件,ls都能卡住,清理的时候也没法按会话整体删。

3.2 分片接收的代码实现

下面这段是我常用的写法,核心是不把分片读进堆,直接用流对接文件:

@PostMapping("/chunk") public Result<Void> uploadChunk(@RequestParam String uploadId, @RequestParam int index, @RequestParam(required = false) String md5, @RequestParam("file") MultipartFile file) throws IOException { UploadSession session = sessionService.get(uploadId); if (session == null || session.isExpired()) { return Result.fail("会话不存在或已过期"); } // 分片大小校验,防止前端传错或恶意超大分片 long expected = session.expectedChunkSize(index); if (file.getSize() != expected) { return Result.fail("分片大小不符, 期望 " + expected + " 实际 " + file.getSize()); } Path dir = Paths.get(session.getTmpDir(), uploadId); Files.createDirectories(dir); Path target = dir.resolve(index + ".part"); // 已存在且大小一致,直接幂等返回,支持重传 if (Files.exists(target) && Files.size(target) == expected) { return Result.ok(); } // 关键:用流拷贝,缓冲区 64KB,绝不整体读入内存 try (InputStream in = file.getInputStream(); OutputStream out = Files.newOutputStream(target, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { byte[] buf = new byte[64 * 1024]; int len; while ((len = in.read(buf)) != -1) { out.write(buf, 0, len); } } // 分片级校验(可选但强烈建议) if (md5 != null && !md5.equalsIgnoreCase(digest(target, "MD5"))) { Files.deleteIfExists(target); return Result.fail("分片校验失败"); } sessionService.markChunkDone(uploadId, index); return Result.ok(); }

几个点值得单独说。MultipartFile本身在 Spring 里可能已经落到临时文件了(取决于配置),所以getInputStream()拿到的是文件流,不会占堆,但如果你的MultipartResolver配置了resolveLazily=false且阈值很大,它可能还在内存里。保险起见,配置里显式设置spring.servlet.multipart.file-size-threshold=0,让它全部落盘,同时max-file-size和max-request-size要开到分片大小的 1.5 倍以上,否则分片直接被容器拒掉,你会看到一个 500 而没有堆栈,非常迷惑。

幂等那段也很重要。用户网络抖动时前端会重试同一个分片,如果不判断直接覆盖,赶上两次写入交错,文件可能被写坏。判断“存在且大小一致”就返回成功,是成本最低的幂等方案。想更严格可以比 MD5,但会多读一次磁盘。

3.3 合并策略:顺序合并和随机写合并怎么选

分片收齐后要合并。两个思路。

顺序合并:按 0 到 N 依次读分片、写入目标文件。代码简单,磁盘访问是顺序的,对机械盘友好。缺点是只有一条流,速度上限就是单块盘的顺序写速度,而且如果分片存储在不同物理盘上,无法并行。

try (FileChannel out = FileChannel.open(target, CREATE, WRITE, TRUNCATE_EXISTING)) { for (int i = 0; i < total; i++) { Path part = dir.resolve(i + ".part"); try (FileChannel in = FileChannel.open(part, READ)) { // transferTo 走内核,避免用户态中转 long pos = 0, size = in.size(); while (pos < size) { pos += in.transferTo(pos, size - pos, out); } } } }

FileChannel.transferTo是我强烈推荐的方式。它底层会尝试sendfile或类似的零拷贝能力,数据不经过 JVM 堆,500MB 的文件合并实测在我的测试机上从 2.8 秒降到 1.1 秒左右,差距很明显。

随机写合并:预先RandomAccessFile.setLength(fileSize)分配好空间,然后每个分片按index * chunkSize定位写入。好处是分片可以乱序到达也能并行写,多线程下吞吐更高。坏处是随机写对机械盘不友好,而且如果文件系统不支持稀疏文件,预分配那一下会瞬间占满磁盘配额,容易触发告警。

我的选择是:默认顺序合并,分片数超过 500 且存储在高性能盘上时才开随机写 + 4 线程。这个阈值没有理论依据,纯粹是我在不同环境里试出来的体感——分片少的时候并行收益抵不过线程调度和随机 IO 的损耗。

还有一种偷懒方案:分片直接按最终位置写入,也就是上传阶段就用RandomAccessFile.seek(index * chunkSize)直接写到目标文件的对应位置,省掉合并这一步。这方案在“分片乱序到达”时确实很香,但有两个致命问题:一是目标文件在被声明“完成”之前一直是半成品状态,如果这时候被别的逻辑读到就是脏数据;二是没法做整体校验前的隔离。所以我不推荐,除非你能保证读者永远不会读到未完成的文件。

3.4 校验、清理与幂等这三件事

合并完成后必须做一次全量校验。计算目标文件的 SHA-256(或 MD5),跟前端在init时传来的整体哈希比对。不匹配就判定失败,删掉目标文件,让前端重传。这一步是最后一道防线,别省。

清理策略是最容易烂尾的地方,我见过太多系统分片目录从来不清理。我的做法是双保险:

  • 会话过期清理:会话表里记lastActiveTime,定时任务每 10 分钟扫一遍,超过 24 小时未完成的会话,整体删除临时目录并置为EXPIRED。
  • 孤儿目录清理:直接扫临时根目录,凡是目录修改时间超过 48 小时且没有对应会话记录的,一并删除。这一步专门兜“会话创建了但记录丢了”的情况。

清理任务本身要加锁,多实例部署时不能让三台机器同时去删同一批文件。用数据库的乐观更新抢任务,或者干脆用分布式锁,抢到的那台执行。

注意:清理任务千万别用Files.walk()递归删大目录然后不关流。Files.walk返回的Stream持有目录句柄,忘记try-with-resources在分片多的时候会直接把文件句柄耗光,表现出来是Too many open files,但报错位置在一个毫不相干的地方,排查起来非常费劲。

4. 服务端下载实现:零拷贝和 Range 请求

4.1 Range 请求必须支持

浏览器下载大文件、播放器加载视频、断点续传工具,全都依赖 HTTP 的Range头。如果你不处理,浏览器拿到的永远是200加完整内容,用户暂停后重连得从 0 开始。

实现上其实不难,关键是响应头要设对:

@GetMapping("/download/{fileId}") public void download(@PathVariable String fileId, HttpServletRequest request, HttpServletResponse response) throws IOException { FileMeta meta = metaService.get(fileId); Path path = Paths.get(meta.getStoragePath()); long fileSize = Files.size(path); String range = request.getHeader("Range"); long start = 0, end = fileSize - 1; boolean partial = false; if (range != null && range.startsWith("bytes=")) { String[] parts = range.substring(6).split("-", -1); try { if (!parts[0].isEmpty()) start = Long.parseLong(parts[0]); if (parts.length > 1 && !parts[1].isEmpty()) end = Long.parseLong(parts[1]); } catch (NumberFormatException e) { response.setStatus(416); response.setHeader("Content-Range", "bytes */" + fileSize); return; } if (start < 0 || start >= fileSize || end < start) { response.setStatus(416); response.setHeader("Content-Range", "bytes */" + fileSize); return; } end = Math.min(end, fileSize - 1); partial = true; } long len = end - start + 1; response.setHeader("Accept-Ranges", "bytes"); response.setContentType(meta.getContentType()); response.setHeader("Content-Disposition", "attachment; filename=\"" + encode(meta.getOriginalName()) + "\""); if (partial) { response.setStatus(206); response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileSize); } response.setContentLengthLong(len); try (FileChannel in = FileChannel.open(path, StandardOpenOption.READ)) { // 直接对 socket 输出流做 zero-copy WritableByteChannel out = Channels.newChannel(response.getOutputStream()); long pos = start; while (pos <= end) { long n = in.transferTo(pos, end - pos + 1, out); if (n <= 0) break; pos += n; } out.close(); // 注意:这里关闭的是包装 channel,不是真正的 response 流 } }

关于transferTo有个坑要提。它底层能不能真走sendfile,取决于 JDK 实现、操作系统和目标 channel 的类型。当目标是HttpServletResponse.getOutputStream()包装出来的 channel 时,某些容器(尤其是带 SSL 的时候)会退化成普通读写,零拷贝失效但不报错。所以别指望它一定快,你要做的是保证它不会把整个文件读进堆,这一点它是稳的。真正的性能收益,在内网、无 TLS 终结、大文件场景下最明显,我测过同一个 800MB 文件,走transferTo比手写 8KB 缓冲循环快了将近 3 倍。

4.2 限速和并发控制

下载功能上线后最怕的场景是:某个用户写了个脚本,100 个线程同时拉同一个大文件,你的带宽瞬间打满,所有正常请求跟着遭殃。所以限速不是可选项。

限速的实现思路是在写流的时候加节流。简单版:每写一块就统计已发送字节和已耗时,超速就Thread.sleep。这个方案简单但精度差,而且sleep会占着线程。我一般用令牌桶,放在网关层或者用轻量的本地实现,单用户维度限制在 5MB/s 到 10MB/s 之间,具体看你的带宽盘子。

并发控制上,同一个文件被重复下载时,可以在应用层做一个“下载连接数”计数,超过阈值直接返回 429。这个计数用本地缓存就行,不必上分布式,因为下载请求天然会分散到各实例。

还有个容易被忽略的点:Content-Disposition里的中文文件名要做 URL 编码,且要用filename*=UTF-8''xxx的格式,否则在某些浏览器里会变成乱码或者直接把文件名截断。这个小问题我至少在三四个项目里见过重新踩一遍。

5. 常见问题与排查实录

5.1 内存和句柄相关的坑

OOM 但不报在大文件相关的代码上。这种情况八成是某个地方用了Files.readAllBytes或者ByteArrayOutputStream,堆被慢慢吃掉,最后在别的地方触发 OOM。排查方法是用jmap -histo:live看有没有大量byte[],再结合jstack看哪个线程在跑。我抓过一次,是一个“下载后自动转存”的逻辑,用byte[]中转了一下,单个文件 300MB,三个并发就挂了。

Too many open files。分片上传合并阶段最容易出。每打开一个分片就占一个句柄,如果合并循环里忘了关(比如FileChannel没用 try-with-resources,或者异常分支漏了 close),几百个分片很快就把默认的 1024 上限吃光。表现是后面所有文件操作都失败,包括日志写入,整个服务看起来像是“莫名其妙全挂了”。处理办法:把ulimit -n调到 65535,同时代码里严格用 try-with-resources,别手动 close。

临时目录所在分区写满。上传过程会同时存在“分片”和“合并后的文件”,峰值占用接近文件大小的两倍。如果上传目录和正式存储目录在同一个分区,一个 5GB 的文件上传就可能把分区压到 100%,接着数据库连不上、日志写不进去。我的做法是上传临时目录单独挂盘,且定时监控使用率,超过 80% 就告警,同时限制单用户同时在传的会话数(我一般设 3 个)。

5.2 断点续传相关的坑

续传后文件损坏。最常见的根因是“分片序号从 0 还是从 1 开始”前后端不一致。这种问题在小文件上传时也会出,但大文件才会暴露,因为要合并。我的对策是在init接口里就把indexBase显式约定为 0,写进接口文档,并且合并前校验分片文件总数和chunkTotal严格相等。

重复上传导致合并两次。前端在“完成”接口超时后会重试,如果后端没做幂等,就会触发两次合并。第二次合并时临时分片可能已经被清理,于是报“分片缺失”,前端以为失败又重试,形成死循环。用合并锁 + 状态机判断能彻底解决。

秒传误判。前面说的抽样哈希,如果实现时没处理小文件重叠,会导致两个不同的文件算出同一个指纹,用户拿到的是别人的文件——这是数据安全事故。所以秒传命中后,必须再校验一次文件大小,大小不一致绝不能返回命中。

5.3 问题速查表

现象最可能的原因排查手段处置
上传到 99% 卡住不再动合并任务失败或未触发查会话状态是否停在 MERGING,看合并线程池队列补一个合并重试机制,用状态机兜底
请求返回 413容器 max-request-size 小于分片大小看网关和容器配置调大到分片大小 1.5 倍
下载几秒后断开未设置 Content-Length 或超时太短抓包看响应头补 Content-Length,调大写超时
合并后文件比原文件小分片缺失或写入了空片比对分片文件数量与 chunkTotal合并前强制校验数量
上传速度远低于带宽分片过小导致请求开销占比高统计单请求耗时分布增大分片或提高并发
服务端内存持续上涨某处整体读取了文件jmap 看 byte[] 分布改为流式读写
临时目录越来越大清理任务未执行或异常查定时任务日志补孤儿目录清理

5.4 几条从实战里换来的经验

第一条,把“进度”做成服务端算出来的,不是前端估的。前端按已上传字节数估进度在并发分片时很不准,经常出现“瞬间到 80% 然后卡住”的观感。让后端在会话里维护uploadedBytes,前端轮询拿真实值,体验会好很多。

第二条,合并完成后立刻做一次“可读性抽检”。随机取三个位置各读 1KB,确认能正常读出来。这能挡住“文件存在但实际是空壳”的情况——我遇到过磁盘配额满导致写入静默截断,文件大小对但内容是空的,全量哈希能查出来但要算很久,抽检是低成本的第一道关。

第三条,别在请求线程里做大文件的任何 IO 重活。合并、全量哈希、转码,全部丢线程池异步做。线程池要单独隔离,别和业务线程池共用,否则一次大文件上传能把整个应用的接口拖慢。

第四条,给上传下载单独做一个接口前缀,方便在网关层单独配超时和限流。普通接口 3 秒超时,上传接口可能要给 5 分钟,混在一起配就只能迁就最慢的,反过来又容易被慢接口拖死。

6. 参数规划与前后端配合的几个细节

6.1 容量和并发怎么算才不慌

上线前一定要算两个数:峰值磁盘占用和峰值带宽。

磁盘峰值 = 单用户并发会话数 × 平均文件大小 × 2(分片 + 合并后文件)。假设限制每人 3 个并发会话、平均 300MB,那单用户峰值约 1.8GB;100 个并发用户就是 180GB。这个数如果超出你的临时盘容量,就得降并发数或者缩短会话过期时间。我会把过期时间从 24 小时调到 6 小时来换空间,代价是用户隔夜续传会失败,但实际业务里跨天续传的比例很低。

带宽峰值 = 并发上传数 × 单用户带宽上限 + 并发下载数 × 单用户下载上限。这里的并发数不是用户数,是把同时在传的人数。经验上同时在大文件传输的用户数大概是日活的 1% 到 3%,按这个估再加 2 倍余量,基本不会被打爆。

6.2 前端配合的几个硬约定

后端写完了,前端不配合照样出问题。我一般会在接口文档里把这几条写成硬约定:

  • 分片顺序必须由前端保证唯一,同一分片并发重传时后端做幂等,但前端不能主动发两个相同序号的分片。
  • 每个分片请求必须带uploadId和index,不要用文件名当标识。
  • 暂停后续传必须先调一次查询接口,以服务端返回的已上传列表为准,本地缓存只做预判。
  • 不要在前端做全量哈希,除非文件小于 20MB。大文件前端算哈希会阻塞主线程,页面直接卡死,用 Web Worker 也只能缓解。
  • 进度上报频率控制在 500ms 一次,太快会把后端打出一堆无意义的查询,太慢用户觉得卡。

提示:如果用fetch上传分片,记得不要设置Content-Type,让浏览器自己带 boundary。手动设成multipart/form-data会导致 boundary 缺失,服务端解析出空文件,这个错误在本地测试时有时能过、上线就挂,非常阴。

6.3 关于存储落地的取舍

最后说说文件最终存哪。小规模场景直接放本地磁盘加一层路径映射就够了,简单可控,但扩展性差,多实例部署时得挂共享存储或者做路由。规模上去以后一般会上对象存储,好处是分片上传、合并、Range 下载这些能力存储侧原生的就有,你不用自己实现。

但我个人的建议是:即使最终用对象存储,服务端也要保留自己的一套会话管理和校验逻辑。因为对象存储的分片上传接口语义和业务需求经常对不上,比如它的分片大小有最小值限制、分片有效期有约束、合并失败的错误码不好识别。自己管一层,出问题时至少知道是哪一段出了问题,而不是只能对着一个模糊的错误码猜。

这套东西我前后在几个不同规模的系统里落地过,最深的体会是:分片算法本身不难,难的是状态管理和异常兜底。写代码的时候多想想“这一步失败了会怎样”“这一步重复执行会怎样”“这时候断电了会怎样”,把这三个问题都答上来,这套上传下载基本就不会出大问题了。我自己的习惯是每加一个环节,就在纸上画一遍失败路径,画不出来就是还没想清楚。

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

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

立即咨询