跳到主要内容

设计秒杀系统的前端

问题

如何设计一个高并发秒杀/抢购系统的前端架构?从页面静态化、倒计时精准控制、请求优化、安全防护到降级容错,请详细说明核心模块的设计思路与关键技术实现。

答案

秒杀系统是前端面临的最极端的高并发场景之一——在一个精确的时间点,海量用户同时发起请求争抢有限库存。前端在这个过程中扮演着至关重要的角色:精准控制用户操作时机过滤无效请求减轻服务端压力保障用户体验的流畅性,同时还要防范各种作弊手段

一个设计良好的秒杀前端架构,能够在瞬时高并发下依然保持页面可用、操作流畅,同时将无效请求挡在后端之外,是前端工程能力的综合体现。


一、秒杀系统前端挑战

核心挑战

挑战维度具体问题难度
瞬时高并发万级甚至十万级用户在同一秒点击抢购按钮,QPS 可达万级极高
时间精准性倒计时必须精准,不能依赖客户端时间,误差需控制在毫秒级
用户体验响应即时、状态明确、结果及时反馈,不能出现"卡死"或"无响应"
防重复提交同一用户多次点击只能产生一次有效请求
安全性防脚本刷单、防接口暴露、防机器人自动化抢购
降级容错服务不可用时有兜底方案,不能出现白屏或错误页面
面试关键认知

秒杀系统的核心矛盾是:有限的库存 vs 海量的请求。前端的首要职责不是"让请求更快送达后端",而是尽可能拦截无效请求,减少打到后端的流量。面试中能体现这个认知非常重要。

前端 vs 后端职责边界

职责前端后端
时间控制展示倒计时、控制按钮状态提供服务端时间、校验请求时间
流量控制防重复提交、请求节流、前端排队网关限流、令牌桶、队列削峰
库存展示实时展示库存状态库存扣减、一致性保证
安全防护验证码、接口签名、混淆Token 校验、风控系统、IP 限制
降级兜底CDN 降级页、错误边界、本地缓存熔断降级、限流拒绝、排队机制

二、整体架构设计

前端核心模块


三、页面静态化与 CDN

秒杀页面的第一个优化策略就是静态化——将秒杀页面做成纯静态页面部署到 CDN,动态数据通过 Ajax 异步加载。这样即使后端服务崩溃,页面本身依然可以正常展示。

静态化策略

seckill-page-strategy.ts
// 核心原则:秒杀页面与主站隔离,独立部署到 CDN
interface SeckillPageConfig {
/** 秒杀活动 ID */
activityId: string;
/** CDN 域名,与主站不同 */
cdnDomain: string;
/** 动态接口域名 */
apiDomain: string;
/** 降级页面 URL */
fallbackUrl: string;
}

// 页面加载策略
const loadSeckillPage = async (config: SeckillPageConfig): Promise<void> => {
// 1. 静态 HTML + CSS + JS 从 CDN 加载(已预渲染)
// 2. 异步加载动态数据
try {
const [serverTime, activityInfo, userInfo] = await Promise.allSettled([
fetchServerTime(config.apiDomain),
fetchActivityInfo(config.activityId),
fetchUserInfo(),
]);

// 3. 初始化倒计时(基于服务端时间)
if (serverTime.status === 'fulfilled') {
initCountdown(serverTime.value);
}

// 4. 渲染活动信息
if (activityInfo.status === 'fulfilled') {
renderActivityInfo(activityInfo.value);
}
} catch {
// 5. 降级:展示骨架屏 + 重试按钮
showFallbackUI();
}
};

静态化部署架构

层级内容缓存策略说明
HTML秒杀页面骨架s-maxage=300 CDN 缓存 5 分钟包含骨架屏,不含动态数据
JS/CSS业务逻辑和样式max-age=31536000 长期缓存 + hash资源指纹命名,可长期缓存
图片商品图、背景图CDN 缓存 + WebP 自适应提前预加载关键图片
动态数据库存、倒计时、用户态不缓存或短缓存Ajax 异步获取
独立部署的好处

秒杀页面使用独立域名(如 seckill.example.com),好处是:

  1. 流量隔离:秒杀流量不影响主站
  2. 独立扩容:CDN 可针对秒杀域名单独扩容
  3. 快速回滚:出问题时可快速切换到降级页面
  4. 缓存控制:缓存策略可独立配置,不受主站影响

更多缓存策略参考:浏览器缓存机制


四、倒计时设计

倒计时是秒杀前端最核心的模块之一,直接决定用户能否在正确的时间点发起请求。倒计时的精准度和可靠性至关重要。

4.1 为什么不能依赖客户端时间

why-not-client-time.ts
// 客户端时间不可信的三个原因:
// 1. 用户可以手动修改系统时间
// 2. 不同设备时钟有偏差(可达数秒甚至数分钟)
// 3. 时区设置可能错误

// 错误示例:使用客户端时间
const badCountdown = (): void => {
const now = Date.now(); // 不可信!
const remaining = targetTime - now;
// 用户修改系统时间就能提前抢购
};

// 正确方案:使用服务端时间 + 本地补偿
const getServerTimeDiff = async (): Promise<number> => {
const beforeRequest = Date.now();
const response = await fetch('/api/time');
const afterRequest = Date.now();
const serverTime = (await response.json()).timestamp;

// 网络延迟补偿:假设请求耗时的一半是单程延迟
const networkDelay = (afterRequest - beforeRequest) / 2;
const correctedServerTime = serverTime + networkDelay;

// 返回服务端时间与本地时间的差值
return correctedServerTime - afterRequest;
};

4.2 高精度倒计时实现

使用 requestAnimationFrame 替代 setInterval,实现更精准的倒计时渲染。定期与服务端时间同步校正误差。

precision-countdown.ts
interface CountdownState {
/** 服务端与本地的时间差(ms) */
timeDiff: number;
/** 秒杀开始时间戳(ms) */
targetTime: number;
/** 是否正在运行 */
isRunning: boolean;
/** rAF ID */
rafId: number | null;
/** 上次同步时间 */
lastSyncTime: number;
/** 同步间隔(ms),默认 30 秒 */
syncInterval: number;
}

type CountdownCallback = (remaining: number) => void;

class PrecisionCountdown {
private state: CountdownState;
private onTick: CountdownCallback;
private onEnd: () => void;

constructor(
targetTime: number,
onTick: CountdownCallback,
onEnd: () => void
) {
this.state = {
timeDiff: 0,
targetTime,
isRunning: false,
rafId: null,
lastSyncTime: 0,
syncInterval: 30_000, // 每 30 秒与服务端同步一次
};
this.onTick = onTick;
this.onEnd = onEnd;
}

/** 初始化:同步服务端时间 */
async init(): Promise<void> {
await this.syncServerTime();
this.start();
}

/** 与服务端同步时间 */
private async syncServerTime(): Promise<void> {
try {
// 多次采样取中位数,减少网络波动影响
const diffs: number[] = [];
for (let i = 0; i < 3; i++) {
const before = performance.now();
const res = await fetch('/api/seckill/time');
const after = performance.now();
const { timestamp } = await res.json();

const rtt = after - before;
const serverNow = timestamp + rtt / 2;
const localNow = Date.now();
diffs.push(serverNow - localNow);
}

// 取中位数作为时间差
diffs.sort((a, b) => a - b);
this.state.timeDiff = diffs[Math.floor(diffs.length / 2)];
this.state.lastSyncTime = Date.now();
} catch {
console.warn('时间同步失败,使用上次同步结果');
}
}

/** 获取校正后的当前时间 */
private getCorrectedNow(): number {
return Date.now() + this.state.timeDiff;
}

/** 启动倒计时 */
private start(): void {
this.state.isRunning = true;
const tick = (): void => {
if (!this.state.isRunning) return;

const now = this.getCorrectedNow();
const remaining = this.state.targetTime - now;

if (remaining <= 0) {
this.onTick(0);
this.onEnd(); // 倒计时结束,触发抢购逻辑
this.stop();
return;
}

this.onTick(remaining);

// 定期重新同步服务端时间
if (now - this.state.lastSyncTime > this.state.syncInterval) {
this.syncServerTime();
}

this.state.rafId = requestAnimationFrame(tick);
};

this.state.rafId = requestAnimationFrame(tick);
}

/** 停止倒计时 */
stop(): void {
this.state.isRunning = false;
if (this.state.rafId !== null) {
cancelAnimationFrame(this.state.rafId);
this.state.rafId = null;
}
}

/** 格式化剩余时间 */
static format(remaining: number): string {
const hours = Math.floor(remaining / 3_600_000);
const minutes = Math.floor((remaining % 3_600_000) / 60_000);
const seconds = Math.floor((remaining % 60_000) / 1_000);
const ms = Math.floor((remaining % 1_000) / 10);

return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(ms).padStart(2, '0')}`;
}
}

// 使用示例
const countdown = new PrecisionCountdown(
1735689600000, // 秒杀开始时间
(remaining) => {
document.getElementById('countdown')!.textContent =
PrecisionCountdown.format(remaining);
},
() => {
// 倒计时结束,启用抢购按钮
enableSeckillButton();
}
);
countdown.init();
requestAnimationFrame vs setInterval
  • setInterval(fn, 1000) 的实际执行间隔受主线程阻塞影响,可能出现 1000ms+ 的误差累积
  • requestAnimationFrame 与屏幕刷新率同步(通常 16.67ms),精度更高
  • 使用 performance.now() 进行差值计算,比 Date.now() 精度高出数个数量级
  • 更多关于 rAF 的细节参考:requestAnimationFrame

五、按钮状态管理与防重复提交

5.1 按钮状态机

秒杀按钮不是简单的"可点击"和"不可点击"两种状态,而是一个完整的状态机:

5.2 状态机实现

seckill-button-state.ts
/** 按钮状态枚举 */
enum SeckillButtonState {
/** 未开始:倒计时中 */
NOT_STARTED = 'NOT_STARTED',
/** 就绪:可以点击 */
READY = 'READY',
/** 提交中:请求发送中 */
SUBMITTING = 'SUBMITTING',
/** 排队中:等待后端处理结果 */
QUEUING = 'QUEUING',
/** 成功 */
SUCCESS = 'SUCCESS',
/** 失败:可重试 */
FAILED = 'FAILED',
/** 已售罄 */
SOLD_OUT = 'SOLD_OUT',
}

/** 按钮 UI 配置 */
interface ButtonUIConfig {
text: string;
disabled: boolean;
className: string;
}

const BUTTON_CONFIG: Record<SeckillButtonState, ButtonUIConfig> = {
[SeckillButtonState.NOT_STARTED]: {
text: '即将开始',
disabled: true,
className: 'btn-disabled',
},
[SeckillButtonState.READY]: {
text: '立即抢购',
disabled: false,
className: 'btn-primary btn-pulse',
},
[SeckillButtonState.SUBMITTING]: {
text: '提交中...',
disabled: true,
className: 'btn-loading',
},
[SeckillButtonState.QUEUING]: {
text: '排队中...',
disabled: true,
className: 'btn-queuing',
},
[SeckillButtonState.SUCCESS]: {
text: '抢购成功!去付款',
disabled: false,
className: 'btn-success',
},
[SeckillButtonState.FAILED]: {
text: '再试一次',
disabled: false,
className: 'btn-retry',
},
[SeckillButtonState.SOLD_OUT]: {
text: '已售罄',
disabled: true,
className: 'btn-sold-out',
},
};

/** 合法的状态转换定义 */
const VALID_TRANSITIONS: Record<SeckillButtonState, SeckillButtonState[]> = {
[SeckillButtonState.NOT_STARTED]: [SeckillButtonState.READY, SeckillButtonState.SOLD_OUT],
[SeckillButtonState.READY]: [SeckillButtonState.SUBMITTING, SeckillButtonState.SOLD_OUT],
[SeckillButtonState.SUBMITTING]: [SeckillButtonState.QUEUING, SeckillButtonState.SUCCESS, SeckillButtonState.FAILED, SeckillButtonState.SOLD_OUT],
[SeckillButtonState.QUEUING]: [SeckillButtonState.SUCCESS, SeckillButtonState.FAILED, SeckillButtonState.SOLD_OUT],
[SeckillButtonState.SUCCESS]: [],
[SeckillButtonState.FAILED]: [SeckillButtonState.READY, SeckillButtonState.SOLD_OUT],
[SeckillButtonState.SOLD_OUT]: [],
};

class SeckillButtonController {
private state: SeckillButtonState = SeckillButtonState.NOT_STARTED;
private listeners: Set<(state: SeckillButtonState) => void> = new Set();

getState(): SeckillButtonState {
return this.state;
}

getUIConfig(): ButtonUIConfig {
return BUTTON_CONFIG[this.state];
}

transition(newState: SeckillButtonState): boolean {
const allowed = VALID_TRANSITIONS[this.state];
if (!allowed.includes(newState)) {
console.warn(`非法状态转换: ${this.state}${newState}`);
return false;
}
this.state = newState;
this.listeners.forEach((listener) => listener(newState));
return true;
}

onStateChange(listener: (state: SeckillButtonState) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}

5.3 防重复提交

prevent-duplicate-submit.ts
// 三重防护:按钮禁用 + 请求节流 + Token 校验

class SeckillRequestGuard {
private isSubmitting = false;
private submitToken: string | null = null;

/** 1. 提交前获取一次性 Token */
async prepareToken(activityId: string): Promise<void> {
const res = await fetch(`/api/seckill/token?activityId=${activityId}`);
const data = await res.json();
this.submitToken = data.token; // 一次性 Token,用后即失效
}

/** 2. 提交秒杀请求 */
async submit(activityId: string, skuId: string): Promise<SeckillResult> {
// 第一重:内存锁防止并发提交
if (this.isSubmitting) {
return { success: false, message: '请勿重复提交' };
}

// 第二重:Token 校验
if (!this.submitToken) {
return { success: false, message: 'Token 无效,请刷新页面' };
}

this.isSubmitting = true;

try {
const res = await fetch('/api/seckill/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Seckill-Token': this.submitToken, // 一次性 Token
},
body: JSON.stringify({ activityId, skuId }),
});

// Token 用后置空,防止重复使用
this.submitToken = null;

return await res.json();
} finally {
this.isSubmitting = false;
}
}
}

interface SeckillResult {
success: boolean;
message: string;
orderId?: string;
queuePosition?: number;
}
防重复提交参考

前端节流和防抖是防重复提交的基础手段,更多实现细节参考:限流调度器


六、请求优化

6.1 前端请求限流

在秒杀场景中,前端需要主动限流,避免无效请求打到后端。

request-optimizer.ts
/** 秒杀请求优化器 */
class SeckillRequestOptimizer {
private requestQueue: Array<() => Promise<void>> = [];
private maxConcurrent: number;
private running = 0;

constructor(maxConcurrent = 1) {
this.maxConcurrent = maxConcurrent;
}

/** 添加请求到队列 */
async enqueue<T>(requestFn: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const task = async (): Promise<void> => {
this.running++;
try {
const result = await requestFn();
resolve(result);
} catch (err) {
reject(err);
} finally {
this.running--;
this.runNext();
}
};

if (this.running < this.maxConcurrent) {
task();
} else {
this.requestQueue.push(task);
}
});
}

private runNext(): void {
if (this.requestQueue.length > 0 && this.running < this.maxConcurrent) {
const next = this.requestQueue.shift();
next?.();
}
}
}

/**
* 秒杀请求:精简请求体
* 秒杀接口只传必要参数,减少请求体积
*/
interface SeckillRequest {
/** 活动 ID */
a: string;
/** SKU ID */
s: string;
/** Token */
t: string;
/** 时间戳 */
ts: number;
/** 签名 */
sign: string;
}

const buildSeckillRequest = (
activityId: string,
skuId: string,
token: string
): SeckillRequest => {
const ts = Date.now();
return {
a: activityId,
s: skuId,
t: token,
ts,
sign: generateSign(activityId, skuId, token, ts), // 接口签名
};
};

6.2 WebSocket 实时推送 vs 轮询

realtime-stock.ts
/** 库存实时更新策略 */
// 方案一:WebSocket(推荐,延迟低、服务端主动推送)
class StockWebSocket {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnects = 5;

connect(activityId: string): void {
this.ws = new WebSocket(`wss://seckill.example.com/ws/stock/${activityId}`);

this.ws.onmessage = (event: MessageEvent) => {
const data = JSON.parse(event.data) as StockUpdate;
this.handleStockUpdate(data);
};

this.ws.onclose = () => {
if (this.reconnectAttempts < this.maxReconnects) {
// 指数退避重连
const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 10_000);
setTimeout(() => {
this.reconnectAttempts++;
this.connect(activityId);
}, delay);
}
};
}

private handleStockUpdate(data: StockUpdate): void {
if (data.stock === 0) {
// 库存为零,立即更新按钮状态为"已售罄"
seckillButton.transition(SeckillButtonState.SOLD_OUT);
}
updateStockDisplay(data.stock);
}

disconnect(): void {
this.ws?.close();
this.ws = null;
}
}

interface StockUpdate {
activityId: string;
stock: number;
timestamp: number;
}

// 方案二:短轮询(WebSocket 不可用时的降级方案)
class StockPolling {
private timer: ReturnType<typeof setInterval> | null = null;

start(activityId: string, interval = 3000): void {
this.timer = setInterval(async () => {
try {
const res = await fetch(`/api/seckill/stock?id=${activityId}`);
const data: StockUpdate = await res.json();
updateStockDisplay(data.stock);
} catch {
// 静默失败,下次轮询再试
}
}, interval);
}

stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
}

6.3 WebSocket vs 轮询对比

特性WebSocket短轮询
延迟实时推送,几乎零延迟取决于轮询间隔(秒级)
服务端压力维持长连接,内存占用高频繁建立/断开连接
带宽消耗低(只在有更新时发数据)高(空轮询也有开销)
实现复杂度高(需要心跳、重连、集群)低(普通 HTTP 请求)
适用场景秒杀进行中的高频库存更新秒杀前的低频状态轮询
降级方案降级为轮询无需降级
选择建议

实际项目中推荐混合策略:秒杀开始前使用轮询(频率低、实现简单),秒杀开始时切换为 WebSocket(实时性强)。如果 WebSocket 连接失败,自动降级为短轮询。更多实时通信方案参考:WebSocket 与 SSE


七、安全防护

7.1 安全防护体系

7.2 接口签名与动态 Token

security-module.ts
/**
* 安全模块:接口签名 + 动态 Token + 时间戳校验
*/
class SeckillSecurity {
private secretKey: string;

constructor(secretKey: string) {
this.secretKey = secretKey;
}

/**
* 生成请求签名
* 将关键参数拼接后进行 HMAC 签名,防止参数篡改
*/
async generateSign(params: Record<string, string | number>): Promise<string> {
// 1. 参数按 key 排序
const sortedKeys = Object.keys(params).sort();
const signStr = sortedKeys.map((k) => `${k}=${params[k]}`).join('&');

// 2. HMAC-SHA256 签名
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(this.secretKey),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);

const signature = await crypto.subtle.sign(
'HMAC',
key,
encoder.encode(signStr)
);

// 3. 转为 hex 字符串
return Array.from(new Uint8Array(signature))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}

/**
* 请求频率检测(前端初步拦截)
*/
private requestTimestamps: number[] = [];

checkFrequency(maxRequests = 3, windowMs = 10_000): boolean {
const now = Date.now();
// 清理过期记录
this.requestTimestamps = this.requestTimestamps.filter(
(t) => now - t < windowMs
);
if (this.requestTimestamps.length >= maxRequests) {
return false; // 频率过高
}
this.requestTimestamps.push(now);
return true;
}
}

7.3 验证码与人机识别

captcha-integration.ts
/** 验证码集成方案 */
interface CaptchaProvider {
/** 初始化验证码 */
init(containerId: string): Promise<void>;
/** 验证并获取 token */
verify(): Promise<string>;
/** 重置 */
reset(): void;
}

// 秒杀前弹出滑块验证,通过后才能获取秒杀 Token
class SeckillCaptchaFlow {
private captcha: CaptchaProvider;

constructor(captcha: CaptchaProvider) {
this.captcha = captcha;
}

/**
* 倒计时最后 N 秒弹出验证码
* 好处:提前验证,不占用秒杀时间
*/
async preSeckillVerify(): Promise<string | null> {
try {
const captchaToken = await this.captcha.verify();
// 用验证码 token 换取秒杀 token
const res = await fetch('/api/seckill/prepare', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ captchaToken }),
});
const data = await res.json();
return data.seckillToken;
} catch {
return null;
}
}
}
前端安全的局限性

前端安全措施只能提高攻击成本,不能完全防止作弊。核心防护必须在服务端完成:

  • 接口签名密钥不能直接暴露在前端代码中,应通过服务端动态下发或使用临时密钥
  • Token 校验库存扣减订单生成等核心逻辑必须在服务端执行
  • 前端代码即使混淆也可以被反编译,不要在前端存放任何敏感逻辑

八、降级与容错

8.1 多级降级策略

8.2 降级实现

fallback-strategy.ts
/** 降级策略管理器 */
class SeckillFallback {
/**
* 接口超时降级
* 超时后不直接展示失败,而是轮询结果
*/
async submitWithFallback(
submitFn: () => Promise<SeckillResult>,
timeoutMs = 5000
): Promise<SeckillResult> {
try {
const result = await Promise.race([
submitFn(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('timeout')), timeoutMs)
),
]);
return result;
} catch (err) {
if (err instanceof Error && err.message === 'timeout') {
// 超时不代表失败,可能在排队中
return {
success: false,
message: '系统繁忙,正在排队处理中...',
queuePosition: -1, // 未知排队位置
};
}
return { success: false, message: '网络异常,请稍后重试' };
}
}

/**
* 轮询订单结果
* 秒杀提交后,异步轮询订单是否生成成功
*/
async pollOrderResult(
orderId: string,
maxAttempts = 10,
interval = 2000
): Promise<OrderResult> {
for (let i = 0; i < maxAttempts; i++) {
try {
const res = await fetch(`/api/seckill/result?orderId=${orderId}`);
const data: OrderResult = await res.json();

if (data.status !== 'pending') {
return data;
}
} catch {
// 网络错误,继续轮询
}

await new Promise((resolve) => setTimeout(resolve, interval));
}

return {
status: 'unknown',
message: '结果查询超时,请到"我的订单"中查看',
};
}

/**
* React ErrorBoundary 降级
*/
static renderFallbackUI(): string {
return `
<div class="seckill-error">
<h2>页面加载异常</h2>
<p>秒杀页面遇到问题,请刷新重试</p>
<button onclick="location.reload()">刷新页面</button>
</div>
`;
}
}

interface OrderResult {
status: 'success' | 'failed' | 'pending' | 'unknown';
message: string;
orderId?: string;
payUrl?: string;
}

8.3 CDN 降级页面

cdn-fallback.ts
/**
* CDN 层降级配置
* 在 Nginx / CDN 配置中,当源站返回 5xx 时自动返回降级页面
*/

// 降级页面是纯静态 HTML,提前部署到 CDN
// 不依赖任何 API 接口,确保在服务完全不可用时也能展示

const FALLBACK_HTML = `
<!DOCTYPE html>
<html>
<head><title>秒杀活动</title></head>
<body>
<div class="fallback">
<h1>系统繁忙</h1>
<p>当前参与人数过多,请稍后刷新再试</p>
<p>您也可以到"我的订单"中查看是否抢购成功</p>
<button onclick="location.reload()">刷新页面</button>
</div>
</body>
</html>
`;

// Nginx 降级配置示例(伪代码)
// location /seckill/ {
// proxy_pass http://seckill-backend;
// proxy_intercept_errors on;
// error_page 502 503 504 /fallback/seckill.html;
// }

九、性能优化

9.1 关键路径优化

critical-path.ts
/**
* 首屏只加载秒杀核心模块
* 非核心模块(商品详情、评论、推荐)延迟加载
*/

// 核心模块:倒计时 + 按钮 + 请求(内联或预加载)
// 非核心模块:商品详情、评论、推荐(懒加载)

// React 代码分割示例
const SeckillCore = React.lazy(() => import('./SeckillCore'));
const ProductDetail = React.lazy(() => import('./ProductDetail'));
const Comments = React.lazy(() => import('./Comments'));
const Recommendations = React.lazy(() => import('./Recommendations'));

const SeckillPage: React.FC = () => {
return (
<>
{/* 核心模块:不用 Suspense 包裹,直接内联 */}
<CountdownTimer />
<SeckillButton />

{/* 非核心模块:懒加载 */}
<React.Suspense fallback={<Skeleton />}>
<ProductDetail />
</React.Suspense>

<React.Suspense fallback={null}>
<Comments />
<Recommendations />
</React.Suspense>
</>
);
};

9.2 资源预加载策略

preload-strategy.html
<!-- 关键资源预加载 -->
<link rel="preconnect" href="https://seckill-api.example.com" />
<link rel="dns-prefetch" href="https://seckill-api.example.com" />

<!-- 秒杀核心 JS 预加载 -->
<link rel="preload" href="/js/seckill-core.js" as="script" />

<!-- 商品图片预加载 -->
<link rel="preload" href="/img/product-main.webp" as="image" />

<!-- 秒杀结果页预取(用户抢购后大概率跳转) -->
<link rel="prefetch" href="/seckill/result.html" />

9.3 Web Worker 处理计算密集任务

seckill-worker.ts
// 将加密签名、倒计时校正等 CPU 密集型任务放到 Worker 中

// worker.ts
self.addEventListener('message', async (event: MessageEvent) => {
const { type, payload } = event.data;

switch (type) {
case 'GENERATE_SIGN': {
// 在 Worker 中进行签名计算,不阻塞主线程
const sign = await computeHMAC(payload.params, payload.key);
self.postMessage({ type: 'SIGN_RESULT', sign });
break;
}
case 'CORRECT_TIME': {
// 多次采样计算时间差
const diffs = await measureTimeDiffs(payload.url, payload.samples);
const median = getMedian(diffs);
self.postMessage({ type: 'TIME_DIFF', diff: median });
break;
}
}
});

// 主线程
const seckillWorker = new Worker('/workers/seckill-worker.js');

seckillWorker.postMessage({
type: 'GENERATE_SIGN',
payload: { params: requestParams, key: tempKey },
});

seckillWorker.addEventListener('message', (event: MessageEvent) => {
if (event.data.type === 'SIGN_RESULT') {
sendSeckillRequest(event.data.sign);
}
});
更多性能优化参考

十、实际案例分析

10.1 电商秒杀方案(淘宝双 11、京东秒杀)

策略淘宝双 11京东秒杀
页面方案预渲染 + CDN 静态化独立秒杀域名 + CDN
倒计时服务端时间同步 + 毫秒级精度NTP 校时 + rAF 渲染
流量控制答题验证码削峰预约制 + 排队机制
库存展示模糊库存("库存紧张")实时库存倒数
降级方案多级降级 + 限流排队页CDN 降级 + 异步出结果
安全防护风控系统 + 行为验证滑块验证 + 设备指纹

10.2 抢票系统(12306)

12306 的前端策略在高并发场景下具有参考价值:

  • 排队机制:用户提交后进入排队队列,前端轮询排队进度
  • 候补购票:抢票失败后自动进入候补队列,减少用户反复刷新
  • 验证码前置:在关键操作前弹出验证码,过滤机器人
  • 分时段放票:错峰放票减少同时并发
queue-polling.ts
/** 模拟 12306 排队轮询机制 */
class QueuePolling {
async waitInQueue(queueId: string): Promise<QueueResult> {
const maxWaitTime = 60_000; // 最长等待 60 秒
const startTime = Date.now();

while (Date.now() - startTime < maxWaitTime) {
const res = await fetch(`/api/seckill/queue/${queueId}`);
const data: QueueStatus = await res.json();

// 更新排队 UI
updateQueueUI(data);

if (data.status === 'completed') {
return { success: true, orderId: data.orderId! };
}
if (data.status === 'failed') {
return { success: false, reason: data.reason! };
}

// 根据排队位置动态调整轮询间隔
const pollInterval = data.position > 100 ? 5000 : 2000;
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}

return { success: false, reason: '排队超时,请查看订单列表' };
}
}

interface QueueStatus {
status: 'waiting' | 'processing' | 'completed' | 'failed';
position: number;
estimatedWaitTime: number;
orderId?: string;
reason?: string;
}

interface QueueResult {
success: boolean;
orderId?: string;
reason?: string;
}

const updateQueueUI = (data: QueueStatus): void => {
const el = document.getElementById('queue-info')!;
el.innerHTML = `
<p>当前排队位置:第 ${data.position}</p>
<p>预计等待时间:${Math.ceil(data.estimatedWaitTime / 1000)}</p>
`;
};

常见面试问题

Q1: 秒杀系统的前端架构应该如何设计?

答案

秒杀系统前端架构需要围绕"减少无效请求、保障用户体验、多级降级容错"三个核心目标展开:

1. 分层架构:

层级职责关键技术
CDN 层静态资源加速、降级兜底CDN + 预渲染 + 独立域名
前端应用层倒计时、按钮控制、请求管理rAF + 状态机 + 请求队列
安全层防刷、签名、人机验证HMAC 签名 + 验证码 + Token
通信层实时库存、排队状态WebSocket + 轮询降级

2. 核心模块:

architecture-overview.ts
// 秒杀前端核心模块
interface SeckillFrontendArchitecture {
/** 页面静态化:CDN + 预渲染,动态数据异步加载 */
staticPage: CDNConfig;
/** 倒计时引擎:服务端时间同步 + rAF 高精度渲染 */
countdown: PrecisionCountdown;
/** 按钮状态机:6 种状态,严格的转换规则 */
buttonFSM: SeckillButtonController;
/** 请求控制:节流 + 队列 + 签名 + Token */
requestGuard: SeckillRequestGuard;
/** 降级策略:超时降级 + CDN 降级 + 本地缓存 */
fallback: SeckillFallback;
}

3. 与后端协作方式:

  • 秒杀开始前:前端通过 HTTP 接口获取活动信息和服务端时间
  • 秒杀进行中:通过 WebSocket 实时接收库存更新
  • 秒杀提交后:前端提交请求后,后端返回排队 ID,前端轮询结果

Q2: 如何实现一个精准的秒杀倒计时?

答案

精准倒计时的三个关键点:服务端时间同步requestAnimationFrame 渲染定期误差校正

countdown-key-points.ts
// 1. 服务端时间同步:多次采样取中位数
const syncTime = async (): Promise<number> => {
const diffs: number[] = [];
for (let i = 0; i < 3; i++) {
const t1 = Date.now();
const res = await fetch('/api/time');
const t2 = Date.now();
const serverTime = (await res.json()).timestamp;
// RTT 补偿:假设去程和回程各占一半
diffs.push(serverTime + (t2 - t1) / 2 - t2);
}
diffs.sort((a, b) => a - b);
return diffs[1]; // 中位数
};

// 2. rAF 渲染:精度远高于 setInterval
const startCountdown = (targetTime: number, timeDiff: number): void => {
const tick = (): void => {
const correctedNow = Date.now() + timeDiff;
const remaining = targetTime - correctedNow;
if (remaining <= 0) {
enableButton();
return;
}
renderTime(remaining);
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
};

// 3. 定期校正:每 30 秒重新同步一次
// 防止长时间运行后误差累积

为什么不用 setInterval

对比项setInterval(fn, 1000)requestAnimationFrame
精度可能延迟数十毫秒与屏幕刷新率同步(~16.67ms)
主线程阻塞被阻塞时跳帧浏览器自动调度
页面不可见时继续执行(浪费资源)自动暂停
误差累积长时间运行误差明显每帧重新计算差值,无累积

Q3: 如何防止用户重复提交秒杀请求?

答案

采用三重防护策略:

triple-protection.ts
class TripleProtection {
private isSubmitting = false;
private token: string | null = null;
private lastClickTime = 0;
private readonly THROTTLE_MS = 1000; // 1 秒内只允许点击一次

async handleClick(activityId: string): Promise<SeckillResult | null> {
// 第一重:前端节流
const now = Date.now();
if (now - this.lastClickTime < this.THROTTLE_MS) {
return null;
}
this.lastClickTime = now;

// 第二重:内存锁(防并发)
if (this.isSubmitting) {
return null;
}
this.isSubmitting = true;

try {
// 第三重:一次性 Token
if (!this.token) {
return { success: false, message: 'Token 已过期' };
}
const result = await submitSeckill(activityId, this.token);
this.token = null; // Token 用后即废
return result;
} finally {
this.isSubmitting = false;
}
}
}

补充手段

  • 按钮状态机:提交后立即将按钮切换为"提交中"状态(disabled)
  • 后端幂等性:同一用户 + 同一活动只能生成一个订单
  • 请求去重:网关层根据用户 ID + 活动 ID 去重

Q4: 秒杀页面的性能优化有哪些策略?

答案

优化方向具体策略效果
静态化页面预渲染 + CDN 部署首屏秒开,不依赖后端
资源优化关键 CSS 内联 + JS 最小化减少请求数和体积
代码分割核心模块内联、非核心懒加载减少首屏 JS 体积 60%+
预加载preconnect + preload + prefetch提前建连、预取资源
图片优化WebP/AVIF + 懒加载 + CDN 裁剪图片体积减少 50%+
Web Worker签名计算、时间校正在 Worker 中执行不阻塞主线程 UI 渲染
Service Worker缓存静态资源和降级页面离线可用,秒开体验
接口精简请求/响应体最小化、字段压缩减少网络传输时间
service-worker-cache.ts
// Service Worker 缓存秒杀静态资源
const SECKILL_CACHE = 'seckill-v1';
const CACHE_URLS = [
'/seckill/',
'/seckill/index.html',
'/seckill/core.js',
'/seckill/style.css',
'/seckill/fallback.html', // 降级页面也要缓存
];

self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches.open(SECKILL_CACHE).then((cache) => cache.addAll(CACHE_URLS))
);
});

// Network First:优先网络,失败时使用缓存
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.url.includes('/seckill/')) {
event.respondWith(
fetch(event.request).catch(() => {
return caches.match(event.request) as Promise<Response>;
})
);
}
});

Q5: 如何防止脚本刷单?前端能做哪些安全防护?

答案

前端安全防护是提高攻击成本的第一道防线,核心思路是让脚本难以模拟正常用户行为。

1. 人机验证(最有效):

captcha-strategy.ts
// 倒计时最后 10 秒弹出滑块验证
// 好处:通过验证后才能获取秒杀 Token,脚本无法绕过
// 验证码时机:秒杀前 10 秒,不占用抢购时间
const handleCountdownNearEnd = async (remaining: number): Promise<void> => {
if (remaining <= 10_000 && !hasVerified) {
const captchaToken = await showCaptchaModal();
seckillToken = await exchangeForSeckillToken(captchaToken);
hasVerified = true;
}
};

2. 接口签名:

  • 请求参数 + 时间戳 + 随机数 → HMAC 签名
  • 服务端校验签名有效性和时间戳时效性
  • 密钥通过服务端动态下发,定期轮换

3. 行为检测:

  • 记录鼠标轨迹、点击间隔、页面停留时间
  • 异常行为(如精确到毫秒的点击、无鼠标移动轨迹)触发风控

4. 代码混淆:

  • 秒杀相关 JS 代码额外混淆
  • 接口 URL 动态生成(不硬编码)
  • 关键变量名随机化
重要提醒

前端安全只是"围栏",不是"城墙"。所有关键校验必须在服务端完成,前端防护只是增加攻击成本。

Q6: 秒杀接口请求失败了怎么办?如何设计降级方案?

答案

降级方案需要按照严重程度分级处理

multi-level-fallback.ts
enum FallbackLevel {
/** 级别 1:接口超时 → 展示排队中 */
TIMEOUT = 'TIMEOUT',
/** 级别 2:接口报错 → 允许重试 */
API_ERROR = 'API_ERROR',
/** 级别 3:服务不可用 → CDN 降级页 */
SERVICE_DOWN = 'SERVICE_DOWN',
/** 级别 4:CDN 也挂了 → Service Worker 缓存 */
CDN_DOWN = 'CDN_DOWN',
}

const handleFallback = async (
level: FallbackLevel
): Promise<void> => {
switch (level) {
case FallbackLevel.TIMEOUT:
// 不直接展示失败,改为轮询结果
showQueueingUI();
await pollOrderResult(orderId);
break;

case FallbackLevel.API_ERROR:
// 展示重试按钮,限制最多 3 次
if (retryCount < 3) {
showRetryButton();
} else {
showMessage('系统繁忙,请稍后到订单列表查看');
}
break;

case FallbackLevel.SERVICE_DOWN:
// 整个服务不可用,展示 CDN 静态降级页
window.location.href = '/seckill/fallback.html';
break;

case FallbackLevel.CDN_DOWN:
// Service Worker 兜底
showOfflineFallback();
break;
}
};

关键原则

  1. 超时不等于失败:秒杀场景下超时很常见,应展示"排队中"而非"失败"
  2. 结果最终一致:即使前端未收到明确结果,也要引导用户去订单页确认
  3. 降级是预案:降级页面必须提前部署、提前测试,不能临时写
  4. 用户感知:每次降级都要给用户明确的提示和下一步操作指引

Q7: WebSocket 和轮询在秒杀场景中如何选择?

答案

推荐混合策略,不同阶段使用不同方案:

阶段推荐方案原因
秒杀前(预热期)短轮询(5-10s 间隔)数据变化慢,轮询开销小
秒杀倒计时(最后 1 分钟)WebSocket需要精准时间同步
秒杀进行中WebSocket库存实时变化,需要即时推送
秒杀结束后(等结果)短轮询(2-3s 间隔)查询个人订单结果,无需广播
hybrid-realtime.ts
class HybridRealtime {
private ws: StockWebSocket;
private polling: StockPolling;
private currentMode: 'polling' | 'websocket' = 'polling';

/** 根据阶段自动切换通信方式 */
switchMode(phase: 'preheat' | 'countdown' | 'active' | 'ended'): void {
switch (phase) {
case 'preheat':
this.usePolling(10_000); // 10 秒轮询
break;
case 'countdown':
case 'active':
this.useWebSocket(); // 切换到 WebSocket
break;
case 'ended':
this.usePolling(3_000); // 3 秒轮询查结果
break;
}
}

private useWebSocket(): void {
if (this.currentMode === 'websocket') return;
this.polling.stop();
this.ws.connect(this.activityId);
this.currentMode = 'websocket';

// WebSocket 连接失败时自动降级为轮询
this.ws.onError(() => {
console.warn('WebSocket 连接失败,降级为轮询');
this.usePolling(2_000);
});
}

private usePolling(interval: number): void {
if (this.currentMode === 'polling') return;
this.ws.disconnect();
this.polling.start(this.activityId, interval);
this.currentMode = 'polling';
}
}

WebSocket 注意事项

  • 秒杀场景下 WebSocket 连接数巨大,服务端需要专门的连接管理集群
  • 必须有轮询降级方案,不能只依赖 WebSocket
  • 消息格式尽量精简(考虑 Protobuf 序列化)

Q8: 如何设计秒杀页面的按钮状态机?

答案

秒杀按钮状态机包含 7 种状态严格的转换规则

设计关键点

  1. 状态转换必须有严格规则:不能从 SUCCESS 跳回 READY,不能从 SOLD_OUT 跳回任何状态
  2. 每个状态有唯一的 UI 表现:文案、颜色、是否可点击都明确定义
  3. 状态变化驱动 UI 更新:采用发布-订阅模式,状态变化自动触发 UI 渲染
button-state-machine-usage.ts
// React Hook 封装
const useSeckillButton = (activityId: string) => {
const [state, setState] = useState<SeckillButtonState>(
SeckillButtonState.NOT_STARTED
);

const controller = useMemo(() => {
const ctrl = new SeckillButtonController();
ctrl.onStateChange(setState);
return ctrl;
}, []);

const handleClick = useCallback(async () => {
// 只有 READY 和 FAILED 状态可以点击
if (
state !== SeckillButtonState.READY &&
state !== SeckillButtonState.FAILED
) {
return;
}

controller.transition(SeckillButtonState.SUBMITTING);

try {
const result = await submitSeckill(activityId);
if (result.success) {
controller.transition(SeckillButtonState.SUCCESS);
} else if (result.message === 'sold_out') {
controller.transition(SeckillButtonState.SOLD_OUT);
} else {
controller.transition(SeckillButtonState.FAILED);
}
} catch {
controller.transition(SeckillButtonState.FAILED);
}
}, [state, activityId, controller]);

const config = BUTTON_CONFIG[state];

return { state, config, handleClick };
};

面试加分点

  • 提到有限状态机(FSM) 的概念,说明为什么用状态机而不是多个 boolean 变量
  • 状态转换规则用白名单模式:只定义合法转换,未定义的一律拒绝
  • 结合后端推送(WebSocket)和用户操作(点击)两种触发源

相关链接

内部文档

外部参考