早期 Web 运行在纯文本之上。你在表单中输入用户名或评论,点击提交,一段短字符串便通过 HTTP 传输到服务器。这种简单的请求-响应循环定义了当时的架构。当人们开始想要分享照片、文档和视频时,工程师们必须想办法如何通过一个完全为可读文本构建的系统来传输原始二进制数据。
如果你尝试将 JPEG 放入 JSON 对象中,你会遇到一个根本性的障碍。JSON 是一种文本协议。它期望的是有效的 Unicode 字符、引号、花括号和经过正确转义的字符串。而二进制文件只是一长串字节,其中许多字节没有可打印的表示形式。将这些字节强行塞进 JSON 字符串中,解析器就会崩溃,转义序列会损坏有效载荷(payload),导致整个消息在另一端变得无法读取。
Base64 编码成为了显而易见的权宜之计。它将二进制数据重新映射为一组有限的 64 个可打印 ASCII 字符。每三个字节的二进制数据都会变成四个字符的文本。现在,有效载荷变成了合法的 JSON,这意味着它可以在任何标准 API 的传输过程中保持完整。但代价是显而易见的。这种重新编码会使文件大小增加约 33%。在网络传输中,一个 3 MB 的图像会变成 4 MB。客户端和服务器都需要消耗额外的 CPU 周期来进行数据的来回转换。更重要的是,许多 JSON 服务器框架在解析之前会将整个请求体读入内存。由于每个请求在保存到磁盘之前都会以臃肿的文本字符串形式保存在 RAM 中,少量的并发大文件上传就可能压垮一台配置适中的服务器。Base64 在应急时很管用,但它从未被设计用于在生产规模下承载大文件。
更好的方案是 multipart/form-data。这种格式将单个 HTTP 请求视为一组独立部分的集合,每个部分由一个唯一的边界字符串(boundary string)分隔。其中一部分可能包含纯文本字段,下一部分可能包含原始二进制图像,并带有其自身的 Content-Type 和 Content-Disposition 请求头。服务器顺序地读取传入的流,监听边界标记,并将每个部分交给相应的处理器,而无需将整个有效载荷视为一个单一的文本块。
在 Node.js 中,这种区别尤为重要。express.json() 中间件知道如何解析 JSON 请求体,但它无法处理文件流。要处理 multipart 上传,你需要像 Multer 或 Busboy 这样的流式解析器。这些工具挂载到原始请求流上并逐块(chunk by chunk)读取。例如,Multer 允许你选择是将传入的文件写入磁盘上的临时文件夹,还是将较小的文件保存在内存中。这个配置决策至关重要。如果你将所有内容都保存在内存中,而你的应用突然收到几个大文件,你的进程可能会耗尽堆空间(heap space)并崩溃。写入磁盘是以 I/O 为代价换取稳定性,但这会引入关于清理和路径安全的新问题。
对于小型应用,将文件保存到像 ./uploads 这样的本地文件夹中感觉既自然又快速。文件落地在运行代码的同一台机器上,将其提供服务只需指向正确的路径即可。这种做法在出问题之前一直很好用。
一旦你在第二个应用服务器前放置了负载均衡器,本地存储就会变成一个 Bug。用户上传了一张头像。负载均衡器将请求路由到服务器 A,文件被写入服务器 A 的磁盘。稍后,该用户请求查看图像,但负载均衡器将请求发送到了服务器 B。服务器 B 检查自己的文件系统,发现空无一物。文件实际上“丢失”了。你可以实现会话保持(sticky sessions)将用户固定在同一台机器上,但这是一种脆弱的修复方案。如果服务器 A 重启、重新部署或被自动扩缩容实例替换,数据就会消失。在容器化环境中,本地磁盘更加瞬态。Docker 容器的文件系统本就是可丢弃的。将其视为永久存储是丢失用户数据的可靠方式。
标准的解决方案是将计算与存储分离。你保持应用服务器为无状态(stateless),并将上传的文件发送到专门的对象存储中,如 AWS S3 或 Google Cloud Storage。这些服务专为持久性、地理分布和大规模并发而构建。应用服务器处理请求,验证元数据,然后将字节交给专门设计用于存储它们的底层基础设施。
然而,如果实现不当,即使是这种模式也会造成瓶颈。许多团队最初的做法是让浏览器将文件上传到后端,然后由后端将每一个字节转发到对象存储。如果用户上传一个 500 MB 的视频,你的服务器就会变成一个中间人。它在拉取文件时消耗带宽,然后在将其推送到 S3 时消耗更多带宽。连接在整个传输过程中都保持开启状态。网络状况较差的用户上传速度慢,可能会占用服务器连接数分钟之久。如果服务器对流进行缓冲,内存占用会持续处于高位;如果你使用的是按量计费的托管服务,你实际上为同一份数据传输支付了两次费用。水平扩展也无法解决这个问题,因为你每增加一台服务器,它仍然会卡在搬运那些它根本不需要查看的字节上。
现代系统通过将后端完全从数据路径中移除来解决这个问题。后端不再接收文件,而只是接收上传权限的请求。流程如下:
- 浏览器请求后端发起上传,通常仅发送文件名、文件类型和预期用途。
- 后端对用户进行身份验证,根据业务规则验证请求,并使用 SDK 从对象存储提供商处生成一个临时的 presigned URL。
- 后端将该 URL 返回给浏览器。该 URL 的作用范围限定在特定的 bucket 和 key,有效期很短(例如 5 或 15 分钟),并使用仅授予精确所需权限的 token 进行签名。
- 浏览器使用标准的 PUT 或 POST 直接将文件上传到 S3 或 GCS。字节直接从用户的设备传输到
