最近给系统做大屏监控和图片墙展示功能,结果被一个诡异的性能瓶颈折腾得够呛。页面初始化时需要同时加载上百张商品缩略图和各种图表接口,本以为开启了 HTTP/2 多路复用(Multiplexing)就能高枕无忧,谁知道在测试环境的网络模拟下,图片加载速度慢得像老旧的 3G 网络,甚至部分关键业务接口直接被挂起(Pending)超时。
按理说,HTTP/1.1 有着浏览器同域名下 6 个并发连接数的限制,而 HTTP/2 可以在同一个 TCP 连接上并发处理成百上千个 Stream,不应该出现连接排队的情况。
查了半天 Chrome DevTools 的 Network 瀑布图,才发现这些请求的 Connection ID 确实是同一个,但其 Queueing 和 Connection Start 之间存在长达数秒的延迟,一旦遇上丢包,后面的资源直接全部假死。
这其实就是经典的 **TCP 头部队列阻塞(Head-of-Line Blocking)**在 HTTP/2 下的典型表现。
HTTP/2 的蜜糖与毒药
在 HTTP/1.1 时代,我们为了绕开同源 6 个连接的限制,通常会用「域名分片(Domain Sharding)」把静态资源分发到 img1.example.com、img2.example.com 等多个域名下。
HTTP/2 用一个 TCP 连接解决了这层套娃,通过帧(Frame)和流(Stream)的设计,把不同的请求混杂在同一个通道里传输。但这种设计有一个致命的底层假设:底层 TCP 连接是稳健且不丢包的。
一旦网络发生抖动,出现丢包,TCP 协议的滑动窗口机制就会触发重传。此时,整个 TCP 连接必须停下来等待这个丢失的包被重新收到并确认。
- 在 HTTP/1.1 中,如果 6 个连接里的 1 个连接丢包了,其余 5 个连接还能继续传输数据。
- 在 HTTP/2 中,因为所有流都寄生在这唯一一个 TCP 连接上,只要丢了一个包,整条马路上的所有车(所有的图片、JS、API 请求)都会原地熄火,直到那个包被补上。
这就导致在移动端网络、弱 Wi-Fi 或者是跨国高延迟网络下,HTTP/2 的实际性能表现可能还不如 HTTP/1.1。
怎么破局?
最彻底的解决方案当然是上 HTTP/3 (QUIC)。因为 HTTP/3 抛弃了 TCP,改用基于 UDP 的 QUIC 协议。在 QUIC 里,流与流之间在传输层是完全独立的,A 流丢包只影响 A 流自己,彻底消灭了 TCP 层的头队阻塞。
但在 HTTP/3 还没有完全普及、或者运维还没配置好 UDP 端口之前,我们前端必须在应用层做限制,防止无节制的并发请求瞬间把单个 TCP 链接的拥塞窗口(Congestion Window)撑爆。
最有效的办法是限制并发请求的数量。下面是我手写的一个基于 Promise 的前端并发请求控制器,用来平滑加载大量图片资源,避免瞬间压垮网络。
type Task<T> = () => Promise<T>;
export async function limitConcurrency<T>(tasks: Task<T>[], limit: number): Promise<T[]> {
const results: T[] = [];
const executing: Promise<void>[] = [];
// 辅助函数,执行单个任务
async function runTask(index: number): Promise<void> {
if (index >= tasks.length) return;
const task = tasks[index];
try {
const val = await task();
results[index] = val;
} catch (err) {
// 真实业务中应该有更细致的错误处理或重试逻辑
results[index] = null as unknown as T;
} finally {
// 任务完成后,从执行队列中移除自己
const currentPromiseIndex = executing.indexOf(currentPromise);
if (currentPromiseIndex > -1) {
executing.splice(currentPromiseIndex, 1);
}
}
}
// 按限额依次启动初始任务
const promises: Promise<void>[] = [];
for (let i = 0; i < tasks.length; i++) {
// 这里的 currentPromise 必须提前声明,用于 finally 中的 self-remove
let currentPromise: Promise<void>;
if (executing.length >= limit) {
// 如果当前执行队列已满,等待其中最快的一个任务完成
await Promise.race(executing);
}
const run = async (taskIndex: number) => {
await runTask(taskIndex);
};
currentPromise = run(i);
executing.push(currentPromise);
promises.push(currentPromise);
}
// 等待所有的任务流全部收尾
await Promise.all(promises);
return results;
}
// 实际使用示例:批量并发加载 100 张图片
const imageUrls = Array.from({ length: 100 }, (_, i) => `https://api.example.com/images/${i}.jpg`);
const tasks = imageUrls.map(url => () => fetchImage(url));
async function fetchImage(url: string): Promise<string> {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to load ${url}`);
const blob = await res.blob();
return URL.createObjectURL(blob);
}
// 限制同一时间只能有 6 个请求在飞
limitConcurrency(tasks, 6).then(images => {
console.log("所有图片控制加载完毕,渲染页面!");
});
总结与反思
不要迷信技术规格书上的「理论性能」。很多时候,协议上的优化在现实残酷的网络环境下会变成双刃剑。
把并发限制从「无限制」调整到 6 到 10 之间后,配合 Intersection Observer 懒加载,整个页面的图片加载流畅度提升了至少一倍,网络阻塞超时的警报也彻底消失了。
在 HTTP/3 落地前,你们对于海量图片加载有什么更好的避坑指南吗?还是干脆直接梭哈雪碧图或者 WebP 格式?