← 返回蜂巢洞察

当你上传一个较大的文件并生成一个临时的下载链接时,会发生什么?

发送一个大小为5GB的视频文件,这个过程看似很简单:你只需选择文件,点击“上传”,等待进度条完成显示,然后将链接分享给他人即可。但实际上,在文件上传的过程中,背后会发生许多复杂的操作。 首先,文件需要通过网络传输到服务器,并被存储在某个地方,之后才能被其他人下载。同时,系统还需要确保接收者能够在不访问发送者账户的情况下获取文件。如果在上传过程中网络连接中断,系统也需要能够继续上传操作,而无需重新发送整个文件。 文件传输服务会利用HTTP请求、存储系统、数据库、加密技术以及后台进程来完成这些任务。但是,当文件大小达到几GB时,网站究竟是如何处理这些上传请求的呢?它是通过一次请求就上传完整个文件吗

发送一个大小为5GB的视频文件,这个过程看似很简单:你只需选择文件,点击“上传”,等待进度条完成显示,然后将链接分享给他人即可。但实际上,在文件上传的过程中,背后会发生许多复杂的操作。

首先,文件需要通过网络传输到服务器,并被存储在某个地方,之后才能被其他人下载。同时,系统还需要确保接收者能够在不访问发送者账户的情况下获取文件。如果在上传过程中网络连接中断,系统也需要能够继续上传操作,而无需重新发送整个文件。

文件传输服务会利用HTTP请求、存储系统、数据库、加密技术以及后台进程来完成这些任务。但是,当文件大小达到几GB时,网站究竟是如何处理这些上传请求的呢?它是通过一次请求就上传完整个文件吗?文件上传完成后会被保存在哪里?而下载链接过期后又会发生什么情况呢?

在本文中,我们将探讨文件传输服务是如何处理大文件的上传、如何存储文件、如何生成下载链接,以及如何应对连接中断或传输失败的情况。一旦了解了这些背后的工作原理,我们还会使用Node.js构建一个简单的分块文件上传工具,实际演示其中的一些流程。

本文内容涵盖:

为什么电子邮件附件会失败:SMTP并非为处理大文件而设计

在了解专门的文件传输服务之前,先弄清楚为什么我们不能直接将大型文件附加到电子邮件中是很有帮助的。通过电子邮件发送大型文件本质上存在缺陷,因为其底层架构是为传输文本信息而设计的,并非为处理高分辨率媒体文件而设计的。

SMTP与附件大小限制

简单邮件传输协议(SMTP)用于在互联网上发送电子邮件。由于服务器的带宽和存储空间有限,电子邮件服务提供商通常会设置严格的附件大小限制,一般不超过25MB。不过,这些限制指的是经过编码后的文件大小,而不是用户硬盘上的原始文件大小。

Base64编码的问题

电子邮件附件通常会使用MIME和Base64格式进行编码。之所以需要使用Base64编码,是因为它可以将二进制数据转换为电子邮件服务器能够安全处理的文本格式。虽然这种编码方式解决了数据传输的问题,但却会导致效率大幅下降:经过Base64编码后,文件的大小会增加大约33%。

你可以在RFC 2045中找到关于这一机制的详细规范。RFC 2045定义了MIME中的Base64编码规则。实际上,这意味着一个30MB大小的视频文件,在经过Base64编码后,其占用的存储空间会增加到大约40MB。当然,这里还没有考虑其他额外的传输开销,但可以肯定的是,这样的文件大小几乎会让所有电子邮件服务器都无法正常接收。

核心架构:文件传输服务背后的工作原理

为了突破电子邮件的限制,文件传输服务采用了专门的、解耦式的架构设计。我自己也开发过一些这样的系统,因此我认为,明确了解这些系统中五个主要组件的功能对于理解数据传输的整个流程来说至关重要。

客户端

整个传输过程始于客户端,通常是网页浏览器或桌面应用程序。发送者会在客户端选择要上传的文件。现代浏览器会利用JavaScript的File API来获取文件的元信息、读取文件内容,并将文件智能地分割成较小的、易于处理的片段,然后再将这些数据发送到网络中。

上传服务器

一旦客户端开始发送数据,上传服务器就会接手处理任务。该服务器会接收上传请求,验证用户的身份,检查传入的数据是否合法,并协调整个上传过程。它就像交通警察一样,确保各个数据片段都能以正确的格式被传输到目的地。

存储层

传统的应用服务器并不适合存储大型文件,因此存储层承担了实际的数据存储任务。上传的文件通常会被直接发送到对象存储系统中。这种设计使得系统能够无限扩展,而不会导致主上传服务器出现故障。

元数据数据库

虽然物理文件会被存储在对象存储系统中,但应用程序仍然需要一种方式来识别这些文件。元数据数据库会保存一些关键信息,包括原始文件名、文件大小、内容类型、具体的对象存储键、唯一的传输标识符以及文件的过期时间戳。

下载服务器

当有人点击共享链接时,下载服务器就会开始工作。它会检查数据库以确认用户的权限是否有效、文件是否已经过期;如果一切正常,它就会从存储系统中取出相应的文件进行下载。

分块上传与可恢复上传:大文件如何在网络连接不稳定时顺利完成传输

如果尝试通过一次连续的网络请求来传输5GB大小的文件,那无疑会遇到麻烦。因为如果网络连接在传输过程中中断了,采用一次性请求的方式会导致用户必须重新开始整个上传过程。

将大文件分割成小块

为了解决这个问题,现代客户端会将大文件分割成较小的部分,比如每部分10MB的大小。每个部分都会通过独立的HTTP请求被上传到服务器上。服务器会仔细记录哪些部分已经成功上传。一些先进的系统甚至允许多个部分同时并行上传,从而大大减少整体的等待时间。

需要特别注意的是,分块上传只是一种编程架构模式,并与HTTP的分块传输编码机制是完全不同的——后者只是用于对HTTP消息体进行分割的一种技术手段而已。

网络连接中断时会发生什么

假设在上传一个大型文件的过程中,已经有80%的内容成功上传了,但此时你的Wi-Fi突然断了连接。如果没有可恢复上传的功能,那么之前上传的所有数据就会丢失。而采用可恢复上传机制的话,客户端重新连接后可以向服务器询问当前的上传位置以及已经成功接收并存储了多少字节的数据,然后就可以从那个位置继续上传文件了。

校验和:确保数据不会被损坏

在网络传输过程中,数据有时会发生位错,从而导致文件损坏。为了防止这种情况发生,客户端会在发送每个数据块之前生成一个加密哈希值(即校验和)。服务器收到这些数据块后也会计算出自己的哈希值,并将两者进行比较。如果两个哈希值不一致,服务器就会拒绝接收这个数据块,并要求客户端重新尝试上传。

如果你想要实现这种功能,TUS可恢复上传协议是一个非常优秀的、标准化的参考方案,它可以帮助你管理上传进度以及验证数据的完整性。另外,Amazon S3多部分上传文档也提供了处理并行传输以及重试失败部分的行业标准方案。

一旦所有数据块都经过验证并重新组合完毕,服务就需要一个安全的地方来存放这些文件,直到接收者准备下载它们为止。

为什么服务会使用对象存储?

在我看来,试图将数吉字节的用户上传文件保存在传统的本地服务器目录中,是一种架构上的错误做法。相反,服务应该依赖对象存储系统。文件以对象的形式被存储,每个对象都会被分配一个唯一的键。对象存储具有极高的稳定性,并且能够轻松扩展,从而同时处理数百万份大型文件。

存储的文件与其可共享链接之间的关系,是依赖于元数据数据库来实现的。当上传操作完成之后,应用程序会将文件的存储键保存到数据库中,并生成一个唯一的访问令牌。接收者会收到一个包含该令牌的URL地址;当他们点击这个链接时,服务器会读取该令牌,在数据库中找到相应的文件,并检查其下载权限。

一个安全的文件传输链接,很大程度上依赖于高熵性——也就是随机性,这种随机性使得令牌几乎不可能被预测出来。此外,安全系统还会使用带加密签名的URL地址;这类URL包含了能够授权在有限时间内访问文件的加密签名。

正如OWASP会话管理速查表中所提到的,如果没有适当的访问控制机制、过期策略以及防止令牌泄露的措施,一个随机的链接并不会自动成为安全的链接。

过期与清理:当传输任务过期时会发生什么?

文件传输服务需要处理海量数据,因此它们不可能无限期地保存用户上传的文件,否则就会导致运营成本过高甚至破产。因此,这些服务必须采用严格的生命周期管理机制。

有效期(TTL)

每项文件传输任务都会被分配一个有效期或过期时间戳。一旦这个时间到期,数据库会立即使该传输任务的标识符失效,从而确保下载链接不再具备访问权限。

自动清理任务

由于链接的过期与文件的物理删除并不一定是同时发生的,因此系统会使用后台工作进程或定时执行的cron作业来负责实际的清理工作。这些进程会定期扫描数据库,找出已过期的传输任务,将它们标记为永久不可用状态,从存储系统中删除相关文件,并最终清除过时的元数据信息。

下载限制

某些服务还允许发送方限制文件可以被下载的次数。一旦下载次数达到预设阈值,该链接就会像过期一样无法再被使用,这种方式为防止文件被未经授权的人分发提供了非常有效的手段。

安全防护层:在文件传输与存储过程中提供保护

文件传输过程中的安全性绝不是依赖于某一项单一的技术或功能。为了确保数据不会被截获或盗取,需要采取多层次的安全防护措施。

传输过程中的TLS加密

当数据在发送方、传输服务提供商以及接收方之间传递时,这些数据都会被进行高强度的加密处理。现代平台通常会使用基于严格TLS协议的HTTPS来确保在数据传输过程中没有人能够窃取这些数据包的内容。

如果你想深入了解企业层面是如何配置这些安全措施的,NIST SP 800-52 第2版提供了关于TLS加密实现的详细指南。

静态数据加密

一旦文件被存储到服务器上,静态数据加密技术就能确保即使有人物理上侵入了服务器硬件,他们也只能看到已经被打乱、无法读取的数据。需要注意的是,静态数据加密与端到端加密是不同的;在标准配置下,服务提供商仍然会负责管理加密密钥。

了解知名企业是如何将这些安全理念付诸实践的,有助于我们更好地理解理论与现实之间的差距。

WeTransfer:临时链接与密码保护功能

WeTransfer完全依赖于临时共享链接来实现文件传输。所有传输链接都设有明确的有效期,高级用户还可以启用密码保护功能。根据他们的安全文档,他们在数据传输过程中使用标准的TLS加密技术,在文件存储时则采用AES-256加密算法来保障文件的安全。

Dropbox Transfer:无需协作即可发送文件

与普通的共享文件夹不同,Dropbox Transfer会生成独立的可下载链接。接收方不需要拥有Dropbox账户就可以下载文件。该服务支持设置过期日期、密码以及自动下载通知功能,同时文件的大小也会受到发送方所选订阅计划的限制。

Send Anywhere采用了一种非常巧妙的混合技术。它的六位数字密钥传输机制可以实时运行,在10分钟的时间窗口内仅使用一次,这一过程通常会利用点对点的连接方式。而其标准的“分享链接”功能则依赖于服务器端架构,它会将文件上传到服务器,并使链接在48小时内保持有效。

Filemail:大文件传输与过期时间控制

Filemail提供了强大的电子邮件发送功能以及可分享的链接服务,能够轻松处理大型文件的传输。它支持高度可配置的过期时间设置,并提供严格的密码保护机制。目前,其免费服务版本允许用户进行最大5GB大小的文件传输,而且这些数据会被安全地存储最多7天,之后系统会自动进行清理。

实践操作:使用Node.js构建简单的分块文件上传工具

了解相关理论固然很重要,但自己动手实际操作才能真正理解其工作原理。让我们一起来创建一个简单应用程序,该程序能够在浏览器中将文件分割成多个小块,然后把这些小块上传到Node.js服务器上,最后再将它们重新组合成完整的文件。

步骤1:搭建Node.js项目

你需要安装Node.js、Express框架以及multer库来处理传入的文件分块数据。内置的fs模块将帮助我们完成文件操作。首先,创建一个Node.js项目,并安装所需的依赖项:

mkdir file-transfer
cd file-transfer
npm init -y
npm install express multer

步骤2:将文件分割成小块

首先,在你的项目文件夹中创建一个名为public的目录(如果这个目录还不存在的话)。然后在该目录下创建一个名为upload.js的文件。我们将利用浏览器的JavaScript File API及其slice()方法来读取文件的部分内容,而无需将整个5GB大小的文件加载到计算机的RAM中。

const chunkSize = 5 * 1024 * 1024;

async function uploadFile(file) {
    const totalChunks = Math.ceil(file.size / chunkSize);
    const uploadId = crypto.randomUUID();

    for (let index = 0; index < totalChunks; index++) {
        const chunk = file.slice(
            index * chunkSize,
            (index + 1) * chunkSize
        );

        const formData = new FormData();
        formData.append("chunk", chunk);

        const response = await fetch(
            `/upload/${uploadId}/${index}/${totalchunks}`,
            {
                method: "POST",
                body: formData
            }
        );

        if (!response.ok) {
            throw new Error(`第${index}个文件分块上传失败`);
        }
    }

    const response = await fetch(`/complete/${uploadId}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
            totalChunks,
            filename: file.name
        })
    });

    if (!response.ok) {
        throw new Error("文件重组失败");
    }
}

在这里,crypto.randomUUID()会生成一个唯一的ID,用于标识上传的文件。浏览器会分别发送每个数据块,并使用这个相同的ID将所有数据块与原始文件关联起来。

步骤3:在Express中创建上传端点

在服务器端(server.js文件中),我们需要一个端点来接收并暂时存储每个数据块。服务器会利用上传ID和数据块的索引来确定每个数据块应该存放的位置,而数据块的总数也有助于验证上传过程是否正常完成。我们使用multer库来解析传入的多部分表单数据。

const express = require("express");
const multer = require("multer");
const fs = require("fs");
const path = require("path");
const { pipeline } = require("stream/promises");

const app = express();

app.use(express.static(path.join(__dirname, "public"));

const chunksDir = path.join(__dirname, "chunks");
const tempDir = path.join(chunksDir, "temp");

fs.mkdirSync(tempDir, { recursive: true });

const upload = multer({
    dest: tempDir,
    limits: { fileSize: 5 * 1024 * 1024 }
});

app.post(
    "/upload/:id/:index/:total",
    upload.single("chunk"),
    async (req, res) => {
        try {
            const { id, index, total } = req.params;

            if (!req.file) {
                return res.status(400).send("没有收到任何数据块");
            }

            if (
                !/^[a-f0-9-]{36}$/i.test(id) ||
                !Number.isInteger(Number(index)) ||
                !Number.isInteger(Number(total)) ||
                Number(total) < 1 ||
                Number(total) > 10000 ||
                Number(index) < 0 ||
                Number(index) >= Number(total)
            ) {
                return res.status(400).send("上传参数无效");
            }

            const folder = path.join(chunksDir, id);

            await fs.promises.mkdir(folder, {
                recursive: true
            });

            await fs/promises.rename(
                req.file.path,
                path.join(folder, `${index}.part`)
            );

            res.send("数据块已接收成功");
        } catch (error) {
            res.status(500).send("上传失败");
        }
    }
);

服务器会利用每个数据块的上传ID和索引来存储它们,这样在上传完成之后,就可以轻松地识别并提取这些单独的数据块。

步骤4:重新组装原始文件

当客户端确认所有数据块都已经传输完毕后,服务器会触发最后的重组操作。首先,服务器会检查chunks/目录中是否包含了所有预期存在的数据块;然后,它会使用fs.createReadStream()依次读取这些数据块,并通过fs.createWriteStream()将它们写入同一个输出文件中。第一个数据块会用于创建文件,后续的数据块则会被追加到这个文件中。一旦原始文件重新组装完成,服务器就会删除那些临时存储的数据块,以释放磁盘空间。

const uploadsDir = path.join(__dirname, "uploads");

fs.mkdirSync(uploadsDir, { recursive: true });

app.use(express.json());

app.post("/complete/:id", async (req, res) => {
    try {
        const { id } = req.params;
        const { totalChunks, filename } = req.body;

        if (
            !/^[a-f0-9-]{36}$/i.test(id) ||
            !Number.isInteger(totalChunks) ||
            totalChunks < 1 ||
            totalChunks > 10000 ||
            typeof filename !== "string"
        ) {
            return res.status(400).send("上传信息无效");
        }

        const folder = path.join(chunksDir, id);

        for (let i = 0; i < totalChunks; i++) {
            const chunkPath = path.join(folder, `${i}.part`);
            if (!fs.existsSync(chunkPath)) {
                return res.status(400).send(`缺少第${i}个分块文件`);
            }
        }

        const safeName =
            path.basename(filename).replace(/[^\w.-]/g, "_") ||
            "upload.bin";

        const outputPath = path.join(
            uploadsDir,
            `${id}-${safeName}`
        );

        await fs.promises.rm(outputPath, { force: true });

        for (let i = 0; i < totalChunks; i++) {
            const chunkPath = path.join(folder, `${i}.part`);
            await pipeline(
                fs.createReadStream(chunkPath),
                fs.createWriteStream(outputPath, {
                    flags: i === 0 ? "w" : "a"
                })
            );
        }

        await fs.promises.rm文件夹, {
            recursive: true,
            force: true
        });

        res.json({
            message: "文件组装成功"
        });

    } catch (error) {
        res.status(500).send("文件组装失败");
    }
});

最后,在server.js文件的底部添加以下代码,以启动Express服务器:

app.listen(3000, () => {
    console.log("服务器正在运行,地址为http://localhost:3000");
});

步骤5:测试上传功能

在终端中运行node server.js以启动服务器。然后,在浏览器中访问http://localhost:3000,选择一个中等大小的视频文件进行上传测试。打开浏览器的“网络”选项卡,观察文件是如何被分割成多个分块并依次发送的。最后,检查服务器上的uploads/目录,确认组装后的文件与原始文件的字节大小是否相同,并且能够正常打开。

创建public/index.html文件:

<!DOCTYPE html>
<html>
<head>>
    文件上传器
</head>>
<body>>
    

上传文件

上传</p> </body>> </html>>
注意:此实现仅展示了分块上传及文件重组的基本机制,并未包括可恢复上传功能、校验和验证、身份认证、对象存储服务或临时下载链接等功能。

一个真正适用于生产环境的文件传输服务还需要具备持续上传状态管理、访问控制机制、重试处理功能、数据完整性检查机制以及自动清理系统。

发送大型文件需要多个环节协同工作。从浏览器中的上传处理与分块管理,到对象存储、元数据跟踪、加密处理以及自动清理操作,每一个组成部分都在文件传输过程中发挥着关键作用。

这些流程也解释了各类服务是如何处理上传中断情况、如何控制对共享文件的访问权限,以及如何在传输任务过期后清除相关数据的。了解这些组件之间的协作关系,就能更容易地理解当有人上传大型文件并分享下载链接时,幕后究竟发生了什么。

相关文章

技术实践

如何在 Cloud Run 上让文件仅被保存几分钟而不是几年

一份打印任务、一次性下载的内容或支持文件,通常只需要在有人使用完它们之后被删除即可。但大多数后端系统仍然会像Google Drive那样存储这些文件:给它们起个名字,不设置过期时间,并在数据库中记录相关信息,以便日后能够检索到这些文件。 本教程将向你介绍我在实际开发中使用的一种简单模式,这种模式适用于处理打印任务。这些原理并不局限于打印领域,你可以将其应用于任何需要临时存储数据的任务:接收数据、短暂保存它们、将它们交给授权的使用者,然后删除它们。 你不会得到一个完整的应用程序,而是会学到一些能够防止数据队列变成一个庞大的文件库的方法。 目录 你将构建什么 你需要什么 一个服务,而不是多个服务

阅读全文
技术实践

如何使用JavaScript开发一款可将基于浏览器的图像故事板转换为PDF格式的工具

在任何人开始拍摄视频、制作广告或绘制漫画的最终画面之前,通常会先画出一份故事板草图:按照拍摄顺序为每一镜头绘制一幅图片,并附上简短的说明,说明该镜头中发生了什么以及持续时间有多长。 大多数人已经拥有这些图片了——它们可能是手机照片、截图、粗略的草图,或者是通过人工智能生成的图像文件,这些文件都保存在某个文件夹里。但他们所缺乏的是一种能够将这些文件快速整理成格式规范、编号整齐的文档,并将其打印出来或发送给客户的工具。 在这个教程中,你将学习如何制作这样一个基于浏览器的工具。你需要添加一组图片,为每张图片指定标题、描述和持续时间,选择合适的布局方式,预览生成的文档,然后下载可供打印的PDF文件。

阅读全文
技术实践

数据管道中的模式漂移与语义漂移

当从源数据库或数据集中删除某列时,下一次管道运行通常会失败,因此这个问题很容易被发现。但当某个字段的名称和数据类型保持不变,但其含义发生了变化(这种现象被称为“语义漂移”)时,管道仍可能正常运行,从而导致问题被忽视。 之所以会出现这种情况,是因为数据库模式仅描述了数据的结构,并没有说明这些数据的实际含义。它告诉我们哪些字段存在、它们的数据类型是什么,以及它们是否可以包含空值,但却无法说明时间戳是否使用UTC格式、持久性得分的范围应该是多少,或者空字符串与空值是否有不同的含义。 因此,大多数管道检查机制都是为了检测那些显而易见的错误,比如缺失的列或错误的数据类型,但往往无法发现那些数据表面上看似

阅读全文
技术实践

演讲主题:打开“黑箱”:利用Kiji Inspector实现人工智能代理工具选择的透明度与可解释性

Hannes Hapke介绍了Dataiku开源工具Kiji Inspector是如何直接将稀疏自编码器应用于决策流程中的,从而帮助人们理解为什么智能体会选择特定的工具来完成任务。他不仅探讨了表面上的思维过程分析方法,还讨论了因果关系分析、通过修改后的vLLM引擎提取各层神经网络的激活值数据,以及为软件架构师和高级工程领导者设计的监控工作流程。 作者:Hannes Hapke

阅读全文