秒杀页面的最后一公里:前端高并发场景下的请求调度与降级策略
一、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 适用边界
| 场景 | 是否适用 | 原因 |
|---|---|---|
| 秒杀/抢购 | 适用 | 流量瞬时峰值,降级可保核心体验 |
| 金融交易 | 部分适用 | 缓存降级不可用于余额/订单数据 |
| 内容展示 | 适用 | 缓存不一致影响小,降级体验好 |
| 实时协作 | 不适用 | 数据一致性要求高,降级会导致冲突 |
五、总结
前端高并发场景的核心挑战不是"页面渲染慢",而是"无序请求导致系统雪崩"。解决方案的三个层次:
- 请求调度层:通过并发控制器限制同时发出的请求数,避免浏览器连接池被打满。队列满时直接拒绝,不让请求积压。
- 多级降级链路:正常请求 -> 指数退避重试 -> 本地缓存兜底 -> 预设兜底数据。每一层都是对后端不可用的防御,确保用户始终能看到有效内容。
- 客户端节流:秒杀按钮加冷却时间,防止用户重复点击放大请求量。
落地路线:先实现并发控制器,将所有 API 请求接入调度层;再实现降级策略,对核心接口配置缓存兜底;最后在秒杀等高并发场景中加客户端节流。每一步上线前用压测验证,确认降级链路在极端流量下能正常工作。