最近在重构项目里的大文件上传模块,本想着按常规方案实现:File API 切片、计算 Hash、封装一个带并发限制(通常习惯性设为 6)的请求池,最后调个 Merge 接口收尾。
这套方案可以说是前端圈处理大文件的标配了。但看着 Network 面板里稳步推进的进度条,我突然意识到一个经常被忽略的细节:平时我们习惯性设置的"6 个并发请求",其实是 HTTP/1.1 时代的经验产物。
在 HTTP/1.1 中,由于协议本身的限制,一个 TCP 连接在同一时刻只能处理一个请求。为了避免某个慢请求把后面的请求全堵死(队头阻塞),浏览器不得不采取一种妥协的策略:针对同一个域名,建立多个独立的 TCP 连接 (大部分浏览器限制为 6 个)。
这就像是超市里开了 6 个结账通道,虽然不多,但在物理上是实打实并行的。
根据现代浏览器的机制,对于同一个域名,HTTP/1.1 确实会建立最多 6 个左右的 TCP 连接来实现物理层面的并行。但如今我们的生产环境几乎都已经全面拥抱了 HTTP/2。
根据 RFC 7540 规范,HTTP/2 的核心特性之一就是单连接多路复用 。这就意味着,浏览器面对同一个域名,通常只会建立 1 个 TCP 连接。
这就引发了我的一个思考:
虽然 HTTP/2 的多路复用通过二进制分帧解决了 HTTP/1.1 必须等待上一个请求响应才能发下一个的痛点,省去了大量的排队等待时间,但只要这些分片同属一个域名,底层就依然只有一条 TCP 连接。在物理传输层面上,这些并发切片的数据依然是串行发送的。
那么,如果我们能像 HTTP/1.1 时代那样,打破单条 TCP 连接的束缚,逼着浏览器多开几条物理 TCP 通道,是不是就能在 HTTP/2 的基础上实现真正的物理并行,从而进一步提升上传速度?
既然浏览器是按"域名"来复用 TCP 连接的,顺着这个思路,我周末写了个 Demo,尝试用多域名分片来验证这个猜想,还真拿到了一组直观的对比数据。
搭建多域名对照组实验
为了验证多 TCP 通道是否能带来上传速度的提升,我们需要在本地搭一个对照组。本地跑这个实验只有一个前置难点:HTTP/2 的开启条件。
目前所有主流浏览器(Chrome, Firefox, Safari)在实现 HTTP/2 时,都强制要求基于 TLS(HTTPS),通过 ALPN (Application-Layer Protocol Negotiation) 扩展来协商协议。因此,本地跑 HTTP/2 必须配置 SSL 证书。
1. 配置多域名与证书
首先修改一下系统的 /etc/hosts 文件,映射几个指向本地的别名域名:
text
127.0.0.1 u1.local.com
127.0.0.1 u2.local.com
127.0.0.1 u6.local.com
然后,使用 mkcert 在本地给这几个域名一键签发受信任的 SSL 证书,留给后端服务使用。
2. 极简的 Node.js 后端
后端不需要复杂的业务逻辑,起个 Express 服务,挂载刚刚签发的证书,写个接口专门接收切片即可。这里唯一要注意的是 cors 跨域配置,因为前端接下来会跨好几个子域名来发请求。
js
const https = require('https');
const express = require('express');
const cors = require('cors');
const app = express();
// 允许携带 credentials 以及来自不同子域名的跨域请求
app.use(cors({
origin: (origin, callback) => callback(null, true),
credentials: true
}));
app.post('/upload', (req, res) => {
// 省略切片落盘逻辑...
});
// 使用 mkcert 生成的证书启动 HTTPS 服务
https.createServer(sslOptions, app).listen(443);
3. 前端的动态网关调度
平时我们写上传,目标 URL 通常写死为一个。现在我们需要维护一个"域名池"。在遍历分片数组时,通过简单的取模算法,把不同的分片请求均匀地分配给这些不同的子域名。
js
// 我们预设的上传域名池
const SHARDING_DOMAINS =[
'https://u1.local.com',
'https://u2.local.com',
'https://u6.local.com'
];
async function uploadChunks(chunks) {
const uploadTasks = chunks.map((chunk, index) => {
// 轮询分配域名
const targetDomain = SHARDING_DOMAINS[index % SHARDING_DOMAINS.length];
const url = `${targetDomain}/upload`;
const formData = new FormData();
formData.append('chunk', chunk.file);
return fetch(url, { method: 'POST', body: formData });
});
// 使用并发控制函数(这里省略 p-limit 的实现),最大并发保持 6
await asyncPool(6, uploadTasks);
}
直观的数据对比
实验准备就绪。我用一个约 1.5G 的测试文件,在同样的本地网络环境下,分别跑了"单域名常规上传"和"多域名分片上传"。
直接看 Chrome Network 面板的截图对比:

这张图里验证了几个关键的细节:
- 左侧(策略 A:单域名):
耗时 3.58s,吞吐量大概在 420.35 MB/s。
注意看下方请求列表中 u1.local.com 对应的 连接 ID (Connection ID) ,所有的分片请求,其 ID 完全一致(均为 2081087)。这在工程实际上证明了,哪怕你发起了并发请求,HTTP/2 依然尽职尽责地把它们全塞进了这一条 TCP 隧道里串行发送。
2. 右侧(策略 B:域名分片):
耗时缩短到了 2.44s,吞吐量达到了 616.82 MB/s,速度提升了将近 46% 。
再看底下的请求列表,发往 u1.local.com、u2.local.com 和 u6.local.com 的请求,分别拿到了 三个独立的 TCP 连接 ID(2081087、2082684、2081775)。

事实证明,通过我们在前端引入多域名策略,成功越过了浏览器针对 HTTP/2 的单连接复用机制,在物理层面上拓宽了上传的整体带宽,实现了真正的物理并行传输。
以上实验的完整前后端代码已经提交到了 GitHub,代码比较精简,主要为了提供一个验证思路。欢迎大家在本地跑跑看,或者交流不同的见解。
🔗 前端 Demo 源码: large-file-upload-demo-frontend
🔗 后端 Demo 源码: large-file-upload-demo-backend