最近给系统做大屏监控和图片墙展示功能,结果被一个诡异的性能瓶颈折腾得够呛。页面初始化时需要同时加载上百张商品缩略图和各种图表接口,本以为开启了 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.comimg2.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("所有图片控制加载完毕,渲染页面!");
});

总结与反思

不要迷信技术规格书上的「理论性能」。很多时候,协议上的优化在现实残酷的网络环境下会变成双刃剑。

把并发限制从「无限制」调整到 610 之间后,配合 Intersection Observer 懒加载,整个页面的图片加载流畅度提升了至少一倍,网络阻塞超时的警报也彻底消失了。

在 HTTP/3 落地前,你们对于海量图片加载有什么更好的避坑指南吗?还是干脆直接梭哈雪碧图或者 WebP 格式?