秒杀页面的最后一公里:前端高并发场景下的请求调度与降级策略

一、10 万 QPS 压垮前端的瞬间:流量洪峰下的真实崩溃链路

大促秒杀活动开始前 5 秒,前端监控面板显示页面白屏率从 0.1% 飙升到 35%。后端服务还在正常运行,API 响应时间 P99 维持在 200ms 以内。问题出在前端——10 万用户在同一秒点击"抢购"按钮,浏览器并发请求瞬间打满 Chrome 的 6 个 TCP 连接限制,后续请求全部排队。更严重的是,排队超时后前端自动重试,请求量翻倍,形成雪崩。

这不是后端扛不住,而是前端没有做流量调度。高并发场景下,前端不是简单的"UI 渲染层",而是整个系统的流量入口。如果入口不设防,后端再强的集群也会被指数级放大的重试请求冲垮。

本文聚焦前端高并发业务中的三个核心问题:请求并发控制、接口降级策略、本地缓存兜底,并给出生产级的实现方案。

二、浏览器并发模型与请求调度机制

浏览器对同一域名的并发连接数有严格限制(HTTP/1.1 下 Chrome 为 6 个,HTTP/2 下受 stream 并发数限制)。当请求量超过并发上限时,浏览器会将多余请求放入等待队列。如果队列中的请求等待时间超过预设超时,就会触发前端的重试逻辑。

flowchart TD
    A[用户点击抢购] --> B{本地请求队列是否已满?}
    B -->|否| C[加入请求队列]
    C --> D{活跃并发数 < 上限?}
    D -->|是| E[发送请求]
    D -->|否| F[等待队列轮转]
    F --> D
    E --> G{响应状态码}
    G -->|200| H[处理成功响应]
    G -->|429/503| I[触发降级策略]
    I --> J{降级级别}
    J -->|Level 1| K[延迟重试 + 指数退避]
    J -->|Level 2| L[切换到降级接口]
    J -->|Level 3| M[读取本地缓存兜底]
    G -->|超时| N[标记该接口为降级状态]
    N --> I
    B -->|是| O[直接拒绝 + 展示排队提示]

    style O fill:#ff6b6b,color:#fff
    style I fill:#ffa94d,color:#fff
    style M fill:#51cf66,color:#fff

关键设计点:请求在进入浏览器发送队列之前,必须先经过前端的调度层。调度层控制请求速率、决定是否降级、管理重试策略,避免无序请求冲垮浏览器和后端。

三、生产级请求调度与降级代码实现

3.1 请求并发控制器

/**
 * 请求并发控制器——限制同时发出的请求数量
 * 设计意图:避免浏览器并发连接被打满,导致所有请求排队超时
 */
class RequestConcurrencyController {
  private activeCount = 0;
  private queue: Array<{
    execute: () => Promise<unknown>;
    resolve: (value: unknown) => void;
    reject: (reason: unknown) => void;
  }> = [];

  constructor(
    private maxConcurrency: number = 4,  // 低于浏览器 6 连接上限,留出余量
    private maxQueueSize: number = 50,    // 队列上限,超过直接拒绝
  ) {}

  async request<T>(fn: () => Promise<T>): Promise<T> {
    // 队列已满时直接拒绝,避免内存溢出
    if (this.queue.length >= this.maxQueueSize) {
      throw new Error('[ConcurrencyController] 请求队列已满,请稍后重试');
    }

    return new Promise<T>((resolve, reject) => {
      this.queue.push({ execute: fn, resolve, reject });
      this.processQueue();
    });
  }

  private processQueue(): void {
    // 活跃请求数未达上限时,从队列中取出请求执行
    while (this.activeCount < this.maxConcurrency && this.queue.length > 0) {
      const item = this.queue.shift()!;
      this.activeCount++;

      item
        .execute()
        .then((result) => {
          item.resolve(result);
        })
        .catch((error) => {
          item.reject(error);
        })
        .finally(() => {
          this.activeCount--;
          this.processQueue();  // 完成一个请求后继续处理队列
        });
    }
  }
}

// 全局单例——所有 API 请求共享同一调度器
export const requestController = new RequestConcurrencyController(4, 50);

3.2 多级降级策略

/**
 * 多级降级策略——从重试到缓存兜底的完整链路
 * 设计意图:后端不可用时,前端仍能提供可用的用户体验
 */
interface DegradationConfig {
  maxRetries: number;          // 最大重试次数
  retryBaseDelay: number;      // 重试基础延迟(ms)
  cacheTTL: number;            // 本地缓存过期时间(ms)
  fallbackData?: unknown;      // 最终兜底数据
}

class DegradableRequest {
  private cache = new Map<string, { data: unknown; expireAt: number }>();
  private circuitOpen = new Map<string, { openUntil: number }>();

  constructor(private config: DegradationConfig) {}

  async request<T>(key: string, fn: () => Promise<T>): Promise<T> {
    // Level 0:熔断检查——如果该接口已熔断,直接走降级
    const circuit = this.circuitOpen.get(key);
    if (circuit && Date.now() < circuit.openUntil) {
      return this.fallback<T>(key);
    }

    // Level 1:正常请求 + 指数退避重试
    let lastError: Error | null = null;
    for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
      try {
        const result = await fn();
        // 请求成功,更新缓存并关闭熔断
        this.cache.set(key, {
          data: result,
          expireAt: Date.now() + this.config.cacheTTL,
        });
        this.circuitOpen.delete(key);
        return result;
      } catch (error) {
        lastError = error as Error;
        // 只对可重试的错误进行重试(429、503、网络超时)
        if (!this.isRetryable(error)) break;
        // 指数退避:200ms -> 400ms -> 800ms
        const delay = this.config.retryBaseDelay * Math.pow(2, attempt);
        await this.sleep(delay);
      }
    }

    // Level 2:连续失败后开启熔断,后续请求直接走缓存
    this.circuitOpen.set(key, {
      openUntil: Date.now() + 30_000,  // 熔断 30 秒
    });

    return this.fallback<T>(key);
  }

  private async fallback<T>(key: string): Promise<T> {
    // Level 2:读取本地缓存
    const cached = this.cache.get(key);
    if (cached && Date.now() < cached.expireAt) {
      return cached.data as T;
    }

    // Level 3:返回预设兜底数据
    if (this.config.fallbackData !== undefined) {
      return this.config.fallbackData as T;
    }

    throw new Error(`[DegradableRequest] 接口 ${key} 不可用且无兜底数据`);
  }

  private isRetryable(error: unknown): boolean {
    // 只重试 429(限流)和 503(服务不可用),其他错误不重试
    if (error && typeof error === 'object' && 'status' in error) {
      const status = (error as { status: number }).status;
      return status === 429 || status === 503;
    }
    return true;  // 网络错误默认可重试
  }

  private sleep(ms: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }
}

3.3 秒杀场景的请求节流

/**
 * 秒杀按钮的请求节流——防止用户重复点击导致请求洪峰
 * 设计意图:在客户端限制请求频率,配合后端限流形成双重防护
 */
function useSeckillThrottle() {
  const submitting = ref(false);
  const cooldownMs = 2000;  // 点击冷却时间 2 秒

  const submit = async (seckillFn: () => Promise<void>) => {
    if (submitting.value) return;  // 正在提交中,忽略重复点击

    submitting.value = true;
    try {
      await requestController.request(seckillFn);
    } catch (error) {
      // 降级请求已处理错误,此处只做日志上报
      reportError(error);
    } finally {
      // 冷却期结束后才允许再次点击
      setTimeout(() => {
        submitting.value = false;
      }, cooldownMs);
    }
  };

  return { submitting, submit };
}

四、前端降级策略的代价与适用边界

4.1 本地缓存的一致性代价

降级到本地缓存意味着用户可能看到过期数据。在秒杀场景中,如果库存数据来自缓存,用户可能看到"有货"但实际已售罄,点击后仍然失败。这是可接受的——因为最终的下单请求会走后端校验,缓存不一致只影响展示,不影响数据正确性。

但在金融、交易等场景中,缓存不一致不可接受,降级策略必须改为"展示服务不可用提示",而非展示过期数据。

4.2 并发控制器的吞吐量上限

将最大并发数设为 4,意味着前端每秒最多发出约 20 个请求(假设每个请求 200ms 响应)。如果页面有 30 个接口需要同时请求,其中 26 个必须排队等待。这会显著增加首屏加载时间。

解决方案:对请求做优先级分级。首屏关键接口(商品信息、价格)优先级最高,非关键接口(推荐列表、评论)优先级最低,排队时高优先级请求插队。

4.3 适用边界

场景是否适用原因
秒杀/抢购适用流量瞬时峰值,降级可保核心体验
金融交易部分适用缓存降级不可用于余额/订单数据
内容展示适用缓存不一致影响小,降级体验好
实时协作不适用数据一致性要求高,降级会导致冲突

五、总结

前端高并发场景的核心挑战不是"页面渲染慢",而是"无序请求导致系统雪崩"。解决方案的三个层次:

  1. 请求调度层:通过并发控制器限制同时发出的请求数,避免浏览器连接池被打满。队列满时直接拒绝,不让请求积压。
  2. 多级降级链路:正常请求 -> 指数退避重试 -> 本地缓存兜底 -> 预设兜底数据。每一层都是对后端不可用的防御,确保用户始终能看到有效内容。
  3. 客户端节流:秒杀按钮加冷却时间,防止用户重复点击放大请求量。

落地路线:先实现并发控制器,将所有 API 请求接入调度层;再实现降级策略,对核心接口配置缓存兜底;最后在秒杀等高并发场景中加客户端节流。每一步上线前用压测验证,确认降级链路在极端流量下能正常工作。

更多推荐