基于oss的断点续传实现

一次文件上传的"网络考古":从 multipart 全量缓冲到流式分片直传

起因是我们对 IM 系统的文件上传做了一次架构改造。改造过程中把 HTTP、Tomcat、multipart、socket 这些"以为很熟"的东西重新拆开看了一遍,发现好几个"显然如此"的认知其实是错的。本文把这些细节整理成问答,希望你读完也能对"字节是怎么流动的"建立一个精确的心智模型。

出于公司资产考虑,文中不出现真实业务代码与具体云厂商,统一用通用化示例表述。

TL;DR

  • TCP 是一条没有消息边界的纯字节流,所有"边界"都是应用层约定(Content-Length 定请求体结束,boundary 字符串定 multipart 内部分段),socket 包怎么切与这些无关;
  • 传统 MultipartFile 上传的"等待",根源是 multipart/form-data 编码:Spring 必须在进入你的代码之前把整个请求体解析完(大文件还要落临时盘);
  • 把上传协议改成"元数据放请求头、请求体为裸二进制 "的分片直传后,服务端可以边收边转发(流式直通),临时文件、整体等待、磁盘往返全部消失,内存占用从 O(文件) 变成 O(8KB 缓冲);
  • 能不能流式,取决于数据的前缀有没有独立意义------这也是为什么标准 RPC(如 Dubbo 2.x)传不了流,而 gRPC 可以。

一、背景:一个 IM 系统的文件上传之痛

我们的场景很典型:用户在聊天窗口发文件,前端把文件传给应用服务端,服务端再转传到对象存储(下称 OSS),拿到 URL 后以"文件消息"的形式发进会话,接收方点开时直接从 OSS 下载------文件本体不在消息里,消息里只有一个 URL。

改造前的上传接口,长得和大多数人写的一样:

java 复制代码
@PostMapping("/file/upload")
public ApiResponse<FileUploadResp> uploadFile(@RequestParam("file") MultipartFile file) {
    String url = fileStorage.upload(file.getOriginalFilename(), file.getInputStream());
    return ApiResponse.success(new FileUploadResp(url));
}

前端用标准的 FormData 把文件塞进 multipart/form-data,一次请求传整个文件。能用,但在真实的弱网环境和大文件面前,暴露出三个问题:

痛点 1:整体等待 + 磁盘往返。 服务端代码要等"整个文件传完 + 解析完"才开始执行,而且文件会先被 Web 容器整体写入临时文件,应用再从临时文件读出来发给 OSS------一次上传,文件过两遍网、过一遍盘(后文细拆)。

痛点 2:弱网断点 = 全量重传。 一次 HTTP 请求是一个原子整体:传到 80% 断了,服务端什么都拿不到,重试只能从头再来。用户网络差 + 文件大时,体验是灾难性的。

痛点 3:内存压力。 内部还有走 RPC(Dubbo)的 byte[] 上传通道,整个文件在调用方内存、序列化缓冲、服务端堆里各存在一份,堆内存压力随文件大小线性增长。

二、新方案:前端切片 + 裸二进制分片 + 服务端流式直通

新协议的核心决定:

  1. 切片由前端做:大文件在前端切成固定大小的片(如 8MB,服务端在初始化接口中下发片大小,前端不得自定);
  2. 每片一个独立 HTTP 请求 ,请求体是裸二进制 (application/octet-stream),所有元数据(文件 hash、uploadId、片号、本片字节数)放在 URL 参数里;
  3. 服务端无状态 :初始化时向 OSS 发起 multipart 会话拿到 uploadId,之后每收一个分片请求,就把请求体流式转发 给 OSS 的 uploadPart 接口------InputStream 进 InputStream 出,全程内存里只有一个小缓冲;
  4. 断点续传:客户端重试时先问服务端"这个文件已收到哪些片"(权威数据是 OSS 侧的 listParts),只补缺失的片;分片号重复上传自动覆盖,天然幂等;
  5. 全部片传完,服务端调 OSS 的"完成合并"接口(我们用的 SDK 支持 completeAll 模式:OSS 自行列举已传分片按序合并,连 ETag 清单都不需要任何人保存)。

先不急着展开实现,因为要做到"流式直通",你得先对下面这些基础细节建立精确认知------这也是本文的主体。

三、补课:几个"以为很熟"的问题

Q1:一次 HTTP 请求只"调用一次" socket 吗?

socket 不是"调用一次"的东西,它是一根持续打开的水管。

浏览器上传一个 500MB 文件:打开一条 TCP 连接(或复用 keep-alive 连接)→ 往同一个 socket 写很多次(每次 write 几十 KB,边读磁盘边写)→ 写满 Content-Length 声明的字节数,这个请求才算发完。

所以:整个过程在"同一个请求"里没错,但"一次 socket 调用"这个概念不存在------socket 是句柄,写 N 次都是同一个请求的一部分。接收端怎么知道 body 到哪结束?应用层约定:请求头里的 Content-Length,接收方数着字节,读满即止。

Q2:Tomcat 什么时候构造 request?哪里阻塞?

以 multipart 上传为例,精确时间线:

scss 复制代码
字节流到达
  → Tomcat 收满"请求头"(读到空行)就构造出 HttpServletRequest
    此时 body 一个字节都没读,还躺在 socket 缓冲区里
  → 过滤器链 → DispatcherServlet → 开始路由        ← 都不等 body!
  → Spring 发现方法签名里有 MultipartFile
    → 调 request.getParts()
       ★ 阻塞点:真正开始读 body、边读边解析边落盘
         getParts() 要等"整个 body 解析完"才返回
  → getParts() 返回后,controller 才开始执行

三个小问的答案:

  • request 构造时机:请求头收完的那一刻,不等 body;
  • 构造完就进 Spring 分发器吗:是,此时 body 可能才传了一半;
  • 要等浏览器传完吗:分发器不等,但 getParts() 等------它必须返回"解析完成的 Part 列表",所以一直阻塞到浏览器全部传完 + 容器解析完。

很多人以为"Tomcat 收完请求才给应用",其实 Tomcat 本身是流式的;真正的等待来自 multipart 解析 ,而解析是被 MultipartFile 这个便利参数触发的。

Q3:multipart 的"边界"是 socket 的边界吗?数据落盘前存在哪?

完全无关,这是两套边界:

是什么 谁定义
socket/TCP 的切分 网络包的物理切分,无语义------一个包里可能装着半个边界串 操作系统按 MTU 随机切
multipart 的边界 body 里的一个字符序列 (如 ----WebKitFormBoundaryXXX) 应用层格式约定

容器解析 multipart 时是逐字节扫描字节流找边界字符串,根本不关心数据怎么分包到达------把"乱切的包"还原成"连续有序的字节流"正是 TCP 的本职。

没写临时文件之前,数据在哪? 三级接力,没有任何一处存整个 body:

perl 复制代码
浏览器 write
  → ① 内核 socket 接收缓冲区(OS 内存,每连接几百 KB 级)
       容器 NIO 线程 read 出来
  → ② 容器的读缓冲(JVM 内存,8KB 级小块)
       直接喂给 multipart 解析器
  → ③ 解析器:part 的头(几行字符串)留内存
             part 的内容直接流写临时文件(边收边写,不攒)

顺带一个漂亮的机制:如果应用迟迟不读,①会满,TCP 流控(接收窗口收缩)会让发送端自动发不动------不用写任何代码,TCP 自己形成背压。这就是慢客户端挤不爆服务端内存的原因。

Q4:ApplicationPart 是什么?是多次请求合并成一个文件吗?

不是"多次请求合并",而是:一个请求的 body 里本来就装了多段内容。

multipart/form-data 的设计目的就是往一个 HTTP 请求里塞多个字段/文件------浏览器把"表单值 + 文件字节"都编进同一个 body,中间用边界字符串隔开。所以只有一次请求、一条字节流。解析器从头扫到尾:遇到边界串 → 当前段结束 → 生成一个 Part 对象;继续扫下一段 → 再一个 Part。

所以 ApplicationPart = 这次解析切出来的一个逻辑单元:它的"内容"是临时文件里那一段字节(同一请求内的连续字节按到达顺序 append 进同一个临时文件,天然就是原文件),它的"元数据"是段头那几行(文件名、Content-Type)。没有跨请求的合并。

一句话收束

一次 HTTP 请求 = 一根水管上的一段连续字节流;它的"结束"由 Content-Length 定义,"内部分段"由 boundary 字符串定义------socket 包怎么切,与这两件事都无关。

四、新旧链路逐帧对照

新方案中,每个分片请求的处理流程:

perl 复制代码
浏览器                      网络            Web容器            MVC框架           服务端           OSS
 ① 本地切片(Blob.slice,引用不复制)
 ② PUT /chunk?key=..&uploadId=..&partNumber=3&length=8388608
    body = 裸二进制(8MB 原始字节,没有任何包装)
 ③ 容器收满请求头 → 构造 request → 进框架          ← body 还在传
 ④ 框架绑定参数:只读 URL 里的 query 串            ← 不碰 body!
    controller 立即执行                            ← 分片还在传输中就开始了
 ⑤ request.getInputStream() → 从 socket 直读
 ⑥ OSS SDK:read 应用缓冲 → write 到 OSS 的 socket,循环   ← 边收边发
 ⑦ 读满 8388608 字节 → uploadPart 返回 ETag → JSON ack

逐项对照旧方案的三个坑:

坑 1:multipart 解析 → 消失。 旧方案触发解析的原因:Content-Type 是 multipart/form-data + 方法签名有 MultipartFile。新方案两者都不满足------Content-Type 是 application/octet-stream,参数从 URL 查询串取(查询串在请求头里,早就到了)。框架没有任何理由碰 body,参数绑定瞬间完成。

坑 2:临时文件 → 消失。 没有解析器,就没有"解析器写临时文件"这个环节。body 的唯一消费者是我们的代码:从 socket 直读,直接喂给 OSS SDK。服务端磁盘全程零接触。

坑 3:数据过两遍网/过一遍盘 → 变成"流过"。 旧:网络 → 磁盘 →(读)→ 网络。新:网络 → 8KB 缓冲 → 网络。8MB 的分片经过服务端时,任意时刻内存里只有正在搬运的那 8KB。

数据形态变化对比:

阶段 旧方案(multipart) 新方案(裸流)
参数绑定 等 body 全部解析完 只读请求头(URL 参数),瞬间
服务端磁盘 容器写临时文件 → 应用再读 零接触
controller 开始时机 body 完整解析后 body 传输中
内存占用 O(缓冲)(但延迟串行化) O(缓冲),且无延迟等待
断在第 N 片附近 整个文件重来 重发 1 片

一个附带的好性质:整条链路自动限流。 流式直通后各环节串行咬合:OSS 接收慢 → SDK 的 socket write 变慢 → 它 read 应用流变慢 → 容器 read 变慢 → 内核接收缓冲涨满 → TCP 窗口收缩 → 发送端自动慢下来。全程没有任何环节需要"攒数据",压力不会在中间任何一层堆积。旧方案里 OSS 慢的时候,数据会先整个堆在临时文件里。

五、延伸:那 Dubbo 这类 RPC 为什么不能流式?

改造前的系统里还有一条 byte[] 参数的 RPC 上传通道。有同学会问:都是网络传输,为什么 HTTP 能流式、RPC 不能?

TCP 层面两者一字不差,都是字节流进 socket。差别不在"怎么传",而在**"接收方被允许什么时候开始用这些字节"**:

复制代码
HTTP 分片:字节到达 → 应用直接读 → 转手写给 OSS → 再读下一批
RPC 调用: 字节到达 → 框架攒着 → 攒到消息完整 → 反序列化 → 才变成参数对象 → 才进方法

关键在于:RPC 传的字节是一个序列化消息 ,它的"含义"要最后一个字节到齐才成立------你没法把"半条序列化流"还原成 byte[] 参数,就像没法拆一封只拆了一半的信。哪怕 TCP 正在逐字节送达,框架也必须攒到消息结束。这个"必须攒"不是 TCP 强制的,是调用模型强制的。

由此可以提炼一条普适的判断标准------能不能流式,取决于"数据的前缀有没有独立意义":

数据 前缀有意义吗 能流式吗
文件内容(转发场景) 有------读到哪转发到哪 ✅
视频直播流 有------播到哪看到哪 ✅
序列化的方法调用参数 没有------对象要最后一个字节才"存在" ❌
一封 JSON 报文 没有------半截 JSON 不是 JSON ❌

注意这不是"RPC 天生不能流式":gRPC 就有流式调用,它的底层恰好是 HTTP/2 的流。准确说法是------"能不能流式"取决于协议有没有定义流式语义。我们的分片协议特意定义成流式(元数据先行、body 无结构),Dubbo 2.x 的调用模型没有定义,所以不能。

类比收束:HTTP 流式是自来水 ,管道边流边接;传统 RPC 是快递整箱,必须整箱签收才能拆。两家用同样的公路(TCP),但货物的形态不同。

顺带一提:用 RPC 的 byte[] 传文件还有一个隐藏天花板------不少 RPC 框架默认单次载荷上限就是 8MB 左右(如 Dubbo 的 payload 配置),大文件先撞配置墙,改完配置还有"调用方一份、序列化缓冲一份、服务端一份"的三重内存拷贝等着。文件数据面,别走 RPC。

六、每片一个请求的经济学

新方案里 800MB 的文件 = 100 个分片请求 + init + complete。有同学会心疼:100 个请求的开销不大吗?

前提是 HTTP keep-alive(连接复用):前端开 5~6 条长连接,100 个请求排队复用,每个请求的额外开销 ≈ 1KB 请求头,对比 8MB 的 body 是 0.01% 级别。换来的是:

1 个大请求 100 个分片请求
断在第 37 片附近 整个 800MB 重来 重发 1 片(8MB)
进度条 一个模糊的"已发送" 每片 ack,精确到 1/100
服务端内存 需要落盘或攒流 每请求 8KB 流过

用 0.01% 的开销,换断点粒度、精确进度、内存安全------这就是"分片协议"的全部经济学。

七、写在最后:两条设计心法

  1. 元数据先行,数据流式。 把"这批字节是什么、要怎么处理"放在最先到达的请求头里,让 body 成为不需要理解的裸数据------接收方读完头就知道该怎么处置后续的每一个字节。反例就是 multipart:元数据和数据混在一个编码体里,接收方必须读完整体才知道每段是什么,于是只能整体等待。
  2. 能否流式,取决于前缀语义。 设计传输协议时先问自己:这条数据的"前一半"有没有独立的意义?有,就设计成流;没有(如序列化对象、JSON 报文),就老老实实接受整体到达,并把它的体积控制住。

至于那些"以为很熟"的基础设施------TCP 的无边界字节流、容器的流式交付、框架参数绑定的隐藏等待------它们值得每隔一段时间被重新考古一次。


本文基于一次真实的文件上传架构改造整理,示例均已通用化处理。如有理解偏差欢迎评论区指正。

相关推荐
AliCloudROS2 小时前
从 1 万到 20 万:OOS Loop 任务规模提升 20 倍的攻坚记录
运维·性能优化
yunwei373 小时前
eBPF 示例:使用 CUPTI 构建 GPU 火焰图分析器
linux·后端·性能优化
步行cgn4 小时前
HTTP 常用请求方法详解
网络·网络协议·http
打工仔折腾 AI4 小时前
从零开始大模型微调:基于 PyTorch 与 ChatGLM 的 LoRA 实践指南
人工智能·pytorch·后端·python·深度学习·性能优化·ai agent 实战
Cx330❀5 小时前
深入浅出 Qt 绘图机制:从 QPainter 到四大图像类解析
开发语言·qt·ui·性能优化·图形渲染
江厌017 小时前
128GB 统一内存能跑多大模型?PGX 的容量、带宽与适用边界
性能优化
西红柿炖牛腩3547 小时前
机房电脑不能联网,怎么离线把课件里的 3D 模型压小?Draco / Meshopt 参数清单
前端·javascript·3d·性能优化·大屏端
智购科技无人售货机工厂7 小时前
MQTT还是HTTP?无人售货机弱网通信协议选型与带宽优化实战~YH
网络·网络协议·http
2401_872418788 小时前
计算机网络——应用层详解:DNS、电子邮件、FTP 与 HTTP 的原理及通信过程
网络协议·计算机网络·http